mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 21:02:12 +03:00
* chore(release): open v3.8.38 development cycle
* fix(executors): strip client_metadata for cerebras and mistral (#4727)
Integrated into release/v3.8.38 (leva 5)
* fix(codebuddy): only send reasoning params when client requests reasoning (#5019)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): keep streaming for forceStream providers when client requests JSON (#5021)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): guard non-JSON SSE lines and duplicate [DONE] (#4937)
Integrated into release/v3.8.38 (leva 5)
* feat(blackbox): refresh provider model catalog (#4935)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): dedupe case-variant Anthropic version/beta headers (#4846)
Integrated into release/v3.8.38 (leva 5)
* feat(sse): Kiro inline <thinking> stream splitter (#4911)
Integrated into release/v3.8.38 (leva 5)
* feat(cursor): parse Composer DeepSeek-style inline tool calls (#4912)
Integrated into release/v3.8.38 (leva 5)
* feat(proxy): auth-less host:port batch import (#4938)
Integrated into release/v3.8.38 (leva 5)
* fix(oauth): support Kiro IDC (organization) token import (#4944)
Integrated into release/v3.8.38 (leva 5)
* fix(translator): preserve cache_control for DashScope OpenAI-compat providers (port from 9router#2069) (#5013)
Integrated into release/v3.8.38 (leva 5)
* fix(tts): resolve Gemini TTS models from catalog (#4934)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): don't cool down the connection on a self-inflicted upstream timeout (504) (#5064)
Integrated into release/v3.8.38 (leva 5)
* fix(sse): robust Anthropic /v1/messages streaming — real ping keepalive + client-disconnect guard (#5063)
Integrated into release/v3.8.38 (leva 5)
* feat(video): add Alibaba DashScope (wan2.7-t2v) provider (#5051)
Integrated into release/v3.8.38 (leva 5)
* fix: preserve model hidden flags (isHidden) across model sync (#5086)
Integrated into release/v3.8.38 (leva 5)
* fix(models): derive model discovery config from registry modelsUrl (#5087)
Integrated into release/v3.8.38 (leva 5)
* fix(compression): replace fileURLToPath(import.meta.url) with runtime anchors for standalone bundle (#5089)
Integrated into release/v3.8.38 (leva 5)
* feat(cc): add summarized thinking display toggle (#5055)
Integrated into release/v3.8.38 (leva 5)
* Harden selected API error responses (#5032)
Integrated into release/v3.8.38 (leva 5)
* chore(quality): rebaseline file-size for leva 5 PR batch drift
6 frozen files grew from merged leva-5 PRs (cursor #4912, kiro #4911,
videoGeneration #5051, default #4727, base #4846, chat #5064); all covered
by per-PR tests. See _rebaseline_2026_06_26_leva5 in the baseline.
* feat(compression): compression playground (Play + Compare tabs) in the studio (#5080)
Integrated into release/v3.8.38
* fix(combo): fail over on empty-content 502 instead of exhausting the provider (#5085) (#5104)
* fix(dashboard): surface detailed credential-validation error in add-connection modal (#5088) (#5106)
* feat(providers): allow local/private provider URLs by default with scoped metadata-safe guard (#5066) (#5107)
* fix(diagnostics): treat non-streaming Claude messages shape as valid output (#5108) (#5116)
* fix(db): translate pt-BR SQLite driver-fallback log lines to English (#5103) (#5115)
* fix(sse): repair release base-reds — malformed-response false positives + header casing + stale tests (#5117)
Repairs the release/v3.8.38 base-reds; unblocks #5078.
* chore(quality): rebaseline file-size for responseSanitizer (#5117) + AddApiKeyModal drift
* fix(translator): forward image tool_result blocks as image_url (#5100)
Base-reds fixed (#5117); image tool_result→image_url. Integrated into release/v3.8.38.
* fix(responses): default text.format for openai-compatible responses providers (#5101)
Base-reds fixed (#5117); default text.format + file-size rebaseline. Integrated into release/v3.8.38.
* feat(dashboard): expose Fusion judgeModel + fusionTuning in the combo editor (#5074)
Base-reds fixed (#5117); Fusion editor + file-size rebaseline. Integrated into release/v3.8.38.
* feat(quota): add opt-in Codex/Claude auto-ping keepalive (#5102)
Base-reds fixed (#5117); auto-ping keepalive + file-size rebaseline. Integrated into release/v3.8.38.
* test(release): relocate 2 orphan test files into the collected flat tests/unit dir (#5120)
Unblocks Lint (test-discovery) on #5078. Integrated into release/v3.8.38.
* fix(translator): preserve reasoning-replay reasoning_content + repair 3 release-green test reds (#5122)
Repairs 3 release-green test reds + test-masking; unblocks #5078.
* test(golden): redact live Node version from provider translate-path snapshot (#5125)
Final golden unblock for #5078.
* test(golden): redact OmniRoute app version from translate-path snapshot (#5126)
Coverage shard golden unblock for #5078.
* Ignore disconnect races during in-band stream error handling (#5007)
Integrated into release/v3.8.38
* Track final connection IDs in failover logs (#5016)
Integrated into release/v3.8.38
* fix(sse): convert Gemini body to OpenAI format in antigravity MITM handler (#4845)
Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)
* feat(providers): add ZenMux Free session-cookie provider (#5105)
Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)
* feat(dashboard): click-to-edit model alias in provider page (#5119)
Integrated into release/v3.8.38 (rebased on tip, i18n scope verified, CHANGELOG re-injected)
* feat(mcp): web-session robustness — cookie dedup (PR6) + browser-pool observability (PR7) (#3368) (#5121)
Integrated into release/v3.8.38 (rebased on tip; cookie-dedup branch extracted to findExistingCookieConnection helper → complexity-neutral; CHANGELOG added)
* fix(usage): dedupe request-usage logging and debounce stats (#4940)
Integrated into release/v3.8.38 (rebased on tip; DB-handle hang was stale-base artifact — resetDbInstance already closes the handle, test green 5/5; file-size drift consolidated at release; CHANGELOG re-injected)
* fix(dashboard): key model visibility toggle on canonical providerId (#5091)
Integrated into release/v3.8.38 (retargeted main→release; .tsx visibility-key test green 2/2)
* chore(deps): bump actions/cache from 5.0.5 to 6.0.0 (#5112)
Integrated into release/v3.8.38 (retargeted main→release; workflow-only actions/cache bump — unit failures were stale main base-reds)
* fix(streaming): harden long OpenAI-compatible SSE streams (#5124)
Integrated into release/v3.8.38 (rebased on tip; streamHandler conflict with #5007 disconnect-guard resolved — both coexist, stream-handler 22/22 green)
* feat: Add Grok Build (xAI) provider with OAuth import-token flow (#5020)
Integrated into release/v3.8.38 (rebased on tip; Hard Rule #11 fix — Grok public client_id now via resolvePublicCred(grok_id), 3 literals removed; grok-oauth 7/7 + check:public-creds green)
* feat(providers): add Factory (factory.ai) as a subscription gateway provider (#5065)
Integrated into release/v3.8.38 (rebased on tip; added factory registry test for PR Test Policy + fixed check:env-doc-sync phantom FACTORY_API_KEY; factory loads in PROVIDERS, no Zod issue — that flag was a false positive)
* chore(test): reconcile golden snapshot + apikey count for new providers
#5020 (grok-cli), #5065 (factory), #5105 (zenmux-free) added providers but did
not regenerate tests/snapshots/provider/translate-path.json (now +3 entries) nor
bump the APIKEY_PROVIDERS count (159->160 for the factory gateway). Test-only
reconciliation; no production change.
* fix(resilience): harden quota and model lockout edge cases (#5093)
Integrated into release/v3.8.38 (rebased on tip). TRUST-BUT-VERIFY: dropped the PR's 0dd7df641 'fix unit gates' commit which reverted #5122 reasoning-replay (preserveReasoningContent) + re-introduced #4849 O(n^2) growth, and restored 5 tests it had realigned. Kept only the 3 declared resilience fixes (quota cutoff guard, gemini MIME, model-lockout maxCooldownMs); 23/23 green.
* Hydrate quota cache and scope auto combo candidates (#5015)
Integrated into release/v3.8.38 (rebased on tip). Kept core quota-cache hydration + auto-combo candidate scoping + combos UI; dropped out-of-scope toolCloaking refactor (conflicted with #4813 stripEnumDescriptions — took tip) and the unrelated sse-auth test split. Added quota-cache-hydrate-5015 regression test (Rule #18); combo-account-allowlist 8/8 + hydration 2/2 green.
* chore(quality): reconcile complexity + file-size baselines for v3.8.38 owner-PR batch
complexity 1972->1978 (+6) and file-size providers.ts 1093->1107 / usageHistory.ts
934->983 — drift from the /review-prs merge batch (#4845/#5105/#5020/#4940/#5093/
#5015 + #5121 cookie-dedup helper extraction). check:complexity/check:file-size do
not run on the PR->release fast-path, so the branch accrued unmeasured; all legit
feature/fix growth, not regression. See per-key justifications in each baseline.
* fix(security): exact-host Anthropic baseUrl check (CodeQL js/incomplete-url-substring-sanitization #674) (#5130)
The anthropic-compatible Bearer-fallback gate decided whether a configured baseUrl
targeted the official api.anthropic.com host via a substring `.includes("api.anthropic.com")`.
A look-alike upstream such as `https://api.anthropic.com.evil.test` or
`https://evil.test/?x=api.anthropic.com` matched the substring and was wrongly treated as
official, suppressing the Bearer fallback meant for third-party gateways
(CodeQL #674, js/incomplete-url-substring-sanitization, high).
Replace the substring test with an exported `isOfficialAnthropicBaseUrl()` helper that
parses the URL and compares the hostname for exact equality. Empty baseUrl stays official;
scheme-less hosts are parsed with an assumed https://; an unparseable baseUrl falls back to
third-party (Bearer emitted) as the safer default. Behavior for legitimate official/third-party
baseUrls is unchanged.
Adds tests/unit/anthropic-official-baseurl-host.test.ts covering official, look-alike,
scheme-less, and unparseable inputs plus a static guard that the substring pattern is gone.
* fix(proxy): repair one-click Deno & Cloudflare relay deployments (#5128) (#5132)
* fix(services): embed WS proxy honours LIVE_WS_HOST; reject empty messages early (#5110) (#5133)
* fix(api): resolve /v1/models/{id} case-insensitively (#5082) (#5135)
* fix(providers): add MiniMax M3 & Nemotron 3 Ultra to Cline catalog (#3321) (#5136)
* fix(proxy): make SOCKS5 handshake timeout tunable via SOCKS_HANDSHAKE_TIMEOUT_MS (#5109) (#5137)
* feat(sidebar): add support for colored menu icons (#3812)
Integrated into release/v3.8.38 (recreated on tip — fork had unrelated history; added getSidebarIconAccent regression test, Rule #18). Clean 2-file UI feature.
* fix(providers): complete grok-cli OAuth wiring + zenmux-free web-session metadata
Base-red repair for #5020 (grok-cli) and #5105 (zenmux-free), surfaced by the
full CI on the release PR (#5078) — the PR->release fast-path does not run the
oauth-providers-config / web-session-credentials / provider-consistency gates.
- grok-cli: register in OAUTH_PROVIDERS (providers.ts canonical list, fixes
check:provider-consistency), add OAUTH_PROVIDER_IDS.GROK_CLI + GROK_CLI_CONFIG
in oauth constants (provider config now sourced there, not a local literal),
align oauth-providers-config.test.ts (EXPECTED_PROVIDER_KEYS + config map).
- zenmux-free: declare its web-session credential requirement (full Cookie header)
in WEB_SESSION_CREDENTIAL_REQUIREMENTS.
Local: oauth-providers-config 27/27, web-session-credentials 4/4, grok-cli-oauth
7/7, check:provider-consistency OK, +115 OAUTH_PROVIDERS tests green.
* Fix resilience settings page response mapping (#5139)
Integrated into release/v3.8.38. Thanks @rdself for the fix and the regression test.
* fix(kiro): retire claude-sonnet-4.5 from catalog + pin 400 model-unavailable test (#5140)
Extracted the real change from #5140 (the bot PR regenerated the entire
freeModelCatalog.data.ts + touched package-lock.json; only the targeted
edits are kept here):
- remove claude-sonnet-4.5 from the Kiro registry entry
- remove the matching kiro free-model catalog row
- pin Kiro's verbatim 400 "Invalid model..." to isModelUnavailableError
Closes #4484
* fix(sidebar): drop orphan `settings` accent color (typecheck:core red) (#5142)
SIDEBAR_ICON_ACCENTS is typed Partial<Record<HideableSidebarItemId, string>>,
but `settings` is not a hideable item id (only `settings-general`,
`settings-appearance`, … and `context-settings` exist; there is no item with
`id: "settings"`), so the accent was unreachable. It broke `typecheck:core`
on the release tip ("'settings' does not exist in type …", introduced by
#3812 colored menu icons). Removing the orphan key restores a clean
typecheck:core (rc=0).
* feat: salvage batch 2 — diagnostics null-guard (#5096) + observed quota reset windows (#5025) (#5141)
* fix(diagnostics): null-guard content blocks in detectMalformedNonStream
A null (or non-object) entry in a Claude-native `content` array made the
non-stream classifier throw `TypeError: Cannot read properties of null
(reading 'type')`, crashing the malformed-response detection path. Guard
before type-asserting each block: a null/non-object block is simply skipped.
Two regression tests added (null block among valid blocks → null; only-null
blocks → empty_choices).
Salvaged from closed PR #5096 (base-stale; only the defensive guard — the
Claude-shape recognition it also carried already landed via #5108).
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
* feat(quota): persist observed provider quota reset windows
Adds `provider_quota_reset_events` (migration 108) + `db/quotaResetEvents.ts`
to record real upstream weekly-quota window transitions whenever a quota
refresh shows the reset rolling to a new cycle (different day, later resetAt).
`apiKeyUsageLimits` now prefers the observed window start over the inferred
`resetAt − 7d`, falling back to snapshot inference when no event is recorded
yet. `quotaCache.setQuotaCache` records the transition opportunistically.
`recordProviderQuotaResetEventIfChanged` only fires for the primary weekly
window (not daily/sonnet), is idempotent (INSERT OR IGNORE on the unique
window key), and no-ops when the reset didn't actually roll. 4 unit tests
(tests/unit/lib/quota-reset-events.test.ts).
Salvaged from closed PR #5025 (which bundled this with two unrelated
features + a colliding migration 104). Renumbered to 108; module re-exported
from localDb (Rule #2).
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
---------
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
* docs(i18n): sync 3.8.38 CHANGELOG section to 41 mirrors (unblock docs-accuracy) (#5144)
The root CHANGELOG [3.8.38] section grew with this cycle's merged PRs, but the
docs/i18n/<lang>/CHANGELOG.md mirrors were not re-synced — drifting >25% in body
size and failing check:docs-sync (the "Docs accuracy" fast-gate step) for every
open PR against the release.
Ran scripts/release/sync-changelog-i18n.mjs 3.8.38 3.8.37 to copy the root
[3.8.38] section into all 41 mirrors. check:docs-all now passes (exit 0).
Sections are copied verbatim; the per-language translation pass runs at release
time via i18n:run — this only restores the size-sync the gate enforces.
* feat(compression): pure per-step fidelity checker (4 invariants, fail-open)
* feat(compression): fidelityGate config + rejected breakdown fields
* feat(compression): wire per-step fidelity gate into stacked pipeline (opt-in)
* feat(compression): preview route accepts fidelityGate flag (playground)
* feat(compression): playground fidelity-gate toggle + lane rejection display
* docs(compression): note fidelityGate advanced thresholds are intentionally API-omitted
* refactor(compression): extract fidelity-gate step helpers to shrink strategySelector (file-size gate)
bodyToText and gateAdvance moved to fidelityGateStep.ts; StackAccumulator exported.
strategySelector: 889->854 (-35). Residual +6 vs pre-Milestone-B frozen 848 is the
irreducible StackOptions.fidelityGate field + two stacked-loop dispatch reads + import.
Baseline updated to 854 with justification. No cycle introduced (import type only).
940 compression tests pass; typecheck clean.
* test(usage): wire usageHistoryDedup under unit runner brace-list (#5145)
Integrated into release/v3.8.38.
* feat: salvage batch from closed stale PRs (#5038, #5057, #5076) (#5138)
Integrated into release/v3.8.38.
* test(combo): deterministic routing-decision matrix for all 17 strategies (#5146)
Integrated into release/v3.8.38.
* feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass + playground toggle) (#5143)
Integrated into release/v3.8.38.
* chore(quality): rebaseline file-size for sidebarVisibility.ts + chat.ts drift (#5147)
Mid-cycle drift on release/v3.8.38 from already-merged PRs that the fast-path
(PR->release skips check:file-size) let accumulate without a bump:
- src/shared/constants/sidebarVisibility.ts 1100->1198 (#3812 colored menu
icons, per-item accent map; #5142 dropped one orphan, net still above frozen)
- src/sse/handlers/chat.ts 1560->1575 (#5064 self-inflicted-timeout cooldown
skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS
LIVE_WS_HOST honour / early empty-message reject)
Each covered by its own PR tests; structural shrink of chat.ts tracked in #3501.
Unblocks the Fast Quality Gates for PRs targeting release/v3.8.38.
* chore(release): finalize v3.8.38 CHANGELOG + cycle reconciliation
- Reconcile [3.8.38]: +18 bullets (compression fidelity-gate/fuzzy-dedup #5143,
quota keepalive #5102, web-session robustness #5121, MiniMax/Nemotron #5136,
model-visibility #5091, failover logs #5016, disconnect races #5007, sidebar
orphan #5142, SRE playbooks salvage #5138, new Security #5130 + Maintenance roll-up)
- Credit salvaged-PR authors (@JxnLexn / @KooshaPari / @herjarsa / @Witroch4)
- Remove phantom bullet for CLOSED-not-merged #5092 (setup aggregator never landed)
- Fix isHidden bullet PR citation #4389 -> #5086 (@herjarsa)
- Back-fill forgotten v3.8.36 bullet: #5026 crypto.randomUUID ID-gen (@hamsa0x7)
- Sync 41 i18n CHANGELOG mirrors; README What's New -> v3.8.38
- Rebaseline cycle drift: eslint 3987->4002, cognitive 833->841, dead-exports
345->346, cyclomatic 1978->1980 (file-size handled by #5147)
* fix(i18n): add missing English UI labels (#5153)
Integrated into release/v3.8.38
* Preserve non-stream reasoning fields for compatible clients (#5155)
Integrated into release/v3.8.38
* feat(compression): ionizer engine — lossy JSON-array sampling reversible via CCR (#5148)
Integrated into release/v3.8.38
* test(combo): gated live smoke for combo strategies (in-process + VPS HTTP) (#5151)
Integrated into release/v3.8.38
* test: refresh release expectations to match current code (#5150)
Integrated into release/v3.8.38 (test-only base-red alignment extracted from #5150)
---------
Co-authored-by: Éder Costa <eder.almeida.costa@gmail.com>
Co-authored-by: José Victor Ferreira <root@josevictor.me>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: fulorgnas <46461624+fulorgnas@users.noreply.github.com>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
Co-authored-by: R. Beltran <rbeltran8000@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Ramel Tecnologia - Rafa Martins <146174365+rafacpti23@users.noreply.github.com>
Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
1577 lines
59 KiB
TypeScript
1577 lines
59 KiB
TypeScript
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;
|