Skip to content
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2287,6 +2287,13 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/executors/cursor.ts.
# CURSOR_TOOL_DIRECTIVE=1

# Operator-defined system prompt text appended to the system message AFTER
# translation (post-translation injection), so it reaches codex/Responses and
# /v1/messages paths. Also used as the directive prefix stripped from echoed
# system preamble blocks. Leave unset to disable.
# Used by: open-sse/translator/request/claude-to-openai.ts, open-sse/translator/response/openai-to-claude.ts.
# OMNIROUTE_SYSTEM_INSTRUCTION_APPEND=

# Per-image fetch timeout (ms) for remote image_url vision input. Default: 15000.
# Used by: open-sse/utils/cursorImages.ts.
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix(sse): inject the operator's global system prompt once, after request translation, for every target shape (Claude, Gemini, OpenAI Responses, OpenAI/Codex messages) instead of before translation — the pre-translation injection could be lost, repositioned, or duplicated 2-3× depending on the target format, and never reached the Responses API path at all. The new `injectSystemPromptPostTranslation()` is idempotent per request via a non-enumerable marker (#12904)
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,7 @@ changing them requires a code edit, not an env var:
| `CURSOR_DUMP_FILE` | _(unset)_ | `open-sse/executors/cursor.ts` | Optional file path that receives raw decoded Cursor chunks when `CURSOR_DEBUG=1`. |
| `CURSOR_STREAM_TIMEOUT_MS` | `300000` | `open-sse/executors/cursor.ts` | Stream idle timeout (ms) for the Cursor executor. |
| `CURSOR_TOOL_DIRECTIVE` | enabled (`!== "0"`) | `open-sse/executors/cursor.ts` | Tool-commit directive that makes composer-2.5 reliably issue tool calls. Set `0` to disable. |
| `OMNIROUTE_SYSTEM_INSTRUCTION_APPEND` | _(unset)_ | `open-sse/translator/request/claude-to-openai.ts`, `open-sse/translator/response/openai-to-claude.ts` | Operator-defined system prompt text appended to the system message AFTER translation (post-translation injection), reaching codex/Responses and `/v1/messages` paths. Also used as the directive prefix stripped from echoed system preamble blocks. Leave unset to disable. |
| `CURSOR_IMAGE_FETCH_TIMEOUT_MS` | `15000` | `open-sse/utils/cursorImages.ts` | Per-image fetch timeout (ms) for remote `image_url` vision input. |
| `CURSOR_STATE_DB_PATH` | _(probed)_ | `open-sse/utils/cursorVersionDetector.ts` | Override the Cursor IDE state DB lookup used for IDE version detection. |
| `CURSOR_AGENT_CLI_VERSION` | _(detect / pin)_ | `open-sse/utils/cursorAgentCliVersion.ts` | Agent CLI build id (`YYYY.MM.DD-<hash>`) for `x-cursor-client-version: cli-…` on Agent Run. |
Expand Down
26 changes: 24 additions & 2 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
import { resolveOmniGlyphTransport } from "../services/compression/imageTransportPolicy.ts";
import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts";
import { normalizeClaudeToolsForDispatch } from "./chatCore/claudeToolDefaults.ts";
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
import {
injectCustomSystemPrompt,
injectSystemPromptPostTranslation,
injectSystemPromptPreTranslation,
} from "../services/systemPrompt.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
import { collectCustomToolNamesForSourceFormat } from "../translator/request/openai-responses/additionalTools.ts";
Expand Down Expand Up @@ -647,7 +651,6 @@ export async function handleChatCore({
};
};
let tokensCompressed: number | null = null;
body = injectSystemPrompt(body);
// ── Per-endpoint custom system prompt (port of upstream #2063) ──
// Reads from cachedSettings if available (passed in from combo/chat layer)
// to avoid an extra DB read on the hot path. Falls through to getCachedSettings()
Expand Down Expand Up @@ -2485,6 +2488,12 @@ export async function handleChatCore({
model || "",
sourceFormat
);
// Carrier-less targets (kiro / antigravity) have no post-translation
// system carrier for the single pass at ~3068 to write into — inject
// into the client body BEFORE translation so their user-merge /
// relocation paths carry the global prompt (baseline coverage of the
// removed pre-translation pass). The gate writes ONE carrier only.
translatedBody = injectSystemPromptPreTranslation(translatedBody, { targetFormat });
translatedBody = translateRequest(
sourceFormat,
targetFormat,
Expand Down Expand Up @@ -3069,6 +3078,19 @@ export async function handleChatCore({
isOpencodeClient,
});

// Global System Prompt — SINGLE injection point (post-translation) for
// carrier-ful targets. The old unconditional pre-translation pass
// (former chatCore injectSystemPrompt call) was removed: it chained
// with this pass to inject prefix/suffix 2-3x and dual-wrote
// body.system + messages[] on the claude path, which strict upstreams
// (HCP-Vision vLLM: "System message must be at the beginning") reject
// with 400. Format-aware via targetFormat: messages[] (openai/codex —
// prefix FIRST system, suffix LAST), claude `system` field, gemini
// `systemInstruction`, responses `instructions`. Carrier-less targets
// (kiro user-fold, antigravity Cloud Code envelope) are covered by the
// gated PRE-translation pass before translateRequest instead.
bodyToSend = injectSystemPromptPostTranslation(bodyToSend, { targetFormat });

updatePendingScope(pendingScope, {
providerRequest: bodyToSend,
stage: "payload_prepared",
Expand Down
297 changes: 297 additions & 0 deletions open-sse/services/systemPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function injectSystemPrompt<T>(body: T): T {
if (!prefix && !suffix) return body;
if (!isRecord(body)) return body;
if (body._skipSystemPrompt) return body;
if (body._systemPromptInjected) return body;

const result: Record<string, unknown> = { ...body };

Expand Down Expand Up @@ -143,9 +144,305 @@ export function injectSystemPrompt<T>(body: T): T {
}
}

markInjected(result);
return Object.assign({}, body, result);
}

/**
* Prepend `text` to a message content (string or array form).
*/
function prependToContent(msg: Record<string, unknown>, text: string): void {
if (Array.isArray(msg.content)) {
msg.content = [{ type: "text", text }, ...msg.content];
} else {
msg.content = text + "\n\n" + (msg.content || "");
}
}

/**
* Append `text` to a message content (string or array form).
*/
function appendToContent(msg: Record<string, unknown>, text: string): void {
if (Array.isArray(msg.content)) {
msg.content = [...msg.content, { type: "text", text }];
} else {
msg.content = (msg.content || "") + "\n\n" + text;
}
}

// Non-enumerable marker: survives property access for the retry-loop guard,
// invisible to JSON.stringify so it never leaks into the upstream request body.
function markInjected(body: Record<string, unknown>): void {
try {
Object.defineProperty(body, "_systemPromptInjected", { value: true, enumerable: false });
} catch {
/* frozen/non-object edge — ignore */
}
}

/**
* Inject system prompts into a POST-TRANSLATION request body.
*
* Coverage model (the legacy unconditional pre-translation pass was removed —
* it chained into double injection): coverage = this format-aware
* post-translation pass for carrier-ful targets, plus the gated
* PRE-translation pass (injectSystemPromptPreTranslation) for carrier-less
* targets (kiro user-fold, antigravity Cloud Code envelope).
*
* Format-aware (opts.targetFormat) system carriers per target:
* - claude: `system` field (string or {type:"text"} block array)
* - gemini: `systemInstruction` ({ role, parts: [{ text }] })
* - openai-responses: `instructions` string
* - openai/codex (default): messages[] system/developer roles — prefix on
* the FIRST and suffix on the LAST so the suffix retains the highest
* recency position, preserving the "After Prompt" semantics.
*
* @param {object} body - Translated request body (target shape resolved)
* @param {object} [opts] - `{ targetFormat }` from the resolved wire target
* @returns {object} Modified body
*/
export function injectSystemPromptPostTranslation(body, opts?: { targetFormat?: string }) {
const cfg = getConfig();
if (!cfg.enabled) return body;
const prefix = cfg.prefixPrompt || "";
const suffix = cfg.suffixPrompt || "";
if (!prefix && !suffix) return body;
if (!body || typeof body !== "object") return body;
if (body._skipSystemPrompt) return body;
if (body._systemPromptInjected) return body;
const targetFormat = opts?.targetFormat || "";
const combined = [prefix, suffix].filter(Boolean).join("\n\n");

const result = { ...body };

// Claude-format body (separate `system` field, or a claude target whose
// translated body has no system-role message to carry the prompt): inject
// into body.system — a system-role message inside claude messages[] is
// invalid there. When the translated body has no system field at all, CREATE
// it (combined) — previously this body shape fell through the messages[]
// early-return and silently got zero injection.
if (targetFormat === "claude" || result.system !== undefined) {
const hasSystemRole =
Array.isArray(result.messages) &&
result.messages.some((m) => m && (m.role === "system" || m.role === "developer"));
if (!hasSystemRole) {
if (typeof result.system === "string") {
let sys = result.system;
if (prefix) sys = prefix + "\n\n" + sys;
if (suffix) sys = sys + "\n\n" + suffix;
result.system = sys;
} else if (Array.isArray(result.system)) {
let arr = [...result.system];
if (prefix) arr = [{ type: "text", text: prefix }, ...arr];
if (suffix) arr = [...arr, { type: "text", text: suffix }];
result.system = arr;
} else {
result.system = combined;
}
markInjected(result);
return result;
}
}

// Gemini-format body (contents[] + systemInstruction): inject into the
// systemInstruction parts (real translator shape: { role: "system",
// parts: [{ text }] }, see translator/request/claude-to-gemini.ts:95). If
// absent, create it — a messages-less gemini body previously fell through
// the messages[] early-return and silently got zero injection.
// Antigravity reaches 3068 as a Cloud Code envelope whose executor reads
// ONLY envelope.request (antigravity.ts:733) and which rejects unknown
// top-level fields with 400 (:813-815) — its coverage is restored by the
// gated pre-translation pass instead, so it must stay out of this branch.
if (targetFormat === "gemini" && !result.request) {
if (result.systemInstruction && typeof result.systemInstruction === "object") {
const si = result.systemInstruction as { role?: string; parts?: unknown[] };
const parts = Array.isArray(si.parts) ? [...si.parts] : [];
if (prefix) parts.unshift({ text: prefix });
if (suffix) parts.push({ text: suffix });
result.systemInstruction = { ...si, role: si.role || "system", parts };
} else {
const texts = [prefix, suffix].filter(Boolean);
result.systemInstruction = { role: "system", parts: texts.map((text) => ({ text })) };
}
markInjected(result);
return result;
}

// OpenAI Responses-format body (input + instructions): instructions is a
// plain string — wrap once. If absent, create it with the combined prompt.
// Do NOT touch `input` (message items, not a system carrier).
if (targetFormat === "openai-responses") {
const base = typeof result.instructions === "string" ? result.instructions : "";
const parts = [prefix, base, suffix].filter(Boolean);
result.instructions = parts.join("\n\n");
markInjected(result);
return result;
}

// Kiro (conversationState/.../userInputMessage) has NO system carrier at all:
// openai-to-kiro.ts folds system messages into user turns wrapped in
// <system-reminder> tags (#2306) and the executor keeps no system slot.
// Injection here would require inventing a carrier kiro upstreams reject —
// so kiro intentionally receives no global-prompt injection at this seam.
if (targetFormat === "kiro") {
return result;
}

if (!result.messages || !Array.isArray(result.messages)) return result;

result.messages = [...result.messages];
const indices: number[] = [];
for (let i = 0; i < result.messages.length; i++) {
const m = result.messages[i] as { role?: string };
if (m && (m.role === "system" || m.role === "developer")) indices.push(i);
}

if (indices.length === 0) {
// No system message — combine both into one at the front (same as injectSystemPrompt).
if (combined) {
result.messages = [{ role: "system", content: combined }, ...result.messages];
}
markInjected(result);
return result;
}

if (prefix) {
const firstIdx = indices[0];
result.messages[firstIdx] = { ...result.messages[firstIdx] };
prependToContent(result.messages[firstIdx] as Record<string, unknown>, prefix);
}
if (suffix) {
const lastIdx = indices[indices.length - 1];
if (lastIdx !== indices[0]) {
result.messages[lastIdx] = { ...result.messages[lastIdx] };
}
appendToContent(result.messages[lastIdx] as Record<string, unknown>, suffix);
}
markInjected(result);
return result;
}

/**
* Gated PRE-translation injection for targets with NO post-translation system
* carrier. Two such targets exist:
* - kiro: openai-to-kiro.ts folds system messages into user turns wrapped in
* <system-reminder> tags (#2306) — reads body.messages system roles only
* (:283-284/:872), no body.system, no system slot in the Kiro payload.
* - antigravity: the translator wraps the payload in a Cloud Code envelope
* ({project, requestId, request:{contents, systemInstruction, ...}}) and
* the executor reads ONLY envelope.request (antigravity.ts:733/:417-425);
* the envelope rejects unknown top-level fields with 400 (:813-815), and
* envelope.request.systemInstruction is overwritten with
* ANTIGRAVITY_DEFAULT_SYSTEM after relocating client system content into
* the first user message (openai-to-gemini.ts:716-730). Post-translation
* injection at chatCore 3068 cannot reach the real carrier for either.
*
* Pre-translation the client body reaches this gate in one of four shapes,
* ALL covered here: messages[] (openai/codex source), claude `system` field
* (string), responses `input` + `instructions` (hub translation promotes
* instructions to a system message, openai-responses.ts:205-207), and gemini
* `contents` + `systemInstruction`. Not covered — and rejected by the guards
* above — are bodies with none of these carriers (empty/no-op return).
*
* SINGLE-CARRIER guarantee: writes into exactly ONE carrier — never both. The
* removed pass dual-wrote messages[] AND body.system; both would survive
* translation and fold ×2.
*
* @param {object} body - PRE-translation request body (client format)
* @param {object} [opts] - `{ targetFormat }` of the resolved wire target
* @returns {object} Modified body (or the original when gated out)
*/
export function injectSystemPromptPreTranslation(body, opts?: { targetFormat?: string }) {
const cfg = getConfig();
if (!cfg.enabled) return body;
const prefix = cfg.prefixPrompt || "";
const suffix = cfg.suffixPrompt || "";
if (!prefix && !suffix) return body;
if (!body || typeof body !== "object") return body;
if (body._skipSystemPrompt) return body;
if (body._systemPromptInjected) return body;

const targetFormat = opts?.targetFormat || "";
// Carrier-ful targets (openai, codex, claude, gemini, openai-responses,
// cursor) receive their injection at the single post-translation pass
// (chatCore 3068) — pre-injecting here would chain into a double injection.
const CARRIERLESS_TARGETS = new Set(["kiro", "antigravity"]);
if (!CARRIERLESS_TARGETS.has(targetFormat)) return body;

const combined = [prefix, suffix].filter(Boolean).join("\n\n");
const result = { ...body };

// Claude-source client body: the `system` field is the authoritative carrier
// (#2468 ordering — prefix → client content → suffix). Checked FIRST so a
// body that also carries messages[] (user/assistant turns) never gets a
// second write into messages.
if (typeof result.system === "string") {
let sys = result.system;
if (prefix) sys = prefix + "\n\n" + sys;
if (suffix) sys = sys + "\n\n" + suffix;
result.system = sys;
markInjected(result);
return result;
}
if (Array.isArray(result.system)) {
let arr = [...result.system];
if (prefix) arr = [{ type: "text", text: prefix }, ...arr];
if (suffix) arr = [...arr, { type: "text", text: suffix }];
result.system = arr;
markInjected(result);
return result;
}

// Responses-source client body (input + instructions): wrap the instructions
// string once — the hub translation promotes it to a system message
// (openai-responses.ts:205-207) which the target then folds. Do NOT touch
// `input` (message items, not a system carrier).
if (Array.isArray(result.input)) {
const base = typeof result.instructions === "string" ? result.instructions : "";
result.instructions = [prefix, base, suffix].filter(Boolean).join("\n\n");
markInjected(result);
return result;
}

// Gemini-source client body (contents + systemInstruction): inject into the
// parts once each; create the carrier when absent.
if (result.contents !== undefined) {
if (result.systemInstruction && typeof result.systemInstruction === "object") {
const si = result.systemInstruction as { role?: string; parts?: unknown[] };
const parts = Array.isArray(si.parts) ? [...si.parts] : [];
if (prefix) parts.unshift({ text: prefix });
if (suffix) parts.push({ text: suffix });
result.systemInstruction = { ...si, role: si.role || "system", parts };
} else {
const texts = [prefix, suffix].filter(Boolean);
result.systemInstruction = { role: "system", parts: texts.map((text) => ({ text })) };
}
markInjected(result);
return result;
}

// OpenAI-style client body: write into the system/developer message only.
if (Array.isArray(result.messages)) {
result.messages = [...result.messages];
const sysIdx = result.messages.findIndex(
(m) => m && (m.role === "system" || m.role === "developer")
);
if (sysIdx >= 0) {
result.messages[sysIdx] = { ...result.messages[sysIdx] };
if (prefix) prependToContent(result.messages[sysIdx] as Record<string, unknown>, prefix);
if (suffix) appendToContent(result.messages[sysIdx] as Record<string, unknown>, suffix);
} else {
if (combined) {
result.messages = [{ role: "system", content: combined }, ...result.messages];
}
}
markInjected(result);
return result;
}

return result;
}

/**
* Inject a per-request custom system prompt into the request body.
*
Expand Down
Loading