Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions scripts/__tests__/cursor-sdk-local-agent-bridge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
localAgentCreateOptions,
localAgentSendOptions,
isForwardableSDKToolCall,
isOpaqueSDKRunFailure,
isRetryableSDKRunError,
isStaleSdkAuthFailure,
normalizeModel,
normalizeSDKToolCall,
openAiError,
Expand All @@ -36,6 +38,28 @@ describe("Cursor SDK local-agent bridge", () => {
expect(isRetryableSDKRunError({ status: 401, message: "Unauthorized" })).toBe(false)
})

it("treats stale SDK auth token failures as retryable process recovery, not bad API keys", () => {
const staleMessage = "Authentication error If you are logged in, try logging out and back in."
expect(isStaleSdkAuthFailure(new Error(staleMessage))).toBe(true)
expect(isStaleSdkAuthFailure({ code: "ERROR_NOT_LOGGED_IN" })).toBe(true)
expect(isRetryableSDKRunError(new Error(staleMessage))).toBe(true)
expect(isRetryableSDKRunError({ code: "ERROR_NOT_LOGGED_IN", isRetryable: false })).toBe(true)
expect(
sdkRunFailureSummary({
status: "error",
error: { message: staleMessage, code: "ERROR_NOT_LOGGED_IN" },
}),
).toMatchObject({
message: staleMessage,
retryable: true,
})
expect(statusFromError(new Error(staleMessage))).toBe(503)
expect(
statusFromError(Object.assign(new Error(staleMessage), { name: "AuthenticationError" })),
).toBe(503)
expect(isRetryableSDKRunError(new Error("Missing or invalid authorization"))).toBe(false)
})

