cursor-opencode-provider 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -12
- package/dist/activity.d.ts +15 -0
- package/dist/activity.js +76 -0
- package/dist/context/build.js +0 -7
- package/dist/errors.d.ts +59 -0
- package/dist/errors.js +199 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +4 -0
- package/dist/language-model.d.ts +47 -4
- package/dist/language-model.js +617 -183
- package/dist/models.d.ts +7 -0
- package/dist/models.js +259 -79
- package/dist/plugin.js +35 -2
- package/dist/protocol/exec-variants.d.ts +24 -0
- package/dist/protocol/exec-variants.js +55 -0
- package/dist/protocol/messages.js +105 -25
- package/dist/protocol/request.d.ts +0 -2
- package/dist/protocol/request.js +0 -8
- package/dist/protocol/struct.js +1 -1
- package/dist/protocol/tool-call-bridge.js +2 -0
- package/dist/protocol/tools.d.ts +8 -1
- package/dist/protocol/tools.js +258 -32
- package/dist/session.d.ts +115 -8
- package/dist/session.js +362 -31
- package/dist/shell-timeout.d.ts +50 -0
- package/dist/shell-timeout.js +179 -0
- package/dist/transport/connect.d.ts +37 -1
- package/dist/transport/connect.js +679 -115
- package/package.json +6 -1
package/dist/language-model.js
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { bidiRunStream, normalizeAgentRunOrigin } from "./transport/connect.js";
|
|
4
|
+
import { bidiRunStream, CursorRunInterruptedError, normalizeAgentRunOrigin, } from "./transport/connect.js";
|
|
4
5
|
import { trace } from "./debug.js";
|
|
5
6
|
import { buildRunRequest, buildHeartbeat } from "./protocol/request.js";
|
|
6
7
|
import { decodeFramePayload } from "./protocol/framing.js";
|
|
7
8
|
import { decodeMessage } from "./protocol/messages.js";
|
|
8
9
|
import { parseExecServerMessage, buildToolCallPart, buildExecClientMessages, parseExecIdFromToolCallId, detectExecVariantField, buildRequestContextResult, buildMcpStateResult, } from "./protocol/tools.js";
|
|
10
|
+
import { describeCursorExecVariant } from "./protocol/exec-variants.js";
|
|
9
11
|
import { advertisedToolNamesFromDescriptors, extractExecDisplayCallId, extractProtobufSubmessage, listProtobufFieldNumbers, parseDisplayToolCall, resolveBridgedOpenCodeToolCall, } from "./protocol/tool-call-bridge.js";
|
|
10
12
|
import { handleKvServerMessage } from "./protocol/kv.js";
|
|
11
13
|
import { handleInteractionQuery, inspectInteractionQueryWire } from "./protocol/interactions.js";
|
|
12
14
|
import { getCheckpoint, setCheckpoint } from "./protocol/checkpoint.js";
|
|
13
15
|
import { conversationBlobCount } from "./protocol/blob-store.js";
|
|
14
16
|
import { bindConversationId, } from "./protocol/conversation-bind.js";
|
|
15
|
-
import { sessionManager } from "./session.js";
|
|
17
|
+
import { resolveContinuationPolicy, sessionManager, } from "./session.js";
|
|
18
|
+
import { CursorAuthError, CursorLocalCancellationError, CursorProtocolError, CursorProviderError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, isTransientGrpcStatus, retrySuppressedError, toCursorProviderError, } from "./errors.js";
|
|
16
19
|
import { readCache, cacheFilePath, resolveVariantParameters, paramsImplyMaxMode, extractCursorVariantParameters, resolveCursorWireModelId } from "./models.js";
|
|
17
20
|
import { buildRequestContext } from "./context/build.js";
|
|
18
21
|
import { opencodeGlobalCacheDir } from "./context/paths.js";
|
|
19
22
|
import { resolveAgentUrl } from "./agent-url.js";
|
|
20
23
|
import { CURSOR_API_HOST, CURSOR_COMPACTION_OPTION } from "./shared.js";
|
|
24
|
+
import { consumeCursorShellResult, registerCursorShellCall, } from "./shell-timeout.js";
|
|
21
25
|
let _availableModels;
|
|
22
26
|
// mtime of the cache file the last time we loaded it. Compared on each call
|
|
23
27
|
// so discoverModels' background refresh is picked up without a process restart.
|
|
@@ -32,6 +36,173 @@ const toolCatalogBySession = new Map();
|
|
|
32
36
|
// tool use instead of emitting exec requests. Rebase once on the next turn.
|
|
33
37
|
const postCompactionRebaseBySession = new Set();
|
|
34
38
|
export const MAX_TURN_STATE_SESSIONS = 256;
|
|
39
|
+
const DEFAULT_RETRY_POLICY = {
|
|
40
|
+
maxAttempts: 3,
|
|
41
|
+
baseDelayMs: 500,
|
|
42
|
+
maxDelayMs: 8_000,
|
|
43
|
+
};
|
|
44
|
+
const MAX_RETRY_ATTEMPTS = 10;
|
|
45
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
46
|
+
function retryInteger(name, value, fallback) {
|
|
47
|
+
const resolved = value === undefined ? fallback : value;
|
|
48
|
+
if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
49
|
+
throw new CursorProtocolError(`Cursor retry ${name} must be a positive integer`);
|
|
50
|
+
}
|
|
51
|
+
return resolved;
|
|
52
|
+
}
|
|
53
|
+
export function resolveRetryPolicy(options) {
|
|
54
|
+
if (options !== undefined && (options === null || typeof options !== "object" || Array.isArray(options))) {
|
|
55
|
+
throw new CursorProtocolError("Cursor retry options must be an object");
|
|
56
|
+
}
|
|
57
|
+
for (const key of Object.keys(options ?? {})) {
|
|
58
|
+
if (!["maxAttempts", "baseDelayMs", "maxDelayMs"].includes(key)) {
|
|
59
|
+
throw new CursorProtocolError(`Unknown Cursor retry option: ${key}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const maxAttempts = retryInteger("maxAttempts", options?.maxAttempts, DEFAULT_RETRY_POLICY.maxAttempts);
|
|
63
|
+
const baseDelayMs = retryInteger("baseDelayMs", options?.baseDelayMs, DEFAULT_RETRY_POLICY.baseDelayMs);
|
|
64
|
+
const maxDelayMs = retryInteger("maxDelayMs", options?.maxDelayMs, DEFAULT_RETRY_POLICY.maxDelayMs);
|
|
65
|
+
if (maxAttempts > MAX_RETRY_ATTEMPTS) {
|
|
66
|
+
throw new CursorProtocolError(`Cursor retry maxAttempts must be no greater than ${MAX_RETRY_ATTEMPTS}`);
|
|
67
|
+
}
|
|
68
|
+
if (baseDelayMs > MAX_RETRY_DELAY_MS || maxDelayMs > MAX_RETRY_DELAY_MS) {
|
|
69
|
+
throw new CursorProtocolError(`Cursor retry delays must be no greater than ${MAX_RETRY_DELAY_MS}ms`);
|
|
70
|
+
}
|
|
71
|
+
if (baseDelayMs > maxDelayMs) {
|
|
72
|
+
throw new CursorProtocolError("Cursor retry baseDelayMs must be no greater than maxDelayMs");
|
|
73
|
+
}
|
|
74
|
+
return { maxAttempts, baseDelayMs, maxDelayMs };
|
|
75
|
+
}
|
|
76
|
+
function retryDelayMs(error, attempt, policy) {
|
|
77
|
+
if (error.retryAfterMs !== undefined)
|
|
78
|
+
return Math.min(MAX_RETRY_DELAY_MS, error.retryAfterMs);
|
|
79
|
+
const ceiling = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** Math.max(0, attempt - 1));
|
|
80
|
+
return Math.floor(Math.random() * ceiling);
|
|
81
|
+
}
|
|
82
|
+
function sleepForRetry(delayMs, signal) {
|
|
83
|
+
if (signal?.aborted)
|
|
84
|
+
return Promise.reject(new CursorLocalCancellationError("Cursor retry cancelled"));
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const timer = setTimeout(finish, delayMs);
|
|
87
|
+
const onAbort = () => finish(new CursorLocalCancellationError("Cursor retry cancelled"));
|
|
88
|
+
function finish(error) {
|
|
89
|
+
clearTimeout(timer);
|
|
90
|
+
signal?.removeEventListener("abort", onAbort);
|
|
91
|
+
if (error)
|
|
92
|
+
reject(error);
|
|
93
|
+
else
|
|
94
|
+
resolve();
|
|
95
|
+
}
|
|
96
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
function retryDelayFromValue(value) {
|
|
100
|
+
if (typeof value === "string") {
|
|
101
|
+
const seconds = /^(\d+(?:\.\d+)?)s$/.exec(value.trim());
|
|
102
|
+
if (seconds)
|
|
103
|
+
return Math.ceil(Number(seconds[1]) * 1_000);
|
|
104
|
+
const protobufDelay = retryInfoProtobufDelayMs(value.trim());
|
|
105
|
+
if (protobufDelay !== undefined)
|
|
106
|
+
return protobufDelay;
|
|
107
|
+
}
|
|
108
|
+
if (!value || typeof value !== "object")
|
|
109
|
+
return undefined;
|
|
110
|
+
const duration = value;
|
|
111
|
+
const seconds = Number(duration.seconds ?? 0);
|
|
112
|
+
const nanos = Number(duration.nanos ?? 0);
|
|
113
|
+
if (!Number.isFinite(seconds) || !Number.isFinite(nanos) || seconds < 0 || nanos < 0)
|
|
114
|
+
return undefined;
|
|
115
|
+
return Math.ceil(seconds * 1_000 + nanos / 1_000_000);
|
|
116
|
+
}
|
|
117
|
+
/** Decode google.rpc.RetryInfo.value without adding another protobuf schema. */
|
|
118
|
+
function retryInfoProtobufDelayMs(encoded) {
|
|
119
|
+
if (!encoded || encoded.length > 512 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(encoded)) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
let bytes;
|
|
123
|
+
try {
|
|
124
|
+
bytes = Buffer.from(encoded.replaceAll("-", "+").replaceAll("_", "/"), "base64");
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const readVarint = (input, start) => {
|
|
130
|
+
let value = 0n;
|
|
131
|
+
let shift = 0n;
|
|
132
|
+
for (let offset = start; offset < input.length && offset < start + 10; offset++) {
|
|
133
|
+
const byte = input[offset];
|
|
134
|
+
value |= BigInt(byte & 0x7f) << shift;
|
|
135
|
+
if ((byte & 0x80) === 0)
|
|
136
|
+
return [value, offset + 1];
|
|
137
|
+
shift += 7n;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
};
|
|
141
|
+
const outerKey = readVarint(bytes, 0);
|
|
142
|
+
if (!outerKey || outerKey[0] !== 0x0an)
|
|
143
|
+
return undefined;
|
|
144
|
+
const outerLength = readVarint(bytes, outerKey[1]);
|
|
145
|
+
if (!outerLength || outerLength[0] > BigInt(bytes.length - outerLength[1]))
|
|
146
|
+
return undefined;
|
|
147
|
+
const duration = bytes.subarray(outerLength[1], outerLength[1] + Number(outerLength[0]));
|
|
148
|
+
let offset = 0;
|
|
149
|
+
let seconds = 0n;
|
|
150
|
+
let nanos = 0n;
|
|
151
|
+
while (offset < duration.length) {
|
|
152
|
+
const key = readVarint(duration, offset);
|
|
153
|
+
if (!key)
|
|
154
|
+
return undefined;
|
|
155
|
+
offset = key[1];
|
|
156
|
+
const field = Number(key[0] >> 3n);
|
|
157
|
+
if (Number(key[0] & 7n) !== 0)
|
|
158
|
+
return undefined;
|
|
159
|
+
const item = readVarint(duration, offset);
|
|
160
|
+
if (!item)
|
|
161
|
+
return undefined;
|
|
162
|
+
offset = item[1];
|
|
163
|
+
if (field === 1)
|
|
164
|
+
seconds = item[0];
|
|
165
|
+
else if (field === 2)
|
|
166
|
+
nanos = item[0];
|
|
167
|
+
}
|
|
168
|
+
if (seconds > BigInt(Number.MAX_SAFE_INTEGER) || nanos > 999999999n)
|
|
169
|
+
return undefined;
|
|
170
|
+
return Math.ceil(Number(seconds) * 1_000 + Number(nanos) / 1_000_000);
|
|
171
|
+
}
|
|
172
|
+
export function connectFrameError(payload) {
|
|
173
|
+
try {
|
|
174
|
+
const envelope = JSON.parse(payload);
|
|
175
|
+
const code = typeof envelope.error?.code === "string" ? envelope.error.code : "unknown";
|
|
176
|
+
if (code === "unauthenticated" || code === "permission_denied") {
|
|
177
|
+
return new CursorAuthError(`Cursor authentication failed (${code}); reauthenticate with Cursor`, { code });
|
|
178
|
+
}
|
|
179
|
+
let retryAfterMs = retryDelayFromValue(envelope.error?.retryAfter ?? envelope.error?.retry_after);
|
|
180
|
+
let hasRetryInfo = false;
|
|
181
|
+
if (Array.isArray(envelope.error?.details)) {
|
|
182
|
+
for (const detail of envelope.error.details) {
|
|
183
|
+
if (!detail || typeof detail !== "object")
|
|
184
|
+
continue;
|
|
185
|
+
const record = detail;
|
|
186
|
+
const type = record.type;
|
|
187
|
+
if (type === "google.rpc.RetryInfo" || (typeof type === "string" && type.endsWith("/google.rpc.RetryInfo"))) {
|
|
188
|
+
hasRetryInfo = true;
|
|
189
|
+
retryAfterMs ??= retryDelayFromValue(record.retryDelay ?? record.retry_delay ?? record.value);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return new CursorServerError(`Cursor API error (code=${code})`, {
|
|
194
|
+
transient: isTransientGrpcStatus(code) || hasRetryInfo,
|
|
195
|
+
replaySafe: true,
|
|
196
|
+
code,
|
|
197
|
+
retryAfterMs: retryAfterMs === undefined
|
|
198
|
+
? undefined
|
|
199
|
+
: Math.min(MAX_RETRY_DELAY_MS, retryAfterMs),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return new CursorProtocolError("Cursor returned a malformed Connect error envelope");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
35
206
|
function rememberToolCatalog(sessionKey, tools) {
|
|
36
207
|
toolCatalogBySession.delete(sessionKey);
|
|
37
208
|
toolCatalogBySession.set(sessionKey, tools);
|
|
@@ -88,6 +259,12 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
88
259
|
baseUrl: resolveApiBaseURL(options),
|
|
89
260
|
});
|
|
90
261
|
const prompt = callOptions.prompt;
|
|
262
|
+
const promptTokens = estimatePromptTokens(prompt);
|
|
263
|
+
const retryPolicy = resolveRetryPolicy(options.retry);
|
|
264
|
+
// pumpWithRecovery owns the complete per-turn attempt budget. Opening a
|
|
265
|
+
// replacement session here must be a single attempt; otherwise setup retry
|
|
266
|
+
// loops nest inside recovery and `maxAttempts` no longer caps total Runs.
|
|
267
|
+
const openSession = (startOptions) => startSession(modelId, token, callOptions, options, startOptions);
|
|
91
268
|
// ── Continuation vs fresh turn ──
|
|
92
269
|
// OpenCode embeds *all* historical tool results in every prompt. Only the
|
|
93
270
|
// trailing tool-message suffix (after the last assistant/user message) is a
|
|
@@ -97,65 +274,33 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
97
274
|
const trailingToolResults = extractTrailingToolResults(prompt);
|
|
98
275
|
let session = findContinuationSession(trailingToolResults);
|
|
99
276
|
if (session) {
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
`pending={${[...session.pending.keys()].join(",")}}`);
|
|
105
|
-
for (const r of pendingResults) {
|
|
106
|
-
const pending = session.pending.get(r.execId);
|
|
107
|
-
if (!pending)
|
|
108
|
-
continue;
|
|
109
|
-
if (pending.bridged) {
|
|
110
|
-
// Display-only Cursor tool — OpenCode already ran it; nothing to write
|
|
111
|
-
// back on the Run stream.
|
|
112
|
-
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
113
|
-
trace(`continuation: bridged tool result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
114
|
-
sessionManager.resolve(session.sessionId, r.execId);
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
try {
|
|
118
|
-
for (const frame of buildExecClientMessages({
|
|
119
|
-
execId: r.execId,
|
|
120
|
-
resultField: pending.resultField,
|
|
121
|
-
output: r.output,
|
|
122
|
-
error: r.error,
|
|
123
|
-
toolName: pending.toolName ?? r.toolName,
|
|
124
|
-
})) {
|
|
125
|
-
session.stream.write(frame);
|
|
126
|
-
}
|
|
127
|
-
// Tool results enlarge Cursor's context for the rest of this Run.
|
|
128
|
-
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
129
|
-
trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} outLen=${r.output.length}`);
|
|
130
|
-
// Only resolve after the write succeeded — if it threw, the server never
|
|
131
|
-
// got the result and would block on heartbeats forever otherwise.
|
|
132
|
-
sessionManager.resolve(session.sessionId, r.execId);
|
|
133
|
-
}
|
|
134
|
-
catch (e) {
|
|
135
|
-
trace(`continuation: write FAILED execId=${r.execId} err=${e.message} — leaving pending`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
sessionManager.touch(session);
|
|
139
|
-
}
|
|
140
|
-
else if (trailingToolResults.length > 0) {
|
|
141
|
-
// True continuation (prompt ends with tool results) but the Cursor Run is
|
|
142
|
-
// gone. Soft-stop instead of re-prompting (doom loop) or hard-erroring.
|
|
143
|
-
const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
|
|
144
|
-
trace(`continuation: ${trailingToolResults.length} orphaned trailing tool result(s) [${ids}] — soft-stop`);
|
|
145
|
-
return orphanedToolResultsStream(ids);
|
|
277
|
+
// Write pending results onto the held-open Run. A dead stream closes the
|
|
278
|
+
// session and returns undefined so we fall through to history rebase
|
|
279
|
+
// instead of pumping a connection that can no longer accept writes.
|
|
280
|
+
session = deliverContinuationResults(session, trailingToolResults);
|
|
146
281
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
282
|
+
if (!session) {
|
|
283
|
+
if (trailingToolResults.length > 0) {
|
|
284
|
+
// True continuation (prompt ends with tool results) but the held-open Run
|
|
285
|
+
// is gone (or its write path just failed). Rebase the complete OpenCode
|
|
286
|
+
// prompt onto a fresh conversation: its seed history includes the
|
|
287
|
+
// completed tool result, so no result or advertised tool is lost and
|
|
288
|
+
// Cursor can continue instead of deadlocking.
|
|
289
|
+
const ids = trailingToolResults.map((r) => `${r.sessionId}:${r.execId}`).join(",");
|
|
290
|
+
trace(`continuation: ${trailingToolResults.length} interrupted trailing tool result(s) [${ids}] — rebasing fresh Run`);
|
|
291
|
+
session = await openSession({ recovery: true });
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
// Fresh turn (prompt ends with user/assistant text). Historical tool
|
|
295
|
+
// results may exist mid-prompt; they are not live exec replies.
|
|
296
|
+
const historical = extractToolResults(prompt).length;
|
|
297
|
+
if (historical > 0) {
|
|
298
|
+
trace(`fresh turn: ignoring ${historical} historical tool result(s) (not trailing)`);
|
|
299
|
+
}
|
|
300
|
+
session = await openSession();
|
|
153
301
|
}
|
|
154
|
-
session = await startSession(modelId, token, callOptions, options);
|
|
155
302
|
}
|
|
156
|
-
|
|
157
|
-
const textId = crypto.randomUUID();
|
|
158
|
-
const reasoningId = crypto.randomUUID();
|
|
303
|
+
let activeSession = session;
|
|
159
304
|
return {
|
|
160
305
|
stream: new ReadableStream({
|
|
161
306
|
async pull(controller) {
|
|
@@ -172,13 +317,15 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
172
317
|
trace(`pull: stream-start enqueue failed (cancelled) err=${e.message}`);
|
|
173
318
|
return;
|
|
174
319
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
320
|
+
activeSession = await pumpWithRecovery({
|
|
321
|
+
initialSession: activeSession,
|
|
322
|
+
controller,
|
|
323
|
+
abortSignal: callOptions.abortSignal,
|
|
324
|
+
promptTokens,
|
|
325
|
+
retryPolicy,
|
|
326
|
+
recover: () => openSession({ recovery: true }),
|
|
327
|
+
onSession: (next) => { activeSession = next; },
|
|
328
|
+
});
|
|
182
329
|
try {
|
|
183
330
|
controller.close();
|
|
184
331
|
}
|
|
@@ -187,9 +334,9 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
187
334
|
}
|
|
188
335
|
}
|
|
189
336
|
catch (e) {
|
|
190
|
-
|
|
337
|
+
activeSession.pumpActive = false;
|
|
191
338
|
trace(`pull: pump threw (cleaning up): ${e.message}`);
|
|
192
|
-
sessionManager.
|
|
339
|
+
sessionManager.close(activeSession);
|
|
193
340
|
try {
|
|
194
341
|
controller.error(e instanceof Error ? e : new Error(String(e)));
|
|
195
342
|
}
|
|
@@ -202,12 +349,60 @@ async function doStreamImpl(modelId, options, callOptions) {
|
|
|
202
349
|
// OpenCode cancels the ReadableStream after "tool-calls"; keep the
|
|
203
350
|
// Cursor Run stream alive so the next doStream can write results.
|
|
204
351
|
trace("ReadableStream cancel() → closeUnlessPending");
|
|
205
|
-
sessionManager.closeUnlessPending(
|
|
352
|
+
sessionManager.closeUnlessPending(activeSession);
|
|
206
353
|
},
|
|
207
354
|
}),
|
|
208
355
|
};
|
|
209
356
|
}
|
|
210
|
-
async function
|
|
357
|
+
export async function pumpWithRecovery(input) {
|
|
358
|
+
let session = input.initialSession;
|
|
359
|
+
const retryPolicy = input.retryPolicy ?? {
|
|
360
|
+
...DEFAULT_RETRY_POLICY,
|
|
361
|
+
maxAttempts: (input.maxRecoveries ?? 1) + 1,
|
|
362
|
+
};
|
|
363
|
+
const maxRecoveries = retryPolicy.maxAttempts - 1;
|
|
364
|
+
input.onSession?.(session);
|
|
365
|
+
for (let attempt = 0;; attempt++) {
|
|
366
|
+
const pumpedSession = session;
|
|
367
|
+
const pumpOwner = Symbol("cursor-pump");
|
|
368
|
+
sessionManager.beginPump(pumpedSession, pumpOwner);
|
|
369
|
+
try {
|
|
370
|
+
await pump(pumpedSession, input.controller, {
|
|
371
|
+
textId: crypto.randomUUID(),
|
|
372
|
+
reasoningId: crypto.randomUUID(),
|
|
373
|
+
promptTokens: input.promptTokens ?? 0,
|
|
374
|
+
}, input.abortSignal);
|
|
375
|
+
return session;
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
const failure = toCursorProviderError(error, {
|
|
379
|
+
replaySafe: error instanceof CursorProviderError ? error.replaySafe : false,
|
|
380
|
+
fallback: "Cursor Run interrupted",
|
|
381
|
+
});
|
|
382
|
+
if (!failure.transient)
|
|
383
|
+
throw failure;
|
|
384
|
+
if (!failure.replaySafe) {
|
|
385
|
+
throw retrySuppressedError(failure, "after visible output or stateful server activity", attempt + 1, maxRecoveries + 1);
|
|
386
|
+
}
|
|
387
|
+
if (attempt >= maxRecoveries) {
|
|
388
|
+
throw new CursorRetryExhaustedError(attempt + 1, failure);
|
|
389
|
+
}
|
|
390
|
+
trace(`Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` +
|
|
391
|
+
`err=${failure.message} — rebasing fresh Run`);
|
|
392
|
+
sessionManager.close(pumpedSession, "remote-error", failure);
|
|
393
|
+
const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy);
|
|
394
|
+
trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`);
|
|
395
|
+
await sleepForRetry(delayMs, input.abortSignal);
|
|
396
|
+
session = await input.recover();
|
|
397
|
+
input.onSession?.(session);
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
sessionManager.endPump(pumpedSession, pumpOwner);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function startSession(modelId, token, callOptions, options, startOptions) {
|
|
405
|
+
const continuationPolicy = resolveContinuationPolicy(options.continuation);
|
|
211
406
|
const prompt = callOptions.prompt;
|
|
212
407
|
const incomingTools = extractTools(callOptions);
|
|
213
408
|
const sessionKey = opencodeSessionKey(callOptions);
|
|
@@ -224,23 +419,27 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
224
419
|
const tools = toolState.advertisedTools;
|
|
225
420
|
const allowTools = toolState.allowTools;
|
|
226
421
|
const resetState = resolveTurnConversationReset({ sessionKey, isCompaction });
|
|
422
|
+
const recovery = startOptions?.recovery === true;
|
|
227
423
|
// Compaction must not reuse the prior conversation; its first normal turn
|
|
228
424
|
// must also rebase so the summary-agent checkpoint cannot replace the normal
|
|
229
425
|
// system prompt and OpenCode's newly compacted history.
|
|
230
|
-
const bound = bindConversationId(sessionKey, { reset: resetState.reset });
|
|
426
|
+
const bound = bindConversationId(sessionKey, { reset: resetState.reset || recovery });
|
|
231
427
|
const conversationId = bound.conversationId;
|
|
232
428
|
if (bound.reset) {
|
|
233
|
-
trace(`conversation reset: reason=${resetState.reason ?? "unknown"} ` +
|
|
429
|
+
trace(`conversation reset: reason=${recovery ? "interrupted-run" : (resetState.reason ?? "unknown")} ` +
|
|
234
430
|
`sessionKey=${sessionKey ?? "(none)"} ` +
|
|
235
431
|
`previousId=${bound.previousId ?? "-"} → conversationId=${conversationId}`);
|
|
236
432
|
}
|
|
237
|
-
const userText =
|
|
433
|
+
const userText = recovery
|
|
434
|
+
? "Continue the interrupted turn from the conversation history above. Do not repeat completed work."
|
|
435
|
+
: (extractUserText([...prompt].reverse().find((m) => m.role === "user")) || ".");
|
|
436
|
+
const workspaceRoot = path.resolve(options.workspaceRoot || process.cwd());
|
|
238
437
|
const baseSystemPrompt = extractSystemPrompt(prompt);
|
|
239
|
-
const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction);
|
|
438
|
+
const interactionGuidance = buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot);
|
|
240
439
|
const systemPrompt = interactionGuidance
|
|
241
440
|
? [baseSystemPrompt, interactionGuidance].filter(Boolean).join("\n\n")
|
|
242
441
|
: baseSystemPrompt;
|
|
243
|
-
const history = extractPromptHistory(prompt);
|
|
442
|
+
const history = extractPromptHistory(prompt, { preserveTrailingUser: recovery });
|
|
244
443
|
await loadAvailableModels();
|
|
245
444
|
// Resolve the region-specific Run stream origin once per process (memoized
|
|
246
445
|
// in agent-url.ts). Explicit agent host overrides skip GetServerConfig but
|
|
@@ -275,7 +474,6 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
275
474
|
baseURL: agentBaseUrl,
|
|
276
475
|
headers: options.headers,
|
|
277
476
|
});
|
|
278
|
-
const workspaceRoot = options.workspaceRoot || process.cwd();
|
|
279
477
|
const requestContext = await buildRequestContext({ workspaceRoot, tools });
|
|
280
478
|
// Resolve descriptors once from the merged OpenCode config so MCP identity is
|
|
281
479
|
// consistent across AgentRunRequest and both request_context reply paths.
|
|
@@ -294,7 +492,6 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
294
492
|
conversationState,
|
|
295
493
|
parameterValues,
|
|
296
494
|
maxMode,
|
|
297
|
-
availableModels: _availableModels,
|
|
298
495
|
tools,
|
|
299
496
|
toolDescriptors,
|
|
300
497
|
requestContext,
|
|
@@ -331,10 +528,17 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
331
528
|
trace(`hash systemPrompt sha256=${sha(systemPrompt)}`);
|
|
332
529
|
if (conversationState)
|
|
333
530
|
trace(`hash checkpoint sha256=${sha(conversationState)}`);
|
|
334
|
-
|
|
531
|
+
try {
|
|
532
|
+
await writeWithBackpressure(stream, reqBytes, "initial Run request");
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
stream.destroy();
|
|
536
|
+
throw error;
|
|
537
|
+
}
|
|
335
538
|
const session = {
|
|
336
539
|
sessionId: crypto.randomUUID(),
|
|
337
540
|
conversationId,
|
|
541
|
+
openCodeSessionId: sessionKey,
|
|
338
542
|
stream,
|
|
339
543
|
frames: stream.frames()[Symbol.asyncIterator](),
|
|
340
544
|
pending: new Map(),
|
|
@@ -346,15 +550,50 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
346
550
|
allowTools,
|
|
347
551
|
usageEstimate,
|
|
348
552
|
pumpActive: false,
|
|
553
|
+
pumpOwner: null,
|
|
349
554
|
heartbeat: null,
|
|
350
|
-
|
|
555
|
+
heartbeatCancel: null,
|
|
556
|
+
hardDeadlineTimer: null,
|
|
557
|
+
semanticDeadlineCancel: null,
|
|
558
|
+
terminalUnsubscribe: null,
|
|
559
|
+
deferredTerminalReason: null,
|
|
560
|
+
policy: continuationPolicy,
|
|
561
|
+
createdAt: Date.now(),
|
|
562
|
+
lastInboundAt: Date.now(),
|
|
563
|
+
lastHeartbeatWriteAt: Date.now(),
|
|
564
|
+
semanticDeadlineAt: Date.now() + continuationPolicy.semanticIdleMs,
|
|
565
|
+
closeError: null,
|
|
566
|
+
closed: false,
|
|
351
567
|
};
|
|
568
|
+
sessionManager.registerSession(session);
|
|
569
|
+
let heartbeatWritePending = false;
|
|
352
570
|
session.heartbeat = setInterval(() => {
|
|
353
|
-
|
|
354
|
-
|
|
571
|
+
if (session.closed)
|
|
572
|
+
return;
|
|
573
|
+
if (heartbeatWritePending) {
|
|
574
|
+
sessionManager.close(session, "heartbeat-write-failed", new CursorTransportError("Cursor heartbeat write remained backpressured", {
|
|
575
|
+
transient: false,
|
|
576
|
+
replaySafe: false,
|
|
577
|
+
code: "CURSOR_HEARTBEAT_BACKPRESSURE",
|
|
578
|
+
}));
|
|
579
|
+
return;
|
|
355
580
|
}
|
|
356
|
-
|
|
357
|
-
|
|
581
|
+
heartbeatWritePending = true;
|
|
582
|
+
void writeWithBackpressure(stream, buildHeartbeat(), "heartbeat")
|
|
583
|
+
.then(() => sessionManager.recordHeartbeatWrite(session))
|
|
584
|
+
.catch((cause) => {
|
|
585
|
+
sessionManager.close(session, "heartbeat-write-failed", toCursorProviderError(cause, {
|
|
586
|
+
replaySafe: false,
|
|
587
|
+
fallback: "Cursor heartbeat write failed",
|
|
588
|
+
}));
|
|
589
|
+
})
|
|
590
|
+
.finally(() => { heartbeatWritePending = false; });
|
|
591
|
+
}, continuationPolicy.heartbeatMs);
|
|
592
|
+
session.heartbeat.unref?.();
|
|
593
|
+
session.heartbeatCancel = () => {
|
|
594
|
+
if (session.heartbeat)
|
|
595
|
+
clearInterval(session.heartbeat);
|
|
596
|
+
};
|
|
358
597
|
callOptions.abortSignal?.addEventListener("abort", () => {
|
|
359
598
|
// Abort after tool-calls is normal — preserve pending sessions.
|
|
360
599
|
trace("abortSignal aborted → closeUnlessPending");
|
|
@@ -362,37 +601,6 @@ async function startSession(modelId, token, callOptions, options) {
|
|
|
362
601
|
}, { once: true });
|
|
363
602
|
return session;
|
|
364
603
|
}
|
|
365
|
-
/** Emit a soft-stop stream when trailing tool results have no live Cursor Run. */
|
|
366
|
-
function orphanedToolResultsStream(ids) {
|
|
367
|
-
const textId = crypto.randomUUID();
|
|
368
|
-
const message = `The Cursor agent stream ended before tool results could be delivered ` +
|
|
369
|
-
`(${ids.split(",").length} result(s)). Please retry your request.`;
|
|
370
|
-
trace(`orphanedToolResultsStream: soft-stop for [${ids}]`);
|
|
371
|
-
return {
|
|
372
|
-
stream: new ReadableStream({
|
|
373
|
-
pull(controller) {
|
|
374
|
-
try {
|
|
375
|
-
controller.enqueue({ type: "stream-start", warnings: [] });
|
|
376
|
-
controller.enqueue({ type: "text-start", id: textId });
|
|
377
|
-
controller.enqueue({ type: "text-delta", id: textId, delta: message });
|
|
378
|
-
controller.enqueue({ type: "text-end", id: textId });
|
|
379
|
-
controller.enqueue({
|
|
380
|
-
type: "finish",
|
|
381
|
-
finishReason: { unified: "stop", raw: undefined },
|
|
382
|
-
usage: {
|
|
383
|
-
inputTokens: { total: 0, noCache: undefined, cacheRead: 0, cacheWrite: 0 },
|
|
384
|
-
outputTokens: { total: 0, text: undefined, reasoning: undefined },
|
|
385
|
-
},
|
|
386
|
-
});
|
|
387
|
-
controller.close();
|
|
388
|
-
}
|
|
389
|
-
catch (e) {
|
|
390
|
-
trace(`orphanedToolResultsStream: enqueue failed err=${e.message}`);
|
|
391
|
-
}
|
|
392
|
-
},
|
|
393
|
-
}),
|
|
394
|
-
};
|
|
395
|
-
}
|
|
396
604
|
/**
|
|
397
605
|
* OpenCode re-sends the full tool-result history on every continuation. Prefer
|
|
398
606
|
* the newest result that still has a live pending exec on its tagged session.
|
|
@@ -406,6 +614,70 @@ export function findContinuationSession(toolResults) {
|
|
|
406
614
|
}
|
|
407
615
|
return undefined;
|
|
408
616
|
}
|
|
617
|
+
/**
|
|
618
|
+
* Deliver trailing tool results onto a live continuation session.
|
|
619
|
+
* Returns the same session when writes succeed (or only bridged results were
|
|
620
|
+
* cleared). Returns undefined after closing the session when a write fails, so
|
|
621
|
+
* the caller can rebase onto a fresh Run instead of pumping a dead stream.
|
|
622
|
+
*/
|
|
623
|
+
export function deliverContinuationResults(session, trailingToolResults) {
|
|
624
|
+
const pendingResults = trailingToolResults.filter((r) => r.sessionId === session.sessionId && session.pending.has(r.execId));
|
|
625
|
+
trace(`continuation: ${trailingToolResults.length} trailing tool result(s), ` +
|
|
626
|
+
`${pendingResults.length} pending for sessionId=${session.sessionId} ` +
|
|
627
|
+
`pending={${[...session.pending.keys()].join(",")}}`);
|
|
628
|
+
for (const r of pendingResults) {
|
|
629
|
+
const claim = sessionManager.claim(session.sessionId, r.execId);
|
|
630
|
+
if ("kind" in claim) {
|
|
631
|
+
if (claim.kind === "deliverable") {
|
|
632
|
+
throw new CursorProtocolError("Cursor continuation claim remained unclaimed");
|
|
633
|
+
}
|
|
634
|
+
if (claim.kind === "duplicate") {
|
|
635
|
+
trace(`continuation: skipped duplicate execId=${r.execId} reason=${claim.reason}`);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
trace(`continuation: unavailable execId=${r.execId} reason=${claim.reason}`);
|
|
639
|
+
return undefined;
|
|
640
|
+
}
|
|
641
|
+
const pending = claim.pending;
|
|
642
|
+
let frames = [];
|
|
643
|
+
if (!pending.bridged) {
|
|
644
|
+
try {
|
|
645
|
+
const shellResult = pending.resultField === "shell_stream"
|
|
646
|
+
? consumeCursorShellResult(r.toolCallId, r.output)
|
|
647
|
+
: undefined;
|
|
648
|
+
frames = buildExecClientMessages({
|
|
649
|
+
execId: r.execId,
|
|
650
|
+
resultField: pending.resultField,
|
|
651
|
+
output: shellResult?.output ?? r.output,
|
|
652
|
+
error: r.error,
|
|
653
|
+
toolName: pending.toolName ?? r.toolName,
|
|
654
|
+
resultMetadata: pending.resultMetadata,
|
|
655
|
+
shellOutcome: shellResult?.outcome,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
trace(`continuation: result encode FAILED execId=${r.execId} err=${error.message}`);
|
|
660
|
+
sessionManager.close(session, "result-write-failed");
|
|
661
|
+
return undefined;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const outcome = sessionManager.deliverClaim(claim, frames);
|
|
665
|
+
if (outcome.kind !== "delivered") {
|
|
666
|
+
trace(`continuation: delivery stopped execId=${r.execId} reason=${outcome.reason}`);
|
|
667
|
+
if (outcome.kind === "duplicate")
|
|
668
|
+
continue;
|
|
669
|
+
return undefined;
|
|
670
|
+
}
|
|
671
|
+
session.usageEstimate.inputTokens += estimateTokens(r.output.length);
|
|
672
|
+
if (pending.bridged) {
|
|
673
|
+
trace(`continuation: completed bridged result execId=${r.execId} toolName=${pending.toolName ?? r.toolName} outLen=${r.output.length}`);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
trace(`continuation: wrote exec result execId=${r.execId} field=${pending.resultField} ` +
|
|
677
|
+
`frames=${outcome.framesWritten} outLen=${r.output.length}`);
|
|
678
|
+
}
|
|
679
|
+
return session;
|
|
680
|
+
}
|
|
409
681
|
async function loadAvailableModels() {
|
|
410
682
|
const cacheDir = opencodeGlobalCacheDir();
|
|
411
683
|
try {
|
|
@@ -439,23 +711,89 @@ function resolveExplicitAgentBaseURL(options) {
|
|
|
439
711
|
return undefined;
|
|
440
712
|
const normalized = normalizeAgentRunOrigin(raw);
|
|
441
713
|
if (!normalized) {
|
|
442
|
-
throw new
|
|
714
|
+
throw new CursorProtocolError("Invalid Cursor agent base URL override: expected https://*.cursor.sh");
|
|
443
715
|
}
|
|
444
716
|
return normalized;
|
|
445
717
|
}
|
|
446
718
|
function isTruthyEnv(value) {
|
|
447
719
|
return value === "1" || value === "true";
|
|
448
720
|
}
|
|
721
|
+
async function writeWithBackpressure(stream, message, operation) {
|
|
722
|
+
let accepted;
|
|
723
|
+
try {
|
|
724
|
+
accepted = stream.write(message);
|
|
725
|
+
}
|
|
726
|
+
catch (cause) {
|
|
727
|
+
throw toCursorProviderError(cause, {
|
|
728
|
+
replaySafe: false,
|
|
729
|
+
fallback: `Cursor ${operation} write failed`,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
if (accepted !== false)
|
|
733
|
+
return;
|
|
734
|
+
if (!stream.waitForDrain) {
|
|
735
|
+
throw new CursorTransportError(`Cursor ${operation} write was backpressured`, {
|
|
736
|
+
transient: false,
|
|
737
|
+
replaySafe: false,
|
|
738
|
+
code: "CURSOR_WRITE_BACKPRESSURE",
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
await stream.waitForDrain(5_000);
|
|
743
|
+
}
|
|
744
|
+
catch (cause) {
|
|
745
|
+
throw toCursorProviderError(cause, {
|
|
746
|
+
replaySafe: false,
|
|
747
|
+
fallback: `Cursor ${operation} backpressure drain failed`,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
async function nextFrameWithSemanticDeadline(session) {
|
|
752
|
+
const remainingMs = session.semanticDeadlineAt - Date.now();
|
|
753
|
+
if (remainingMs <= 0) {
|
|
754
|
+
throw new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" });
|
|
755
|
+
}
|
|
756
|
+
let timer;
|
|
757
|
+
let rejectCancelled;
|
|
758
|
+
const deadline = new Promise((_, reject) => {
|
|
759
|
+
rejectCancelled = reject;
|
|
760
|
+
timer = setTimeout(() => {
|
|
761
|
+
reject(new CursorTransportError(`Cursor semantic-progress timeout after ${session.policy.semanticIdleMs}ms`, { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }));
|
|
762
|
+
}, remainingMs);
|
|
763
|
+
timer.unref?.();
|
|
764
|
+
});
|
|
765
|
+
session.semanticDeadlineCancel = () => {
|
|
766
|
+
rejectCancelled?.(session.closeError ?? new CursorTransportError("Cursor semantic wait cancelled locally", {
|
|
767
|
+
transient: false,
|
|
768
|
+
replaySafe: false,
|
|
769
|
+
}));
|
|
770
|
+
};
|
|
771
|
+
try {
|
|
772
|
+
return await Promise.race([session.frames.next(), deadline]);
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
if (timer)
|
|
776
|
+
clearTimeout(timer);
|
|
777
|
+
session.semanticDeadlineCancel = null;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
449
780
|
/**
|
|
450
781
|
* Read the held-open stream, emitting stream parts, until the turn boundary:
|
|
451
782
|
* - a tool call (exec_server_message) → emit tool-call, finish "tool-calls",
|
|
452
783
|
* and KEEP the session open for the result on the next doStream call;
|
|
453
|
-
* - turn_ended
|
|
784
|
+
* - turn_ended → finish "stop" and close the session;
|
|
785
|
+
* - transport EOF before turn_ended → throw for one fresh-Run recovery.
|
|
454
786
|
*/
|
|
455
787
|
export async function pump(session, controller, ids, abortSignal) {
|
|
788
|
+
sessionManager.registerSession(session);
|
|
456
789
|
const { textId, reasoningId } = ids;
|
|
790
|
+
const promptTokens = ids.promptTokens ?? 0;
|
|
791
|
+
const advertisedToolNames = advertisedToolNamesFromDescriptors(session.toolDescriptors);
|
|
792
|
+
const advertisedToolNameSet = new Set(advertisedToolNames);
|
|
457
793
|
let textStarted = false;
|
|
458
794
|
let reasoningStarted = false;
|
|
795
|
+
let emittedOutputChars = 0;
|
|
796
|
+
let replaySafe = true;
|
|
459
797
|
// OpenCode cancels the ReadableStream between turns (see the cancel handler
|
|
460
798
|
// in doStreamImpl). The frames iterator can still yield a final `done` after
|
|
461
799
|
// the cancel lands — controller.enqueue on a cancelled controller throws.
|
|
@@ -485,6 +823,30 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
485
823
|
}
|
|
486
824
|
streamClosed = true;
|
|
487
825
|
};
|
|
826
|
+
/** Reply on Cursor's correlated exec channel without exposing a host tool call. */
|
|
827
|
+
const rejectExec = (parsed, reason, label) => {
|
|
828
|
+
try {
|
|
829
|
+
for (const frame of buildExecClientMessages({
|
|
830
|
+
execId: parsed.id,
|
|
831
|
+
resultField: parsed.resultField,
|
|
832
|
+
output: "",
|
|
833
|
+
error: reason,
|
|
834
|
+
toolName: parsed.toolName,
|
|
835
|
+
resultMetadata: parsed.resultMetadata,
|
|
836
|
+
})) {
|
|
837
|
+
session.stream.write(frame);
|
|
838
|
+
}
|
|
839
|
+
trace(`exec: REFUSED ${label} toolName=${parsed.toolName} id=${parsed.id}`);
|
|
840
|
+
return true;
|
|
841
|
+
}
|
|
842
|
+
catch (e) {
|
|
843
|
+
const error = new Error(`Failed to reject Cursor tool request (${label}): ${e.message}`);
|
|
844
|
+
trace(`exec: REFUSED reply FAILED ${error.message}`);
|
|
845
|
+
safeError(error);
|
|
846
|
+
sessionManager.close(session);
|
|
847
|
+
return false;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
488
850
|
/** AI SDK V3 requires text-end / reasoning-end before finish or tool-call. */
|
|
489
851
|
const closeOpenSpans = () => {
|
|
490
852
|
for (const part of spanEndParts({ textStarted, reasoningStarted, textId, reasoningId })) {
|
|
@@ -496,6 +858,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
496
858
|
const emitText = (text) => {
|
|
497
859
|
if (!text)
|
|
498
860
|
return;
|
|
861
|
+
replaySafe = false;
|
|
499
862
|
// Close reasoning before text (hosts expect reasoning-end before text-start).
|
|
500
863
|
if (reasoningStarted && !textStarted) {
|
|
501
864
|
safeEnqueue({ type: "reasoning-end", id: reasoningId });
|
|
@@ -506,17 +869,22 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
506
869
|
textStarted = true;
|
|
507
870
|
}
|
|
508
871
|
session.usageEstimate.outputTokens += estimateTokens(text.length);
|
|
509
|
-
safeEnqueue({ type: "text-delta", id: textId, delta: text })
|
|
872
|
+
if (safeEnqueue({ type: "text-delta", id: textId, delta: text })) {
|
|
873
|
+
emittedOutputChars += text.length;
|
|
874
|
+
}
|
|
510
875
|
};
|
|
511
876
|
const emitReasoning = (text) => {
|
|
512
877
|
if (!text)
|
|
513
878
|
return;
|
|
879
|
+
replaySafe = false;
|
|
514
880
|
if (!reasoningStarted) {
|
|
515
881
|
safeEnqueue({ type: "reasoning-start", id: reasoningId });
|
|
516
882
|
reasoningStarted = true;
|
|
517
883
|
}
|
|
518
884
|
session.usageEstimate.outputTokens += estimateTokens(text.length);
|
|
519
|
-
safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })
|
|
885
|
+
if (safeEnqueue({ type: "reasoning-delta", id: reasoningId, delta: text })) {
|
|
886
|
+
emittedOutputChars += text.length;
|
|
887
|
+
}
|
|
520
888
|
};
|
|
521
889
|
const emitFinish = (te, reason) => {
|
|
522
890
|
closeOpenSpans();
|
|
@@ -532,25 +900,32 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
532
900
|
const est = session.usageEstimate;
|
|
533
901
|
const usage = {
|
|
534
902
|
inputTokens: {
|
|
535
|
-
total:
|
|
903
|
+
total: promptTokens,
|
|
536
904
|
noCache: undefined,
|
|
537
|
-
cacheRead:
|
|
538
|
-
cacheWrite:
|
|
905
|
+
cacheRead: 0,
|
|
906
|
+
cacheWrite: 0,
|
|
539
907
|
},
|
|
540
908
|
outputTokens: {
|
|
541
|
-
total:
|
|
909
|
+
total: estimateTokens(emittedOutputChars),
|
|
542
910
|
text: undefined,
|
|
543
911
|
reasoning: undefined,
|
|
544
912
|
},
|
|
545
913
|
};
|
|
914
|
+
const providerMetadata = te ? cursorTurnEndedProviderMetadata(te) : undefined;
|
|
546
915
|
const reasonLabel = typeof reason === "object" && reason && "unified" in reason
|
|
547
916
|
? String(reason.unified ?? "unknown")
|
|
548
917
|
: String(reason);
|
|
549
918
|
trace(`finish: reason=${reasonLabel} ` +
|
|
550
|
-
`
|
|
551
|
-
`
|
|
919
|
+
`requestIn=${usage.inputTokens.total} requestOut=${usage.outputTokens.total} ` +
|
|
920
|
+
`rawIn=${est.inputTokens} rawOut=${est.outputTokens} ` +
|
|
921
|
+
`rawCacheRead=${est.cacheRead} rawCacheWrite=${est.cacheWrite} ` +
|
|
552
922
|
`source=${te ? "turn_ended" : "estimate"}`);
|
|
553
|
-
safeEnqueue({
|
|
923
|
+
safeEnqueue({
|
|
924
|
+
type: "finish",
|
|
925
|
+
usage,
|
|
926
|
+
finishReason: reason,
|
|
927
|
+
...(providerMetadata ? { providerMetadata } : {}),
|
|
928
|
+
});
|
|
554
929
|
};
|
|
555
930
|
while (true) {
|
|
556
931
|
// Consumer cancelled / closed the ReadableStream. Stop reading Cursor
|
|
@@ -568,31 +943,45 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
568
943
|
sessionManager.closeUnlessPending(session);
|
|
569
944
|
return;
|
|
570
945
|
}
|
|
571
|
-
|
|
946
|
+
let next;
|
|
947
|
+
try {
|
|
948
|
+
next = session.pending.size === 0
|
|
949
|
+
? await nextFrameWithSemanticDeadline(session)
|
|
950
|
+
: await session.frames.next();
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
closeOpenSpans();
|
|
954
|
+
const failure = error instanceof CursorProviderError
|
|
955
|
+
? error
|
|
956
|
+
: new CursorRunInterruptedError(`Cursor Run frame stream interrupted: ${error.message}`, { cause: error });
|
|
957
|
+
failure.replaySafe = replaySafe && failure.replaySafe;
|
|
958
|
+
throw failure;
|
|
959
|
+
}
|
|
572
960
|
if (next.done) {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
961
|
+
closeOpenSpans();
|
|
962
|
+
trace("pump: frames iterator ended before turn_ended");
|
|
963
|
+
const failure = new CursorRunInterruptedError();
|
|
964
|
+
failure.replaySafe = replaySafe;
|
|
965
|
+
throw failure;
|
|
577
966
|
}
|
|
578
967
|
const frame = next.value;
|
|
579
968
|
if (frame.flags & 0x02) {
|
|
580
|
-
//
|
|
581
|
-
//
|
|
969
|
+
// A successful agent turn has an explicit turn_ended update before the
|
|
970
|
+
// Connect envelope closes. Reaching end-stream here means the Run was
|
|
971
|
+
// interrupted, even if the HTTP status itself was 200.
|
|
972
|
+
let payload = "";
|
|
582
973
|
if (frame.payload.length > 0) {
|
|
583
974
|
try {
|
|
584
|
-
|
|
585
|
-
if (text.includes('"error"')) {
|
|
586
|
-
safeError(new Error(`Cursor API error: ${text.slice(0, 500)}`));
|
|
587
|
-
sessionManager.close(session);
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
975
|
+
payload = new TextDecoder().decode(decodeFramePayload(frame));
|
|
590
976
|
}
|
|
591
977
|
catch { /* not decodable */ }
|
|
592
978
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
979
|
+
closeOpenSpans();
|
|
980
|
+
const failure = payload
|
|
981
|
+
? connectFrameError(payload)
|
|
982
|
+
: new CursorRunInterruptedError();
|
|
983
|
+
failure.replaySafe = replaySafe && failure.replaySafe;
|
|
984
|
+
throw failure;
|
|
596
985
|
}
|
|
597
986
|
// decodeFramePayload can throw on a corrupt gzip payload (gunzipSync).
|
|
598
987
|
// Skip the frame rather than abort the whole turn.
|
|
@@ -612,6 +1001,7 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
612
1001
|
// A single malformed/truncated frame must not abort the whole turn
|
|
613
1002
|
// (protobufjs throws "index out of range: …" on length overruns). Log it
|
|
614
1003
|
// and keep pumping.
|
|
1004
|
+
replaySafe = false;
|
|
615
1005
|
const preview = Array.from(payload.subarray(0, 32))
|
|
616
1006
|
.map((x) => x.toString(16).padStart(2, "0"))
|
|
617
1007
|
.join("");
|
|
@@ -625,6 +1015,21 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
625
1015
|
const interactionQuery = asm.interaction_query;
|
|
626
1016
|
const checkpointRaw = asm.conversation_checkpoint_update;
|
|
627
1017
|
const topField = payload.length > 0 ? payload[0] >> 3 : 0;
|
|
1018
|
+
const textProgress = iu?.text_delta?.text;
|
|
1019
|
+
const thinkingProgress = iu?.thinking_delta?.text;
|
|
1020
|
+
const checkpointProgress = normalizeCheckpointBytes(checkpointRaw);
|
|
1021
|
+
if ((typeof textProgress === "string" && textProgress.length > 0) ||
|
|
1022
|
+
(typeof thinkingProgress === "string" && thinkingProgress.length > 0) ||
|
|
1023
|
+
!!iu?.turn_ended ||
|
|
1024
|
+
!!iu?.tool_call_started ||
|
|
1025
|
+
!!iu?.tool_call_completed ||
|
|
1026
|
+
!!esm ||
|
|
1027
|
+
!!kv ||
|
|
1028
|
+
!!interactionQuery ||
|
|
1029
|
+
!!checkpointProgress?.length) {
|
|
1030
|
+
replaySafe = false;
|
|
1031
|
+
sessionManager.recordSemanticProgress(session);
|
|
1032
|
+
}
|
|
628
1033
|
{
|
|
629
1034
|
const iuKind = iu ? Object.keys(iu).find((k) => iu[k]) : undefined;
|
|
630
1035
|
trace(`pump frame: topField=${topField} interaction_update=${iuKind ?? "-"} ` +
|
|
@@ -781,25 +1186,8 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
781
1186
|
trace(`exec: id=${parsed?.id} variant=${parsed ? Object.keys(parsed).join(",") : "none"} toolName=${parsed?.toolName} resultField=${parsed?.resultField}`);
|
|
782
1187
|
if (parsed) {
|
|
783
1188
|
if (parsed.localError) {
|
|
784
|
-
|
|
785
|
-
try {
|
|
786
|
-
for (const frame of buildExecClientMessages({
|
|
787
|
-
execId: parsed.id,
|
|
788
|
-
resultField: parsed.resultField,
|
|
789
|
-
output: "",
|
|
790
|
-
error: parsed.localError,
|
|
791
|
-
toolName: parsed.toolName,
|
|
792
|
-
})) {
|
|
793
|
-
session.stream.write(frame);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
catch (e) {
|
|
797
|
-
const error = new Error(`Failed to reject unsupported Cursor tool request: ${e.message}`);
|
|
798
|
-
trace(`exec: invalid-mapping reply FAILED ${error.message}`);
|
|
799
|
-
safeError(error);
|
|
800
|
-
sessionManager.close(session);
|
|
1189
|
+
if (!rejectExec(parsed, parsed.localError, "invalid mapping"))
|
|
801
1190
|
return;
|
|
802
|
-
}
|
|
803
1191
|
continue;
|
|
804
1192
|
}
|
|
805
1193
|
// OpenCode throws "Tool call not allowed while generating summary"
|
|
@@ -808,26 +1196,35 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
808
1196
|
// pumping for text / turn_ended instead of emitting tool-call.
|
|
809
1197
|
if (!session.allowTools) {
|
|
810
1198
|
const reason = "Tool calls are not available during this turn (summary/compaction).";
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
}
|
|
1199
|
+
if (!rejectExec(parsed, reason, "allowTools=false"))
|
|
1200
|
+
return;
|
|
1201
|
+
continue;
|
|
1202
|
+
}
|
|
1203
|
+
// Cursor has native capabilities (Task, filesystem, shell, etc.) in
|
|
1204
|
+
// addition to the MCP descriptors sent by this provider. The model
|
|
1205
|
+
// can request one even when the current OpenCode agent omitted its
|
|
1206
|
+
// corresponding host tool. Emitting that request makes OpenCode
|
|
1207
|
+
// manufacture an `invalid` tool result. Refuse it on the held-open
|
|
1208
|
+
// Cursor exec channel instead, using the request's exact typed result.
|
|
1209
|
+
if (!advertisedToolNameSet.has(parsed.toolName)) {
|
|
1210
|
+
const available = advertisedToolNames.length > 0
|
|
1211
|
+
? advertisedToolNames.join(", ")
|
|
1212
|
+
: "none";
|
|
1213
|
+
const reason = `OpenCode tool '${parsed.toolName}' is unavailable for the current agent. ` +
|
|
1214
|
+
`Available tools: ${available}. Continue using only available tools; do not retry ` +
|
|
1215
|
+
`'${parsed.toolName}'.`;
|
|
1216
|
+
trace(`exec: unavailable catalog target toolName=${parsed.toolName} ` +
|
|
1217
|
+
`advertised=[${advertisedToolNames.join(",")}]`);
|
|
1218
|
+
if (!rejectExec(parsed, reason, "unavailable tool"))
|
|
1219
|
+
return;
|
|
826
1220
|
continue;
|
|
827
1221
|
}
|
|
828
1222
|
const tc = buildToolCallPart(parsed, session.sessionId);
|
|
1223
|
+
if (parsed.resultField === "shell_stream") {
|
|
1224
|
+
registerCursorShellCall(tc.toolCallId, parsed.resultMetadata);
|
|
1225
|
+
}
|
|
829
1226
|
// Keep the stream open; the result arrives on the next doStream call.
|
|
830
|
-
sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName);
|
|
1227
|
+
sessionManager.registerPending(parsed.id, session, parsed.resultField, parsed.toolName, false, parsed.resultMetadata);
|
|
831
1228
|
// tc.input is already a JSON string (LanguageModelV3ToolCall.input).
|
|
832
1229
|
trace(`exec: EMITTED tool-call toolCallId=${tc.toolCallId} toolName=${tc.toolName} inputLen=${tc.input.length}`);
|
|
833
1230
|
// Close open text/reasoning spans before tool-call (required by AI SDK V3).
|
|
@@ -846,11 +1243,12 @@ export async function pump(session, controller, ids, abortSignal) {
|
|
|
846
1243
|
// wrong reply recreates the heartbeat-only deadlock. Fail promptly so
|
|
847
1244
|
// schema drift is actionable.
|
|
848
1245
|
const variantField = detectExecVariantField(payload);
|
|
1246
|
+
const variantDescription = describeCursorExecVariant(variantField);
|
|
849
1247
|
const hex = Array.from(payload.subarray(0, 48))
|
|
850
1248
|
.map((x) => x.toString(16).padStart(2, "0"))
|
|
851
1249
|
.join("");
|
|
852
|
-
trace(`exec UNMAPPED: id=${esmId}
|
|
853
|
-
const err = new Error(`Unsupported Cursor exec variant
|
|
1250
|
+
trace(`exec UNMAPPED: id=${esmId} variant=${variantDescription} keys=[${Object.keys(esm).join(",")}] hex=${hex}`);
|
|
1251
|
+
const err = new Error(`Unsupported Cursor exec variant ${variantDescription} (id=${esmId})`);
|
|
854
1252
|
safeError(err);
|
|
855
1253
|
sessionManager.close(session);
|
|
856
1254
|
return;
|
|
@@ -931,11 +1329,13 @@ function extractToolResults(prompt) {
|
|
|
931
1329
|
const p = part;
|
|
932
1330
|
if (p.type !== "tool-result")
|
|
933
1331
|
continue;
|
|
934
|
-
const
|
|
1332
|
+
const toolCallId = p.toolCallId ?? "";
|
|
1333
|
+
const parsed = parseExecIdFromToolCallId(toolCallId);
|
|
935
1334
|
if (!parsed)
|
|
936
1335
|
continue;
|
|
937
1336
|
const { text, isError } = toolResultOutputToText(p.output);
|
|
938
1337
|
out.push({
|
|
1338
|
+
toolCallId,
|
|
939
1339
|
sessionId: parsed.sessionId,
|
|
940
1340
|
execId: parsed.execId,
|
|
941
1341
|
toolName: p.toolName ?? "mcp",
|
|
@@ -1001,10 +1401,12 @@ function extractSystemPrompt(prompt) {
|
|
|
1001
1401
|
* Redirect only to OpenCode tools that are genuinely advertised this turn;
|
|
1002
1402
|
* compaction keeps its dedicated summary prompt unchanged.
|
|
1003
1403
|
*/
|
|
1004
|
-
export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
|
|
1404
|
+
export function buildOpenCodeInteractionGuidance(tools, isCompaction, workspaceRoot) {
|
|
1005
1405
|
if (isCompaction)
|
|
1006
1406
|
return undefined;
|
|
1007
1407
|
const names = new Set(tools.map((tool) => tool.name));
|
|
1408
|
+
if (names.size === 0)
|
|
1409
|
+
return undefined;
|
|
1008
1410
|
const instructions = [];
|
|
1009
1411
|
if (names.has("question")) {
|
|
1010
1412
|
instructions.push("- When user input is required, call the OpenCode `question` tool; do not use Cursor's native AskQuestion interaction.");
|
|
@@ -1021,10 +1423,13 @@ export function buildOpenCodeInteractionGuidance(tools, isCompaction) {
|
|
|
1021
1423
|
if (names.has("webfetch")) {
|
|
1022
1424
|
instructions.push("- To fetch a known URL, call the OpenCode `webfetch` tool; do not use Cursor's native WebFetch interaction.");
|
|
1023
1425
|
}
|
|
1024
|
-
if (instructions.length === 0)
|
|
1025
|
-
return undefined;
|
|
1026
1426
|
return [
|
|
1027
|
-
|
|
1427
|
+
`OpenCode exposes exactly these executable tools for this turn: ${[...names].map((name) => `\`${name}\``).join(", ")}.`,
|
|
1428
|
+
`Workspace root: ${JSON.stringify(workspaceRoot)}. Resolve workspace paths against exactly this root; never invent an absolute prefix, and verify uncertain paths with an available tool before using them.`,
|
|
1429
|
+
"Call only tools in that exact list. Cursor-native tools that are not listed—including Task/subagents—are unavailable; do not invoke them. If a capability is absent, complete the work directly with the listed tools or explain the limitation.",
|
|
1430
|
+
...(instructions.length > 0
|
|
1431
|
+
? ["Use these OpenCode tools instead of equivalent Cursor-native UI interactions:"]
|
|
1432
|
+
: []),
|
|
1028
1433
|
...instructions,
|
|
1029
1434
|
"Emit the actual tool call and wait for its result; never merely claim or summarize that a tool was used.",
|
|
1030
1435
|
].join("\n");
|
|
@@ -1035,12 +1440,40 @@ export function estimateTokens(chars) {
|
|
|
1035
1440
|
return 0;
|
|
1036
1441
|
return Math.ceil(chars / 4);
|
|
1037
1442
|
}
|
|
1443
|
+
/** Estimate current-request prompt tokens from the complete serialized V3 prompt. */
|
|
1444
|
+
export function estimatePromptTokens(prompt) {
|
|
1445
|
+
const serializedContent = prompt
|
|
1446
|
+
.map((message) => typeof message.content === "string"
|
|
1447
|
+
? message.content
|
|
1448
|
+
: (JSON.stringify(message.content) ?? ""))
|
|
1449
|
+
.join("\n");
|
|
1450
|
+
return estimateTokens(serializedContent.length);
|
|
1451
|
+
}
|
|
1452
|
+
/** Preserve cumulative Cursor counters as diagnostics, never as AI SDK request usage. */
|
|
1453
|
+
export function cursorTurnEndedProviderMetadata(te) {
|
|
1454
|
+
const counter = (key) => {
|
|
1455
|
+
const value = te[key];
|
|
1456
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
1457
|
+
? Math.trunc(value)
|
|
1458
|
+
: 0;
|
|
1459
|
+
};
|
|
1460
|
+
return {
|
|
1461
|
+
cursor: {
|
|
1462
|
+
usageVersion: 2,
|
|
1463
|
+
inputTokensRaw: counter("input_tokens"),
|
|
1464
|
+
outputTokensRaw: counter("output_tokens"),
|
|
1465
|
+
cacheReadRaw: counter("cache_read"),
|
|
1466
|
+
cacheWriteRaw: counter("cache_write"),
|
|
1467
|
+
reasoningTokensRaw: counter("reasoning_tokens"),
|
|
1468
|
+
},
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1038
1471
|
/**
|
|
1039
1472
|
* Prior prompt turns for a seed ConversationStateStructure after compaction
|
|
1040
1473
|
* reset. Drops the trailing user message (live action), but preserves tool
|
|
1041
1474
|
* result payloads as labeled assistant observations for Cursor's text history.
|
|
1042
1475
|
*/
|
|
1043
|
-
export function extractPromptHistory(prompt) {
|
|
1476
|
+
export function extractPromptHistory(prompt, options) {
|
|
1044
1477
|
const out = [];
|
|
1045
1478
|
for (const m of prompt) {
|
|
1046
1479
|
if (m.role === "system") {
|
|
@@ -1077,8 +1510,9 @@ export function extractPromptHistory(prompt) {
|
|
|
1077
1510
|
}
|
|
1078
1511
|
}
|
|
1079
1512
|
// Live user message is the Run action, not seed history.
|
|
1080
|
-
if (out.length > 0 && out[out.length - 1].role === "user")
|
|
1513
|
+
if (!options?.preserveTrailingUser && out.length > 0 && out[out.length - 1].role === "user") {
|
|
1081
1514
|
out.pop();
|
|
1515
|
+
}
|
|
1082
1516
|
return out;
|
|
1083
1517
|
}
|
|
1084
1518
|
function extractAssistantHistoryText(msg) {
|