@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.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/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DO8liQVL.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
- package/gui/dist/assets/index-DX0RGj3e.js +0 -112
|
@@ -10,12 +10,14 @@ import { normalizeCursorToolResultText } from "./tool-result-normalize";
|
|
|
10
10
|
import { debugProviderDiagnostic } from "../../lib/debug";
|
|
11
11
|
import {
|
|
12
12
|
createCursorBlobRequestScope,
|
|
13
|
+
cursorBlobByteLength,
|
|
13
14
|
cursorBlobMaxEntryBytes,
|
|
14
15
|
releaseCursorBlobRequestScope,
|
|
15
16
|
sealCursorBlobRequestScope,
|
|
16
17
|
storeCursorBlob,
|
|
17
18
|
type CursorBlobRequestScopeToken,
|
|
18
19
|
} from "./native-exec";
|
|
20
|
+
import { CursorRootEnvelopeLimitError } from "./cursor-errors";
|
|
19
21
|
import { buildSelectedContext, CURSOR_VISION_IMAGE_HISTORY_MARKER } from "./images";
|
|
20
22
|
import { estimateTokens } from "../../lib/token-estimate";
|
|
21
23
|
import { parseDataUrl } from "../image";
|
|
@@ -71,6 +73,13 @@ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
|
|
|
71
73
|
export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192;
|
|
72
74
|
/** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */
|
|
73
75
|
export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024;
|
|
76
|
+
/**
|
|
77
|
+
* Byte budget for the serialized arguments named inside ONE replayed tool-result envelope. The
|
|
78
|
+
* invocation identifies the call; the result is the payload. Without an independent cap, a single
|
|
79
|
+
* large-but-legitimate argument (a 600 KiB file write) consumed the whole root history budget and
|
|
80
|
+
* the result output was truncated away instead.
|
|
81
|
+
*/
|
|
82
|
+
export const CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT = 2 * 1024;
|
|
74
83
|
|
|
75
84
|
/**
|
|
76
85
|
* Action text for external-model tool-result continuations. Native models keep
|
|
@@ -116,6 +125,13 @@ type RootBlobCandidate = {
|
|
|
116
125
|
messageIndex?: number;
|
|
117
126
|
/** Original JSON text payload used when an active tool result must be truncated to fit. */
|
|
118
127
|
text?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Set when a tool result was truncated past the point where any of its own output survives — either down
|
|
130
|
+
* to the truncation marker alone, or mid-envelope before the `output:` line. The model reads both as an
|
|
131
|
+
* empty answer to its own call, so a caller deciding whether the result "survived" must be able to tell
|
|
132
|
+
* them apart from a real one (devlog 260829 070).
|
|
133
|
+
*/
|
|
134
|
+
outputElided?: true;
|
|
119
135
|
};
|
|
120
136
|
|
|
121
137
|
function rootBlobCandidate(
|
|
@@ -154,7 +170,14 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo
|
|
|
154
170
|
"toolResult",
|
|
155
171
|
{ messageIndex: entry.messageIndex, text: truncated },
|
|
156
172
|
);
|
|
157
|
-
if (result.byteLength <= maxBytes)
|
|
173
|
+
if (result.byteLength <= maxBytes) {
|
|
174
|
+
// `output:` is the last fixed line of the envelope, so a cut landing before it leaves the header
|
|
175
|
+
// and no answer. Flag it: "a result root survived" would otherwise be true of a root that tells the
|
|
176
|
+
// model nothing about what its tool returned.
|
|
177
|
+
const outputStart = truncated.indexOf("\noutput:\n");
|
|
178
|
+
const keptOutput = outputStart >= 0 && truncated.length > outputStart + "\noutput:\n".length + marker.length;
|
|
179
|
+
return keptOutput ? result : { ...result, outputElided: true };
|
|
180
|
+
}
|
|
158
181
|
if (end === 0) break;
|
|
159
182
|
keepBytes = Math.max(0, end - (result.byteLength - maxBytes) - 16);
|
|
160
183
|
}
|
|
@@ -163,7 +186,7 @@ function truncateToolResultBlob(entry: RootBlobCandidate, maxBytes: number): Roo
|
|
|
163
186
|
"toolResult",
|
|
164
187
|
{ messageIndex: entry.messageIndex, text: marker.trimStart() },
|
|
165
188
|
);
|
|
166
|
-
return markerOnly.byteLength <= maxBytes ? markerOnly : null;
|
|
189
|
+
return markerOnly.byteLength <= maxBytes ? { ...markerOnly, outputElided: true } : null;
|
|
167
190
|
}
|
|
168
191
|
|
|
169
192
|
function systemPromptBlobs(request: CursorRunRequest): RootBlobCandidate[] {
|
|
@@ -195,12 +218,59 @@ function assistantRootText(
|
|
|
195
218
|
// [Tool Error] marker so Cursor does not wrap them as `<user_query>` (#1992). Native resume models
|
|
196
219
|
// already carry the paired MCP result on turns[], so that marker is omitted from root replay — Auto
|
|
197
220
|
// few-shot-mimics it as chat text otherwise. Each entry is a SHA-256 blob ID.
|
|
198
|
-
function rootPromptMessages(
|
|
221
|
+
function rootPromptMessages(
|
|
222
|
+
request: CursorRunRequest,
|
|
223
|
+
requestScope: CursorBlobRequestScopeToken,
|
|
224
|
+
/**
|
|
225
|
+
* Calls indexed from the FULL history. The checkpoint path replays only a suffix of
|
|
226
|
+
* `rawMessages`, so a result in that suffix can have its originating call before the cut; indexing
|
|
227
|
+
* from the slice alone silently dropped the invocation line for every checkpoint continuation,
|
|
228
|
+
* which is where the defect this line prevents actually reappeared in live use.
|
|
229
|
+
*/
|
|
230
|
+
knownCalls?: Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>,
|
|
231
|
+
/**
|
|
232
|
+
* Full-history index of `rawMessages[0]` for this call. Non-zero only on the checkpoint path, where
|
|
233
|
+
* only a suffix is replayed but `knownCalls` still spans full history; the positional bound needs
|
|
234
|
+
* both sides in the same space (devlog 260829 060).
|
|
235
|
+
*/
|
|
236
|
+
knownCallsOffset = 0,
|
|
237
|
+
/**
|
|
238
|
+
* Roots the decoded checkpoint already carries, which this call's pruning must leave room for.
|
|
239
|
+
*
|
|
240
|
+
* The envelope guard downstream measures checkpoint roots PLUS this suffix and throws a
|
|
241
|
+
* non-retryable 400 when the total exceeds the limit. Pruning against the full limit therefore
|
|
242
|
+
* emitted a suffix that was individually legal and cumulatively fatal — invisible until suffix
|
|
243
|
+
* replay actually grew (devlog 260829 070, audit r8 finding 3).
|
|
244
|
+
*/
|
|
245
|
+
carriedRoots: { count: number; byteLength: number } = { count: 0, byteLength: 0 },
|
|
246
|
+
): {
|
|
199
247
|
ids: Uint8Array[];
|
|
200
248
|
byteLength: number;
|
|
201
249
|
historyMessageStart: number;
|
|
202
250
|
/** Serialized text of the roots that survived pruning, in wire order. */
|
|
203
251
|
serialized: string[];
|
|
252
|
+
/**
|
|
253
|
+
* Source message index of each HISTORY root that survived pruning (system roots excluded).
|
|
254
|
+
*
|
|
255
|
+
* The checkpoint caller needs to know whether the specific message it is continuing from survived.
|
|
256
|
+
* Neither role nor text can answer that: roles repeat, and matching the result's own output against the
|
|
257
|
+
* serialized root fails on JSON escaping the moment real output contains a newline or a quote — which
|
|
258
|
+
* made live continuations abandon their checkpoint on every turn (devlog 260829 070).
|
|
259
|
+
*/
|
|
260
|
+
historyMessageIndexes: number[];
|
|
261
|
+
/**
|
|
262
|
+
* Message indexes whose root survived pruning but lost ALL of its own output to truncation, so only the
|
|
263
|
+
* truncation marker remains. Aligned with nothing — membership is the whole signal (devlog 260829 070).
|
|
264
|
+
*/
|
|
265
|
+
historyOutputElided: number[];
|
|
266
|
+
/**
|
|
267
|
+
* Message indexes of the trailing tool-result run as PRUNING saw it — root space, not raw-message space.
|
|
268
|
+
* The two spaces diverge: a bare tool call with no text emits no root, so two sequentially-executed
|
|
269
|
+
* results become adjacent roots while a raw-space scan still sees a trailing run of one. A caller that
|
|
270
|
+
* re-derives the run from `rawMessages` therefore cannot see a result this function dropped for count
|
|
271
|
+
* (audit r10). Emitted so the abandon decision reads the same set pruning acted on.
|
|
272
|
+
*/
|
|
273
|
+
activeMessageIndexes: number[];
|
|
204
274
|
} {
|
|
205
275
|
const entries = systemPromptBlobs(request);
|
|
206
276
|
const systemEntryCount = entries.length;
|
|
@@ -211,11 +281,17 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
211
281
|
byteLength: entries.reduce((sum, entry) => sum + entry.byteLength, 0),
|
|
212
282
|
historyMessageStart: 0,
|
|
213
283
|
serialized: entries.map(entry => entry.serialized),
|
|
284
|
+
historyMessageIndexes: [],
|
|
285
|
+
historyOutputElided: [],
|
|
286
|
+
activeMessageIndexes: [],
|
|
214
287
|
};
|
|
215
288
|
}
|
|
216
289
|
|
|
217
290
|
const externalModel = isCursorExternalWireModel(request.modelId);
|
|
218
291
|
const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId);
|
|
292
|
+
// Replayed results name the invocation that produced them; without it the result is orphaned
|
|
293
|
+
// (devlog 260829 000_rca). Indexed once per request rather than rescanned per result.
|
|
294
|
+
const replayedCalls = echoToolResultInRoot ? (knownCalls ?? toolCallsByCallId(messages)) : undefined;
|
|
219
295
|
const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
|
|
220
296
|
const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
|
|
221
297
|
// Repetition breaker (devlog 260826 gap-9): external full-replay flattens history to text,
|
|
@@ -285,7 +361,11 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
285
361
|
text,
|
|
286
362
|
);
|
|
287
363
|
}
|
|
288
|
-
// Assistant tool CALLS are
|
|
364
|
+
// Assistant tool CALLS are NOT replayed as a separate visible "[Tool Call]" entry: a model
|
|
365
|
+
// few-shot-mimics that marker and emits later tool calls as inert text (363-B guard in
|
|
366
|
+
// tests/cursor-tool-continuation.test.ts). The invocation is instead named INSIDE the paired
|
|
367
|
+
// "[Tool Result]" envelope below, which carries the same information without a mimickable
|
|
368
|
+
// call template (devlog 260829 002_audit_round2).
|
|
289
369
|
} else if (message.role === "toolResult") {
|
|
290
370
|
// Native resume models already receive the paired MCP result through turns[]. Replaying
|
|
291
371
|
// the same payload as assistant-role "[Tool Result]" / "[tool_result]" text teaches Auto
|
|
@@ -294,7 +374,9 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
294
374
|
// #1920: the prefix must reflect the NORMALIZED error state (an empty
|
|
295
375
|
// node_repl result is an error even when the runtime said isError=false).
|
|
296
376
|
const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]";
|
|
297
|
-
|
|
377
|
+
// The bound compares in full-history space: this loop's `i` is already full-history on the
|
|
378
|
+
// full-replay path, and `knownCallsOffset` re-bases it when only a suffix is replayed.
|
|
379
|
+
const text = `${prefix}\n${toolResultToText(message, callBefore(replayedCalls, decodeCursorCallId(message.toolCallId), knownCallsOffset + i))}`;
|
|
298
380
|
pushDeduped(toolResultRootPayload(text), "toolResult", { messageIndex: i, text }, text);
|
|
299
381
|
}
|
|
300
382
|
}
|
|
@@ -308,29 +390,124 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
308
390
|
|
|
309
391
|
let selected = entries;
|
|
310
392
|
let historyMessageStart = 0;
|
|
393
|
+
// The trailing tool-result run in ROOT space, recorded before pruning can drop from it. Empty for a
|
|
394
|
+
// native model or a non-external one, which never assemble a trailing run here at all.
|
|
395
|
+
let activeMessageIndexes: number[] = [];
|
|
311
396
|
if (externalModel) {
|
|
397
|
+
// A non-zero offset means `rawMessages[0]` is NOT the conversation start: only the checkpoint
|
|
398
|
+
// path passes one, and it passes `suffixStart`, the count of messages the checkpoint carries.
|
|
399
|
+
// Named rather than tested inline because `knownCallsOffset` answers two different questions —
|
|
400
|
+
// where to re-base a position (#2936) and whether this history has a covered predecessor — and
|
|
401
|
+
// collapsing them back into one bare `!== 0` is how the second meaning gets lost again.
|
|
402
|
+
const suffixContinuesCoveredTurn = knownCallsOffset > 0;
|
|
312
403
|
const systemEntries = entries.slice(0, systemEntryCount);
|
|
313
404
|
const history = entries.slice(systemEntryCount);
|
|
314
405
|
const systemBytes = systemEntries.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
315
|
-
|
|
316
|
-
|
|
406
|
+
// On the checkpoint path the caller appends ONLY the history roots to what the checkpoint already
|
|
407
|
+
// carries (`suffixHistoryIds` is `ids.slice(suffixSystemCount)`), and the system roots the checkpoint
|
|
408
|
+
// carries are already inside `carriedRoots`. Subtracting `systemEntryCount` there charges for them
|
|
409
|
+
// twice, which cost a slot that was genuinely free: at 190 carried roots the limit came out 1 when 2
|
|
410
|
+
// results fit, and the count bound below then dropped an answered call for no reason (audit r10).
|
|
411
|
+
const chargeableSystemCount = suffixContinuesCoveredTurn ? 0 : systemEntryCount;
|
|
412
|
+
const historyLimit = Math.max(0, CURSOR_EXTERNAL_ROOT_BLOB_LIMIT - chargeableSystemCount - carriedRoots.count);
|
|
413
|
+
// Only the COUNT is relaxed. The byte side keeps charging `systemBytes` on both paths: the same
|
|
414
|
+
// double-charge argument applies in principle, but no configuration could be found where relaxing it
|
|
415
|
+
// changes the assembled payload — 6 crossings of carried bytes against system size against result size
|
|
416
|
+
// in the deciding band produced byte-identical output with and without it. Untested new code on the
|
|
417
|
+
// envelope path is a liability rather than a saving, and charging the bytes twice only ever errs
|
|
418
|
+
// conservative, so the relaxation is deliberately not made here (audit r11).
|
|
419
|
+
const historyBudget = Math.max(0, CURSOR_EXTERNAL_ROOT_BYTE_LIMIT - systemBytes - carriedRoots.byteLength);
|
|
317
420
|
|
|
318
421
|
// Retain the active trailing tool-result block when it fits (may truncate text).
|
|
319
422
|
// If even a truncation marker cannot fit the remaining budget, omit it rather than
|
|
320
423
|
// emitting an oversized root blob.
|
|
321
|
-
|
|
424
|
+
//
|
|
425
|
+
// Walk past any SYNTHETIC trailing root first. The repetition breaker above appends a
|
|
426
|
+
// `[context note]` user root after the transcript, and it stands for no message, so it carries no
|
|
427
|
+
// `messageIndex`. Without this step the result-run walk stopped dead on that note: `activeStart`
|
|
428
|
+
// came out equal to `history.length`, the trailing run was empty, and the results lost their
|
|
429
|
+
// trailing-run status entirely — they fell through to `prior` and were pruned as ordinary history,
|
|
430
|
+
// with no "keep at least one" guarantee and an empty `activeMessageIndexes` that sent the abandon
|
|
431
|
+
// check back to the raw-message scan it must not use. Measured: a note-armed continuation at 186
|
|
432
|
+
// carried roots was retained where the same shape without the note correctly abandoned, and the
|
|
433
|
+
// note arms on three identical assistant narrations — the runaway-repetition shape this whole unit
|
|
434
|
+
// exists to end, so the one input most likely to hit it (audit r11).
|
|
435
|
+
let activeEnd = history.length;
|
|
436
|
+
while (activeEnd > 0 && history[activeEnd - 1]?.messageIndex === undefined) activeEnd -= 1;
|
|
437
|
+
let activeStart = activeEnd;
|
|
322
438
|
while (activeStart > 0 && history[activeStart - 1]?.role === "toolResult") activeStart -= 1;
|
|
439
|
+
// Reserve the synthetic tail's slots and bytes FIRST, and express every budget below net of it.
|
|
440
|
+
// The tail is appended after all pruning, so a block that spends its room overruns the envelope,
|
|
441
|
+
// and a block that divides the gross budget produces shares that cannot fit once it returns. Both
|
|
442
|
+
// happened: the equal-share pass became structurally unfittable and fell through to deleting a whole
|
|
443
|
+
// result, and the initiator-recovery block committed 51 bytes over the limit (audit r11, r12).
|
|
444
|
+
// Affordability is decided BEFORE the reservation, because the reservation cannot represent a
|
|
445
|
+
// deficit. `Math.max(0, …)` turns "the note cannot be paid for" into "the note costs nothing", and
|
|
446
|
+
// the tail was appended regardless — so an envelope with 26 bytes free emitted a 246-byte note and
|
|
447
|
+
// overran by 220. Holding the tail out of `historyEntries` is what made that unrecoverable: no block
|
|
448
|
+
// below could see it to charge it.
|
|
449
|
+
//
|
|
450
|
+
// A trailing tool result hid this, because the abandon check's survival disjuncts rescue that shape.
|
|
451
|
+
// The exposed shape is a turn that does NOT end in a result — an ordinary user interjection after a
|
|
452
|
+
// repetitive stretch — where nothing else bounds the tail: 13 of 42 positions threw the
|
|
453
|
+
// non-retryable 400 with the note armed and none without it (audit r13).
|
|
454
|
+
//
|
|
455
|
+
// When it does not fit, the note is dropped. That is the unit's own priority order: a missing
|
|
456
|
+
// instruction is recoverable, a missing tool result restarts the loop this unit exists to end.
|
|
457
|
+
const syntheticEntries = history.slice(activeEnd);
|
|
458
|
+
const syntheticCountRaw = syntheticEntries.length;
|
|
459
|
+
const syntheticBytesRaw = syntheticEntries.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
460
|
+
// BOTH axes. The count conjunct was briefly dropped as inert on the reasoning that the count bound
|
|
461
|
+
// below keeps one result and therefore always leaves a slot — which is exactly wrong at
|
|
462
|
+
// `historyLimit === 1`, where that one free slot is the one the result takes. The note was then judged
|
|
463
|
+
// affordable on bytes, the reservation clamped to 0, and the append pushed full replay to 193 roots:
|
|
464
|
+
// four armed-only `CursorRootEnvelopeLimitError` throws at 191 system prompts, on both tails and both
|
|
465
|
+
// suffix widths, where the same request without the note assembled 192 and succeeded.
|
|
466
|
+
//
|
|
467
|
+
// The sweep that called it inert varied CARRIED roots on the checkpoint path, where the count-full
|
|
468
|
+
// disjunct abandons the checkpoint before `historyLimit` can reach 1. The reachable route is full
|
|
469
|
+
// replay with many system prompts, and full replay has no abandon branch to rescue it (audit r14).
|
|
470
|
+
const syntheticAffordable = historyBudget - syntheticBytesRaw >= 0
|
|
471
|
+
&& historyLimit - syntheticCountRaw >= 1;
|
|
472
|
+
const syntheticCount = syntheticAffordable ? syntheticCountRaw : 0;
|
|
473
|
+
const syntheticBytes = syntheticAffordable ? syntheticBytesRaw : 0;
|
|
474
|
+
const historyLimitForReal = Math.max(0, historyLimit - syntheticCount);
|
|
475
|
+
const historyBudgetForReal = Math.max(0, historyBudget - syntheticBytes);
|
|
323
476
|
const active = history
|
|
324
|
-
.slice(activeStart)
|
|
325
|
-
.map(entry => truncateToolResultBlob(entry,
|
|
477
|
+
.slice(activeStart, activeEnd)
|
|
478
|
+
.map(entry => truncateToolResultBlob(entry, historyBudgetForReal))
|
|
326
479
|
.filter((entry): entry is RootBlobCandidate => entry !== null);
|
|
480
|
+
// Record the run BEFORE any pruning below can shrink it, so the abandon decision downstream compares
|
|
481
|
+
// against what pruning was asked to preserve rather than against a raw-message scan that cannot see
|
|
482
|
+
// this run's true width (audit r10).
|
|
483
|
+
activeMessageIndexes = history
|
|
484
|
+
.slice(activeStart, activeEnd)
|
|
485
|
+
.map(entry => entry.messageIndex)
|
|
486
|
+
.filter((index): index is number => index !== undefined);
|
|
327
487
|
let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
328
|
-
|
|
488
|
+
// Shrink every active result toward an equal share before dropping any of them. Review found
|
|
489
|
+
// that the previous `active.shift()` loop DELETED whole results: three ~220 KB results emitted
|
|
490
|
+
// only the last two, and `call_0` vanished with its tool call still in the transcript. A
|
|
491
|
+
// missing result is worse than a truncated one — the model sees a call it never got an answer
|
|
492
|
+
// to, which is the pairing break #1527 reports, and the caller cannot tell it happened.
|
|
493
|
+
if (active.length > 1 && activeBytes > historyBudgetForReal) {
|
|
494
|
+
const share = Math.floor(historyBudgetForReal / active.length);
|
|
495
|
+
for (let index = 0; index < active.length; index++) {
|
|
496
|
+
const entry = active[index];
|
|
497
|
+
if (!entry || entry.byteLength <= share) continue;
|
|
498
|
+
const shrunk = truncateToolResultBlob(entry, share);
|
|
499
|
+
if (shrunk) active[index] = shrunk;
|
|
500
|
+
}
|
|
501
|
+
activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
502
|
+
}
|
|
503
|
+
// Only when even an equal share cannot fit — the marker alone has a floor, so enough results
|
|
504
|
+
// still overflow — fall back to dropping the oldest.
|
|
505
|
+
while (active.length > 1 && activeBytes > historyBudgetForReal) {
|
|
329
506
|
const dropped = active.shift();
|
|
330
507
|
activeBytes -= dropped?.byteLength ?? 0;
|
|
331
508
|
}
|
|
332
|
-
if (active.length === 1 && active[0] && activeBytes >
|
|
333
|
-
const truncated = truncateToolResultBlob(active[0],
|
|
509
|
+
if (active.length === 1 && active[0] && activeBytes > historyBudgetForReal) {
|
|
510
|
+
const truncated = truncateToolResultBlob(active[0], historyBudgetForReal);
|
|
334
511
|
if (truncated) {
|
|
335
512
|
active[0] = truncated;
|
|
336
513
|
activeBytes = truncated.byteLength;
|
|
@@ -339,20 +516,48 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
339
516
|
activeBytes = 0;
|
|
340
517
|
}
|
|
341
518
|
}
|
|
519
|
+
// COUNT-bound the trailing run, not only its bytes. `historyLimit` already subtracts what the
|
|
520
|
+
// checkpoint carries, but until now it was consulted ONLY by the prior-history loop below, and
|
|
521
|
+
// `historyEntries` was assembled as `[...keptPrior, ...active]` with no count check at all. The
|
|
522
|
+
// shrink/drop loops above answer to `historyBudget` alone — `truncateToolResultBlob` makes a
|
|
523
|
+
// result smaller, it never removes one to free a root SLOT — so a parallel tool-call batch
|
|
524
|
+
// arrived unbounded: 190 carried roots plus a 3-result batch assembled 193 and threw the
|
|
525
|
+
// non-retryable 400 this unit exists to remove (audit r9). Sequential pairs hid it, because a
|
|
526
|
+
// trailing run of length 1 is the one case where the abandon test's `+ 1` is exactly right.
|
|
527
|
+
//
|
|
528
|
+
// Drop the OLDEST results first, matching the direction byte pressure already prunes, and keep
|
|
529
|
+
// at least one: a continuation with no result is worthless, and the abandon decision downstream
|
|
530
|
+
// reads `historyMessageIndexes` to notice exactly that and fall back to a full replay.
|
|
531
|
+
while (active.length > 1 && active.length > historyLimitForReal) {
|
|
532
|
+
const dropped = active.shift();
|
|
533
|
+
activeBytes -= dropped?.byteLength ?? 0;
|
|
534
|
+
}
|
|
342
535
|
|
|
343
536
|
const prior = history.slice(0, activeStart);
|
|
344
537
|
const keptPrior: RootBlobCandidate[] = [];
|
|
345
538
|
let priorBytes = 0;
|
|
346
539
|
// Take complete turns from the end: a turn starts at a user/developer root entry.
|
|
540
|
+
//
|
|
541
|
+
// Turn-granular admission needs a turn boundary to exist. A checkpoint suffix has NO user root at
|
|
542
|
+
// all — its initiating turn is inside the checkpoint — so `turnStart` walks to 0, the entire prior
|
|
543
|
+
// block becomes one all-or-nothing pseudo-turn, and the first budget overrun drops ALL of it.
|
|
544
|
+
// Measured on the checkpoint path with 8 pairs of 64 KiB results: 2 roots, unchanged by the orphan
|
|
545
|
+
// guard fix, because there was nothing left for that guard to strip. Admitting entry-by-entry keeps
|
|
546
|
+
// as much recent history as fits instead of none (devlog 260829 070, audit r8 finding 2).
|
|
347
547
|
let i = prior.length - 1;
|
|
348
|
-
while (i >= 0 && keptPrior.length + active.length <
|
|
548
|
+
while (i >= 0 && keptPrior.length + active.length < historyLimitForReal) {
|
|
349
549
|
let turnStart = i;
|
|
350
|
-
|
|
550
|
+
if (!suffixContinuesCoveredTurn) {
|
|
551
|
+
// Root-blob roles are a closed set of four (system, user, assistant, toolResult): a
|
|
552
|
+
// developer message is normalized to a user root upstream, so "user" IS the turn start.
|
|
553
|
+
// Review suspected a developer-role gap here; the type says it cannot occur.
|
|
554
|
+
while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1;
|
|
555
|
+
}
|
|
351
556
|
const turn = prior.slice(turnStart, i + 1);
|
|
352
557
|
const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
353
558
|
if (
|
|
354
|
-
keptPrior.length + active.length + turn.length >
|
|
355
|
-
|| priorBytes + activeBytes + turnBytes >
|
|
559
|
+
keptPrior.length + active.length + turn.length > historyLimitForReal
|
|
560
|
+
|| priorBytes + activeBytes + turnBytes > historyBudgetForReal
|
|
356
561
|
) {
|
|
357
562
|
break;
|
|
358
563
|
}
|
|
@@ -361,14 +566,107 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
361
566
|
i = turnStart - 1;
|
|
362
567
|
}
|
|
363
568
|
|
|
569
|
+
const trailingSynthetic = syntheticAffordable ? syntheticEntries : [];
|
|
570
|
+
// Synthetic trailing roots — today only the repetition-breaker note — are held OUT of
|
|
571
|
+
// `historyEntries` while the blocks below decide what survives, and appended once at assembly.
|
|
572
|
+
//
|
|
573
|
+
// They were briefly appended here instead, and every subsequent block then had to recognise a tail
|
|
574
|
+
// it could not identify except by position. The initiator-recovery loop below could not: its floor
|
|
575
|
+
// is "stop when one entry is left", so with `[toolResult, note]` it counted the note as the
|
|
576
|
+
// survivor and shifted off the RESULT — a 600 KB tool output replaced by 193 bytes of note, leaving
|
|
577
|
+
// a prompt that instructs the model to change strategy while showing it nothing its command
|
|
578
|
+
// returned. Measured 166 of 432 byte-pressure configurations losing an answer that way. Keeping the
|
|
579
|
+
// tail out means those blocks stay purely about real history and cannot mistake one for the other;
|
|
580
|
+
// the budgets still charge for it, which is what stops it overrunning the envelope (audit r12).
|
|
364
581
|
const historyEntries = [...keptPrior, ...active];
|
|
365
582
|
// Guard against orphan assistant / toolResult at the start of the retained suffix.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
583
|
+
//
|
|
584
|
+
// Premised on `history` starting where the CONVERSATION starts: only then does a leading
|
|
585
|
+
// assistant/result entry mean its user turn was pruned. A checkpoint suffix breaks that premise —
|
|
586
|
+
// it begins at `suffixStart`, so its first entry is routinely the assistant message whose
|
|
587
|
+
// initiating user turn is inside the checkpoint. Running the loop there strips pair after pair
|
|
588
|
+
// until only `active` survives, because the `break` fires only once the survivors ARE `active`:
|
|
589
|
+
// measured 2 roots for 1, 2, 3 and 4 completed pairs in the suffix, so a growing conversation
|
|
590
|
+
// replayed a constant payload and the model never saw the output of the command it just ran
|
|
591
|
+
// (devlog 260829 070).
|
|
592
|
+
if (!suffixContinuesCoveredTurn) {
|
|
593
|
+
while (historyEntries[0]?.role === "assistant" || historyEntries[0]?.role === "toolResult") {
|
|
594
|
+
// Never drop the sole active tool-result block.
|
|
595
|
+
if (historyEntries.length <= active.length) break;
|
|
596
|
+
historyEntries.shift();
|
|
597
|
+
}
|
|
370
598
|
}
|
|
371
|
-
|
|
599
|
+
// #1527: the surviving history must not begin with a tool result. Byte pressure can consume the
|
|
600
|
+
// whole budget with one large active result and drop the user turn that asked for it, and
|
|
601
|
+
// `conversationTurns()` then discards the result too for lack of a current turn — the wire
|
|
602
|
+
// request becomes system roots plus a bare result marker with no instruction, which a model
|
|
603
|
+
// answers in a handful of tokens.
|
|
604
|
+
//
|
|
605
|
+
// Recover the initiating root and pay for it out of the tool-result text instead.
|
|
606
|
+
//
|
|
607
|
+
// This needs no full-replay/checkpoint distinction, which is worth stating because the plan
|
|
608
|
+
// called for one. `activeStart > 0` confines the search to entries present in THIS call's
|
|
609
|
+
// history, so a checkpoint suffix can only ever recover a turn from inside its own uncovered
|
|
610
|
+
// slice — never one the checkpoint already carries. And for a suffix that does contain its
|
|
611
|
+
// initiating turn, recovery is exactly as necessary as it is for a full replay: mode-gating it
|
|
612
|
+
// would have recreated this defect for checkpoint continuations. Mutation testing found that;
|
|
613
|
+
// the mode flag could not be made to fail a test because it was never load-bearing.
|
|
614
|
+
if (
|
|
615
|
+
historyEntries.length > 0
|
|
616
|
+
&& historyEntries[0]?.role === "toolResult"
|
|
617
|
+
&& activeStart > 0
|
|
618
|
+
) {
|
|
619
|
+
let initiatorIndex = activeStart - 1;
|
|
620
|
+
while (initiatorIndex >= 0 && history[initiatorIndex]?.role !== "user") {
|
|
621
|
+
initiatorIndex -= 1;
|
|
622
|
+
}
|
|
623
|
+
const initiator = initiatorIndex >= 0 ? history[initiatorIndex] : undefined;
|
|
624
|
+
if (initiator) {
|
|
625
|
+
const withInitiator = [initiator, ...historyEntries];
|
|
626
|
+
const initiatorBytes = withInitiator.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
627
|
+
if (withInitiator.length <= historyLimitForReal && initiatorBytes <= historyBudgetForReal) {
|
|
628
|
+
historyEntries.length = 0;
|
|
629
|
+
historyEntries.push(...withInitiator);
|
|
630
|
+
} else {
|
|
631
|
+
// Make room for the initiator instead of abandoning it. Review found that gating this on
|
|
632
|
+
// `historyEntries.length === 1` left the defect fully intact for the far more common
|
|
633
|
+
// multi-result shape: one system root plus 191 small trailing results already fills the
|
|
634
|
+
// count limit, so the initiator did not fit, the single-result branch did not apply, and
|
|
635
|
+
// the request went out as 191 bare results with nothing asking for them — inside the new
|
|
636
|
+
// envelope, so the guard could not catch it either.
|
|
637
|
+
//
|
|
638
|
+
// Drop the OLDEST results first, which is the same direction the byte-pressure loop above
|
|
639
|
+
// already prunes, then truncate whatever survives. An instruction with fewer or shorter
|
|
640
|
+
// results is answerable; results with no instruction are not.
|
|
641
|
+
const kept = [...historyEntries];
|
|
642
|
+
while (kept.length > 1 && kept.length + 1 > historyLimitForReal) kept.shift();
|
|
643
|
+
let keptBytes = kept.reduce((sum, entry) => sum + entry.byteLength, 0);
|
|
644
|
+
while (kept.length > 1 && initiator.byteLength + keptBytes > historyBudgetForReal) {
|
|
645
|
+
const dropped = kept.shift();
|
|
646
|
+
keptBytes -= dropped?.byteLength ?? 0;
|
|
647
|
+
}
|
|
648
|
+
if (kept.length === 1 && kept[0] && initiator.byteLength + keptBytes > historyBudgetForReal) {
|
|
649
|
+
const room = historyBudgetForReal - initiator.byteLength;
|
|
650
|
+
const shrunk = room > 0 ? truncateToolResultBlob(kept[0], room) : null;
|
|
651
|
+
if (shrunk) {
|
|
652
|
+
kept[0] = shrunk;
|
|
653
|
+
keptBytes = shrunk.byteLength;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// Only commit when the initiator genuinely fits alongside what is left. If the system
|
|
657
|
+
// prompt has consumed the budget so completely that not even a truncation marker fits,
|
|
658
|
+
// there is nothing honest to send here; the envelope guard downstream owns that case.
|
|
659
|
+
if (kept.length + 1 <= historyLimitForReal && initiator.byteLength + keptBytes <= historyBudgetForReal) {
|
|
660
|
+
historyEntries.length = 0;
|
|
661
|
+
historyEntries.push(initiator, ...kept);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
// The synthetic tail goes on last, after every pruning decision is made, so telling the model to
|
|
667
|
+
// change strategy is not dropped by the walk that stopped ignoring it — and so no pruning block has
|
|
668
|
+
// to distinguish it from a real result by position. Its slots and bytes were already reserved above.
|
|
669
|
+
selected = [...systemEntries, ...historyEntries, ...trailingSynthetic];
|
|
372
670
|
const firstKept = historyEntries.find(entry => entry.messageIndex !== undefined);
|
|
373
671
|
historyMessageStart = firstKept?.messageIndex ?? (messages.length);
|
|
374
672
|
}
|
|
@@ -378,6 +676,16 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
|
|
|
378
676
|
byteLength: selected.reduce((sum, entry) => sum + entry.byteLength, 0),
|
|
379
677
|
historyMessageStart,
|
|
380
678
|
serialized: selected.map(entry => entry.serialized),
|
|
679
|
+
historyMessageIndexes: selected
|
|
680
|
+
.slice(systemEntryCount)
|
|
681
|
+
.map(entry => entry.messageIndex)
|
|
682
|
+
.filter((index): index is number => index !== undefined),
|
|
683
|
+
historyOutputElided: selected
|
|
684
|
+
.slice(systemEntryCount)
|
|
685
|
+
.filter(entry => entry.outputElided === true)
|
|
686
|
+
.map(entry => entry.messageIndex)
|
|
687
|
+
.filter((index): index is number => index !== undefined),
|
|
688
|
+
activeMessageIndexes,
|
|
381
689
|
};
|
|
382
690
|
}
|
|
383
691
|
|
|
@@ -571,12 +879,171 @@ function toolResultContentItems(
|
|
|
571
879
|
return items;
|
|
572
880
|
}
|
|
573
881
|
|
|
574
|
-
|
|
882
|
+
/**
|
|
883
|
+
* Serialize tool-call arguments for the replayed transcript, or `undefined` when they cannot be
|
|
884
|
+
* serialized at all. `OcxToolCall.arguments` is always an object, but it originates in provider
|
|
885
|
+
* JSON, so a cyclic or BigInt-bearing value must degrade instead of throwing inside request
|
|
886
|
+
* encoding. The failure is reported as `undefined` rather than a marker string so callers can tell
|
|
887
|
+
* "these two argument sets are equal" apart from "neither could be read" — collapsing both onto one
|
|
888
|
+
* marker made every unserializable argument set compare equal to every other.
|
|
889
|
+
*/
|
|
890
|
+
function serializeToolCallArguments(args: Record<string, unknown>): string | undefined {
|
|
891
|
+
try {
|
|
892
|
+
const serialized = JSON.stringify(args);
|
|
893
|
+
return typeof serialized === "string" ? serialized : undefined;
|
|
894
|
+
} catch {
|
|
895
|
+
return undefined;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Truncate to a byte budget without splitting a UTF-8 sequence. */
|
|
900
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
901
|
+
const encoded = encoder.encode(text);
|
|
902
|
+
if (encoded.byteLength <= maxBytes) return text;
|
|
903
|
+
let end = Math.max(0, maxBytes);
|
|
904
|
+
while (end > 0 && (encoded[end]! & 0xc0) === 0x80) end -= 1;
|
|
905
|
+
return decoder.decode(encoded.subarray(0, end));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Rendered argument text for one invocation line, bounded independently of the result it describes.
|
|
910
|
+
*
|
|
911
|
+
* The invocation is CONTEXT for a replayed result; the result itself is the payload. Serializing
|
|
912
|
+
* arguments in full inverted that: a legitimate 600 KiB `write_file` argument consumed the entire
|
|
913
|
+
* `CURSOR_EXTERNAL_ROOT_BYTE_LIMIT` history budget, so `truncateToolResultBlob` kept the invocation
|
|
914
|
+
* prefix and cut the actual output away — reproducing the very orphaned-result failure this line
|
|
915
|
+
* exists to prevent. A bounded prefix still identifies the call (tool name plus the head of its
|
|
916
|
+
* arguments) while leaving the output room to survive.
|
|
917
|
+
*/
|
|
918
|
+
function toolCallArgumentsText(args: Record<string, unknown>): string {
|
|
919
|
+
const serialized = serializeToolCallArguments(args);
|
|
920
|
+
if (serialized === undefined) return "[unserializable arguments]";
|
|
921
|
+
if (encoder.encode(serialized).byteLength <= CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) return serialized;
|
|
922
|
+
// The budget is the size of the RENDERED line, so the marker has to come out of it rather than be
|
|
923
|
+
// added on top: otherwise every truncated invocation exceeds the declared limit by the marker.
|
|
924
|
+
const marker = "…[arguments truncated]";
|
|
925
|
+
const markerBytes = encoder.encode(marker).byteLength;
|
|
926
|
+
const keep = Math.max(0, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT - markerBytes);
|
|
927
|
+
return `${truncateUtf8(serialized, keep)}${marker}`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the
|
|
932
|
+
* result envelope.
|
|
933
|
+
*
|
|
934
|
+
* Why not a separate "[Tool Call]" entry: a model few-shot-mimics that marker and starts emitting
|
|
935
|
+
* later tool calls as inert text instead of real tool frames, which halts multi-tool continuations
|
|
936
|
+
* (363-B guard, tests/cursor-tool-continuation.test.ts). Why it must exist at all: without any
|
|
937
|
+
* record of the invocation, the replayed result is orphaned — its `call_id` refers to nothing the
|
|
938
|
+
* model can see — and live cursor/grok-4.6 turns re-ran commands that had already succeeded while
|
|
939
|
+
* narrating a phantom interrupt (devlog 260829 000_rca). A prose line inside the result satisfies
|
|
940
|
+
* both: the invocation is visible, but there is no call-shaped template to copy.
|
|
941
|
+
*/
|
|
942
|
+
function toolInvocationLine(call: Extract<OcxAssistantContentPart, { type: "toolCall" }>): string {
|
|
943
|
+
return `invoked: ${namespacedToolName(call.namespace, call.name)} with ${toolCallArgumentsText(call.arguments)}`;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* History position of each indexed call, keyed by the map `toolCallsByCallId` returned.
|
|
948
|
+
*
|
|
949
|
+
* A side table rather than a wider return type: the map is threaded through two builders and the
|
|
950
|
+
* checkpoint site, and changing its shape would touch every one of them for data only the bound reads.
|
|
951
|
+
*/
|
|
952
|
+
const callPositions = new WeakMap<
|
|
953
|
+
Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>,
|
|
954
|
+
Map<string, number>
|
|
955
|
+
>();
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The indexed call for `callId`, but only when it appears BEFORE `resultIndex` in history.
|
|
959
|
+
*
|
|
960
|
+
* `toolCallsByCallId` has no ordering constraint, so it would happily name a call that runs LATER than
|
|
961
|
+
* the result being labelled — a result whose own output is `EARLY-OUT` was measured on the shipped tree
|
|
962
|
+
* as `invoked: exec_command with {"cmd":"echo LATER"}`. That is the mislabel the index's own comment
|
|
963
|
+
* calls worse than no label, because nothing downstream can detect it (devlog 260829 060).
|
|
964
|
+
*
|
|
965
|
+
* `resultIndex` MUST be in full-history space. The checkpoint path replays a suffix and the turn
|
|
966
|
+
* builder starts at `historyMessageStart`, so a caller composes `knownCallsOffset + start + local`
|
|
967
|
+
* before calling; comparing a full-history call index against a slice-local result index silently
|
|
968
|
+
* drops legitimate pairings and re-creates the orphan #2910 fixed.
|
|
969
|
+
*/
|
|
970
|
+
function callBefore(
|
|
971
|
+
calls: Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>> | undefined,
|
|
972
|
+
callId: string,
|
|
973
|
+
resultIndex: number,
|
|
974
|
+
): Extract<OcxAssistantContentPart, { type: "toolCall" }> | undefined {
|
|
975
|
+
const call = calls?.get(callId);
|
|
976
|
+
if (!call || !calls) return undefined;
|
|
977
|
+
const position = callPositions.get(calls)?.get(callId);
|
|
978
|
+
if (position === undefined || position >= resultIndex) return undefined;
|
|
979
|
+
return call;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* Index assistant tool calls by decoded call id so a replayed result can name its invocation.
|
|
984
|
+
*
|
|
985
|
+
* A call id is supposed to be unique, but nothing upstream guarantees it across a long history, and
|
|
986
|
+
* `decodeCursorCallId` can map distinct wire ids onto the same decoded id. Two calls sharing one id
|
|
987
|
+
* would make the LAST one describe every result bearing it, so an early result could be labelled
|
|
988
|
+
* with a later command — a wrong invocation is worse than none, since it is the kind of mislabel the
|
|
989
|
+
* model cannot detect. Keep the FIRST call for an id (results follow their call, so the first
|
|
990
|
+
* binding is the one an earlier result belongs to) and drop the ambiguous id entirely once a second
|
|
991
|
+
* distinct call claims it, which degrades to the honest no-invocation-line path.
|
|
992
|
+
*/
|
|
993
|
+
function toolCallsByCallId(messages: readonly OcxMessage[]): Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>> {
|
|
994
|
+
const calls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
|
|
995
|
+
const ambiguous = new Set<string>();
|
|
996
|
+
const positions = new Map<string, number>();
|
|
997
|
+
for (let index = 0; index < messages.length; index++) {
|
|
998
|
+
const message = messages[index];
|
|
999
|
+
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
1000
|
+
for (const part of message.content) {
|
|
1001
|
+
if (part.type !== "toolCall") continue;
|
|
1002
|
+
const callId = decodeCursorCallId(part.id);
|
|
1003
|
+
if (ambiguous.has(callId)) continue;
|
|
1004
|
+
const existing = calls.get(callId);
|
|
1005
|
+
if (!existing) {
|
|
1006
|
+
calls.set(callId, part);
|
|
1007
|
+
positions.set(callId, index);
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
// Same id, and not the same invocation: neither claim can be trusted for a given result.
|
|
1011
|
+
// Identity is the FULL namespaced name — `one__read` and `two__read` are different tools, and
|
|
1012
|
+
// comparing bare `name` labelled both results with the first namespace. Arguments count as
|
|
1013
|
+
// different whenever either side cannot be serialized: two distinct unserializable argument
|
|
1014
|
+
// sets are not evidence of the same call, so they must not compare equal.
|
|
1015
|
+
const existingArgs = serializeToolCallArguments(existing.arguments);
|
|
1016
|
+
const partArgs = serializeToolCallArguments(part.arguments);
|
|
1017
|
+
const sameInvocation = namespacedToolName(existing.namespace, existing.name) === namespacedToolName(part.namespace, part.name)
|
|
1018
|
+
&& existingArgs !== undefined
|
|
1019
|
+
&& partArgs !== undefined
|
|
1020
|
+
&& existingArgs === partArgs;
|
|
1021
|
+
if (!sameInvocation) {
|
|
1022
|
+
calls.delete(callId);
|
|
1023
|
+
positions.delete(callId);
|
|
1024
|
+
ambiguous.add(callId);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
callPositions.set(calls, positions);
|
|
1029
|
+
return calls;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* The replayed text of one tool result. When `call` is supplied, the invocation that produced it is
|
|
1034
|
+
* named inline so the result is not orphaned; when it is absent (no match, or an ambiguous call id)
|
|
1035
|
+
* the envelope is emitted unchanged rather than guessing.
|
|
1036
|
+
*/
|
|
1037
|
+
function toolResultToText(
|
|
1038
|
+
message: OcxToolResultMessage,
|
|
1039
|
+
call?: Extract<OcxAssistantContentPart, { type: "toolCall" }>,
|
|
1040
|
+
): string {
|
|
575
1041
|
const normalized = normalizedToolResult(message, contentToText(message.content));
|
|
576
1042
|
return [
|
|
577
1043
|
"[tool_result]",
|
|
578
1044
|
`call_id: ${decodeCursorCallId(message.toolCallId)}`,
|
|
579
1045
|
`name: ${namespacedToolName(message.toolNamespace, message.toolName)}`,
|
|
1046
|
+
...(call ? [toolInvocationLine(call)] : []),
|
|
580
1047
|
`is_error: ${normalized.isError}`,
|
|
581
1048
|
"output:",
|
|
582
1049
|
normalized.text,
|
|
@@ -710,6 +1177,10 @@ function conversationTurns(
|
|
|
710
1177
|
request: CursorRunRequest,
|
|
711
1178
|
requestScope: CursorBlobRequestScopeToken,
|
|
712
1179
|
historyMessageStart = 0,
|
|
1180
|
+
/** Calls indexed from the FULL history; see {@link rootPromptMessages}. */
|
|
1181
|
+
knownCalls?: Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>,
|
|
1182
|
+
/** Full-history index of `rawMessages[0]`; see {@link rootPromptMessages}. */
|
|
1183
|
+
knownCallsOffset = 0,
|
|
713
1184
|
): Uint8Array[] {
|
|
714
1185
|
const messages = request.rawMessages;
|
|
715
1186
|
if (!messages?.length) return [];
|
|
@@ -717,6 +1188,7 @@ function conversationTurns(
|
|
|
717
1188
|
const externalModel = isCursorExternalWireModel(request.modelId);
|
|
718
1189
|
const historyEnd = messages.at(-1)?.role === "toolResult" ? messages.length : Math.max(0, end);
|
|
719
1190
|
const start = externalModel ? Math.max(0, historyMessageStart) : 0;
|
|
1191
|
+
const turnCalls = externalModel ? (knownCalls ?? toolCallsByCallId(messages)) : undefined;
|
|
720
1192
|
const turns: Uint8Array[] = [];
|
|
721
1193
|
let current: { userMessage: Uint8Array; steps: Uint8Array[] } | undefined;
|
|
722
1194
|
const pendingToolCalls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
|
|
@@ -733,7 +1205,15 @@ function conversationTurns(
|
|
|
733
1205
|
pendingToolCalls.clear();
|
|
734
1206
|
};
|
|
735
1207
|
|
|
736
|
-
|
|
1208
|
+
const walked = messages.slice(start, historyEnd);
|
|
1209
|
+
for (let w = 0; w < walked.length; w++) {
|
|
1210
|
+
const message = walked[w];
|
|
1211
|
+
// `for…of` gave this for free; keep it explicit so the indexed loop behaves identically.
|
|
1212
|
+
if (!message) continue;
|
|
1213
|
+
// Full-history position of this message: the slice offset the caller passed, plus where this
|
|
1214
|
+
// loop starts inside `rawMessages`, plus the local step. All three terms are needed — dropping
|
|
1215
|
+
// `start` still passes every test except the checkpoint-plus-pruned-root case (devlog 060).
|
|
1216
|
+
const fullIndex = knownCallsOffset + start + w;
|
|
737
1217
|
if (message.role === "assistant") {
|
|
738
1218
|
if (!current) continue;
|
|
739
1219
|
for (const part of message.content) {
|
|
@@ -768,10 +1248,14 @@ function conversationTurns(
|
|
|
768
1248
|
// reported repro path for empty Computer Use results.
|
|
769
1249
|
const normalized = normalizedToolResult(message, contentToText(message.content));
|
|
770
1250
|
const prefix = normalized.isError ? "[Tool Error]" : "[Tool Result]";
|
|
1251
|
+
// Name the invocation here as well, for the same reason the root replay does: a result with
|
|
1252
|
+
// no visible originating call reads as an interrupted attempt (devlog 260829 000_rca).
|
|
1253
|
+
const call = callBefore(turnCalls, decodeCursorCallId(message.toolCallId), fullIndex);
|
|
1254
|
+
const invocation = call ? `${toolInvocationLine(call)}\n` : "";
|
|
771
1255
|
current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, {
|
|
772
1256
|
message: {
|
|
773
1257
|
case: "assistantMessage",
|
|
774
|
-
value: create(AssistantMessageSchema, { text: `${prefix}\n${normalized.text}` }),
|
|
1258
|
+
value: create(AssistantMessageSchema, { text: `${prefix}\n${invocation}${normalized.text}` }),
|
|
775
1259
|
},
|
|
776
1260
|
})), requestScope));
|
|
777
1261
|
continue;
|
|
@@ -929,9 +1413,104 @@ function buildPreparedCursorRunRequest(
|
|
|
929
1413
|
system: [],
|
|
930
1414
|
rawMessages: request.rawMessages.slice(suffixStart),
|
|
931
1415
|
};
|
|
932
|
-
|
|
933
|
-
|
|
1416
|
+
// Index calls from the FULL history, not the suffix: the cut can fall between a call and
|
|
1417
|
+
// its result, and a result replayed without its invocation is the orphaned-result defect.
|
|
1418
|
+
const fullHistoryCalls = toolCallsByCallId(request.rawMessages);
|
|
1419
|
+
// What the checkpoint already spends against the envelope. An id the local store never held
|
|
1420
|
+
// still occupies a root slot, so it counts toward the COUNT budget with a zero byte
|
|
1421
|
+
// contribution rather than being skipped entirely.
|
|
1422
|
+
let carriedBytes = 0;
|
|
1423
|
+
for (const blobId of conversationState.rootPromptMessagesJson) {
|
|
1424
|
+
carriedBytes += cursorBlobByteLength(blobId) ?? 0;
|
|
1425
|
+
}
|
|
1426
|
+
const carriedRoots = {
|
|
1427
|
+
count: conversationState.rootPromptMessagesJson.length,
|
|
1428
|
+
byteLength: carriedBytes,
|
|
1429
|
+
};
|
|
1430
|
+
// A checkpoint can be so large that nothing useful is left for the suffix. Pruning to fit then
|
|
1431
|
+
// emits the covered prefix and silently drops the uncovered messages — the exact failure this unit
|
|
1432
|
+
// exists to remove — while throwing would hand the caller a non-retryable 400. Abandon the
|
|
1433
|
+
// checkpoint instead: full replay rebuilds a self-contained prompt and prunes it coherently
|
|
1434
|
+
// (devlog 260829 070, audit r8).
|
|
1435
|
+
//
|
|
1436
|
+
// The decision is made on the RESULT of pruning, not on a byte threshold. A threshold has to
|
|
1437
|
+
// predict what pruning will do, and the first attempt mispredicted it: comparing carried bytes
|
|
1438
|
+
// against the raw limit left a band of a few hundred bytes below it where the checkpoint was kept,
|
|
1439
|
+
// the suffix budget collapsed, and the newest tool result vanished. Adding `systemBytes` moved the
|
|
1440
|
+
// band without closing it. Asking pruning what survived cannot drift from what pruning does.
|
|
1441
|
+
const suffixRoots = rootPromptMessages(suffixRequest, requestScope, fullHistoryCalls, suffixStart, carriedRoots);
|
|
934
1442
|
const suffixSystemCount = systemPromptBlobs(suffixRequest).length;
|
|
1443
|
+
// A tool continuation whose own result did not survive is worthless: that result is the whole
|
|
1444
|
+
// reason the turn exists. "Kept SOMETHING" is not enough either — inside the band this fix first
|
|
1445
|
+
// missed, pruning kept the assistant narration and dropped the result, which is worse than keeping
|
|
1446
|
+
// nothing because the model then sees a call it never got an answer to. The test is therefore on
|
|
1447
|
+
// the LAST replayed message specifically, identified by its index rather than its content.
|
|
1448
|
+
const suffixMessages = suffixRequest.rawMessages ?? [];
|
|
1449
|
+
const lastSuffixIndex = suffixMessages.length - 1;
|
|
1450
|
+
// Only models whose results are replayed as root text can answer this question. A native resume
|
|
1451
|
+
// model gets its result through server-side turn state, so `rootPromptMessages` never emits a
|
|
1452
|
+
// toolResult root for it (`echoToolResultInRoot` is false) — asking whether that root survived
|
|
1453
|
+
// returns "no" every single time, and an unguarded check therefore threw away the checkpoint of
|
|
1454
|
+
// every native continuation, including the default `cursor/auto`. The checkpoint is the only place
|
|
1455
|
+
// pendingToolCalls, readPaths and previousWorkspaceUris live, and full replay does not rebuild
|
|
1456
|
+
// them, so that was this unit's own defect relocated to the native path (audit r8 round 3).
|
|
1457
|
+
const resultReplayedAsRoot = cursorNeedsExternalToolContinuation(request.modelId);
|
|
1458
|
+
// Kept, and kept with its output: a root reduced to the truncation marker alone answers the call
|
|
1459
|
+
// with nothing, which is the same failure as dropping it. `outputElided` is set at the one place
|
|
1460
|
+
// that can produce it, so this needs no threshold to guess at.
|
|
1461
|
+
//
|
|
1462
|
+
// Every trailing result is checked, not just the last one. Parallel tool calls land as a run of
|
|
1463
|
+
// results, and under byte pressure the older ones were the ones getting emptied: measured a prompt
|
|
1464
|
+
// carrying three calls and one answer, which is the shape this comment calls worse than keeping
|
|
1465
|
+
// nothing. `historyOutputElided` already knew; only the last index was being read (audit r8
|
|
1466
|
+
// round 3).
|
|
1467
|
+
// The run is read from PRUNING's own report, not re-derived from `suffixMessages`. The two spaces
|
|
1468
|
+
// disagree: root space skips an assistant message that emitted no root — a bare tool call with no
|
|
1469
|
+
// narration, or whitespace-only text — so two sequentially-executed results sit adjacent as roots
|
|
1470
|
+
// while a raw-message scan still sees a trailing run of one. Pruning's count bound acts on the root
|
|
1471
|
+
// run, so a raw-space scan could not see the result it dropped: measured at 190 carried roots with
|
|
1472
|
+
// bare-call pairs, the older answer vanished from the wire entirely while this check reported
|
|
1473
|
+
// "kept" and the checkpoint was retained — an unanswered call, which the comment above rightly
|
|
1474
|
+
// calls worse than keeping nothing (audit r10).
|
|
1475
|
+
//
|
|
1476
|
+
// `activeMessageIndexes` is that run as pruning saw it, recorded before pruning could shrink it.
|
|
1477
|
+
// Falling back to the raw-space scan when it is empty keeps the full-replay and native shapes,
|
|
1478
|
+
// which never populate it, behaving exactly as before.
|
|
1479
|
+
let trailingStart = suffixMessages.length;
|
|
1480
|
+
while (trailingStart > 0 && suffixMessages[trailingStart - 1]?.role === "toolResult") trailingStart -= 1;
|
|
1481
|
+
const trailingIndexes = suffixRoots.activeMessageIndexes.length > 0
|
|
1482
|
+
? suffixRoots.activeMessageIndexes
|
|
1483
|
+
: suffixMessages.slice(trailingStart).map((_, offset) => trailingStart + offset);
|
|
1484
|
+
const keptEnough = trailingIndexes.every(index =>
|
|
1485
|
+
suffixRoots.historyMessageIndexes.includes(index)
|
|
1486
|
+
&& !suffixRoots.historyOutputElided.includes(index));
|
|
1487
|
+
const suffixKeptItsResult = !resultReplayedAsRoot
|
|
1488
|
+
|| suffixMessages[lastSuffixIndex]?.role !== "toolResult"
|
|
1489
|
+
|| keptEnough;
|
|
1490
|
+
if (
|
|
1491
|
+
carriedRoots.count + suffixSystemCount >= CURSOR_EXTERNAL_ROOT_BLOB_LIMIT
|
|
1492
|
+
// Both survival disjuncts are about a REPLAYED root going missing, so both are meaningless for a
|
|
1493
|
+
// model whose results never become roots. Gating only the second one still discarded every native
|
|
1494
|
+
// checkpoint whose assistant turn was a bare tool call: no text root, no result root, zero history
|
|
1495
|
+
// roots, condition true (audit r8 round 4). The count-full disjunct above stays ungated — it is a
|
|
1496
|
+
// real envelope fact, independent of who echoes results.
|
|
1497
|
+
|| (resultReplayedAsRoot && suffixRoots.ids.length <= suffixSystemCount)
|
|
1498
|
+
|| !suffixKeptItsResult
|
|
1499
|
+
) {
|
|
1500
|
+
conversationState = undefined;
|
|
1501
|
+
continuationMode = "full-replay";
|
|
1502
|
+
checkpointInvalidationReason = "envelope_exhausted";
|
|
1503
|
+
// NOT propagated to the checkpoint store, and deliberately so after measuring the attempt.
|
|
1504
|
+
// `src/adapters/cursor.ts` drops a dead checkpoint by reading
|
|
1505
|
+
// `request.checkpointInvalidationReason`, but `live-transport.ts` prepares a SPREAD COPY of that
|
|
1506
|
+
// request, so writing the field here lands on the copy and the caller never sees it — measured
|
|
1507
|
+
// inert, `outer.checkpointInvalidationReason` stayed undefined. Reaching the store needs the
|
|
1508
|
+
// reason threaded back through `PreparedCursorRunRequest`, which is a signature change on the
|
|
1509
|
+
// shared prepare path and belongs to its own phase. The cost of not doing it is bounded: the
|
|
1510
|
+
// checkpoint is re-decoded and re-abandoned each turn until TTL, which is wasted work rather
|
|
1511
|
+
// than wrong output (audit r8 rounds 3 and 4).
|
|
1512
|
+
} else {
|
|
1513
|
+
const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart, fullHistoryCalls, suffixStart);
|
|
935
1514
|
const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount);
|
|
936
1515
|
const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount);
|
|
937
1516
|
conversationState = create(ConversationStateStructureSchema, {
|
|
@@ -950,7 +1529,11 @@ function buildPreparedCursorRunRequest(
|
|
|
950
1529
|
byteLength: suffixRoots.byteLength,
|
|
951
1530
|
historyMessageStart: suffixRoots.historyMessageStart,
|
|
952
1531
|
serialized: suffixHistorySerialized,
|
|
1532
|
+
historyMessageIndexes: suffixRoots.historyMessageIndexes,
|
|
1533
|
+
historyOutputElided: suffixRoots.historyOutputElided,
|
|
1534
|
+
activeMessageIndexes: suffixRoots.activeMessageIndexes,
|
|
953
1535
|
};
|
|
1536
|
+
}
|
|
954
1537
|
}
|
|
955
1538
|
} catch {
|
|
956
1539
|
checkpointInvalidationReason = "decode_failed";
|
|
@@ -976,6 +1559,40 @@ function buildPreparedCursorRunRequest(
|
|
|
976
1559
|
// filtered definitions the wire carries. Both helpers are pure.
|
|
977
1560
|
const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice);
|
|
978
1561
|
const mcpToolDefs = buildCursorToolDefinitions(visibleTools, request.toolChoice);
|
|
1562
|
+
// The envelope is measured HERE, on the final root set, and nowhere else.
|
|
1563
|
+
//
|
|
1564
|
+
// `rootPromptMessages` cannot do it: it sees only a checkpoint suffix, so 192 checkpoint roots
|
|
1565
|
+
// plus a two-root suffix passed its per-call check and emitted 194 roots; and its empty-history
|
|
1566
|
+
// early return skips the pruning branch entirely, which let 193 system prompts through. Both are
|
|
1567
|
+
// downstream of this point, which is the first place the wire content is fully known (#1527).
|
|
1568
|
+
//
|
|
1569
|
+
// The same measurement feeds the diagnostic below, so telemetry cannot disagree with the guard.
|
|
1570
|
+
//
|
|
1571
|
+
// Roots carried inside a decoded checkpoint need not be in the local store — Cursor minted some
|
|
1572
|
+
// of them, and a resumed conversation legitimately references ids this process never wrote. So an
|
|
1573
|
+
// unmeasurable root is counted, not fatal: the COUNT limit still binds it (that is the 194-root
|
|
1574
|
+
// case), and `unmeasuredRoots` records that the byte total is a floor rather than a total. An
|
|
1575
|
+
// earlier fail-closed version broke three passing checkpoint tests, which is the evidence that
|
|
1576
|
+
// failing closed here would reject working continuation.
|
|
1577
|
+
const measuredRootCount = conversationState.rootPromptMessagesJson.length;
|
|
1578
|
+
let measuredRootBytes = 0;
|
|
1579
|
+
let unmeasuredRoots = 0;
|
|
1580
|
+
for (const blobId of conversationState.rootPromptMessagesJson) {
|
|
1581
|
+
const size = cursorBlobByteLength(blobId);
|
|
1582
|
+
if (size === null) unmeasuredRoots += 1;
|
|
1583
|
+
else measuredRootBytes += size;
|
|
1584
|
+
}
|
|
1585
|
+
if (
|
|
1586
|
+
isCursorExternalWireModel(request.modelId)
|
|
1587
|
+
&& (measuredRootCount > CURSOR_EXTERNAL_ROOT_BLOB_LIMIT || measuredRootBytes > CURSOR_EXTERNAL_ROOT_BYTE_LIMIT)
|
|
1588
|
+
) {
|
|
1589
|
+
throw new CursorRootEnvelopeLimitError(
|
|
1590
|
+
measuredRootCount,
|
|
1591
|
+
measuredRootBytes,
|
|
1592
|
+
CURSOR_EXTERNAL_ROOT_BLOB_LIMIT,
|
|
1593
|
+
CURSOR_EXTERNAL_ROOT_BYTE_LIMIT,
|
|
1594
|
+
);
|
|
1595
|
+
}
|
|
979
1596
|
debugProviderDiagnostic("cursor", "run-request", {
|
|
980
1597
|
wireModel: request.modelId,
|
|
981
1598
|
action: actionCase,
|
|
@@ -987,8 +1604,13 @@ function buildPreparedCursorRunRequest(
|
|
|
987
1604
|
checkpointPresent: continuationMode === "checkpoint",
|
|
988
1605
|
checkpointBytes: continuationMode === "checkpoint" ? request.checkpointBytes?.byteLength : undefined,
|
|
989
1606
|
checkpointInvalidationReason,
|
|
990
|
-
rootBlobs:
|
|
991
|
-
|
|
1607
|
+
rootBlobs: measuredRootCount,
|
|
1608
|
+
// Was `rootPromptMessagesState?.byteLength ?? 0`, which reported 0 for a pure checkpoint and,
|
|
1609
|
+
// for a suffix, counted a synthetic system root that had already been sliced off.
|
|
1610
|
+
rootBytes: measuredRootBytes,
|
|
1611
|
+
// Non-zero means rootBytes is a floor: that many roots came from a checkpoint the local store
|
|
1612
|
+
// never held. Recorded rather than hidden, so an operator reading the number knows which it is.
|
|
1613
|
+
...(unmeasuredRoots > 0 ? { unmeasuredRoots } : {}),
|
|
992
1614
|
turnBlobs: conversationState.turns.length,
|
|
993
1615
|
tools: request.tools?.length ?? 0,
|
|
994
1616
|
});
|