it("treats opaque SDK error results as retryable but preserves explicit auth failures", () => {
expect(sdkRunFailureSummary({ status: "error" })).toMatchObject({
message: "",
Expand Down Expand Up @@ -63,6 +87,36 @@ describe("Cursor SDK local-agent bridge", () => {
})
})

it("detects opaque SDK run failures that need agent resume or bridge restart", () => {
const opaque = Object.assign(new Error("Cursor SDK run failed"), {
code: "cursor_sdk_error",
isRetryable: true,
rawMessage: "",
cause: { status: "error", code: "", message: "", retryable: true },
})
expect(isOpaqueSDKRunFailure(opaque)).toBe(true)

const capacity = Object.assign(new Error("Server at capacity"), {
code: "cursor_sdk_error",
isRetryable: true,
rawMessage: "Server at capacity",
cause: {
status: "error",
code: "unavailable",
message: "Server at capacity",
retryable: true,
},
})
expect(isOpaqueSDKRunFailure(capacity)).toBe(false)

const auth = Object.assign(new Error("Missing or invalid authorization"), {
code: "unauthorized",
isRetryable: false,
status: 401,
})
expect(isOpaqueSDKRunFailure(auth)).toBe(false)
})

it("surfaces Cursor SDK authentication failures as unauthorized API errors", () => {
const error = Object.assign(new Error("Error"), {
name: "AuthenticationError",
Expand Down
141 changes: 133 additions & 8 deletions scripts/cursor-sdk-local-agent-bridge.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ const agentRunQueues = new Map()
/** @type {Map<string, Set<unknown>>} */
const activeClientToolCaptures = new Map()
const forceNextRunAgentKeys = new Set()
/** @type {Map<string, string>} */
const pendingResumeAgentIds = new Map()
const opaqueFailureRestartThreshold = parseInteger(
process.env.CURSOR_SDK_BRIDGE_OPAQUE_FAILURE_RESTART,
1,
)
const timeoutSettleMs = parseInteger(process.env.CURSOR_SDK_BRIDGE_TIMEOUT_SETTLE_MS, 2_000)
let consecutiveOpaqueFailures = 0
let bridgeRestartScheduled = false
let server = null

if (isMainModule()) {
Expand All @@ -52,7 +61,9 @@ export {
localAgentCreateOptions,
localAgentSendOptions,
isForwardableSDKToolCall,
isOpaqueSDKRunFailure,
isRetryableSDKRunError,
isStaleSdkAuthFailure,
normalizeSDKToolCall,
normalizeModel,
openAiError,
Expand Down Expand Up @@ -211,6 +222,7 @@ async function runLocalAgentUnlocked(input, onEvent) {
let activeRun = null
let emittedEvent = false
let timer = null
let timedOut = false
const emit = onEvent
? (event) => {
emittedEvent = true
Expand All @@ -226,6 +238,7 @@ async function runLocalAgentUnlocked(input, onEvent) {
)
const timeout = new Promise((_resolve, reject) => {
timer = setTimeout(() => {
timedOut = true
const error = new HttpError("Cursor SDK bridge run timed out.", 504, "cursor_sdk_timeout")
reject(error)
if (activeRun) {
Expand All @@ -235,13 +248,35 @@ async function runLocalAgentUnlocked(input, onEvent) {
})

try {
return await Promise.race([work, timeout])
const output = await Promise.race([work, timeout])
noteSdkRunSuccess()
return output
} catch (error) {
work.catch(() => {})
if (timedOut) {
// Keep the per-agent exclusive lock until the abandoned run settles, or
// the next send() on the same cached agent races a still-live run.
if (activeRun) activeRun.cancel().catch(() => {})
evictCachedAgent(input, { resume: true })
await Promise.race([work.catch(() => {}), sleep(timeoutSettleMs)])
} else {
work.catch(() => {})
}
const shouldRetry = attempt < maxRunRetries && !emittedEvent && isRetryableSDKRunError(error)
if (!shouldRetry) throw error
if (!shouldRetry) {
noteSdkRunFailure(error)
if (!timedOut) {
evictCachedAgent(input, {
resume: isOpaqueSDKRunFailure(error) || isStaleSdkAuthFailure(error),
})
}
throw error
}
if (activeRun) activeRun.cancel().catch(() => {})
evictCachedAgent(input)
// Opaque errors and stale exchanged-token auth both recover via resume/restart,
// not by treating the caller's API key as invalid.
evictCachedAgent(input, {
resume: isOpaqueSDKRunFailure(error) || isStaleSdkAuthFailure(error),
})
console.warn(
`Retrying Cursor SDK run after retryable upstream error (${attempt + 1}/${maxRunRetries}).`,
)
Expand Down Expand Up @@ -384,12 +419,30 @@ async function getAgent(input) {
return { agent: cached.agent, cacheKey, cached: true }
}

const agent = await Agent.create(localAgentCreateOptions(input))
const agent = await createOrResumeAgent(input, cacheKey)
agentCache.set(cacheKey, { agent, touchedAt: Date.now() })
evictAgents()
return { agent, cacheKey, cached: false }
}

async function createOrResumeAgent(input, cacheKey) {
const options = localAgentCreateOptions(input)
const resumeId = pendingResumeAgentIds.get(cacheKey)
if (resumeId) {
pendingResumeAgentIds.delete(cacheKey)
try {
// Cursor's recommended recovery for bare local-agent status=error:
// close the handle, then Agent.resume(agentId) instead of create().
return await Agent.resume(resumeId, options)
} catch (error) {
console.warn(
`Cursor SDK Agent.resume failed after opaque error; falling back to Agent.create (${error?.message || error}).`,
)
}
}
return Agent.create(options)
}

function evictAgent(cacheKey, agent) {
const cached = agentCache.get(cacheKey)
if (cached?.agent === agent) {
Expand All @@ -401,10 +454,17 @@ function evictAgent(cacheKey, agent) {
} catch {}
}

function evictCachedAgent(input) {
function evictCachedAgent(input, options = {}) {
const cacheKey = agentCacheKey(input)
const cached = agentCache.get(cacheKey)
if (cached) evictAgent(cacheKey, cached.agent)
if (!cached) return
const agentId = cached.agent?.agentId
if (options.resume === true && typeof agentId === "string" && agentId) {
pendingResumeAgentIds.set(cacheKey, agentId)
} else {
pendingResumeAgentIds.delete(cacheKey)
}
evictAgent(cacheKey, cached.agent)
}

function registerActiveClientToolCapture(cacheKey, handler) {
Expand Down Expand Up @@ -2550,6 +2610,9 @@ function isBenignPipeError(error) {
}

function isRetryableSDKRunError(error) {
// SDK marks stale local-agent token/connection failures as non-retryable auth,
// but resume/restart recovers them with the same API key.
if (isStaleSdkAuthFailure(error)) return true
const values = flattenErrorValues(error)
if (values.some((value) => value?.isRetryable === true)) return true
if (
Expand Down Expand Up @@ -2582,6 +2645,18 @@ function isRetryableSDKRunError(error) {
)
}

function isStaleSdkAuthFailure(error) {
return flattenErrorValues(error).some((value) => {
const message = String(value?.message || value?.rawMessage || value?.error || "").toLowerCase()
const code = String(value?.code || "").toLowerCase()
return (
code === "error_not_logged_in" ||
message.includes("error_not_logged_in") ||
message.includes("try logging out and back in")
)
})
}

function sdkRunFailureError(result) {
const summary = sdkRunFailureSummary(result)
const error = new HttpError(
Expand All @@ -2592,7 +2667,15 @@ function sdkRunFailureError(result) {
error.rawMessage = summary.message
error.isRetryable = summary.retryable
error.cause = summary
console.warn(`Cursor SDK run returned error status${summary.code ? ` (${summary.code})` : ""}.`)
const detailParts = []
if (summary.code) detailParts.push(`code=${summary.code}`)
if (summary.message) detailParts.push(`message=${summary.message}`)
detailParts.push(summary.retryable ? "retryable" : "non-retryable")
if (result?.id) detailParts.push(`run=${result.id}`)
const detail = detailParts.join(", ")
console.warn(
`Cursor SDK run returned error status${detail ? ` (${detail})` : " (opaque, no code/message)"}.`,
)
return error
}

Expand All @@ -2614,6 +2697,46 @@ function sdkRunFailureSummary(result) {
}
}

function isOpaqueSDKRunFailure(error) {
if (!error || error.isRetryable !== true || isAuthenticationSDKError(error)) return false
const summary = isRecord(error.cause) ? error.cause : null
if (summary?.status === "error" && !summary.message && !summary.code) return true

const message = firstNonEmptyString(error.rawMessage, error.message)
if (message && message !== "Cursor SDK run failed") return false
if (error.code && error.code !== "cursor_sdk_error") return false
// Generic cursor_sdk_error with no upstream code is treated as opaque.
return !firstNonEmptyString(summary?.code)
}

function noteSdkRunSuccess() {
consecutiveOpaqueFailures = 0
}

function noteSdkRunFailure(error) {
if (isOpaqueSDKRunFailure(error) || isStaleSdkAuthFailure(error)) {
consecutiveOpaqueFailures += 1
if (consecutiveOpaqueFailures < opaqueFailureRestartThreshold) return
const kind = isStaleSdkAuthFailure(error)
? "stale SDK auth token/connection"
: "opaque Cursor SDK error status"
scheduleBridgeRestart(
`${kind} x${consecutiveOpaqueFailures} (process restart recovers; API key is usually still valid)`,
)
return
}
consecutiveOpaqueFailures = 0
}

function scheduleBridgeRestart(reason) {
if (bridgeRestartScheduled || !isMainModule()) return
bridgeRestartScheduled = true
console.error(`Restarting Cursor SDK bridge: ${reason}`)
setTimeout(() => {
void closeAndExit(1)
}, 50).unref?.()
}

function firstRecord(...values) {
return values.find((value) => isRecord(value)) || {}
}
Expand Down Expand Up @@ -2694,6 +2817,8 @@ function codeFromError(error, status) {
}

function isAuthenticationSDKError(error) {
// Stale exchanged-token failures look like auth but the API key is still valid.
if (isStaleSdkAuthFailure(error)) return false
return flattenErrorValues(error).some((value) => {
const name = String(value?.name || "").toLowerCase()
const code = String(value?.code || "").toLowerCase()
Expand Down
28 changes: 20 additions & 8 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,17 +63,29 @@ async function startBridge(): Promise<BridgeSetup> {
type SpawnedProcess = ReturnType<typeof Bun.spawn>
function spawnBridgeSubprocess(runtime: string): () => void {
let stopped = false
let child = Bun.spawn([runtime, bridgeScript], { stdout: "inherit", stderr: "inherit" })
const bridgeHost = process.env.CURSOR_SDK_BRIDGE_HOST || "127.0.0.1"
const bridgePort = process.env.CURSOR_SDK_BRIDGE_PORT || "8792"
const healthUrl = `http://${bridgeHost}:${bridgePort}/health`
const spawnChild = (): SpawnedProcess =>
Bun.spawn([runtime, bridgeScript], { stdout: "inherit", stderr: "inherit" })

const superviseExit = (proc: SpawnedProcess) => {
void proc.exited.then((code) => {
let child = spawnChild()

function superviseExit(proc: SpawnedProcess): void {
void proc.exited.then(async (code) => {
if (stopped) return
console.error(`SDK bridge exited with code ${code}; restarting in 1s.`)
setTimeout(() => {
if (stopped) return
child = Bun.spawn([runtime, bridgeScript], { stdout: "inherit", stderr: "inherit" })
superviseExit(child)
}, 1000)
await new Promise((resolve) => setTimeout(resolve, 1000))
if (stopped) return
child = spawnChild()
superviseExit(child)
try {
await waitForBridgeHealth(healthUrl)
console.error("SDK bridge restarted and is healthy.")
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`SDK bridge failed to become healthy after restart: ${message}`)
}
})
}
superviseExit(child)
Expand Down