pi-provider-cursor-ask 0.1.0
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/README.zh-CN.md +87 -0
- package/UPSTREAM_CHANGELOG.md +368 -0
- package/UPSTREAM_SOURCE.md +23 -0
- package/dist/index.js +54 -0
- package/package.json +97 -0
- package/src/auth/cli-credentials.ts +275 -0
- package/src/auth/consent.ts +25 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +282 -0
- package/src/auth/refresh-guard.ts +93 -0
- package/src/client/bridge.ts +673 -0
- package/src/client/cursor-wire.ts +213 -0
- package/src/client/h2-unary.ts +142 -0
- package/src/client/index.ts +18 -0
- package/src/config/index.ts +69 -0
- package/src/diagnostics/diagnostics.ts +116 -0
- package/src/diagnostics/index.ts +1 -0
- package/src/extension/auth.ts +99 -0
- package/src/extension/commands.ts +163 -0
- package/src/extension/compaction-guard.ts +86 -0
- package/src/extension/debug-hooks.ts +359 -0
- package/src/extension/index.ts +8 -0
- package/src/extension/provider.ts +277 -0
- package/src/extension/quota-adapter.ts +175 -0
- package/src/extension/report-dashboard.ts +133 -0
- package/src/identity.ts +16 -0
- package/src/index.ts +186 -0
- package/src/models/ask-catalog.ts +384 -0
- package/src/models/catalog.json +1163 -0
- package/src/models/cost.ts +126 -0
- package/src/models/index.ts +6 -0
- package/src/models/limits.ts +36 -0
- package/src/models/parameterized.ts +416 -0
- package/src/models/processing.ts +313 -0
- package/src/proto/agent_pb.ts +14577 -0
- package/src/stream/bridge-session.ts +215 -0
- package/src/stream/client-transcript.ts +51 -0
- package/src/stream/config.ts +5 -0
- package/src/stream/context-normalize.ts +308 -0
- package/src/stream/context-usage.ts +168 -0
- package/src/stream/debug-log.ts +316 -0
- package/src/stream/drift.ts +122 -0
- package/src/stream/images.ts +201 -0
- package/src/stream/index.ts +68 -0
- package/src/stream/interaction-query.ts +369 -0
- package/src/stream/message-parsing.ts +402 -0
- package/src/stream/model-cache.ts +100 -0
- package/src/stream/model-discovery.ts +242 -0
- package/src/stream/model-routing.ts +100 -0
- package/src/stream/native-core.ts +2121 -0
- package/src/stream/pi-adapter.ts +414 -0
- package/src/stream/protocol.ts +63 -0
- package/src/stream/recovery.ts +494 -0
- package/src/stream/request-build.ts +668 -0
- package/src/stream/root-prompt.ts +184 -0
- package/src/stream/run-journal.ts +474 -0
- package/src/stream/run-usage.ts +107 -0
- package/src/stream/server-messages.ts +777 -0
- package/src/stream/session-state.ts +499 -0
- package/src/stream/stream-writer.ts +211 -0
- package/src/stream/thinking-filter.ts +63 -0
- package/src/stream/tool-schema.ts +185 -0
- package/src/stream/transport-errors.ts +150 -0
- package/src/stream/tuning.ts +250 -0
- package/src/stream/types.ts +330 -0
- package/src/types/enums.ts +103 -0
- package/src/types/index.ts +4 -0
- package/src/usage.ts +262 -0
- package/src/utils/cache-dir.ts +39 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/security.ts +68 -0
- package/src/utils/util.ts +43 -0
|
@@ -0,0 +1,2121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor native provider runtime: translates Pi streamSimple context to Cursor's
|
|
3
|
+
* protobuf/HTTP2 Connect protocol.
|
|
4
|
+
*
|
|
5
|
+
* Based on https://github.com/ephraimduncan/opencode-cursor by Ephraim Duncan.
|
|
6
|
+
* Uses Node's in-process http2 client with persistent streaming sessions.
|
|
7
|
+
*/
|
|
8
|
+
import { create, fromBinary, toBinary } from "@bufbuild/protobuf";
|
|
9
|
+
import {
|
|
10
|
+
createAssistantMessageEventStream,
|
|
11
|
+
type Api,
|
|
12
|
+
type AssistantMessageEventStream,
|
|
13
|
+
type Context,
|
|
14
|
+
type Model,
|
|
15
|
+
type SimpleStreamOptions,
|
|
16
|
+
} from "@earendil-works/pi-ai";
|
|
17
|
+
import {
|
|
18
|
+
AgentClientMessageSchema,
|
|
19
|
+
AgentServerMessageSchema,
|
|
20
|
+
ConversationStateStructureSchema,
|
|
21
|
+
ExecClientMessageSchema,
|
|
22
|
+
McpResultSchema,
|
|
23
|
+
McpSuccessSchema,
|
|
24
|
+
type McpToolDefinition,
|
|
25
|
+
} from "../proto/agent_pb.js";
|
|
26
|
+
import {
|
|
27
|
+
createConnectFrameParser,
|
|
28
|
+
frameConnectMessage,
|
|
29
|
+
parseConnectEndStream,
|
|
30
|
+
type BridgeHandle,
|
|
31
|
+
type ConnectFrameDesyncDiagnostics,
|
|
32
|
+
} from "../client/bridge.js";
|
|
33
|
+
import type { CursorModelParameter } from "../client/cursor-wire.js";
|
|
34
|
+
export type {
|
|
35
|
+
CursorModelParameter,
|
|
36
|
+
CursorParameterizedModel,
|
|
37
|
+
CursorParameterizedVariant,
|
|
38
|
+
} from "../client/cursor-wire.js";
|
|
39
|
+
|
|
40
|
+
import { processServerMessage } from "./server-messages.js";
|
|
41
|
+
import { reportRunUsageBoundary, retainRunReceipt, takePendingRunReceipts } from "./run-usage.js";
|
|
42
|
+
import { positiveContextTokens } from "./context-usage.js";
|
|
43
|
+
import { createThinkingTagFilter } from "./thinking-filter.js";
|
|
44
|
+
import {
|
|
45
|
+
contextToCursorChatCompletionRequest,
|
|
46
|
+
nativeRequestParameterError,
|
|
47
|
+
resolveNativeReasoningEffort,
|
|
48
|
+
resolveToolsForToolChoice,
|
|
49
|
+
} from "./pi-adapter.js";
|
|
50
|
+
import {
|
|
51
|
+
clearStoredMidPauseMetadata,
|
|
52
|
+
commitStoredCheckpoint,
|
|
53
|
+
commitStoredCheckpointMidPause,
|
|
54
|
+
conversationStates,
|
|
55
|
+
deriveBridgeKey,
|
|
56
|
+
deriveConversationKey,
|
|
57
|
+
derivePiSessionId,
|
|
58
|
+
deriveRequestLockKey,
|
|
59
|
+
deterministicConversationId,
|
|
60
|
+
discardStaleCheckpointIfNeeded,
|
|
61
|
+
evictStaleConversations,
|
|
62
|
+
fingerprintCompletedTurns,
|
|
63
|
+
getOrHydrateConversation,
|
|
64
|
+
handleBridgeCloseMidPause,
|
|
65
|
+
mergeBlobStore,
|
|
66
|
+
persistAbortedConversationState,
|
|
67
|
+
trimBlobStore,
|
|
68
|
+
withSessionLock,
|
|
69
|
+
} from "./session-state.js";
|
|
70
|
+
export {
|
|
71
|
+
cleanupAllSessionState,
|
|
72
|
+
cleanupSessionState,
|
|
73
|
+
commitStoredCheckpointMidPause,
|
|
74
|
+
deriveBridgeKey,
|
|
75
|
+
deriveBridgeKeyFromSessionId,
|
|
76
|
+
deriveConversationKey,
|
|
77
|
+
deriveConversationKeyFromSessionId,
|
|
78
|
+
derivePiSessionId,
|
|
79
|
+
deterministicConversationId,
|
|
80
|
+
evictStaleConversations,
|
|
81
|
+
fingerprintCompletedTurns,
|
|
82
|
+
handleBridgeCloseMidPause,
|
|
83
|
+
type HandleBridgeCloseMidPauseInput,
|
|
84
|
+
} from "./session-state.js";
|
|
85
|
+
import {
|
|
86
|
+
buildCursorRequest,
|
|
87
|
+
buildMcpSuccessContent,
|
|
88
|
+
buildMcpToolDefinitions,
|
|
89
|
+
isIdentityConversationalTurn,
|
|
90
|
+
isTrivialConversationalTurn,
|
|
91
|
+
normalizeToolResultForTransport,
|
|
92
|
+
summarizeRequestSize,
|
|
93
|
+
} from "./request-build.js";
|
|
94
|
+
export {
|
|
95
|
+
buildCursorRequest,
|
|
96
|
+
isIdentityConversationalTurn,
|
|
97
|
+
isSlimToolsEnabled,
|
|
98
|
+
isTrivialConversationalTurn,
|
|
99
|
+
slimOpenAIToolsForCursor,
|
|
100
|
+
summarizeRequestSize,
|
|
101
|
+
type BuildCursorRequestOptions,
|
|
102
|
+
} from "./request-build.js";
|
|
103
|
+
import { hashSystemPrompt } from "./root-prompt.js";
|
|
104
|
+
export {
|
|
105
|
+
buildRootPromptMessages,
|
|
106
|
+
hashSystemPrompt,
|
|
107
|
+
isPromptHistoryEnabled,
|
|
108
|
+
turnRootMessages,
|
|
109
|
+
} from "./root-prompt.js";
|
|
110
|
+
import {
|
|
111
|
+
appendAssistantTextToTurn,
|
|
112
|
+
getTurnToolCallResults,
|
|
113
|
+
parseMessages,
|
|
114
|
+
parseToolCallArguments,
|
|
115
|
+
stripInFlightResults,
|
|
116
|
+
systemPromptHasSessionMemory,
|
|
117
|
+
} from "./message-parsing.js";
|
|
118
|
+
export {
|
|
119
|
+
frameContextModeSideChannel,
|
|
120
|
+
isContextModeSideChannelText,
|
|
121
|
+
normalizeMessagesForCursor,
|
|
122
|
+
parseMessages,
|
|
123
|
+
systemPromptHasSessionMemory,
|
|
124
|
+
} from "./message-parsing.js";
|
|
125
|
+
export {
|
|
126
|
+
callCursorUnaryRpc,
|
|
127
|
+
discoverCursorCatalog,
|
|
128
|
+
getCursorModels,
|
|
129
|
+
getCursorParameterizedModels,
|
|
130
|
+
inferCursorContextWindow,
|
|
131
|
+
type CursorCatalog,
|
|
132
|
+
type CursorModel,
|
|
133
|
+
} from "./model-discovery.js";
|
|
134
|
+
export { readCachedCatalog, writeCachedCatalog } from "./model-cache.js";
|
|
135
|
+
import {
|
|
136
|
+
activeBridges,
|
|
137
|
+
cleanupBridge,
|
|
138
|
+
parkIdleBridge,
|
|
139
|
+
removeActiveBridge,
|
|
140
|
+
setActiveBridge,
|
|
141
|
+
startBridge,
|
|
142
|
+
} from "./bridge-session.js";
|
|
143
|
+
export { setBridgeFactoryForTests } from "./bridge-session.js";
|
|
144
|
+
import {
|
|
145
|
+
canBlindIdleRestart,
|
|
146
|
+
canRecoverAfterTransportLoss,
|
|
147
|
+
createStreamIdleWatchdog,
|
|
148
|
+
DEFAULT_STREAM_PARK_TIMEOUT_MS,
|
|
149
|
+
interactionUpdateProgress,
|
|
150
|
+
resolveH2ConnectTimeoutMs,
|
|
151
|
+
resolveH2IdleTimeoutMs,
|
|
152
|
+
resolveMidPauseRebuildMaxAgeMs,
|
|
153
|
+
resolveResumeIdleTimeoutMs,
|
|
154
|
+
resolveStreamIdleMaxRetries,
|
|
155
|
+
resolveStreamIdleTimeoutMs,
|
|
156
|
+
} from "./tuning.js";
|
|
157
|
+
export {
|
|
158
|
+
canBlindIdleRestart,
|
|
159
|
+
canRecoverAfterTransportLoss,
|
|
160
|
+
interactionUpdateProgress,
|
|
161
|
+
resolveActiveBridgeTtlMs,
|
|
162
|
+
resolveH2ConnectTimeoutMs,
|
|
163
|
+
resolveH2IdleTimeoutMs,
|
|
164
|
+
resolveResumeIdleTimeoutMs,
|
|
165
|
+
resolveStreamIdleMaxRetries,
|
|
166
|
+
resolveStreamIdleTimeoutMs,
|
|
167
|
+
} from "./tuning.js";
|
|
168
|
+
import {
|
|
169
|
+
CHECKPOINT_CONTINUATION_PROMPT,
|
|
170
|
+
classifyBridgeExit,
|
|
171
|
+
formatTransportFailure,
|
|
172
|
+
} from "./transport-errors.js";
|
|
173
|
+
export {
|
|
174
|
+
CHECKPOINT_CONTINUATION_PROMPT,
|
|
175
|
+
classifyBridgeExit,
|
|
176
|
+
formatTransportFailure,
|
|
177
|
+
type TransportFailure,
|
|
178
|
+
} from "./transport-errors.js";
|
|
179
|
+
import {
|
|
180
|
+
debugBase64ImageSummary,
|
|
181
|
+
debugLog,
|
|
182
|
+
decodeRequestForTests,
|
|
183
|
+
lifecycleLog,
|
|
184
|
+
nextDebugRequestId,
|
|
185
|
+
redactForDebug,
|
|
186
|
+
reportCursorAnomaly,
|
|
187
|
+
setMetricEmitter,
|
|
188
|
+
type MetricEmitter,
|
|
189
|
+
} from "./debug-log.js";
|
|
190
|
+
import { cloneParsedImage } from "./images.js";
|
|
191
|
+
import {
|
|
192
|
+
resolveModelId as resolveModelIdImpl,
|
|
193
|
+
resolveRequestedModelId as resolveRequestedModelIdImpl,
|
|
194
|
+
type CursorNativeModelRouting as ExtractedCursorNativeModelRouting,
|
|
195
|
+
type CursorResolvableModel as ExtractedCursorResolvableModel,
|
|
196
|
+
type ResolvedCursorModelRouting as ExtractedResolvedCursorModelRouting,
|
|
197
|
+
} from "./model-routing.js";
|
|
198
|
+
import { liveTranscript, withSyntheticCurrentTurn } from "./client-transcript.js";
|
|
199
|
+
import {
|
|
200
|
+
planRecovery as planRecoveryImpl,
|
|
201
|
+
wrapRecoveredToolResults as wrapRecoveredToolResultsImpl,
|
|
202
|
+
collapseToolResultsById as collapseToolResultsByIdImpl,
|
|
203
|
+
lostToolContinuationErrorBody as lostToolContinuationErrorBodyImpl,
|
|
204
|
+
formatLostToolContinuationDiagnostic as formatLostToolContinuationDiagnosticImpl,
|
|
205
|
+
lostToolContinuationMessage as lostToolContinuationMessageImpl,
|
|
206
|
+
bridgeKeyPrefix as bridgeKeyPrefixImpl,
|
|
207
|
+
type RecoveryDecision as ExtractedRecoveryDecision,
|
|
208
|
+
type PlanRecoveryInput as ExtractedPlanRecoveryInput,
|
|
209
|
+
type LostToolContinuationDiagnosticInput as ExtractedLostToolContinuationDiagnosticInput,
|
|
210
|
+
} from "./recovery.js";
|
|
211
|
+
import { enhanceCursorStreamError, isAuthErrorMessage } from "./protocol.js";
|
|
212
|
+
import {
|
|
213
|
+
setLastIdleTimeout,
|
|
214
|
+
setLastRecoverySkipReason,
|
|
215
|
+
setLastRequestSize,
|
|
216
|
+
setLastStreamEvent,
|
|
217
|
+
} from "../diagnostics/diagnostics.js";
|
|
218
|
+
|
|
219
|
+
// URL resolution lives in ./config.ts
|
|
220
|
+
export { getCursorAgentUrl } from "./config.js";
|
|
221
|
+
|
|
222
|
+
// ── Types ──
|
|
223
|
+
//
|
|
224
|
+
// The shared structural types live in ./types.ts so recovery/parsing/building
|
|
225
|
+
// modules can reference them without importing this runtime.
|
|
226
|
+
|
|
227
|
+
import type {
|
|
228
|
+
ActiveBridge,
|
|
229
|
+
ChatCompletionRequest,
|
|
230
|
+
CheckpointRef,
|
|
231
|
+
ClientTranscript,
|
|
232
|
+
CursorNativeStreamConfig,
|
|
233
|
+
CursorNativeStreamOptions,
|
|
234
|
+
IdleRestartContext,
|
|
235
|
+
NativeStreamAttemptInput,
|
|
236
|
+
NativeStreamWriter,
|
|
237
|
+
ParsedImageContent,
|
|
238
|
+
ParsedMessages,
|
|
239
|
+
ParsedTurn,
|
|
240
|
+
ParsedToolCallStep,
|
|
241
|
+
PendingExec,
|
|
242
|
+
StreamIdleRetryController,
|
|
243
|
+
StreamState,
|
|
244
|
+
ToolResultInfo,
|
|
245
|
+
} from "./types.js";
|
|
246
|
+
|
|
247
|
+
export type {
|
|
248
|
+
CursorNativeStreamConfig,
|
|
249
|
+
ParsedAssistantTextStep,
|
|
250
|
+
ParsedImageContent,
|
|
251
|
+
ParsedToolCallStep,
|
|
252
|
+
ParsedToolResult,
|
|
253
|
+
ParsedTurn,
|
|
254
|
+
ParsedTurnStep,
|
|
255
|
+
StoredConversation,
|
|
256
|
+
} from "./types.js";
|
|
257
|
+
|
|
258
|
+
// ── State ──
|
|
259
|
+
|
|
260
|
+
export const __testInternals = {
|
|
261
|
+
activeBridges,
|
|
262
|
+
conversationStates,
|
|
263
|
+
createStreamIdleWatchdog,
|
|
264
|
+
canBlindIdleRestart,
|
|
265
|
+
canRecoverAfterTransportLoss,
|
|
266
|
+
clearStoredMidPauseMetadata,
|
|
267
|
+
collectToolResultImages,
|
|
268
|
+
debugBase64ImageSummary,
|
|
269
|
+
decodeRequestForTests,
|
|
270
|
+
discardStaleCheckpointIfNeeded,
|
|
271
|
+
fingerprintCompletedTurns,
|
|
272
|
+
getOrHydrateConversation,
|
|
273
|
+
interactionUpdateProgress,
|
|
274
|
+
redactForDebug,
|
|
275
|
+
resolveH2ConnectTimeoutMs,
|
|
276
|
+
resolveH2IdleTimeoutMs,
|
|
277
|
+
resolveMidPauseRebuildMaxAgeMs,
|
|
278
|
+
resolveNativeReasoningEffort,
|
|
279
|
+
resolveResumeIdleTimeoutMs,
|
|
280
|
+
resolveStreamIdleMaxRetries,
|
|
281
|
+
resolveStreamIdleTimeoutMs,
|
|
282
|
+
persistAbortedConversationState,
|
|
283
|
+
trimBlobStore,
|
|
284
|
+
classifyBridgeExit,
|
|
285
|
+
writeNativeStream,
|
|
286
|
+
logFullHistoryRebuild,
|
|
287
|
+
setMetricEmitterForTests(factory?: MetricEmitter) {
|
|
288
|
+
setMetricEmitter(factory);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// ── Native pi streamSimple provider ──
|
|
293
|
+
|
|
294
|
+
export type CursorNativeModelRouting = ExtractedCursorNativeModelRouting;
|
|
295
|
+
import { createNativeStreamWriter } from "./stream-writer.js";
|
|
296
|
+
export { createNativeStreamWriter } from "./stream-writer.js";
|
|
297
|
+
|
|
298
|
+
function lostToolContinuationMessage(): string {
|
|
299
|
+
return lostToolContinuationMessageImpl();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export type LostToolContinuationDiagnosticInput = ExtractedLostToolContinuationDiagnosticInput;
|
|
303
|
+
|
|
304
|
+
export function lostToolContinuationErrorBody(input: LostToolContinuationDiagnosticInput): {
|
|
305
|
+
error: Record<string, unknown>;
|
|
306
|
+
} {
|
|
307
|
+
return lostToolContinuationErrorBodyImpl(input);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function bridgeKeyPrefix(bridgeKey: string): string {
|
|
311
|
+
return bridgeKeyPrefixImpl(bridgeKey);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function formatLostToolContinuationDiagnostic(
|
|
315
|
+
input: LostToolContinuationDiagnosticInput,
|
|
316
|
+
): string {
|
|
317
|
+
return formatLostToolContinuationDiagnosticImpl(input);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function wrapRecoveredToolResults(
|
|
321
|
+
toolResults: Array<Pick<ToolResultInfo, "toolCallId" | "content">>,
|
|
322
|
+
recoveryId: string = crypto.randomUUID(),
|
|
323
|
+
): string {
|
|
324
|
+
return wrapRecoveredToolResultsImpl(toolResults, recoveryId);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function collectToolResultImages(toolResults: ToolResultInfo[]): ParsedImageContent[] {
|
|
328
|
+
return collapseToolResultsByIdImpl(toolResults).flatMap((result) =>
|
|
329
|
+
(result.images ?? []).map(cloneParsedImage),
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function toolResultsContainRecoverySentinel(
|
|
334
|
+
toolResults: Array<Pick<ToolResultInfo, "content">>,
|
|
335
|
+
): boolean {
|
|
336
|
+
return toolResults.some(
|
|
337
|
+
(result) =>
|
|
338
|
+
result.content.includes("[Recovered tool output after upstream bridge loss") ||
|
|
339
|
+
result.content.includes("[End recovered tool output"),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parsedTurnHasImages(turn: ParsedTurn): boolean {
|
|
344
|
+
return (turn.userImages?.length ?? 0) > 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
type FullHistoryRebuildDecision = Extract<RecoveryDecision, { kind: "rebuild_full_history" }>;
|
|
348
|
+
|
|
349
|
+
function logFullHistoryRebuild(
|
|
350
|
+
event: "native.rebuild_full_history" | "chat.rebuild_full_history",
|
|
351
|
+
input: {
|
|
352
|
+
requestId?: string;
|
|
353
|
+
bridgeKey: string;
|
|
354
|
+
convKey: string;
|
|
355
|
+
modelId: string;
|
|
356
|
+
decision: FullHistoryRebuildDecision;
|
|
357
|
+
},
|
|
358
|
+
): void {
|
|
359
|
+
const fields = {
|
|
360
|
+
requestId: input.requestId,
|
|
361
|
+
bridgeKeyPrefix: bridgeKeyPrefix(input.bridgeKey),
|
|
362
|
+
convKey: input.convKey,
|
|
363
|
+
modelId: input.modelId,
|
|
364
|
+
rebuildReason: input.decision.rebuildReason,
|
|
365
|
+
completedTurnCount: input.decision.completedTurns.length,
|
|
366
|
+
inFlightTurnHasImages: parsedTurnHasImages(input.decision.inFlightTurn),
|
|
367
|
+
toolResultCount: input.decision.toolResults.length,
|
|
368
|
+
pendingToolCallIds: input.decision.toolResults.map((result) => result.toolCallId),
|
|
369
|
+
sentinelInjectionDetected: toolResultsContainRecoverySentinel(input.decision.toolResults),
|
|
370
|
+
};
|
|
371
|
+
debugLog(event, fields);
|
|
372
|
+
const lifecycleFields = {
|
|
373
|
+
reason: input.decision.rebuildReason,
|
|
374
|
+
modelId: input.modelId,
|
|
375
|
+
convKey: input.convKey,
|
|
376
|
+
requestId: input.requestId,
|
|
377
|
+
bridgeKeyPrefix: bridgeKeyPrefix(input.bridgeKey),
|
|
378
|
+
};
|
|
379
|
+
debugLog("metric.cursor_provider.rebuild_full_history", lifecycleFields);
|
|
380
|
+
reportCursorAnomaly(
|
|
381
|
+
"rebuild_full_history",
|
|
382
|
+
`Cursor rebuilt conversation history (${input.decision.rebuildReason})`,
|
|
383
|
+
lifecycleFields,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export type RecoveryDecision = ExtractedRecoveryDecision;
|
|
388
|
+
export type PlanRecoveryInput = ExtractedPlanRecoveryInput;
|
|
389
|
+
|
|
390
|
+
export function planRecovery(input: PlanRecoveryInput): RecoveryDecision {
|
|
391
|
+
return planRecoveryImpl({
|
|
392
|
+
...input,
|
|
393
|
+
discardStaleCheckpoint: discardStaleCheckpointIfNeeded,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function createCursorNativeStream(
|
|
398
|
+
config: CursorNativeStreamConfig,
|
|
399
|
+
): (
|
|
400
|
+
model: Model<Api>,
|
|
401
|
+
context: Context,
|
|
402
|
+
options?: SimpleStreamOptions,
|
|
403
|
+
) => AssistantMessageEventStream {
|
|
404
|
+
return (model, context, options) => {
|
|
405
|
+
const stream = createAssistantMessageEventStream();
|
|
406
|
+
const writer = createNativeStreamWriter(stream, model, context, options);
|
|
407
|
+
writer.start();
|
|
408
|
+
|
|
409
|
+
(async () => {
|
|
410
|
+
let body = contextToCursorChatCompletionRequest(
|
|
411
|
+
model,
|
|
412
|
+
context,
|
|
413
|
+
options as CursorNativeStreamOptions | undefined,
|
|
414
|
+
config,
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (options?.onPayload) {
|
|
418
|
+
const replacement = await options.onPayload(body, model);
|
|
419
|
+
if (replacement && typeof replacement === "object")
|
|
420
|
+
body = replacement as ChatCompletionRequest;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
await withSessionLock(deriveRequestLockKey(body), async () => {
|
|
424
|
+
if (writer.closed) return;
|
|
425
|
+
const accessToken = await config.getAccessToken();
|
|
426
|
+
await handleCursorNativeRequest(
|
|
427
|
+
body,
|
|
428
|
+
accessToken,
|
|
429
|
+
model,
|
|
430
|
+
options as CursorNativeStreamOptions | undefined,
|
|
431
|
+
writer,
|
|
432
|
+
nextDebugRequestId(),
|
|
433
|
+
config.getAccessToken,
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
})().catch((error) => {
|
|
437
|
+
writer.error(error instanceof Error ? error.message : String(error), "error");
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
return stream;
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function handleCursorNativeRequest(
|
|
445
|
+
body: ChatCompletionRequest,
|
|
446
|
+
accessToken: string,
|
|
447
|
+
model: Model<Api>,
|
|
448
|
+
options: CursorNativeStreamOptions | undefined,
|
|
449
|
+
writer: NativeStreamWriter,
|
|
450
|
+
requestId: string,
|
|
451
|
+
getAccessToken?: (options?: { forceRefresh?: boolean }) => Promise<string>,
|
|
452
|
+
): Promise<void> {
|
|
453
|
+
let parsedMessages: ParsedMessages;
|
|
454
|
+
try {
|
|
455
|
+
parsedMessages = parseMessages(body.messages, body.cursor_tool_result_images);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
writer.error(error instanceof Error ? error.message : String(error), "error");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const parameterError = nativeRequestParameterError(body);
|
|
462
|
+
if (parameterError) {
|
|
463
|
+
debugLog("native.unsupported_parameters", { requestId, message: parameterError });
|
|
464
|
+
writer.error(parameterError, "error");
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const toolResolution = resolveToolsForToolChoice(body.tools ?? [], body.tool_choice);
|
|
469
|
+
if ("error" in toolResolution) {
|
|
470
|
+
debugLog("native.unsupported_tool_choice", { requestId, tool_choice: body.tool_choice });
|
|
471
|
+
writer.error(toolResolution.error, "error");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const { systemPrompt, userText, userImages, turns, toolResults, inFlightTurn } = parsedMessages;
|
|
476
|
+
const omitToolsForTrivialTurn =
|
|
477
|
+
toolResolution.tools.length > 0 &&
|
|
478
|
+
toolResults.length === 0 &&
|
|
479
|
+
userImages.length === 0 &&
|
|
480
|
+
isTrivialConversationalTurn(userText);
|
|
481
|
+
const selectedTools = omitToolsForTrivialTurn ? [] : toolResolution.tools;
|
|
482
|
+
// Greetings do not need Pi's large agent prompt: sending it can cost tens of
|
|
483
|
+
// thousands of input tokens before the user text is even considered. Keep the
|
|
484
|
+
// full prompt for anything actionable, for identity/capability questions the
|
|
485
|
+
// prompt itself answers, and whenever it carries folded session/compaction
|
|
486
|
+
// memory.
|
|
487
|
+
const PI_MCP_TOOLS_ONLY =
|
|
488
|
+
"You are running inside Pi, not the Cursor IDE. " +
|
|
489
|
+
"Cursor-native tools (read, write, ls, grep, shell, fetch, delete) are not available. " +
|
|
490
|
+
"Use only the MCP tools listed in this request. " +
|
|
491
|
+
"Do not re-list the workspace or re-read files to recover context unless the latest user message asks you to.";
|
|
492
|
+
// Even a dropped prompt leaves this much behind: without it the model answers
|
|
493
|
+
// a greeting as Cursor's IDE assistant.
|
|
494
|
+
const PI_IDENTITY_ONLY = "You are running inside Pi, not the Cursor IDE.";
|
|
495
|
+
const dropSystemPrompt =
|
|
496
|
+
omitToolsForTrivialTurn &&
|
|
497
|
+
!isIdentityConversationalTurn(userText) &&
|
|
498
|
+
!systemPromptHasSessionMemory(systemPrompt);
|
|
499
|
+
let effectiveSystemPrompt = dropSystemPrompt ? PI_IDENTITY_ONLY : systemPrompt;
|
|
500
|
+
if (selectedTools.length > 0) {
|
|
501
|
+
effectiveSystemPrompt = effectiveSystemPrompt
|
|
502
|
+
? `${effectiveSystemPrompt}\n\n${PI_MCP_TOOLS_ONLY}`
|
|
503
|
+
: PI_MCP_TOOLS_ONLY;
|
|
504
|
+
}
|
|
505
|
+
if (omitToolsForTrivialTurn) {
|
|
506
|
+
setLastStreamEvent("tools_omitted_trivial_turn");
|
|
507
|
+
lifecycleLog("tools_omitted", {
|
|
508
|
+
requestId,
|
|
509
|
+
reason: "trivial_conversational_turn",
|
|
510
|
+
originalToolCount: toolResolution.tools.length,
|
|
511
|
+
systemPromptDropped: dropSystemPrompt,
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const modelId = resolveRequestedModelId(body.model, body.reasoning_effort, body.cursor_model_id);
|
|
515
|
+
const maxMode =
|
|
516
|
+
typeof body.cursor_model_max_mode === "boolean"
|
|
517
|
+
? body.cursor_model_max_mode
|
|
518
|
+
: body.cursor_requires_max_mode === true;
|
|
519
|
+
const sessionId = derivePiSessionId(body);
|
|
520
|
+
const bridgeKey = deriveBridgeKey(body.messages, sessionId);
|
|
521
|
+
const convKey = deriveConversationKey(body.messages, sessionId);
|
|
522
|
+
const activeBridge = activeBridges.get(bridgeKey);
|
|
523
|
+
if (writer.carryUsage) {
|
|
524
|
+
for (const receipt of takePendingRunReceipts(conversationStates.get(convKey), model.id))
|
|
525
|
+
writer.carryUsage(receipt);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
debugLog("native.request", {
|
|
529
|
+
requestId,
|
|
530
|
+
sessionId,
|
|
531
|
+
bridgeKey,
|
|
532
|
+
convKey,
|
|
533
|
+
model: body.model,
|
|
534
|
+
resolvedModelId: modelId,
|
|
535
|
+
cursorModelId: body.cursor_model_id,
|
|
536
|
+
cursorModelParameters: body.cursor_model_parameters,
|
|
537
|
+
cursorRequiresMaxMode: body.cursor_requires_max_mode,
|
|
538
|
+
cursorModelMaxMode: body.cursor_model_max_mode,
|
|
539
|
+
maxMode,
|
|
540
|
+
messageCount: body.messages.length,
|
|
541
|
+
turnCount: turns.length,
|
|
542
|
+
userText,
|
|
543
|
+
toolResults,
|
|
544
|
+
inFlightTurn,
|
|
545
|
+
hasActiveBridge: !!activeBridge,
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
if (!userText && userImages.length === 0 && toolResults.length === 0) {
|
|
549
|
+
writer.error("No user message found", "error");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (toolResults.length > 0) {
|
|
554
|
+
const resumeIdleTimeoutMs = resolveResumeIdleTimeoutMs(
|
|
555
|
+
process.env.PI_CURSOR_RESUME_IDLE_TIMEOUT_MS,
|
|
556
|
+
);
|
|
557
|
+
if (activeBridge) {
|
|
558
|
+
removeActiveBridge(bridgeKey);
|
|
559
|
+
// Without a Pi session id the bridge key is only a hash of the opening user message, so two
|
|
560
|
+
// conversations that start alike land on the same key. Resuming the wrong bridge would splice
|
|
561
|
+
// one conversation's tool results into another; the history fingerprint is what tells them
|
|
562
|
+
// apart. Recovery already fingerprints — this closes the same hole on the live path.
|
|
563
|
+
//
|
|
564
|
+
// Scoped to sessionless keys on purpose. A session-derived key cannot collide, so there the
|
|
565
|
+
// check could only ever produce false negatives — tearing down a healthy bridge if the client
|
|
566
|
+
// reshapes its history mid-turn — with no collision to protect against.
|
|
567
|
+
const currentHistoryFingerprint = fingerprintCompletedTurns(turns);
|
|
568
|
+
const historyMatches =
|
|
569
|
+
!!sessionId || activeBridge.historyFingerprint === currentHistoryFingerprint;
|
|
570
|
+
if (!historyMatches) {
|
|
571
|
+
debugLog("bridge.active_history_mismatch", {
|
|
572
|
+
requestId,
|
|
573
|
+
bridgeKey,
|
|
574
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
575
|
+
convKey,
|
|
576
|
+
storedFingerprint: activeBridge.historyFingerprint,
|
|
577
|
+
currentFingerprint: currentHistoryFingerprint,
|
|
578
|
+
});
|
|
579
|
+
setLastStreamEvent("active_bridge_history_mismatch");
|
|
580
|
+
}
|
|
581
|
+
if (activeBridge.bridge.alive && historyMatches) {
|
|
582
|
+
handleNativeToolResultResume(
|
|
583
|
+
activeBridge,
|
|
584
|
+
toolResults,
|
|
585
|
+
{
|
|
586
|
+
accessToken,
|
|
587
|
+
systemPrompt,
|
|
588
|
+
model,
|
|
589
|
+
modelId,
|
|
590
|
+
bridgeKey,
|
|
591
|
+
convKey,
|
|
592
|
+
sessionId,
|
|
593
|
+
completedTurns: turns,
|
|
594
|
+
inFlightTurn,
|
|
595
|
+
maxMode,
|
|
596
|
+
cursorModelParameters: body.cursor_model_parameters ?? [],
|
|
597
|
+
getAccessToken,
|
|
598
|
+
},
|
|
599
|
+
writer,
|
|
600
|
+
options,
|
|
601
|
+
requestId,
|
|
602
|
+
);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
clearInterval(activeBridge.heartbeatTimer);
|
|
606
|
+
activeBridge.bridge.end();
|
|
607
|
+
}
|
|
608
|
+
const recoveryStored = getOrHydrateConversation(convKey);
|
|
609
|
+
const decision = planRecovery({
|
|
610
|
+
stored: recoveryStored,
|
|
611
|
+
toolResults,
|
|
612
|
+
completedTurns: turns,
|
|
613
|
+
inFlightTurn,
|
|
614
|
+
sessionId,
|
|
615
|
+
requestId,
|
|
616
|
+
convKey,
|
|
617
|
+
});
|
|
618
|
+
if (decision.kind === "recover") {
|
|
619
|
+
setLastStreamEvent("recovered_via_checkpoint");
|
|
620
|
+
debugLog("bridge.recovered_via_checkpoint", {
|
|
621
|
+
requestId,
|
|
622
|
+
bridgeKey,
|
|
623
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
624
|
+
convKey,
|
|
625
|
+
recoveryPath: "stored_checkpoint",
|
|
626
|
+
pendingToolCallIds: toolResults.map((r) => r.toolCallId),
|
|
627
|
+
});
|
|
628
|
+
const mcpTools = buildMcpToolDefinitions(selectedTools);
|
|
629
|
+
// Images ride the recovered user turn on this path too — dropping them here silently lost
|
|
630
|
+
// screenshots that the rebuild path preserves.
|
|
631
|
+
const recoveredUserImages = collectToolResultImages(toolResults);
|
|
632
|
+
const recoveredCurrentTurn: ParsedTurn = {
|
|
633
|
+
userText: decision.wrappedText,
|
|
634
|
+
steps: [],
|
|
635
|
+
...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
|
|
636
|
+
};
|
|
637
|
+
const payload = buildCursorRequest({
|
|
638
|
+
modelId,
|
|
639
|
+
systemPrompt,
|
|
640
|
+
userText: decision.wrappedText,
|
|
641
|
+
userImages: recoveredUserImages,
|
|
642
|
+
turns,
|
|
643
|
+
conversationId: decision.conversationId,
|
|
644
|
+
checkpoint: decision.checkpoint,
|
|
645
|
+
existingBlobStore: decision.blobStore,
|
|
646
|
+
maxMode,
|
|
647
|
+
cursorModelParameters: body.cursor_model_parameters,
|
|
648
|
+
mcpTools,
|
|
649
|
+
});
|
|
650
|
+
payload.mcpTools = mcpTools;
|
|
651
|
+
startNativeStreamWithIdleRetries({
|
|
652
|
+
accessToken,
|
|
653
|
+
requestBytes: payload.requestBytes,
|
|
654
|
+
blobStore: payload.blobStore,
|
|
655
|
+
mcpTools: payload.mcpTools,
|
|
656
|
+
model,
|
|
657
|
+
modelId,
|
|
658
|
+
bridgeKey,
|
|
659
|
+
convKey,
|
|
660
|
+
completedTurns: turns,
|
|
661
|
+
contextCheckpoint: payload.contextCheckpoint,
|
|
662
|
+
currentTurn: recoveredCurrentTurn,
|
|
663
|
+
writer,
|
|
664
|
+
options,
|
|
665
|
+
requestId,
|
|
666
|
+
streamIdleTimeoutMs: resumeIdleTimeoutMs,
|
|
667
|
+
getAccessToken,
|
|
668
|
+
systemPrompt,
|
|
669
|
+
conversationId: decision.conversationId,
|
|
670
|
+
maxMode,
|
|
671
|
+
cursorModelParameters: body.cursor_model_parameters ?? [],
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (decision.kind === "rebuild_full_history") {
|
|
676
|
+
setLastStreamEvent("rebuild_full_history");
|
|
677
|
+
logFullHistoryRebuild("native.rebuild_full_history", {
|
|
678
|
+
requestId,
|
|
679
|
+
bridgeKey,
|
|
680
|
+
convKey,
|
|
681
|
+
modelId,
|
|
682
|
+
decision,
|
|
683
|
+
});
|
|
684
|
+
const mcpTools = buildMcpToolDefinitions(selectedTools);
|
|
685
|
+
const rebuiltCompletedTurns = [...decision.completedTurns, decision.inFlightTurn];
|
|
686
|
+
const recoveredUserImages = collectToolResultImages(decision.toolResults);
|
|
687
|
+
const recoveredCurrentTurn: ParsedTurn = {
|
|
688
|
+
userText: decision.wrappedText,
|
|
689
|
+
steps: [],
|
|
690
|
+
...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
|
|
691
|
+
};
|
|
692
|
+
const payload = buildCursorRequest({
|
|
693
|
+
modelId,
|
|
694
|
+
systemPrompt,
|
|
695
|
+
userText: decision.wrappedText,
|
|
696
|
+
userImages: recoveredUserImages,
|
|
697
|
+
turns: rebuiltCompletedTurns,
|
|
698
|
+
conversationId: decision.conversationId,
|
|
699
|
+
checkpoint: null,
|
|
700
|
+
existingBlobStore: decision.blobStore,
|
|
701
|
+
maxMode,
|
|
702
|
+
cursorModelParameters: body.cursor_model_parameters,
|
|
703
|
+
mcpTools,
|
|
704
|
+
});
|
|
705
|
+
payload.mcpTools = mcpTools;
|
|
706
|
+
if (recoveryStored) recoveryStored.lastAccessMs = Date.now();
|
|
707
|
+
startNativeStreamWithIdleRetries({
|
|
708
|
+
accessToken,
|
|
709
|
+
requestBytes: payload.requestBytes,
|
|
710
|
+
blobStore: payload.blobStore,
|
|
711
|
+
mcpTools: payload.mcpTools,
|
|
712
|
+
model,
|
|
713
|
+
modelId,
|
|
714
|
+
bridgeKey,
|
|
715
|
+
convKey,
|
|
716
|
+
completedTurns: rebuiltCompletedTurns,
|
|
717
|
+
contextCheckpoint: payload.contextCheckpoint,
|
|
718
|
+
currentTurn: recoveredCurrentTurn,
|
|
719
|
+
writer,
|
|
720
|
+
options,
|
|
721
|
+
requestId,
|
|
722
|
+
streamIdleTimeoutMs: resumeIdleTimeoutMs,
|
|
723
|
+
getAccessToken,
|
|
724
|
+
systemPrompt,
|
|
725
|
+
conversationId: decision.conversationId,
|
|
726
|
+
maxMode,
|
|
727
|
+
cursorModelParameters: body.cursor_model_parameters ?? [],
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
setLastRecoverySkipReason(decision.reason);
|
|
732
|
+
setLastStreamEvent(`recovery_skipped:${decision.reason}`);
|
|
733
|
+
debugLog("bridge.recovery_skipped", {
|
|
734
|
+
requestId,
|
|
735
|
+
bridgeKey,
|
|
736
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
737
|
+
convKey,
|
|
738
|
+
skipReason: decision.reason,
|
|
739
|
+
hadStoredCheckpoint: decision.hadStoredCheckpoint,
|
|
740
|
+
...(decision.expected !== undefined ? { expected: decision.expected } : {}),
|
|
741
|
+
...(decision.received !== undefined ? { received: decision.received } : {}),
|
|
742
|
+
});
|
|
743
|
+
const message = `${lostToolContinuationMessage()} ${formatLostToolContinuationDiagnostic({
|
|
744
|
+
bridgeKey,
|
|
745
|
+
hadStoredCheckpoint: decision.hadStoredCheckpoint,
|
|
746
|
+
skipReason: decision.reason,
|
|
747
|
+
})}`;
|
|
748
|
+
debugLog("native.lost_tool_continuation", {
|
|
749
|
+
requestId,
|
|
750
|
+
bridgeKey,
|
|
751
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
752
|
+
convKey,
|
|
753
|
+
skipReason: decision.reason,
|
|
754
|
+
toolResults,
|
|
755
|
+
message,
|
|
756
|
+
});
|
|
757
|
+
writer.error(message, "error");
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (activeBridge && activeBridges.has(bridgeKey)) {
|
|
762
|
+
clearInterval(activeBridge.heartbeatTimer);
|
|
763
|
+
activeBridge.bridge.end();
|
|
764
|
+
removeActiveBridge(bridgeKey);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
let stored = getOrHydrateConversation(convKey);
|
|
768
|
+
if (!stored) {
|
|
769
|
+
stored = {
|
|
770
|
+
conversationId: deterministicConversationId(convKey),
|
|
771
|
+
checkpoint: null,
|
|
772
|
+
sessionScoped: !!sessionId,
|
|
773
|
+
...(sessionId ? { sessionId } : {}),
|
|
774
|
+
blobStore: new Map(),
|
|
775
|
+
lastAccessMs: Date.now(),
|
|
776
|
+
};
|
|
777
|
+
conversationStates.set(convKey, stored);
|
|
778
|
+
}
|
|
779
|
+
stored.lastAccessMs = Date.now();
|
|
780
|
+
evictStaleConversations();
|
|
781
|
+
discardStaleCheckpointIfNeeded(stored, turns, requestId, convKey);
|
|
782
|
+
|
|
783
|
+
const mcpTools = buildMcpToolDefinitions(selectedTools);
|
|
784
|
+
const effectiveUserText = userText;
|
|
785
|
+
const effectiveUserImages = userText || userImages.length > 0 ? userImages : [];
|
|
786
|
+
// Pi rewrites its system prompt as a session evolves (context-mode folds
|
|
787
|
+
// session memory into it). A checkpoint carries the prompt recorded when the
|
|
788
|
+
// conversation started, so a changed prompt has to be re-published.
|
|
789
|
+
const systemPromptHash = hashSystemPrompt(effectiveSystemPrompt);
|
|
790
|
+
const refreshSystemPrompt = !!stored.checkpoint && stored.systemPromptHash !== systemPromptHash;
|
|
791
|
+
const payload = buildCursorRequest({
|
|
792
|
+
modelId,
|
|
793
|
+
systemPrompt: effectiveSystemPrompt,
|
|
794
|
+
userText: effectiveUserText,
|
|
795
|
+
turns,
|
|
796
|
+
conversationId: stored.conversationId,
|
|
797
|
+
checkpoint: stored.checkpoint,
|
|
798
|
+
existingBlobStore: stored.blobStore,
|
|
799
|
+
maxMode,
|
|
800
|
+
cursorModelParameters: body.cursor_model_parameters,
|
|
801
|
+
mcpTools,
|
|
802
|
+
userImages: effectiveUserImages,
|
|
803
|
+
refreshSystemPrompt,
|
|
804
|
+
});
|
|
805
|
+
stored.systemPromptHash = systemPromptHash;
|
|
806
|
+
payload.mcpTools = mcpTools;
|
|
807
|
+
|
|
808
|
+
const currentTurn: ParsedTurn = {
|
|
809
|
+
userText: effectiveUserText,
|
|
810
|
+
steps: [],
|
|
811
|
+
...(effectiveUserImages.length > 0 ? { userImages: effectiveUserImages } : {}),
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const size = summarizeRequestSize({
|
|
815
|
+
systemPrompt: effectiveSystemPrompt,
|
|
816
|
+
userText: effectiveUserText,
|
|
817
|
+
tools: selectedTools,
|
|
818
|
+
mcpTools,
|
|
819
|
+
requestBytes: payload.requestBytes,
|
|
820
|
+
blobStore: payload.blobStore,
|
|
821
|
+
turnCount: turns.length,
|
|
822
|
+
});
|
|
823
|
+
const sizeSummary =
|
|
824
|
+
`approxTokens=${size.approxInputTokens} systemChars=${size.systemChars} ` +
|
|
825
|
+
`userChars=${size.userChars} tools=${size.toolCount} toolJsonChars=${size.toolJsonChars} ` +
|
|
826
|
+
`mcpSchemaBytes=${size.mcpSchemaBytes} requestBytes=${size.requestBytes} ` +
|
|
827
|
+
`blobBytes=${size.blobBytes} wireBytes=${size.wireBytes} turns=${size.turnCount}`;
|
|
828
|
+
setLastRequestSize(sizeSummary);
|
|
829
|
+
lifecycleLog("request_size", {
|
|
830
|
+
requestId,
|
|
831
|
+
bridgeKey: bridgeKeyPrefix(bridgeKey),
|
|
832
|
+
convKey,
|
|
833
|
+
modelId,
|
|
834
|
+
...size,
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
debugLog("native.dispatch_stream", {
|
|
838
|
+
requestId,
|
|
839
|
+
bridgeKey,
|
|
840
|
+
convKey,
|
|
841
|
+
conversationId: stored.conversationId,
|
|
842
|
+
hasCheckpoint: !!stored.checkpoint,
|
|
843
|
+
requestSize: size,
|
|
844
|
+
payload,
|
|
845
|
+
});
|
|
846
|
+
startNativeStreamWithIdleRetries({
|
|
847
|
+
accessToken,
|
|
848
|
+
requestBytes: payload.requestBytes,
|
|
849
|
+
blobStore: payload.blobStore,
|
|
850
|
+
mcpTools: payload.mcpTools,
|
|
851
|
+
model,
|
|
852
|
+
modelId,
|
|
853
|
+
bridgeKey,
|
|
854
|
+
convKey,
|
|
855
|
+
completedTurns: turns,
|
|
856
|
+
contextCheckpoint: payload.contextCheckpoint,
|
|
857
|
+
currentTurn,
|
|
858
|
+
writer,
|
|
859
|
+
options,
|
|
860
|
+
requestId,
|
|
861
|
+
getAccessToken,
|
|
862
|
+
recoverBeforeRetry: true,
|
|
863
|
+
systemPrompt: effectiveSystemPrompt,
|
|
864
|
+
conversationId: stored.conversationId,
|
|
865
|
+
maxMode,
|
|
866
|
+
cursorModelParameters: body.cursor_model_parameters ?? [],
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function writeNativeStream(
|
|
871
|
+
bridge: BridgeHandle,
|
|
872
|
+
heartbeatTimer: ReturnType<typeof setInterval>,
|
|
873
|
+
blobStore: Map<string, Uint8Array>,
|
|
874
|
+
mcpTools: McpToolDefinition[],
|
|
875
|
+
_model: Model<Api>,
|
|
876
|
+
modelId: string,
|
|
877
|
+
bridgeKey: string,
|
|
878
|
+
convKey: string,
|
|
879
|
+
completedTurns: ParsedTurn[],
|
|
880
|
+
currentTurn: ParsedTurn,
|
|
881
|
+
writer: NativeStreamWriter,
|
|
882
|
+
options?: CursorNativeStreamOptions,
|
|
883
|
+
requestId?: string,
|
|
884
|
+
idleRetry?: StreamIdleRetryController,
|
|
885
|
+
streamIdleTimeoutMs = resolveStreamIdleTimeoutMs(process.env.PI_CURSOR_STREAM_IDLE_TIMEOUT_MS),
|
|
886
|
+
checkpointRef: CheckpointRef = { current: null },
|
|
887
|
+
preservedMidPauseExecs: PendingExec[] = [],
|
|
888
|
+
clientTranscript: ClientTranscript = liveTranscript(completedTurns),
|
|
889
|
+
): void {
|
|
890
|
+
const persistenceTurns = clientTranscript.completedTurns;
|
|
891
|
+
debugLog("native.stream.start", {
|
|
892
|
+
requestId,
|
|
893
|
+
bridgeKey,
|
|
894
|
+
convKey,
|
|
895
|
+
modelId,
|
|
896
|
+
attempt: idleRetry?.currentAttempt ?? 1,
|
|
897
|
+
maxRetries: idleRetry?.maxRetries ?? 0,
|
|
898
|
+
});
|
|
899
|
+
lifecycleLog("stream_start", {
|
|
900
|
+
requestId,
|
|
901
|
+
bridgeKey: bridgeKeyPrefix(bridgeKey),
|
|
902
|
+
convKey,
|
|
903
|
+
modelId,
|
|
904
|
+
attempt: idleRetry?.currentAttempt ?? 1,
|
|
905
|
+
});
|
|
906
|
+
const runUsage = (checkpointRef.usage ??= {});
|
|
907
|
+
runUsage.modelId ??= _model.id;
|
|
908
|
+
runUsage.rates ??= _model.cost;
|
|
909
|
+
const onUsageBoundary = (reason: string) => {
|
|
910
|
+
retainRunReceipt(conversationStates.get(convKey), runUsage);
|
|
911
|
+
reportRunUsageBoundary(runUsage, reason);
|
|
912
|
+
};
|
|
913
|
+
const state: StreamState = {
|
|
914
|
+
runUsage,
|
|
915
|
+
toolCallIndex: 0,
|
|
916
|
+
pendingExecs: [],
|
|
917
|
+
outputTokens: 0,
|
|
918
|
+
totalTokens: checkpointRef.contextTokens ?? 0,
|
|
919
|
+
turnEnded: false,
|
|
920
|
+
};
|
|
921
|
+
const tagFilter = createThinkingTagFilter();
|
|
922
|
+
let mcpExecReceived = false;
|
|
923
|
+
let cancelled = false;
|
|
924
|
+
let frameParseFailed = false;
|
|
925
|
+
let streamError: Error | null = null;
|
|
926
|
+
let emittedUserVisibleContent = false;
|
|
927
|
+
// Only execs the client was actually told about may be recorded as pending: recovery matches the
|
|
928
|
+
// snapshot against the tool results the client sends back, and it can only send back what it saw.
|
|
929
|
+
const emittedExecs: PendingExec[] = [];
|
|
930
|
+
// Cursor can emit several execs in one chunk. Closing the writer on the first would hide the
|
|
931
|
+
// rest, so the pause is deferred until the whole chunk has been parsed.
|
|
932
|
+
let pauseRequested = false;
|
|
933
|
+
let cachedHistoryFingerprint: string | undefined;
|
|
934
|
+
// An exec Cursor asked for and we could not answer. The run is waiting on a reply
|
|
935
|
+
// it will never recognize, so heartbeats stop counting as progress and the watchdog
|
|
936
|
+
// switches to the shorter park deadline until real work resumes.
|
|
937
|
+
let parkedExecCase: string | undefined;
|
|
938
|
+
// A park deadline can only be shorter than the silence deadline, and an explicitly
|
|
939
|
+
// disabled watchdog stays disabled.
|
|
940
|
+
const parkTimeoutMs =
|
|
941
|
+
streamIdleTimeoutMs <= 0 ? 0 : Math.min(streamIdleTimeoutMs, DEFAULT_STREAM_PARK_TIMEOUT_MS);
|
|
942
|
+
// Completed turns are fixed for the life of a stream, so this is hashed at most once.
|
|
943
|
+
const historyFingerprint = () =>
|
|
944
|
+
(cachedHistoryFingerprint ??= fingerprintCompletedTurns(completedTurns));
|
|
945
|
+
const idleWatchdog = createStreamIdleWatchdog({
|
|
946
|
+
timeoutMs: streamIdleTimeoutMs,
|
|
947
|
+
onTimeout: () => {
|
|
948
|
+
if (cancelled || writer.closed) return;
|
|
949
|
+
cancelled = true;
|
|
950
|
+
idleWatchdog.clear();
|
|
951
|
+
const attempt = idleRetry?.currentAttempt ?? 1;
|
|
952
|
+
const maxRetries = idleRetry?.maxRetries ?? 0;
|
|
953
|
+
const restartContext: IdleRestartContext = {
|
|
954
|
+
emittedUserVisibleContent,
|
|
955
|
+
latestCheckpoint: checkpointRef.current,
|
|
956
|
+
blobStore,
|
|
957
|
+
completedTurns,
|
|
958
|
+
currentTurn,
|
|
959
|
+
};
|
|
960
|
+
debugLog("native.stream.idle_timeout", {
|
|
961
|
+
requestId,
|
|
962
|
+
bridgeKey,
|
|
963
|
+
convKey,
|
|
964
|
+
modelId,
|
|
965
|
+
timeoutMs: streamIdleTimeoutMs,
|
|
966
|
+
attempt,
|
|
967
|
+
maxRetries,
|
|
968
|
+
emittedUserVisibleContent,
|
|
969
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
970
|
+
});
|
|
971
|
+
setLastIdleTimeout({
|
|
972
|
+
timeoutMs: parkedExecCase === undefined ? streamIdleTimeoutMs : parkTimeoutMs,
|
|
973
|
+
attempt,
|
|
974
|
+
event: parkedExecCase === undefined ? "idle_timeout" : "park_timeout",
|
|
975
|
+
});
|
|
976
|
+
persistAbortedConversationState(
|
|
977
|
+
convKey,
|
|
978
|
+
checkpointRef.current,
|
|
979
|
+
blobStore,
|
|
980
|
+
persistenceTurns,
|
|
981
|
+
currentTurn,
|
|
982
|
+
emittedExecs.length > 0 ? emittedExecs : preservedMidPauseExecs,
|
|
983
|
+
);
|
|
984
|
+
cleanupBridge(bridge, heartbeatTimer, bridgeKey);
|
|
985
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
986
|
+
|
|
987
|
+
// An unanswered exec is a schema gap, not silence. Retrying the same
|
|
988
|
+
// request re-issues the exec we still cannot decode.
|
|
989
|
+
if (parkedExecCase !== undefined) {
|
|
990
|
+
writer.error(formatStreamParkMessage(parkedExecCase, parkTimeoutMs), "error", state);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Blind restart is only safe with zero streamed content. Checkpoint
|
|
995
|
+
// continuation is safe even after partial text: Cursor resumes server
|
|
996
|
+
// state and emits only new tokens, which Pi appends to the writer.
|
|
997
|
+
const allowRestart = canRecoverAfterTransportLoss({
|
|
998
|
+
emittedUserVisibleContent,
|
|
999
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// Recovery is not a retry, so it can run even when maxRetries is zero.
|
|
1003
|
+
if (idleRetry?.recoverBeforeRetry && allowRestart) {
|
|
1004
|
+
debugLog("native.stream.idle_recovery_before_retry", {
|
|
1005
|
+
requestId,
|
|
1006
|
+
bridgeKey,
|
|
1007
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
1008
|
+
convKey,
|
|
1009
|
+
modelId,
|
|
1010
|
+
attempt,
|
|
1011
|
+
maxRetries,
|
|
1012
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
1013
|
+
emittedUserVisibleContent,
|
|
1014
|
+
});
|
|
1015
|
+
setLastStreamEvent("idle_recovery_before_retry");
|
|
1016
|
+
try {
|
|
1017
|
+
if (idleRetry.restart(attempt, restartContext)) return;
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
// Recovery errors fall through into the normal retry-budget path below.
|
|
1020
|
+
debugLog("native.stream.idle_recovery_before_retry_error", {
|
|
1021
|
+
requestId,
|
|
1022
|
+
bridgeKey,
|
|
1023
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
1024
|
+
convKey,
|
|
1025
|
+
modelId,
|
|
1026
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
let finalAttempt = attempt;
|
|
1031
|
+
if (idleRetry && attempt <= maxRetries && allowRestart) {
|
|
1032
|
+
const nextAttempt = attempt + 1;
|
|
1033
|
+
finalAttempt = nextAttempt;
|
|
1034
|
+
debugLog("native.stream.idle_retry", {
|
|
1035
|
+
requestId,
|
|
1036
|
+
bridgeKey,
|
|
1037
|
+
convKey,
|
|
1038
|
+
modelId,
|
|
1039
|
+
attempt,
|
|
1040
|
+
nextAttempt,
|
|
1041
|
+
maxRetries,
|
|
1042
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
1043
|
+
emittedUserVisibleContent,
|
|
1044
|
+
});
|
|
1045
|
+
setLastStreamEvent("idle_retry");
|
|
1046
|
+
try {
|
|
1047
|
+
if (idleRetry.restart(nextAttempt, restartContext)) return;
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
debugLog("native.stream.idle_retry_error", {
|
|
1050
|
+
requestId,
|
|
1051
|
+
bridgeKey,
|
|
1052
|
+
convKey,
|
|
1053
|
+
modelId,
|
|
1054
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1055
|
+
});
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
writer.error(
|
|
1059
|
+
formatStreamIdleTimeoutMessage(
|
|
1060
|
+
streamIdleTimeoutMs,
|
|
1061
|
+
finalAttempt,
|
|
1062
|
+
maxRetries,
|
|
1063
|
+
emittedUserVisibleContent,
|
|
1064
|
+
),
|
|
1065
|
+
"error",
|
|
1066
|
+
state,
|
|
1067
|
+
);
|
|
1068
|
+
},
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
const abort = () => {
|
|
1072
|
+
if (cancelled || writer.closed) return;
|
|
1073
|
+
cancelled = true;
|
|
1074
|
+
persistAbortedConversationState(
|
|
1075
|
+
convKey,
|
|
1076
|
+
checkpointRef.current,
|
|
1077
|
+
blobStore,
|
|
1078
|
+
persistenceTurns,
|
|
1079
|
+
currentTurn,
|
|
1080
|
+
emittedExecs.length > 0 ? emittedExecs : preservedMidPauseExecs,
|
|
1081
|
+
);
|
|
1082
|
+
debugLog("native.stream.abort", {
|
|
1083
|
+
requestId,
|
|
1084
|
+
bridgeKey,
|
|
1085
|
+
convKey,
|
|
1086
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
1087
|
+
});
|
|
1088
|
+
idleWatchdog.clear();
|
|
1089
|
+
cleanupBridge(bridge, heartbeatTimer, bridgeKey);
|
|
1090
|
+
writer.error("Aborted", "aborted", state);
|
|
1091
|
+
};
|
|
1092
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
1093
|
+
if (options?.signal?.aborted) {
|
|
1094
|
+
abort();
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
idleWatchdog.start();
|
|
1098
|
+
|
|
1099
|
+
let streamFinalized = false;
|
|
1100
|
+
|
|
1101
|
+
const emitText = (text: string, isThinking?: boolean) => {
|
|
1102
|
+
if (writer.closed) return;
|
|
1103
|
+
// A staged pause means the tool-call block is already on the response and `toolUse` is about to
|
|
1104
|
+
// close it. Text emitted now would land *after* that block, which breaks the invariant that a
|
|
1105
|
+
// tool-use turn ends on its tool call. Before the pause was deferred the closed writer dropped
|
|
1106
|
+
// this text anyway, so nothing regresses by dropping it explicitly.
|
|
1107
|
+
if (pauseRequested) return;
|
|
1108
|
+
if (isThinking) {
|
|
1109
|
+
emittedUserVisibleContent = true;
|
|
1110
|
+
writer.thinking(text);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
const { content, reasoning } = tagFilter.process(text);
|
|
1114
|
+
if (reasoning) {
|
|
1115
|
+
emittedUserVisibleContent = true;
|
|
1116
|
+
writer.thinking(reasoning);
|
|
1117
|
+
}
|
|
1118
|
+
if (content) {
|
|
1119
|
+
emittedUserVisibleContent = true;
|
|
1120
|
+
appendAssistantTextToTurn(currentTurn, content);
|
|
1121
|
+
writer.text(content);
|
|
1122
|
+
}
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
const emitFlushed = () => {
|
|
1126
|
+
// Same ordering rule as emitText: once a tool-call block is on the response, nothing may be
|
|
1127
|
+
// appended after it. The first exec of a chunk flushes before staging its pause, so pending
|
|
1128
|
+
// text still reaches the client ahead of the tool call.
|
|
1129
|
+
if (pauseRequested) return;
|
|
1130
|
+
const flushed = tagFilter.flush();
|
|
1131
|
+
if (flushed.reasoning) {
|
|
1132
|
+
emittedUserVisibleContent = true;
|
|
1133
|
+
writer.thinking(flushed.reasoning);
|
|
1134
|
+
}
|
|
1135
|
+
if (flushed.content) {
|
|
1136
|
+
emittedUserVisibleContent = true;
|
|
1137
|
+
appendAssistantTextToTurn(currentTurn, flushed.content);
|
|
1138
|
+
writer.text(flushed.content);
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
|
|
1142
|
+
const finalizeSuccessfulStream = () => {
|
|
1143
|
+
if (cancelled || streamFinalized) return;
|
|
1144
|
+
streamFinalized = true;
|
|
1145
|
+
queueMicrotask(() => onUsageBoundary("stream_end"));
|
|
1146
|
+
idleWatchdog.clear();
|
|
1147
|
+
clearInterval(heartbeatTimer);
|
|
1148
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
1149
|
+
const stored = conversationStates.get(convKey);
|
|
1150
|
+
if (mcpExecReceived) {
|
|
1151
|
+
handleBridgeCloseMidPause({
|
|
1152
|
+
stored,
|
|
1153
|
+
latestCheckpoint: checkpointRef.current,
|
|
1154
|
+
blobStore,
|
|
1155
|
+
completedTurns: persistenceTurns,
|
|
1156
|
+
pendingExecs: emittedExecs,
|
|
1157
|
+
convKey,
|
|
1158
|
+
});
|
|
1159
|
+
removeActiveBridge(bridgeKey);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
emitFlushed();
|
|
1163
|
+
if (stored) {
|
|
1164
|
+
if (checkpointRef.current) {
|
|
1165
|
+
commitStoredCheckpoint(
|
|
1166
|
+
stored,
|
|
1167
|
+
checkpointRef.current,
|
|
1168
|
+
blobStore,
|
|
1169
|
+
completedTurns,
|
|
1170
|
+
currentTurn,
|
|
1171
|
+
convKey,
|
|
1172
|
+
);
|
|
1173
|
+
debugLog("native.stream.checkpoint_committed", { requestId, convKey, stored });
|
|
1174
|
+
} else {
|
|
1175
|
+
mergeBlobStore(stored, blobStore);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
writer.done("stop", state);
|
|
1179
|
+
parkIdleBridge(bridgeKey, bridge);
|
|
1180
|
+
};
|
|
1181
|
+
bridge.onStreamDone?.(finalizeSuccessfulStream);
|
|
1182
|
+
|
|
1183
|
+
const processChunk = createConnectFrameParser(
|
|
1184
|
+
(messageBytes) => {
|
|
1185
|
+
try {
|
|
1186
|
+
const serverMessage = fromBinary(AgentServerMessageSchema, messageBytes);
|
|
1187
|
+
const progress = processServerMessage(
|
|
1188
|
+
serverMessage,
|
|
1189
|
+
blobStore,
|
|
1190
|
+
mcpTools,
|
|
1191
|
+
(data) => bridge.write(data),
|
|
1192
|
+
state,
|
|
1193
|
+
emitText,
|
|
1194
|
+
(exec) => {
|
|
1195
|
+
idleWatchdog.pause();
|
|
1196
|
+
state.pendingExecs.push(exec);
|
|
1197
|
+
mcpExecReceived = true;
|
|
1198
|
+
emitFlushed();
|
|
1199
|
+
currentTurn.steps.push({
|
|
1200
|
+
kind: "toolCall",
|
|
1201
|
+
toolCallId: exec.toolCallId,
|
|
1202
|
+
toolName: exec.toolName,
|
|
1203
|
+
arguments: parseToolCallArguments(exec.decodedArgs),
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
// Emit before snapshotting: an exec the writer refuses is one the client will never
|
|
1207
|
+
// answer, so it must not be recorded as pending.
|
|
1208
|
+
if (!writer.closed) {
|
|
1209
|
+
writer.toolCall(exec);
|
|
1210
|
+
emittedExecs.push(exec);
|
|
1211
|
+
pauseRequested = true;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const stored = conversationStates.get(convKey);
|
|
1215
|
+
// Nothing reached the client, so there is no continuation to snapshot — and writing an
|
|
1216
|
+
// empty one here would clear a checkpoint this stream may still need.
|
|
1217
|
+
if (stored && emittedExecs.length > 0) {
|
|
1218
|
+
commitStoredCheckpointMidPause(
|
|
1219
|
+
stored,
|
|
1220
|
+
checkpointRef.current,
|
|
1221
|
+
blobStore,
|
|
1222
|
+
persistenceTurns,
|
|
1223
|
+
emittedExecs,
|
|
1224
|
+
convKey,
|
|
1225
|
+
);
|
|
1226
|
+
debugLog(
|
|
1227
|
+
checkpointRef.current
|
|
1228
|
+
? "native.stream.tool_call_checkpoint_saved"
|
|
1229
|
+
: "native.stream.tool_call_snapshot_saved",
|
|
1230
|
+
{
|
|
1231
|
+
requestId,
|
|
1232
|
+
bridgeKey,
|
|
1233
|
+
convKey,
|
|
1234
|
+
checkpointSource: checkpointRef.current ? "upstream" : "absent",
|
|
1235
|
+
pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
|
|
1236
|
+
},
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
setActiveBridge(bridgeKey, {
|
|
1241
|
+
bridge,
|
|
1242
|
+
heartbeatTimer,
|
|
1243
|
+
blobStore,
|
|
1244
|
+
mcpTools,
|
|
1245
|
+
pendingExecs: state.pendingExecs,
|
|
1246
|
+
currentTurn,
|
|
1247
|
+
checkpointRef,
|
|
1248
|
+
state,
|
|
1249
|
+
historyFingerprint: historyFingerprint(),
|
|
1250
|
+
clientTranscript,
|
|
1251
|
+
});
|
|
1252
|
+
debugLog("native.stream.tool_call_pause", {
|
|
1253
|
+
requestId,
|
|
1254
|
+
bridgeKey,
|
|
1255
|
+
exec,
|
|
1256
|
+
pendingExecs: state.pendingExecs,
|
|
1257
|
+
emittedToolCallIds: emittedExecs.map((e) => e.toolCallId),
|
|
1258
|
+
currentTurn,
|
|
1259
|
+
});
|
|
1260
|
+
},
|
|
1261
|
+
(checkpointBytes, contextTokens) => {
|
|
1262
|
+
checkpointRef.current = checkpointBytes;
|
|
1263
|
+
if (contextTokens !== undefined) {
|
|
1264
|
+
checkpointRef.contextTokens = contextTokens;
|
|
1265
|
+
writer.contextSnapshot?.(contextTokens, checkpointBytes);
|
|
1266
|
+
}
|
|
1267
|
+
debugLog("native.stream.checkpoint_buffered", { requestId, convKey, checkpointBytes });
|
|
1268
|
+
},
|
|
1269
|
+
(execCase) => {
|
|
1270
|
+
parkedExecCase = execCase ?? "unknown";
|
|
1271
|
+
debugLog("native.stream.exec_park", { requestId, bridgeKey, convKey, execCase });
|
|
1272
|
+
idleWatchdog.setTimeoutMs(parkTimeoutMs);
|
|
1273
|
+
idleWatchdog.reset();
|
|
1274
|
+
},
|
|
1275
|
+
convKey,
|
|
1276
|
+
);
|
|
1277
|
+
if (progress === "work") {
|
|
1278
|
+
if (parkedExecCase !== undefined) {
|
|
1279
|
+
parkedExecCase = undefined;
|
|
1280
|
+
idleWatchdog.setTimeoutMs(streamIdleTimeoutMs);
|
|
1281
|
+
}
|
|
1282
|
+
idleWatchdog.reset();
|
|
1283
|
+
} else if (progress === "liveness" && parkedExecCase === undefined) {
|
|
1284
|
+
idleWatchdog.reset();
|
|
1285
|
+
}
|
|
1286
|
+
} catch (err) {
|
|
1287
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1288
|
+
debugLog("native.stream.process_error", { requestId, message });
|
|
1289
|
+
if (!cancelled) {
|
|
1290
|
+
cancelled = true;
|
|
1291
|
+
idleWatchdog.clear();
|
|
1292
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
1293
|
+
cleanupBridge(bridge, heartbeatTimer, bridgeKey);
|
|
1294
|
+
if (!writer.closed) writer.error(message, "error", state);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
},
|
|
1298
|
+
(endStreamBytes) => {
|
|
1299
|
+
const endError = parseConnectEndStream(endStreamBytes);
|
|
1300
|
+
if (endError) {
|
|
1301
|
+
// Cursor closes the connection right after `turnEnded`. That close ends a completed
|
|
1302
|
+
// turn; reporting it as an error made Pi retry and duplicate the answer (upstream #3).
|
|
1303
|
+
if (state.turnEnded && !pauseRequested) {
|
|
1304
|
+
debugLog("native.stream.post_turn_close", {
|
|
1305
|
+
requestId,
|
|
1306
|
+
modelId,
|
|
1307
|
+
message: endError.message,
|
|
1308
|
+
});
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
streamError = endError;
|
|
1312
|
+
const enhanced = enhanceCursorStreamError(endError.message);
|
|
1313
|
+
debugLog("native.stream.cursor_error", {
|
|
1314
|
+
requestId,
|
|
1315
|
+
modelId,
|
|
1316
|
+
message: endError.message,
|
|
1317
|
+
enhanced,
|
|
1318
|
+
isAuthError: isAuthErrorMessage(endError.message),
|
|
1319
|
+
deferredToToolPause: pauseRequested,
|
|
1320
|
+
});
|
|
1321
|
+
// A tool pause staged earlier in this same chunk wins: the client needs the tool call to
|
|
1322
|
+
// continue, and `streamError` still routes the close through mid-pause snapshotting so the
|
|
1323
|
+
// returning results land in recovery rather than on a bridge nobody is holding.
|
|
1324
|
+
if (!pauseRequested) writer.error(enhanced, "error", state);
|
|
1325
|
+
}
|
|
1326
|
+
},
|
|
1327
|
+
);
|
|
1328
|
+
|
|
1329
|
+
bridge.onData((chunk) => {
|
|
1330
|
+
// Watchdog reset moved into the framed-message handler above so non-progress chunks
|
|
1331
|
+
// (notably `interactionUpdate{tokenDelta}`-only frames) cannot keep the stream alive
|
|
1332
|
+
// forever.
|
|
1333
|
+
try {
|
|
1334
|
+
processChunk(chunk);
|
|
1335
|
+
} catch (error) {
|
|
1336
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1337
|
+
const desync =
|
|
1338
|
+
error instanceof Error
|
|
1339
|
+
? (error as Error & { connectFrameDesync?: ConnectFrameDesyncDiagnostics })
|
|
1340
|
+
.connectFrameDesync
|
|
1341
|
+
: undefined;
|
|
1342
|
+
debugLog("native.stream.frame_error", { requestId, message, desync });
|
|
1343
|
+
// A corrupted/misaligned Connect frame boundary can't be recovered within this
|
|
1344
|
+
// connection, but the desync is local per-connection state, not a permanent condition —
|
|
1345
|
+
// killing the transport (rather than failing the stream outright) routes this through the
|
|
1346
|
+
// same bridge.onClose retry path as any other transport loss (GOAWAY, ECONNRESET, ...),
|
|
1347
|
+
// so a fresh connection + checkpoint/history recovery can continue the turn instead of
|
|
1348
|
+
// the whole turn failing on what may be a one-off glitch.
|
|
1349
|
+
if (!cancelled && !frameParseFailed) {
|
|
1350
|
+
frameParseFailed = true;
|
|
1351
|
+
try {
|
|
1352
|
+
bridge.kill();
|
|
1353
|
+
} catch {
|
|
1354
|
+
// Transport may already be closing.
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
// Closing the response is deferred to here so that every exec framed in this chunk reaches
|
|
1360
|
+
// the client, not just the first. Parallel tool calls arrive as sibling frames.
|
|
1361
|
+
if (pauseRequested) {
|
|
1362
|
+
pauseRequested = false;
|
|
1363
|
+
if (!writer.closed) writer.done("toolUse", state);
|
|
1364
|
+
}
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
bridge.onClose((code) => {
|
|
1368
|
+
// Let this callback finalize/claim any receipt first; then diagnose genuinely unsettled runs.
|
|
1369
|
+
queueMicrotask(() => onUsageBoundary("transport_close"));
|
|
1370
|
+
debugLog("native.stream.bridge_close", {
|
|
1371
|
+
requestId,
|
|
1372
|
+
bridgeKey,
|
|
1373
|
+
convKey,
|
|
1374
|
+
code,
|
|
1375
|
+
cancelled,
|
|
1376
|
+
mcpExecReceived,
|
|
1377
|
+
streamFinalized,
|
|
1378
|
+
currentTurn,
|
|
1379
|
+
latestCheckpoint: checkpointRef.current,
|
|
1380
|
+
});
|
|
1381
|
+
lifecycleLog("bridge_close", {
|
|
1382
|
+
requestId,
|
|
1383
|
+
bridgeKey: bridgeKeyPrefix(bridgeKey),
|
|
1384
|
+
convKey,
|
|
1385
|
+
code,
|
|
1386
|
+
cancelled,
|
|
1387
|
+
mcpExecReceived,
|
|
1388
|
+
emittedUserVisibleContent,
|
|
1389
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
1390
|
+
});
|
|
1391
|
+
if (streamFinalized) return;
|
|
1392
|
+
idleWatchdog.clear();
|
|
1393
|
+
clearInterval(heartbeatTimer);
|
|
1394
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
1395
|
+
|
|
1396
|
+
if (cancelled) return;
|
|
1397
|
+
const stored = conversationStates.get(convKey);
|
|
1398
|
+
if (streamError) {
|
|
1399
|
+
if (mcpExecReceived) {
|
|
1400
|
+
const midPauseResult = handleBridgeCloseMidPause({
|
|
1401
|
+
stored,
|
|
1402
|
+
latestCheckpoint: checkpointRef.current,
|
|
1403
|
+
blobStore,
|
|
1404
|
+
completedTurns: persistenceTurns,
|
|
1405
|
+
pendingExecs: emittedExecs,
|
|
1406
|
+
convKey,
|
|
1407
|
+
});
|
|
1408
|
+
debugLog(
|
|
1409
|
+
midPauseResult.committed
|
|
1410
|
+
? "bridge.died_mid_pause_checkpoint_saved"
|
|
1411
|
+
: "bridge.died_mid_pause_no_checkpoint",
|
|
1412
|
+
{
|
|
1413
|
+
requestId,
|
|
1414
|
+
bridgeKey,
|
|
1415
|
+
convKey,
|
|
1416
|
+
cause: "stream_error",
|
|
1417
|
+
pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
|
|
1418
|
+
},
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
removeActiveBridge(bridgeKey);
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Same completed-turn rule as the end-stream frame: the non-zero exit that follows a
|
|
1426
|
+
// GOAWAY after `turnEnded` must finalize the turn, not restart it (upstream #3).
|
|
1427
|
+
const completedTurnClose = state.turnEnded && !mcpExecReceived;
|
|
1428
|
+
|
|
1429
|
+
if (code !== 0 && !completedTurnClose) {
|
|
1430
|
+
const failure = classifyBridgeExit({
|
|
1431
|
+
exitCode: code,
|
|
1432
|
+
stderr: typeof bridge.lastStderr === "function" ? bridge.lastStderr() : "",
|
|
1433
|
+
});
|
|
1434
|
+
const allowRestart =
|
|
1435
|
+
failure.retryable &&
|
|
1436
|
+
canRecoverAfterTransportLoss({
|
|
1437
|
+
emittedUserVisibleContent,
|
|
1438
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
1439
|
+
});
|
|
1440
|
+
if (allowRestart && idleRetry) {
|
|
1441
|
+
const attempt = idleRetry.currentAttempt;
|
|
1442
|
+
const maxRetries = idleRetry.maxRetries;
|
|
1443
|
+
if (attempt <= maxRetries) {
|
|
1444
|
+
debugLog("native.stream.transport_retry", {
|
|
1445
|
+
requestId,
|
|
1446
|
+
bridgeKey,
|
|
1447
|
+
convKey,
|
|
1448
|
+
modelId,
|
|
1449
|
+
attempt,
|
|
1450
|
+
maxRetries,
|
|
1451
|
+
failureKind: failure.kind,
|
|
1452
|
+
hasCheckpoint: !!checkpointRef.current,
|
|
1453
|
+
emittedUserVisibleContent,
|
|
1454
|
+
});
|
|
1455
|
+
setLastStreamEvent(`transport_retry:${failure.kind}`);
|
|
1456
|
+
persistAbortedConversationState(
|
|
1457
|
+
convKey,
|
|
1458
|
+
checkpointRef.current,
|
|
1459
|
+
blobStore,
|
|
1460
|
+
persistenceTurns,
|
|
1461
|
+
currentTurn,
|
|
1462
|
+
emittedExecs.length > 0 ? emittedExecs : preservedMidPauseExecs,
|
|
1463
|
+
);
|
|
1464
|
+
cleanupBridge(bridge, heartbeatTimer, bridgeKey);
|
|
1465
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
1466
|
+
try {
|
|
1467
|
+
if (
|
|
1468
|
+
idleRetry.restart(attempt + 1, {
|
|
1469
|
+
emittedUserVisibleContent,
|
|
1470
|
+
latestCheckpoint: checkpointRef.current,
|
|
1471
|
+
blobStore,
|
|
1472
|
+
completedTurns,
|
|
1473
|
+
currentTurn,
|
|
1474
|
+
})
|
|
1475
|
+
)
|
|
1476
|
+
return;
|
|
1477
|
+
} catch {
|
|
1478
|
+
// Fall through to error
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (mcpExecReceived) {
|
|
1483
|
+
const midPauseResult = handleBridgeCloseMidPause({
|
|
1484
|
+
stored,
|
|
1485
|
+
latestCheckpoint: checkpointRef.current,
|
|
1486
|
+
blobStore,
|
|
1487
|
+
completedTurns: persistenceTurns,
|
|
1488
|
+
pendingExecs: emittedExecs,
|
|
1489
|
+
convKey,
|
|
1490
|
+
});
|
|
1491
|
+
debugLog(
|
|
1492
|
+
midPauseResult.committed
|
|
1493
|
+
? "bridge.died_mid_pause_checkpoint_saved"
|
|
1494
|
+
: "bridge.died_mid_pause_no_checkpoint",
|
|
1495
|
+
{
|
|
1496
|
+
requestId,
|
|
1497
|
+
bridgeKey,
|
|
1498
|
+
convKey,
|
|
1499
|
+
code,
|
|
1500
|
+
failureKind: failure.kind,
|
|
1501
|
+
pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
|
|
1502
|
+
},
|
|
1503
|
+
);
|
|
1504
|
+
}
|
|
1505
|
+
writer.error(formatTransportFailure(failure), "error", state);
|
|
1506
|
+
removeActiveBridge(bridgeKey);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (!mcpExecReceived) {
|
|
1511
|
+
emitFlushed();
|
|
1512
|
+
if (stored) {
|
|
1513
|
+
if (checkpointRef.current) {
|
|
1514
|
+
commitStoredCheckpoint(
|
|
1515
|
+
stored,
|
|
1516
|
+
checkpointRef.current,
|
|
1517
|
+
blobStore,
|
|
1518
|
+
completedTurns,
|
|
1519
|
+
currentTurn,
|
|
1520
|
+
convKey,
|
|
1521
|
+
);
|
|
1522
|
+
debugLog("native.stream.checkpoint_committed", { requestId, convKey, stored });
|
|
1523
|
+
} else {
|
|
1524
|
+
mergeBlobStore(stored, blobStore);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
writer.done("stop", state);
|
|
1528
|
+
} else {
|
|
1529
|
+
const midPauseResult = handleBridgeCloseMidPause({
|
|
1530
|
+
stored,
|
|
1531
|
+
latestCheckpoint: checkpointRef.current,
|
|
1532
|
+
blobStore,
|
|
1533
|
+
completedTurns: persistenceTurns,
|
|
1534
|
+
pendingExecs: emittedExecs,
|
|
1535
|
+
convKey,
|
|
1536
|
+
});
|
|
1537
|
+
debugLog(
|
|
1538
|
+
midPauseResult.committed
|
|
1539
|
+
? "bridge.died_mid_pause_checkpoint_saved"
|
|
1540
|
+
: "bridge.died_mid_pause_no_checkpoint",
|
|
1541
|
+
{
|
|
1542
|
+
requestId,
|
|
1543
|
+
bridgeKey,
|
|
1544
|
+
convKey,
|
|
1545
|
+
pendingToolCallIds: emittedExecs.map((e) => e.toolCallId),
|
|
1546
|
+
},
|
|
1547
|
+
);
|
|
1548
|
+
removeActiveBridge(bridgeKey);
|
|
1549
|
+
}
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
interface ResumeContext {
|
|
1554
|
+
accessToken: string;
|
|
1555
|
+
systemPrompt: string;
|
|
1556
|
+
model: Model<Api>;
|
|
1557
|
+
modelId: string;
|
|
1558
|
+
bridgeKey: string;
|
|
1559
|
+
convKey: string;
|
|
1560
|
+
sessionId: string | undefined;
|
|
1561
|
+
completedTurns: ParsedTurn[];
|
|
1562
|
+
/** Pi's in-flight turn from the resume request, not the bridge's currentTurn suffix. */
|
|
1563
|
+
inFlightTurn?: ParsedTurn;
|
|
1564
|
+
maxMode: boolean;
|
|
1565
|
+
cursorModelParameters: CursorModelParameter[];
|
|
1566
|
+
getAccessToken?: (options?: { forceRefresh?: boolean }) => Promise<string>;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
function handleNativeToolResultResume(
|
|
1570
|
+
active: ActiveBridge,
|
|
1571
|
+
toolResults: ToolResultInfo[],
|
|
1572
|
+
ctx: ResumeContext,
|
|
1573
|
+
writer: NativeStreamWriter,
|
|
1574
|
+
options?: CursorNativeStreamOptions,
|
|
1575
|
+
requestId?: string,
|
|
1576
|
+
): void {
|
|
1577
|
+
const {
|
|
1578
|
+
accessToken,
|
|
1579
|
+
systemPrompt,
|
|
1580
|
+
model,
|
|
1581
|
+
modelId,
|
|
1582
|
+
bridgeKey,
|
|
1583
|
+
convKey,
|
|
1584
|
+
sessionId,
|
|
1585
|
+
completedTurns,
|
|
1586
|
+
maxMode,
|
|
1587
|
+
cursorModelParameters,
|
|
1588
|
+
getAccessToken,
|
|
1589
|
+
} = ctx;
|
|
1590
|
+
const {
|
|
1591
|
+
bridge,
|
|
1592
|
+
heartbeatTimer,
|
|
1593
|
+
blobStore,
|
|
1594
|
+
mcpTools,
|
|
1595
|
+
pendingExecs,
|
|
1596
|
+
currentTurn,
|
|
1597
|
+
checkpointRef,
|
|
1598
|
+
state: pausedState,
|
|
1599
|
+
historyFingerprint,
|
|
1600
|
+
clientTranscript: parkedTranscript,
|
|
1601
|
+
} = active;
|
|
1602
|
+
writer.contextMode?.("live", checkpointRef.contextTokens, checkpointRef.current ?? undefined);
|
|
1603
|
+
const resumeTranscript = parkedTranscript ?? liveTranscript(completedTurns);
|
|
1604
|
+
const recoveredClientTranscript = withSyntheticCurrentTurn(
|
|
1605
|
+
resumeTranscript,
|
|
1606
|
+
ctx.inFlightTurn ?? currentTurn,
|
|
1607
|
+
);
|
|
1608
|
+
const resumeIdleTimeoutMs = resolveResumeIdleTimeoutMs(
|
|
1609
|
+
process.env.PI_CURSOR_RESUME_IDLE_TIMEOUT_MS,
|
|
1610
|
+
);
|
|
1611
|
+
const transportResults = toolResults.map((result) => ({
|
|
1612
|
+
...result,
|
|
1613
|
+
...normalizeToolResultForTransport(result),
|
|
1614
|
+
}));
|
|
1615
|
+
debugLog("native.tool_resume.start", {
|
|
1616
|
+
requestId,
|
|
1617
|
+
bridgeKey,
|
|
1618
|
+
convKey,
|
|
1619
|
+
toolResults: transportResults.map((result) => ({
|
|
1620
|
+
toolCallId: result.toolCallId,
|
|
1621
|
+
contentBytes: Buffer.byteLength(result.content, "utf8"),
|
|
1622
|
+
imageCount: result.images?.length ?? 0,
|
|
1623
|
+
imageBytes: result.images?.reduce((sum, image) => sum + image.data.byteLength, 0) ?? 0,
|
|
1624
|
+
isError: result.isError === true,
|
|
1625
|
+
})),
|
|
1626
|
+
pendingExecs,
|
|
1627
|
+
currentTurn,
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
for (const result of transportResults) {
|
|
1631
|
+
const turnToolStep = currentTurn.steps.find(
|
|
1632
|
+
(step): step is ParsedToolCallStep =>
|
|
1633
|
+
step.kind === "toolCall" && step.toolCallId === result.toolCallId,
|
|
1634
|
+
);
|
|
1635
|
+
if (turnToolStep) {
|
|
1636
|
+
turnToolStep.result = {
|
|
1637
|
+
content: result.content,
|
|
1638
|
+
images: result.images,
|
|
1639
|
+
isError: result.isError === true,
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const turnResults = getTurnToolCallResults(currentTurn);
|
|
1645
|
+
const unresolvedExecs = pendingExecs.filter((exec) => !turnResults.has(exec.toolCallId));
|
|
1646
|
+
if (unresolvedExecs.length > 0) {
|
|
1647
|
+
setActiveBridge(bridgeKey, {
|
|
1648
|
+
bridge,
|
|
1649
|
+
heartbeatTimer,
|
|
1650
|
+
blobStore,
|
|
1651
|
+
mcpTools,
|
|
1652
|
+
pendingExecs,
|
|
1653
|
+
currentTurn,
|
|
1654
|
+
checkpointRef,
|
|
1655
|
+
state: pausedState,
|
|
1656
|
+
historyFingerprint,
|
|
1657
|
+
});
|
|
1658
|
+
debugLog("native.tool_resume.partial_wait", {
|
|
1659
|
+
requestId,
|
|
1660
|
+
bridgeKey,
|
|
1661
|
+
unresolvedExecs,
|
|
1662
|
+
currentTurn,
|
|
1663
|
+
});
|
|
1664
|
+
// Re-emitting here is the only way the client learns about execs that arrived after its
|
|
1665
|
+
// response closed, so the snapshot has to grow with them.
|
|
1666
|
+
const stored = conversationStates.get(convKey);
|
|
1667
|
+
if (stored) {
|
|
1668
|
+
commitStoredCheckpointMidPause(
|
|
1669
|
+
stored,
|
|
1670
|
+
checkpointRef.current,
|
|
1671
|
+
blobStore,
|
|
1672
|
+
completedTurns,
|
|
1673
|
+
[...pendingExecs],
|
|
1674
|
+
convKey,
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
for (const exec of unresolvedExecs) writer.toolCall(exec);
|
|
1678
|
+
writer.done("toolUse", pausedState);
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
for (const exec of pendingExecs) {
|
|
1683
|
+
const result = turnResults.get(exec.toolCallId);
|
|
1684
|
+
if (!result) continue;
|
|
1685
|
+
const mcpResult = create(McpResultSchema, {
|
|
1686
|
+
result: {
|
|
1687
|
+
case: "success",
|
|
1688
|
+
value: create(McpSuccessSchema, {
|
|
1689
|
+
content: buildMcpSuccessContent(result),
|
|
1690
|
+
isError: result.isError === true,
|
|
1691
|
+
}),
|
|
1692
|
+
},
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
const execClientMessage = create(ExecClientMessageSchema, {
|
|
1696
|
+
id: exec.execMsgId,
|
|
1697
|
+
execId: exec.execId,
|
|
1698
|
+
message: { case: "mcpResult" as any, value: mcpResult as any },
|
|
1699
|
+
});
|
|
1700
|
+
const clientMessage = create(AgentClientMessageSchema, {
|
|
1701
|
+
message: { case: "execClientMessage", value: execClientMessage },
|
|
1702
|
+
});
|
|
1703
|
+
bridge.write(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
|
|
1704
|
+
debugLog("native.tool_resume.sent_result", {
|
|
1705
|
+
requestId,
|
|
1706
|
+
exec,
|
|
1707
|
+
contentBytes: Buffer.byteLength(result.content, "utf8"),
|
|
1708
|
+
imageCount: result.images?.length ?? 0,
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
const idleRetry: StreamIdleRetryController = {
|
|
1713
|
+
currentAttempt: 1,
|
|
1714
|
+
maxRetries: resolveStreamIdleMaxRetries(process.env.PI_CURSOR_STREAM_IDLE_MAX_RETRIES),
|
|
1715
|
+
// Phase 0 found mcpArgs-before-checkpoint across composer/gemini/gpt-5.4, so this stays model-agnostic.
|
|
1716
|
+
recoverBeforeRetry: true,
|
|
1717
|
+
restart(nextAttempt: number, _context: IdleRestartContext) {
|
|
1718
|
+
idleRetry.currentAttempt = nextAttempt;
|
|
1719
|
+
const stored = conversationStates.get(convKey);
|
|
1720
|
+
const decision = planRecovery({
|
|
1721
|
+
stored,
|
|
1722
|
+
toolResults,
|
|
1723
|
+
completedTurns,
|
|
1724
|
+
inFlightTurn: stripInFlightResults(ctx.inFlightTurn ?? currentTurn),
|
|
1725
|
+
rebuildReason: "synthesized_after_idle",
|
|
1726
|
+
sessionId,
|
|
1727
|
+
requestId: requestId ?? "native-tool-idle-retry",
|
|
1728
|
+
convKey,
|
|
1729
|
+
});
|
|
1730
|
+
if (decision.kind === "rebuild_full_history") {
|
|
1731
|
+
setLastStreamEvent("rebuild_full_history");
|
|
1732
|
+
logFullHistoryRebuild("native.rebuild_full_history", {
|
|
1733
|
+
requestId,
|
|
1734
|
+
bridgeKey,
|
|
1735
|
+
convKey,
|
|
1736
|
+
modelId,
|
|
1737
|
+
decision,
|
|
1738
|
+
});
|
|
1739
|
+
const rebuiltCompletedTurns = [...decision.completedTurns, decision.inFlightTurn];
|
|
1740
|
+
const recoveredUserImages = collectToolResultImages(decision.toolResults);
|
|
1741
|
+
const recoveredCurrentTurn: ParsedTurn = {
|
|
1742
|
+
userText: decision.wrappedText,
|
|
1743
|
+
steps: [],
|
|
1744
|
+
...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
|
|
1745
|
+
};
|
|
1746
|
+
const payload = buildCursorRequest({
|
|
1747
|
+
modelId,
|
|
1748
|
+
systemPrompt,
|
|
1749
|
+
userText: decision.wrappedText,
|
|
1750
|
+
userImages: recoveredUserImages,
|
|
1751
|
+
turns: rebuiltCompletedTurns,
|
|
1752
|
+
conversationId: decision.conversationId,
|
|
1753
|
+
checkpoint: null,
|
|
1754
|
+
existingBlobStore: decision.blobStore,
|
|
1755
|
+
maxMode,
|
|
1756
|
+
cursorModelParameters,
|
|
1757
|
+
mcpTools,
|
|
1758
|
+
});
|
|
1759
|
+
payload.mcpTools = mcpTools;
|
|
1760
|
+
if (stored) stored.lastAccessMs = Date.now();
|
|
1761
|
+
startNativeStreamWithIdleRetries({
|
|
1762
|
+
accessToken,
|
|
1763
|
+
requestBytes: payload.requestBytes,
|
|
1764
|
+
blobStore: payload.blobStore,
|
|
1765
|
+
mcpTools: payload.mcpTools,
|
|
1766
|
+
model,
|
|
1767
|
+
modelId,
|
|
1768
|
+
bridgeKey,
|
|
1769
|
+
convKey,
|
|
1770
|
+
completedTurns: rebuiltCompletedTurns,
|
|
1771
|
+
contextCheckpoint: payload.contextCheckpoint,
|
|
1772
|
+
currentTurn: recoveredCurrentTurn,
|
|
1773
|
+
clientTranscript: recoveredClientTranscript,
|
|
1774
|
+
writer,
|
|
1775
|
+
options,
|
|
1776
|
+
requestId,
|
|
1777
|
+
maxIdleRetries: idleRetry.maxRetries,
|
|
1778
|
+
streamIdleTimeoutMs: resumeIdleTimeoutMs,
|
|
1779
|
+
getAccessToken,
|
|
1780
|
+
systemPrompt,
|
|
1781
|
+
conversationId: decision.conversationId,
|
|
1782
|
+
maxMode,
|
|
1783
|
+
cursorModelParameters,
|
|
1784
|
+
});
|
|
1785
|
+
return true;
|
|
1786
|
+
}
|
|
1787
|
+
if (decision.kind !== "recover") {
|
|
1788
|
+
debugLog("native.tool_resume.idle_retry_recovery_skipped", {
|
|
1789
|
+
requestId,
|
|
1790
|
+
bridgeKey,
|
|
1791
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
1792
|
+
convKey,
|
|
1793
|
+
skipReason: decision.reason,
|
|
1794
|
+
hadStoredCheckpoint: decision.hadStoredCheckpoint,
|
|
1795
|
+
...(decision.expected !== undefined ? { expected: decision.expected } : {}),
|
|
1796
|
+
...(decision.received !== undefined ? { received: decision.received } : {}),
|
|
1797
|
+
});
|
|
1798
|
+
writer.error(
|
|
1799
|
+
`${lostToolContinuationMessage()} ${formatLostToolContinuationDiagnostic({
|
|
1800
|
+
bridgeKey,
|
|
1801
|
+
hadStoredCheckpoint: decision.hadStoredCheckpoint,
|
|
1802
|
+
skipReason: decision.reason,
|
|
1803
|
+
})}`,
|
|
1804
|
+
"error",
|
|
1805
|
+
);
|
|
1806
|
+
return true;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
debugLog("native.tool_resume.idle_retry_recover", {
|
|
1810
|
+
requestId,
|
|
1811
|
+
bridgeKey,
|
|
1812
|
+
bridgeKeyPrefix: bridgeKeyPrefix(bridgeKey),
|
|
1813
|
+
convKey,
|
|
1814
|
+
recoveryPath: "stored_checkpoint",
|
|
1815
|
+
attempt: nextAttempt,
|
|
1816
|
+
pendingToolCallIds: toolResults.map((r) => r.toolCallId),
|
|
1817
|
+
});
|
|
1818
|
+
const recoveredUserImages = collectToolResultImages(toolResults);
|
|
1819
|
+
const recoveredCurrentTurn: ParsedTurn = {
|
|
1820
|
+
userText: decision.wrappedText,
|
|
1821
|
+
steps: [],
|
|
1822
|
+
...(recoveredUserImages.length ? { userImages: recoveredUserImages } : {}),
|
|
1823
|
+
};
|
|
1824
|
+
const payload = buildCursorRequest({
|
|
1825
|
+
modelId,
|
|
1826
|
+
systemPrompt,
|
|
1827
|
+
userText: decision.wrappedText,
|
|
1828
|
+
userImages: recoveredUserImages,
|
|
1829
|
+
turns: completedTurns,
|
|
1830
|
+
conversationId: decision.conversationId,
|
|
1831
|
+
checkpoint: decision.checkpoint,
|
|
1832
|
+
existingBlobStore: decision.blobStore,
|
|
1833
|
+
maxMode,
|
|
1834
|
+
cursorModelParameters,
|
|
1835
|
+
mcpTools,
|
|
1836
|
+
});
|
|
1837
|
+
payload.mcpTools = mcpTools;
|
|
1838
|
+
startNativeStreamWithIdleRetries({
|
|
1839
|
+
accessToken,
|
|
1840
|
+
requestBytes: payload.requestBytes,
|
|
1841
|
+
blobStore: payload.blobStore,
|
|
1842
|
+
mcpTools: payload.mcpTools,
|
|
1843
|
+
model,
|
|
1844
|
+
modelId,
|
|
1845
|
+
bridgeKey,
|
|
1846
|
+
convKey,
|
|
1847
|
+
completedTurns,
|
|
1848
|
+
contextCheckpoint: payload.contextCheckpoint,
|
|
1849
|
+
currentTurn: recoveredCurrentTurn,
|
|
1850
|
+
clientTranscript: recoveredClientTranscript,
|
|
1851
|
+
writer,
|
|
1852
|
+
options,
|
|
1853
|
+
requestId,
|
|
1854
|
+
maxIdleRetries: idleRetry.maxRetries,
|
|
1855
|
+
streamIdleTimeoutMs: resumeIdleTimeoutMs,
|
|
1856
|
+
getAccessToken,
|
|
1857
|
+
systemPrompt,
|
|
1858
|
+
conversationId: decision.conversationId,
|
|
1859
|
+
maxMode,
|
|
1860
|
+
cursorModelParameters,
|
|
1861
|
+
});
|
|
1862
|
+
return true;
|
|
1863
|
+
},
|
|
1864
|
+
};
|
|
1865
|
+
|
|
1866
|
+
writeNativeStream(
|
|
1867
|
+
bridge,
|
|
1868
|
+
heartbeatTimer,
|
|
1869
|
+
blobStore,
|
|
1870
|
+
mcpTools,
|
|
1871
|
+
model,
|
|
1872
|
+
modelId,
|
|
1873
|
+
bridgeKey,
|
|
1874
|
+
convKey,
|
|
1875
|
+
completedTurns,
|
|
1876
|
+
currentTurn,
|
|
1877
|
+
writer,
|
|
1878
|
+
options,
|
|
1879
|
+
requestId,
|
|
1880
|
+
idleRetry,
|
|
1881
|
+
resumeIdleTimeoutMs,
|
|
1882
|
+
// Same bridge, so the same checkpoint cell: frames that landed during the pause stay visible.
|
|
1883
|
+
checkpointRef,
|
|
1884
|
+
// A timeout after Cursor receives the tool result still needs the original pause
|
|
1885
|
+
// snapshot so recovery can safely recreate that continuation.
|
|
1886
|
+
pendingExecs,
|
|
1887
|
+
resumeTranscript,
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// ── Request handling ──
|
|
1892
|
+
|
|
1893
|
+
export type ResolvedCursorModelRouting = ExtractedResolvedCursorModelRouting;
|
|
1894
|
+
export type CursorResolvableModel = ExtractedCursorResolvableModel;
|
|
1895
|
+
|
|
1896
|
+
export function resolveModelId(model: string, reasoningEffort?: string): string {
|
|
1897
|
+
return resolveModelIdImpl(model, reasoningEffort);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
export function resolveRequestedModelId(
|
|
1901
|
+
model: string,
|
|
1902
|
+
reasoningEffort?: string,
|
|
1903
|
+
cursorModelId?: string,
|
|
1904
|
+
): string;
|
|
1905
|
+
export function resolveRequestedModelId(
|
|
1906
|
+
model: CursorResolvableModel,
|
|
1907
|
+
reasoningEffort?: string,
|
|
1908
|
+
routingByModelId?: Map<
|
|
1909
|
+
string,
|
|
1910
|
+
Record<string, CursorNativeModelRouting> | CursorNativeModelRouting
|
|
1911
|
+
>,
|
|
1912
|
+
): ResolvedCursorModelRouting;
|
|
1913
|
+
export function resolveRequestedModelId(
|
|
1914
|
+
model: string | CursorResolvableModel,
|
|
1915
|
+
reasoningEffort?: string,
|
|
1916
|
+
cursorModelIdOrRoutingByModelId?:
|
|
1917
|
+
string | Map<string, Record<string, CursorNativeModelRouting> | CursorNativeModelRouting>,
|
|
1918
|
+
): string | ResolvedCursorModelRouting {
|
|
1919
|
+
return resolveRequestedModelIdImpl(
|
|
1920
|
+
model as any,
|
|
1921
|
+
reasoningEffort,
|
|
1922
|
+
cursorModelIdOrRoutingByModelId as any,
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// ── Streaming response ──
|
|
1927
|
+
|
|
1928
|
+
function formatStreamIdleTimeoutMessage(
|
|
1929
|
+
timeoutMs: number,
|
|
1930
|
+
attempt: number,
|
|
1931
|
+
maxRetries: number,
|
|
1932
|
+
emittedUserVisibleContent = false,
|
|
1933
|
+
): string {
|
|
1934
|
+
const base = `Cursor stream idle timeout after ${timeoutMs}ms without upstream progress`;
|
|
1935
|
+
const attemptLabel = attempt === 1 ? "attempt" : "attempts";
|
|
1936
|
+
const retryLabel = maxRetries === 1 ? "retry" : "retries";
|
|
1937
|
+
const retryPart =
|
|
1938
|
+
maxRetries > 0 ? ` over ${attempt} ${attemptLabel} (${maxRetries} ${retryLabel})` : "";
|
|
1939
|
+
const partialPart = emittedUserVisibleContent
|
|
1940
|
+
? " Partial assistant output was already streamed; automatic retry requires a checkpoint and was unavailable or exhausted."
|
|
1941
|
+
: "";
|
|
1942
|
+
const tunePart =
|
|
1943
|
+
" Tune PI_CURSOR_STREAM_IDLE_TIMEOUT_MS / PI_CURSOR_RESUME_IDLE_TIMEOUT_MS if long reasoning turns are expected.";
|
|
1944
|
+
return `${base}${retryPart}.${partialPart}${tunePart}`;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function formatStreamParkMessage(execCase: string, timeoutMs: number): string {
|
|
1948
|
+
return (
|
|
1949
|
+
`Cursor parked the turn on exec case "${execCase}", which this build cannot answer ` +
|
|
1950
|
+
`(agent.proto is behind Cursor's wire protocol). The exec was failed with a throw and the ` +
|
|
1951
|
+
`run still did no work for ${timeoutMs}ms. Run /cursor.doctor for the recorded drift signal.`
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
function checkpointContextTokens(checkpoint?: Uint8Array | null): number | undefined {
|
|
1956
|
+
if (!checkpoint) return undefined;
|
|
1957
|
+
try {
|
|
1958
|
+
return positiveContextTokens(
|
|
1959
|
+
fromBinary(ConversationStateStructureSchema, checkpoint).tokenDetails?.usedTokens,
|
|
1960
|
+
);
|
|
1961
|
+
} catch {
|
|
1962
|
+
return undefined;
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function startNativeStreamWithIdleRetries(input: NativeStreamAttemptInput): void {
|
|
1967
|
+
// Recovered/rebuilt streams enter this helper with ordinary retry semantics to avoid recursive recovery loops.
|
|
1968
|
+
let latestAccessToken = input.accessToken;
|
|
1969
|
+
// Mutable across generations so checkpoint continuation can replace the original request body.
|
|
1970
|
+
let requestBytes = input.requestBytes;
|
|
1971
|
+
let contextCheckpoint = input.contextCheckpoint;
|
|
1972
|
+
let blobStore = input.blobStore;
|
|
1973
|
+
let completedTurns = input.completedTurns;
|
|
1974
|
+
let currentTurn = input.currentTurn;
|
|
1975
|
+
|
|
1976
|
+
const controller: StreamIdleRetryController = {
|
|
1977
|
+
currentAttempt: 1,
|
|
1978
|
+
maxRetries:
|
|
1979
|
+
input.maxIdleRetries ??
|
|
1980
|
+
resolveStreamIdleMaxRetries(process.env.PI_CURSOR_STREAM_IDLE_MAX_RETRIES),
|
|
1981
|
+
// Default on: transport loss after partial output can still continue via checkpoint.
|
|
1982
|
+
recoverBeforeRetry: input.recoverBeforeRetry ?? true,
|
|
1983
|
+
restart(nextAttempt: number, context: IdleRestartContext) {
|
|
1984
|
+
controller.currentAttempt = nextAttempt;
|
|
1985
|
+
debugLog(
|
|
1986
|
+
nextAttempt === 1 ? "native.stream.attempt_start" : "native.stream.idle_retry_start",
|
|
1987
|
+
{
|
|
1988
|
+
requestId: input.requestId,
|
|
1989
|
+
bridgeKey: input.bridgeKey,
|
|
1990
|
+
convKey: input.convKey,
|
|
1991
|
+
modelId: input.modelId,
|
|
1992
|
+
attempt: nextAttempt,
|
|
1993
|
+
maxRetries: controller.maxRetries,
|
|
1994
|
+
hasCheckpoint: !!context.latestCheckpoint,
|
|
1995
|
+
emittedUserVisibleContent: context.emittedUserVisibleContent,
|
|
1996
|
+
},
|
|
1997
|
+
);
|
|
1998
|
+
|
|
1999
|
+
// A checkpoint from the interrupted stream determines continuation, not the retry-budget
|
|
2000
|
+
// counter: idle recovery can reuse attempt 1. Initial launch passes no latestCheckpoint.
|
|
2001
|
+
if (
|
|
2002
|
+
context.latestCheckpoint &&
|
|
2003
|
+
typeof input.systemPrompt === "string" &&
|
|
2004
|
+
input.conversationId
|
|
2005
|
+
) {
|
|
2006
|
+
try {
|
|
2007
|
+
const continueText = CHECKPOINT_CONTINUATION_PROMPT;
|
|
2008
|
+
const payload = buildCursorRequest({
|
|
2009
|
+
modelId: input.modelId,
|
|
2010
|
+
systemPrompt: input.systemPrompt,
|
|
2011
|
+
userText: continueText,
|
|
2012
|
+
turns: context.completedTurns,
|
|
2013
|
+
conversationId: input.conversationId,
|
|
2014
|
+
checkpoint: context.latestCheckpoint,
|
|
2015
|
+
existingBlobStore: context.blobStore,
|
|
2016
|
+
maxMode: input.maxMode ?? false,
|
|
2017
|
+
cursorModelParameters: input.cursorModelParameters ?? [],
|
|
2018
|
+
mcpTools: input.mcpTools,
|
|
2019
|
+
});
|
|
2020
|
+
requestBytes = payload.requestBytes;
|
|
2021
|
+
contextCheckpoint = payload.contextCheckpoint;
|
|
2022
|
+
blobStore = payload.blobStore;
|
|
2023
|
+
completedTurns = context.completedTurns;
|
|
2024
|
+
currentTurn = { userText: continueText, steps: [] };
|
|
2025
|
+
const stored = conversationStates.get(input.convKey);
|
|
2026
|
+
if (stored) {
|
|
2027
|
+
commitStoredCheckpoint(
|
|
2028
|
+
stored,
|
|
2029
|
+
context.latestCheckpoint,
|
|
2030
|
+
context.blobStore,
|
|
2031
|
+
context.completedTurns,
|
|
2032
|
+
context.currentTurn,
|
|
2033
|
+
input.convKey,
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
setLastStreamEvent("checkpoint_continuation");
|
|
2037
|
+
debugLog("native.stream.checkpoint_continuation", {
|
|
2038
|
+
requestId: input.requestId,
|
|
2039
|
+
bridgeKey: input.bridgeKey,
|
|
2040
|
+
convKey: input.convKey,
|
|
2041
|
+
attempt: nextAttempt,
|
|
2042
|
+
});
|
|
2043
|
+
} catch (error) {
|
|
2044
|
+
debugLog("native.stream.checkpoint_continuation_failed", {
|
|
2045
|
+
requestId: input.requestId,
|
|
2046
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2047
|
+
});
|
|
2048
|
+
// Fall back to the previous request bytes only when no user-visible content was emitted.
|
|
2049
|
+
if (context.emittedUserVisibleContent) return false;
|
|
2050
|
+
}
|
|
2051
|
+
} else if (context.emittedUserVisibleContent) {
|
|
2052
|
+
// No checkpoint and partial output: cannot safely restart.
|
|
2053
|
+
return false;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const launch = (accessToken: string) => {
|
|
2057
|
+
latestAccessToken = accessToken;
|
|
2058
|
+
input.writer.contextMode?.(
|
|
2059
|
+
contextCheckpoint ? "checkpoint" : "history",
|
|
2060
|
+
checkpointContextTokens(contextCheckpoint),
|
|
2061
|
+
contextCheckpoint ?? undefined,
|
|
2062
|
+
);
|
|
2063
|
+
const { bridge, heartbeatTimer } = startBridge(accessToken, requestBytes, {
|
|
2064
|
+
bridgeKey: input.bridgeKey,
|
|
2065
|
+
hasMcpTools: input.mcpTools.length > 0,
|
|
2066
|
+
});
|
|
2067
|
+
writeNativeStream(
|
|
2068
|
+
bridge,
|
|
2069
|
+
heartbeatTimer,
|
|
2070
|
+
blobStore,
|
|
2071
|
+
input.mcpTools,
|
|
2072
|
+
input.model,
|
|
2073
|
+
input.modelId,
|
|
2074
|
+
input.bridgeKey,
|
|
2075
|
+
input.convKey,
|
|
2076
|
+
completedTurns,
|
|
2077
|
+
currentTurn,
|
|
2078
|
+
input.writer,
|
|
2079
|
+
input.options,
|
|
2080
|
+
input.requestId,
|
|
2081
|
+
controller,
|
|
2082
|
+
input.streamIdleTimeoutMs,
|
|
2083
|
+
{ current: null },
|
|
2084
|
+
[],
|
|
2085
|
+
input.clientTranscript ?? liveTranscript(completedTurns),
|
|
2086
|
+
);
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
// First attempt is synchronous. Later attempts force-refresh credentials when possible.
|
|
2090
|
+
if (nextAttempt === 1 || !input.getAccessToken) {
|
|
2091
|
+
launch(latestAccessToken);
|
|
2092
|
+
return true;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
void input
|
|
2096
|
+
.getAccessToken({ forceRefresh: true })
|
|
2097
|
+
.then((token) => {
|
|
2098
|
+
if (input.writer.closed) return;
|
|
2099
|
+
setLastStreamEvent("idle_retry_token_refreshed");
|
|
2100
|
+
launch(token);
|
|
2101
|
+
})
|
|
2102
|
+
.catch((error) => {
|
|
2103
|
+
debugLog("native.stream.idle_retry_token_refresh_failed", {
|
|
2104
|
+
requestId: input.requestId,
|
|
2105
|
+
message: error instanceof Error ? error.message : String(error),
|
|
2106
|
+
});
|
|
2107
|
+
if (input.writer.closed) return;
|
|
2108
|
+
// Fall back to the previous token rather than hard-failing immediately.
|
|
2109
|
+
launch(latestAccessToken);
|
|
2110
|
+
});
|
|
2111
|
+
return true;
|
|
2112
|
+
},
|
|
2113
|
+
};
|
|
2114
|
+
controller.restart(1, {
|
|
2115
|
+
emittedUserVisibleContent: false,
|
|
2116
|
+
latestCheckpoint: null,
|
|
2117
|
+
blobStore: input.blobStore,
|
|
2118
|
+
completedTurns: input.completedTurns,
|
|
2119
|
+
currentTurn: input.currentTurn,
|
|
2120
|
+
});
|
|
2121
|
+
}
|