mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
* chore(release): open v3.8.37 development cycle * chore(ci): harden release flow — ratchet decoupling, fast-path drift gates, build-scope guard, heap default (#5054) Implements improvements 1-4 from the v3.8.36 release benchmark (_tasks/release-bench/v3.8.36/PLANO-MELHORIA.md): 1. Quality Ratchet decoupled from flaky coverage (ci.yml): the shard→coverage→ratchet chain meant a single flaky Coverage Shard SKIPPED the whole Quality Ratchet on the release PR (v3.8.36 #4854), so cycle drift only surfaced post-merge in #5029. The job now runs on !cancelled(); coverage download is continue-on-error and the ratchet runs --allow-missing, so the DETERMINISTIC gates (eslint/complexity/cognitive/duplication/ codeql) stay blocking even when coverage is unavailable. 2. Fast-path drift gates (quality.yml PR→release): added check:complexity, check:cognitive-complexity, and a new lightweight check:pack-policy (pack-artifact unexpected-files check WITHOUT a build, via --policy-only) so drift + stray-tarball-file regressions are caught/rebaselined PER-PR instead of cascading onto the release PR. 3. Build heap default 4096→8192 MB (build-next-isolated.mjs): the clean graph peaks ~3.9 GB and brushed the old 4 GB ceiling; 8 GB gives headroom. Comment notes heap is NOT the fix for a poisoned scope (run check:build-scope instead). 4. check:build-scope gate (new): fails if .ts/.tsx/.js/.jsx files in the tsconfig scope exceed a threshold — catches worktrees/cruft leaking into the build scope (the v3.8.36 OOM root cause: 355,215 vs 4,547 files) BEFORE it detonates next build. Wired into the fast-path. * fix(auth): only trust forwarding headers from loopback TCP peers (#4689) Integrated into release/v3.8.37 — loopback-gated forwarding headers (IP spoofing fix). Cherry-picked onto current release tip; ipUtils.test.ts 9/9 green. * fix(codex): treat OAuth 401 as unrecoverable refresh failure (#4686) Integrated into release/v3.8.37 — codex OAuth 401 treated as unrecoverable refresh. Cherry-picked onto release tip; token-refresh-service.test.ts 38/38 green. * fix(translator): preserve reasoning_effort for non-Copilot Responses clients (#4688) Integrated into release/v3.8.37 — preserve reasoning_effort for non-Copilot Responses clients. Cherry-picked onto release tip; tests 47/47 green. * fix(translator): coerce tool descriptions to strings in OpenAI normalization (#4675) Integrated into release/v3.8.37 — coerce tool descriptions to strings in OpenAI normalization. Cherry-picked onto release tip; tests 3/3 green. * feat(sse): x-omniroute-strip-reasoning header to drop reasoning_content (#4678) Integrated into release/v3.8.37 — x-omniroute-strip-reasoning header. Cherry-picked onto release tip (resolved chatCore.ts/headers.ts adjacency conflict, kept resolveCompressionHeader + isStripReasoningRequested); tests 8/8 green. * fix(combo): flatten Anthropic tool messages + tool history to prevent upstream 503 (#4648) Integrated into release/v3.8.37 — flattenToolHistory helper (combo anti-503). Cherry-picked onto release tip; tests 9/9 green. * feat(headroom): proxy lifecycle management + dashboard UI (Docker sidecar supported) (#4649) Integrated into release/v3.8.37 — headroom proxy lifecycle (status/start/stop, local-only + spawn-capable per Rules #15/#17). Cherry-picked onto release tip; lifecycle 7/7 + route-guard 43/43 + check:cycles green. * feat(cli): multi-model support for Factory Droid CLI (#4682) Integrated into release/v3.8.37 — Factory Droid multi-model support. Cherry-picked onto release tip (kept readJsoncConfig + droidCustomModels imports); droid-custom-models 11/11 green. * fix(providers): require Default Model in compatible-provider API-key setup (#4641) Integrated into release/v3.8.37 — require Default Model in compatible-provider API-key setup. Cherry-picked fix + test-move onto release tip (kept release providerSpecificData + QuotaScrapingFields; fixed moved-test import path; baseline rebaseline unneeded, 865<866); UI test 2/2 green. * fix(dashboard): stop double-masking already-masked API key in list (E2E 3/9 regression) (#4671) Integrated into release/v3.8.37 — render server-masked key verbatim (drop redundant maskKey call). Note: release's maskKey already guards '****' (since v3.8.34), so this is a safe simplification; added a contract test pinning the **** passthrough invariant (2/2 green, would fail against the pre-guard maskKey = the historical double-mask bug). * chore(quality): rebaseline file-size for rc17 PR batch drift Own growth from the merged rc17 PRs (#4678/#4686/#4688) at existing chokepoints — cohesive, not extractable: - open-sse/handlers/responseSanitizer.ts 1103->1122 (SanitizeOpenAIResponseOptions + stripReasoning, #4678) - open-sse/services/tokenRefresh.ts 2070->2090 (codex 401 unrecoverable-refresh guard, #4686) - tests/unit/token-refresh-service.test.ts 1322->1353 (401 regression case, #4686) - tests/unit/translator-openai-responses-req.test.ts 1047->1050 (reasoning_effort assertion, #4688) * docs(env): document HEADROOM_URL in .env.example + ENVIRONMENT.md The headroom proxy lifecycle (#4649) reads HEADROOM_URL (src/lib/headroom/detect.ts, default http://localhost:8787) but it was missing from the env contract, tripping check:env-doc-sync. Adds the var to both .env.example (commented, has a default) and the Proxy Health table in ENVIRONMENT.md. * fix(sse): stream writer mock abort() returns a Promise (#4788) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(cli): fall back to default data dir when DATA_DIR is not writable (#4767) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(oauth): verify Cursor installation on Linux before auto-import (#4770) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): track Ollama streaming usage from raw NDJSON chunks (#4754) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): strip enumDescriptions from antigravity tool schema (#4740) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): include low-level cause details in formatProviderError (#4741) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(translator): strip x-anthropic-billing-header in claude-to-openai (#4728) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): gate Kiro image attachments behind a Claude-capability check (#4763) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): read Antigravity usage from the response.usageMetadata envelope (#4785) Integrated into release/v3.8.37 — Antigravity response.usageMetadata envelope. Cherry-picked onto release tip (resolved test-tail adjacency with #4754 Ollama block); usage-extractor 23/23 green. * fix(api): fall back to existing access token for any OAuth provider on refresh failure (#4786) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(cli): verify launchd registration + skip self-SIGTERM in macOS autostart (#4765) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(executors): anthropic-compatible-* gateways get Bearer alongside x-api-key (#4729) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): json_schema fallback for OpenAI-compatible providers (#4766) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): use workos auth token shape for cline (#4787) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * feat(sse): parse Gemini CLI 429 retryDelay from structured RetryInfo (#4738) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; tests green. * fix(sse): finalize tool_calls finish_reason on early stream end in OpenAI Responses translator (#4764) Integrated into release/v3.8.37 — computeFinishReason finalizes tool_calls on early stream end (Responses translator). Cherry-picked onto release tip; responses-translation-fixes 29/29 green. * test(sse): golden-lock provider.ts translate-path across all providers (#4734) Integrated into release/v3.8.37 — golden-lock for provider.ts translate-path. Cherry-picked onto release tip; snapshot regenerated against the current provider set (UPDATE_GOLDEN=1, 167 entries); golden test 3/3 deterministic. * chore(quality): rebaseline file-size for rc17 leva2 PR batch drift Own growth from the merged leva2 PRs (cohesive, not extractable): - src/lib/usage/providerLimits.ts 950->955 (#4786) - open-sse/executors/default.ts NEW frozen @828 (#4729 + #4766 + #4787 header branches) - open-sse/translator/request/openai-to-kiro.ts 807->814 (#4763) - open-sse/translator/response/openai-responses.ts 923->937 (#4764) - tests/unit/executor-default-base.test.ts 1339->1440 (#4766) - tests/unit/translator-openai-to-kiro.test.ts 918->980 (#4763) * fix(dashboard): align Engine Combos editor engines with API schema (#4955) (#5062) The named-combos pipeline dropdown offered four engines (headroom, session-dedup, ccr, llmlingua) that stackedPipelineStepSchema rejects, so selecting one made PUT /api/context/combos/[id] return HTTP 400 while saveCombo swallowed the non-OK response (if (!res.ok) return). Editing the default 'Standard Savings' combo and changing an engine reproduced the 400. - Add canonical STACKED_PIPELINE_ENGINE_INTENSITIES next to the schema as the single source of truth; the client dropdown imports it so it can never drift from the discriminated union the API validates against. - Surface save errors and empty-name/empty-pipeline validation in the editor instead of failing silently. - Add a parity unit test asserting the UI engine map equals the schema union and that every (engine, intensity) the UI emits is accepted. * fix(sse): filter nameless hosted tools when converting Responses API to Chat format (#4789) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(dashboard): keep desktop sidebar visible via explicit CSS class (#4812) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip enumDescriptions from Antigravity tool schemas (#4813) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(dashboard): resolve passthrough model aliases by providerId in ModelSelectModal (#4815) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(oauth): allow per-connection refresh lead-time override via providerSpecificData.refreshLeadMs (#4818) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip X-Stainless-* headers and normalize SDK User-Agent for OpenAI-compatible endpoints (#4820) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip Gemini built-in tools when functionDeclarations present in Antigravity envelope (#4821) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(api): surface a Docker-localhost hint on provider-node validation connection errors (#4822) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): resolve bare model names to connection defaultModel before upstream calls (#4825) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(build): trace-include sql.js sql-wasm.wasm in standalone bundle (#4839) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip Composer <|final|> sentinel markers leaking after Composer reasoning (#4842) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(config): sync full SiliconFlow model list into registry (#4844) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): close reasoning before message content in Responses stream (#4848) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): reject unsupported Kiro [1m] context suffix (#4816) Integrated into release/v3.8.37 — cherry-picked onto release tip; test-tail conflict with #4763 resolved (kept both image + [1m] test blocks); CHANGELOG re-merged; 29/29 green. * fix(db): validate HuggingFace tokens via whoami-v2 auth probe (#4819) Integrated into release/v3.8.37 — defining commit re-homed onto the god-file-split validation module (validateHuggingFaceProvider in validation/openaiFormat.ts + map wiring); 115/115 green. * fix(sse): make anthropic-version default-guard case-insensitive (#4823) Integrated into release/v3.8.37 — conflict with #4729 Bearer-fallback resolved (kept both Bearer fallback + case-insensitive anthropic-version guard); 48/48 green. * fix(sse): sanitize Kiro tool schemas to avoid 400 "Improperly formed request" (#4847) Integrated into release/v3.8.37 — conflict in kiro-to-openai.ts resolved (kept release fallbackToolCallId + adopted #1375 toolNameMap remap); 7/7 green. * feat(sse): add GPT-4 to the GitHub Copilot provider (#4798) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(sse): add GPT-4o mini to GitHub Copilot provider (#4797) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(api): add MiniMax-M3 pricing row (#4814) Integrated into release/v3.8.37 — pricing row re-homed onto god-file-split pricing/regional.ts (pricing.ts is now a barrel); 4/4 green. * fix(cli): save runtime deps with --save-exact so a sibling install can't prune them (#4841) Integrated into release/v3.8.37 — trayRuntime conflict resolved (kept release SYSTRAY_SPEC + added --save-exact); 2/2 green. * fix(sse): preserve required fields in antigravity tool schemas (#4843) Integrated into release/v3.8.37 — conflict resolved (kept #4740/#4813 enumDescriptions strip + typed normalizeSchemaTypes, added required-preservation helpers; test-tail merged keeping both enumDescriptions + required tests); 7/7 green. * chore(quality): rebaseline file-size for rc17b leva3 PR batch drift * fix(sse): strip reasoning blobs from agentic context to prevent O(n^2) token growth (#4849) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): unwrap Qoder HTTP 200 SSE error envelope so fallback can trigger (#4850) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): strip temperature for Claude models with extended thinking (#4853) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): emit valid concatenable kiro tool_calls.arguments deltas (#4855) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(sse): add toggleable tool-source diagnostics (#4856) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): redact api key from the AUTH debug log in the chat handler (#4858) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): forward AI SDK image parts in Responses translator (#4859) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): resolve custom combos by id and case-insensitive name (#4446) (#4869) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): exclude WS bridge controller-closed error from provider breaker (#4602) (#4870) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(providers): add xAI Grok inbound translators and thinking patcher (#4910) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(embeddings): add dimensions override field to embedding combos (#4913) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * feat(oauth): Codex bulk-import endpoint — POST /api/oauth/codex/import (#4914) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(antigravity): retry transient upstream failures (#4941) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): surface malformed HTTP-200 upstream responses (#4942) Integrated into release/v3.8.37 — cherry-picked defining commit onto release tip; CHANGELOG re-merged; tests green. * fix(sse): normalize Codex custom tools (apply_patch) to { input: string } schema (#4862) Integrated into release/v3.8.37 — conflict in request/openai-responses.ts resolved (kept #4789 nameless-tool skip + added #1007 custom-tool {input:string} normalization); 48/48 green incl. #4789/#4859 regression. * fix(sse): dense, deterministic output ordering in Responses API response.completed (#4906) Integrated into release/v3.8.37 — manual integration with #4862 in response/openai-responses.ts (custom-tool funcItem + dense recordCompletedItem). Fixed a latent #4848 interaction: the close-reasoning-before-message guard force-closed <think>-tag reasoning prematurely, which dense output (#4906) then snapshotted as a partial buffer ("plan" vs "planning") — scoped the guard to native reasoning_content (!inThinking) in BOTH transformer + translator paths. Full Responses suite 203/203 green incl. #4848/#4862 regression. * feat(sse): auto-promote successful combo model to position #1 (#4852) Integrated into release/v3.8.37 — dropped the stale file-size-baseline.json hunk (re-derived against the rc17b rebaseline); code+test applied clean; 13/13 green. * feat(providers): add Pioneer AI (Fastino Labs) provider (#4909) Integrated into release/v3.8.37 — providers.ts apikey block re-homed onto god-file-split src/shared/constants/providers/apikey/frontier-labs.ts (inline APIKEY_PROVIDERS no longer exists); registry/pioneer + providers/index.ts applied clean; 6/6 green. * add DGrid AI gateway provider (#4931) Integrated into release/v3.8.37 — rebased the contributor's commit onto the release tip; providers.ts god-file-split conflict resolved by relocating the dgrid APIKEY_PROVIDERS entry into apikey/gateways.ts; CHANGELOG added. 7/7 green. Thanks @dgridOP! * chore(quality): rebaseline file-size for rc17b leva4 PR batch drift * docs(routing): sync combo strategy docs for Fusion (17 strategies) (#5067) Fusion (16th strategy, panel fan-out + judge synthesis) and headroom shipped but the strategy-count docs were stale (14/15) and omitted both. Update every combo-strategy reference to the canonical 17, add fusion + headroom to all strategy tables, and add a dedicated Fusion section to AUTO-COMBO.md documenting judgeModel / fusionTuning config + an example. - CLAUDE.md, README.md, FEATURES.md, RESILIENCE_GUIDE.md, ARCHITECTURE.md, OPEN_SSE_ARCHITECTURE.md, OMNIROUTE_VS_ALTERNATIVES.md, docs/README.md, request-pipeline.mmd: 14/15 -> 17, list fusion + headroom - docs/routing/AUTO-COMBO.md: strategy table + new Fusion strategy section - docs/openapi.yaml: add reset-window, headroom, fusion to the strategy enum * fix(oauth): classify /api/oauth/cursor/auto-import as local-only (route-guard) (#5070) The Cursor auto-import route runs execFile("which", ["cursor"]) to verify a local Cursor install before importing credentials — a child-process spawn. The check:route-guard-membership gate (Hard Rules #15/#17) flagged it as an unclassified spawn-capable route: reachable past the loopback gate, an RCE-via-tunnel surface (a leaked JWT over a tunnel could trigger the spawn). Classify the specific path in LOCAL_ONLY_API_PREFIXES so loopback enforcement runs unconditionally before any auth check. Scoped to the exact path — the rest of /api/oauth/ (browser redirect/callback flows) stays remote-reachable. TDD: added a failing-then-passing assertion in route-guard-local-prefix.test.ts (classification + an over-broadening guard proving sibling OAuth paths stay remote). check:route-guard-membership now reports 0 new gaps. * chore(release): v3.8.37 — 2026-06-26 --------- Co-authored-by: dgridOP <dgrid_op@outlook.com>
1474 lines
54 KiB
TypeScript
1474 lines
54 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 { 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;
|
||
};
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
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) {
|
||
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: "" });
|
||
}
|
||
// 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).
|
||
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;
|