diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 25b07cd334..b432493352 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- Managed snapshot machinery no longer fails runs on benign payload-class or readable-proxy roots: an assistant message or stream event whose fields live on prototype getters (which `structuredClone` drops — it copies only own enumerable properties) or behind a proxy whose gets are readable is repaired through the existing guarded-read path instead of throwing a deterministic `shell.role`/`event.unknownType`/`event.snapshot` local snapshot failure. The run-loop message_update replay also builds its event through the managed event snapshot instead of a naive `{ ...event }` spread, which silently dropped prototype-carried fields before the snapshot boundary could see them. Hostile shapes (throwing get traps, sentinel-marked degraded content, malformed non-string event types) keep their named fail-fast diagnostics with no retry authority. + - Managed fallback now validates and byte-measures the detached event snapshot rather than trusting the live payload's JSON result. Custom payload classes whose prototype `toJSON()` hides bigint state are sanitized after `structuredClone` removes that serializer, so every accepted snapshot stays detached, JSON-serializable, and bounded; residual typed `local_snapshot_failure` diagnostics remain outside provider fallback authority and surface without deterministic retry amplification. - Managed fallback buffer overflows now retain a typed `local_buffer_overflow` error kind on the terminal assistant message, so session retry policy surfaces them immediately without provider-fallback attribution instead of admitting them to the bounded `unknown` retry class. - Managed local-failure diagnostics: `ManagedAttemptSnapshotError` and `ManagedAttemptBufferOverflowError` now carry a stable `stage` discriminator naming the exact rejecting site (`shell.role`, `shell.content`, `event.snapshot`, `event.contentIndex`, `event.delta`, `event.content`, `event.toolcall`, `event.done.reason`, `event.error.reason`, `event.unknownType`, `staging.losslessSnapshot`, `staging.measure`, `staging.sanitize`, `staging.overflow`, `overflow.preMeasure`, `overflow.staged`), and the run-loop failure boundary emits ONE bounded shape-only `logger.warn` per stream invocation (stage, error kind, model, provider, snapshot mode, staged event count/bytes, and content block count for the content stage). The diagnostic is gated on the module-private local error identities and its stage is whitelisted against the closed vocabulary, so neither a foreign error that self-labels a local failure kind nor an in-module regression can route arbitrary text into the log; it never records raw text, thinking, tool arguments, or any provider payload, and the user-facing message string is unchanged so session-side classification keeps matching. Previously all 14 rejecting sites shared one static message, leaving no way to identify which provider shape a normalizer must be taught to accept. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index dfce803bbc..976edd93c0 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -923,13 +923,20 @@ function losslessDetachedClone(value: T): T { */ function managedAssistantShell(value: unknown, model: AgentLoopConfig["model"]): AssistantMessage { const detailed = managedAttemptSnapshotDetailed(value); - const source = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : value; - // A root that could not be snapshotted into a plain record is a live - // Proxy (the sanitizer collapses proxies to a placeholder). Benign - // proxy-wrapped provider messages are repaired by reading through the - // proxy — the provider's own view — with every read guarded so a hostile - // trap can only degrade to undefined, never escape. `managedProperty` - // is exactly that guarded read. + const snapshotRecord = isManagedPlainRecord(detailed.snapshot) ? detailed.snapshot : undefined; + // Two benign root degradations are repaired by reading through the + // original object — the provider's own view — with every read guarded so + // a hostile trap can only degrade to undefined, never escape + // (`managedProperty` is exactly that guarded read): + // - a root that could not be snapshotted into a plain record is a live + // Proxy (the sanitizer collapses proxies to a placeholder); + // - a plain-record snapshot that lost `role: "assistant"` came from a + // payload class whose fields live on its prototype: `structuredClone` + // copies only own enumerable properties, so the caller's live + // `message.role === "assistant"` check passes while the detached + // snapshot retains none of the message identity. + const source = + snapshotRecord !== undefined && managedProperty(snapshotRecord, "role") === "assistant" ? snapshotRecord : value; if (managedProperty(source, "role") !== "assistant") throw new ManagedAttemptSnapshotError("shell.role"); const rawContent = managedAttemptSnapshot(managedProperty(source, "content")); // Benign providers occasionally deliver a string or missing content value. @@ -1071,10 +1078,22 @@ export function managedAssistantEventSnapshot( event: AssistantMessageEvent, message: AssistantMessage, ): AssistantMessageEvent { - const snapshot = managedAttemptSnapshot(event); - if (!isManagedPlainRecord(snapshot)) throw new ManagedAttemptSnapshotError("event.snapshot"); - const type = managedProperty(snapshot, "type"); - const contentIndex = managedProperty(snapshot, "contentIndex"); + const detached = managedAttemptSnapshot(event); + const record = isManagedPlainRecord(detached) ? detached : undefined; + // Root repair, mirroring the shell: two benign degradations are re-read + // through the original event with guarded reads (`managedProperty`) — + // - a proxy root (structuredClone rejects proxies; the sanitizer collapses + // them to a placeholder) whose gets are readable, and + // - a payload class whose event fields live on prototype getters + // (`structuredClone` copies only own enumerable properties). + // A hostile trap or throwing getter can only degrade a field to undefined, + // which keeps the named fail-fast diagnostics below; a root that is + // neither snapshottable nor readable as an event keeps the dedicated root + // diagnostic. + const source: unknown = record !== undefined && typeof managedProperty(record, "type") === "string" ? record : event; + const type = managedProperty(source, "type"); + if (record === undefined && typeof type !== "string") throw new ManagedAttemptSnapshotError("event.snapshot"); + const contentIndex = managedProperty(source, "contentIndex"); const indexed = () => { if (!Number.isInteger(contentIndex) || (contentIndex as number) < 0) { throw new ManagedAttemptSnapshotError("event.contentIndex"); @@ -1095,29 +1114,29 @@ export function managedAssistantEventSnapshot( type === "reasoning_summary_delta" || type === "toolcall_delta" ) { - const delta = managedProperty(snapshot, "delta"); + const delta = managedProperty(source, "delta"); if (typeof delta !== "string") throw new ManagedAttemptSnapshotError("event.delta"); return { type, contentIndex: indexed(), delta, partial: message }; } if (type === "text_end" || type === "thinking_end" || type === "reasoning_summary_end") { - const content = managedProperty(snapshot, "content"); + const content = managedProperty(source, "content"); if (typeof content !== "string") throw new ManagedAttemptSnapshotError("event.content"); return { type, contentIndex: indexed(), content, partial: message }; } if (type === "toolcall_end") { - const toolCall = managedAssistantContent(managedProperty(snapshot, "toolCall")); + const toolCall = managedAssistantContent(managedAttemptSnapshot(managedProperty(source, "toolCall"))); if (toolCall?.type !== "toolCall") throw new ManagedAttemptSnapshotError("event.toolcall"); return { type, contentIndex: indexed(), toolCall, partial: message }; } if (type === "done") { - const reason = managedProperty(snapshot, "reason"); + const reason = managedProperty(source, "reason"); // Degrade out-of-vocabulary done reasons to "stop", matching the closed // StopReason vocabulary already normalized by managedAssistantShell. const normalized = reason === "stop" || reason === "length" || reason === "toolUse" ? reason : "stop"; return { type, reason: normalized, message }; } if (type === "error") { - const reason = managedProperty(snapshot, "reason"); + const reason = managedProperty(source, "reason"); const normalized = reason === "aborted" || reason === "error" ? reason : "error"; return { type, reason: normalized, error: message }; } @@ -2978,7 +2997,16 @@ async function streamAssistantResponse( partialMessage = config.fallbackManaged ? managedAssistantShell(event.partial, config.model) : event.partial; - const partialEvent = config.fallbackManaged ? { ...event, partial: partialMessage } : event; + // Normalize through the managed event snapshot instead of a + // naive `{ ...event }` spread: spreading copies only own + // enumerable properties, so a payload-class event carrying its + // fields on prototype getters would lose `type`/`delta` here and + // deterministically fail the whole run as `event.unknownType` + // downstream. The snapshot repairs benign class/prototype shapes + // and keeps the named fail-fast diagnostics for hostile ones. + const partialEvent = config.fallbackManaged + ? managedAssistantEventSnapshot(event, partialMessage) + : event; context.messages[context.messages.length - 1] = partialMessage; if (provisionalToolTransaction) { config.onProvisionalAssistantMessageEvent?.(partialMessage, partialEvent); diff --git a/packages/agent/test/managed-attempt-transaction.test.ts b/packages/agent/test/managed-attempt-transaction.test.ts index 8af0eb0895..776996843d 100644 --- a/packages/agent/test/managed-attempt-transaction.test.ts +++ b/packages/agent/test/managed-attempt-transaction.test.ts @@ -1847,6 +1847,94 @@ describe("managed snapshot benign degradation (PR #4538 salvage)", () => { expect(deltas[0]).toMatchObject({ type: "text_delta", delta: "x" }); }); + it("repairs payload-class messages and events whose fields live on the prototype", async () => { + // A provider payload class keeps its fields as prototype getters: + // `message.role === "assistant"` reads fine live, but `structuredClone` + // copies only own enumerable properties, so the detached snapshot loses + // every field. Before the repair this failed the whole managed run as a + // deterministic `shell.role` local snapshot error (issue #4578 class). + const mock = createMockModel(); + const base = assistantMessage(mock.model); + base.content.push({ type: "text", text: "prototype accepted" }); + class PayloadClassAssistantMessage { + get role(): "assistant" { + return "assistant"; + } + get content(): AssistantMessage["content"] { + return base.content; + } + get api(): AssistantMessage["api"] { + return base.api; + } + get provider(): string { + return base.provider; + } + get model(): string { + return base.model; + } + get usage(): AssistantMessage["usage"] { + return base.usage; + } + get stopReason(): AssistantMessage["stopReason"] { + return base.stopReason; + } + get timestamp(): number { + return base.timestamp; + } + } + const partial = new PayloadClassAssistantMessage() as unknown as AssistantMessage; + class PayloadClassTextEndEvent { + get type(): "text_end" { + return "text_end"; + } + get contentIndex(): number { + return 0; + } + get content(): string { + return "prototype accepted"; + } + get partial(): AssistantMessage { + return partial; + } + } + const eventTypes: string[] = []; + let terminalAssistant: AssistantMessage | undefined; + const agent = new Agent({ + initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, + streamFn: () => { + const stream = new AssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial }); + stream.push({ type: "text_start", contentIndex: 0, partial }); + stream.push(new PayloadClassTextEndEvent() as unknown as AssistantMessageEvent); + stream.push({ type: "done", reason: "stop", message: partial }); + }); + return stream; + }, + }); + agent.subscribe(event => { + eventTypes.push(event.type); + if (event.type === "agent_end") { + terminalAssistant = event.messages.findLast( + (candidate): candidate is AssistantMessage => candidate.role === "assistant", + ); + } + }); + await agent.prompt("run", { fallbackManaged: true }); + expect(agent.state.error).toBeUndefined(); + expectManagedRunStart(eventTypes); + expect(terminalAssistant).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "prototype accepted" }], + stopReason: "stop", + }); + // The repaired shell must be fully detached and JSON-serializable. + expect(JSON.parse(JSON.stringify(terminalAssistant))).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "prototype accepted" }], + }); + }); + it("normalizes malformed terminal and unknown-typed events at the staged snapshot boundary", () => { // Terminal done/error events are consumed by streamAssistantResponse // before the staged-event callback fires, so the normalization contract @@ -1922,8 +2010,16 @@ describe("managed snapshot benign degradation (PR #4538 salvage)", () => { expect(agent.state.error).toBeDefined(); }); - it("keeps hostile getOwnPropertyDescriptor-trap events failing fast", async () => { + it("repairs descriptor-trap proxy events whose guarded gets stay readable", async () => { + // A proxy whose only hostility is a throwing getOwnPropertyDescriptor + // trap defeats structuredClone (and the pre-repair `{ ...event }` + // spread), but its [[Get]]s deliver a well-formed event. The root + // repair reads it through guarded gets, so the run completes instead + // of failing as a deterministic local snapshot error. Truly unreadable + // proxies (throwing get traps) stay fail-closed — see the + // collapsed-root-proxy test above. const mock = createMockModel(); + const callbacks: AssistantMessageEvent[] = []; const agent = new Agent({ initialState: { model: mock.model, systemPrompt: ["test"], tools: [], messages: [] }, streamFn: () => { @@ -1942,17 +2038,13 @@ describe("managed snapshot benign degradation (PR #4538 salvage)", () => { }); return stream; }, + onAssistantMessageEvent: (_message, event) => callbacks.push(event), }); - let outcomes = 0; - await agent.prompt("run", { - fallbackManaged: true, - onManagedAttemptOutcome: () => { - outcomes += 1; - return { type: "retry", continuation: () => {} }; - }, - }); - expect(outcomes).toBe(0); - expect(agent.state.error).toBeDefined(); + await agent.prompt("run", { fallbackManaged: true }); + expect(agent.state.error).toBeUndefined(); + const deltas = callbacks.filter(event => event.type === "text_delta"); + expect(deltas).toHaveLength(1); + expect(deltas[0]).toMatchObject({ type: "text_delta", delta: "x" }); }); it("keeps non-string event types failing fast as malformed provider output", async () => { diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 2ee1641e2b..53ebe7d3ff 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- Cursor native tool calls (shell/read/write/… oneof variants) now convert their protobuf payloads into plain JSON-safe data before attaching them as toolCall `arguments`: `$typeName` markers are stripped, safe-range bigints become numbers (decimal strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 strings, and cycles/functions collapse to null. Raw protobuf-es payloads carry `bigint` fields (`fileSize`, `durationMs`, `fileOutputThresholdBytes`, …) that defeat `JSON.stringify`, which broke managed snapshot staging, JSONL transcript persistence, and provider replay — the issue #4578 local-snapshot producer defect class fixed at its producer boundary. - Generic OpenAI-compatible `/v1/models` discovery now reads served context-window and output-limit metadata instead of defaulting every dynamically listed model to the unknown-window sentinel. `max_model_len` (vLLM/SGLang/oMLX), `context_length`, `context_window`, `max_context_length` (LM Studio), and `max_position_embeddings` populate `contextWindow` in that precedence order, while `max_tokens`/`max_output_tokens` populate `maxTokens`; total-window fields never leak into the output-token ceiling. Malformed values (non-finite, zero, negative, non-numeric) are rejected per-field with fallback to the next candidate, so a `1e400`-style catalog entry can no longer poison compaction thresholds or compact-input budgets. - Refreshed the bundled ZAI catalog with GLM-5.3 and made it the provider's default model. - Added the typed `local_snapshot_failure` and `local_buffer_overflow` assistant error kinds so downstream retry policy can distinguish local event-snapshot and staging-buffer failures from provider failures. diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 9a8a0b27b7..e9008be353 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -1916,7 +1916,75 @@ function cursorNativeToolName(kindKey: string): string { // do not otherwise handle (everything except mcpToolCall / updateTodosToolCall), so // without this they are silently dropped and never render. Build a generic toolCall // block from whichever *ToolCall field is set so the call (and its result) is shown. -function buildNativeToolCallBlock( + +/** Hard node budget for one native-payload conversion; bounds hostile or cyclic graphs. */ +const CURSOR_JSON_SAFE_MAX_NODES = 10_000; +const CURSOR_JSON_SAFE_MAX_DEPTH = 100; + +/** + * Total conversion of a Cursor protobuf payload into plain JSON-safe data. + * + * protobuf-es v2 messages are plain objects, but they carry `$typeName` + * markers, `bigint` fields (e.g. `fileSize`, `durationMs`, `timestampMs`, + * `fileOutputThresholdBytes`), and `Uint8Array` blobs. None of those may leak + * into assistant message content: toolCall `arguments` are staged into managed + * snapshots, persisted to the JSONL transcript, and replayed to providers — + * all of which require `JSON.stringify`-safe values. Attaching the raw payload + * is exactly the local-snapshot producer defect class behind issue #4578. + * + * Rules: `$typeName` is stripped, safe-range bigints become numbers (decimal + * strings beyond `Number.MAX_SAFE_INTEGER`), byte arrays become base64 + * strings, dates become ISO strings, functions/symbols are dropped, cycles + * and over-depth values collapse to null, and containers stop accepting + * entries once the shared node budget is exhausted. + */ +function cursorJsonSafeValue(value: unknown, path?: Set, budget?: { remaining: number }, depth = 0): unknown { + const seen = path ?? new Set(); + const nodes = budget ?? { remaining: CURSOR_JSON_SAFE_MAX_NODES }; + if (nodes.remaining-- <= 0) return null; + if (depth >= CURSOR_JSON_SAFE_MAX_DEPTH) return null; + if (typeof value === "bigint") { + return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) + ? Number(value) + : value.toString(); + } + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return null; + if (typeof value === "number" && !Number.isFinite(value)) return null; + if (value === null || typeof value !== "object") return value; + if (seen.has(value)) return null; + if (value instanceof Uint8Array) + return Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString("base64"); + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null; + seen.add(value); + try { + if (Array.isArray(value)) { + const array: unknown[] = []; + for (const entry of value) { + if (nodes.remaining <= 0) break; + array.push(cursorJsonSafeValue(entry, seen, nodes, depth + 1)); + } + return array; + } + const record: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === "$typeName") continue; + if (nodes.remaining <= 0) break; + record[key] = cursorJsonSafeValue(entry, seen, nodes, depth + 1); + } + return record; + } catch { + return null; + } finally { + seen.delete(value); + } +} + +/** Exported for direct regression coverage of the JSON-safety boundary. */ +export function cursorJsonSafeValueForTest(value: unknown): unknown { + return cursorJsonSafeValue(value); +} + +export function buildNativeToolCallBlock( toolCall: Record, callId: string, index: number, @@ -1925,11 +1993,20 @@ function buildNativeToolCallBlock( if (!/ToolCall$/.test(key) || !payload || typeof payload !== "object") continue; if (key === "mcpToolCall" || key === "updateTodosToolCall") continue; const args = (payload as { args?: unknown }).args; + const hasObjectArgs = args !== null && typeof args === "object"; + const convertedArgs = hasObjectArgs ? cursorJsonSafeValue(args) : undefined; + const safeArguments = + convertedArgs !== undefined && + convertedArgs !== null && + typeof convertedArgs === "object" && + !Array.isArray(convertedArgs) + ? (convertedArgs as Record) + : { raw: hasObjectArgs ? convertedArgs : cursorJsonSafeValue(payload) }; return { type: "toolCall", id: callId, name: cursorNativeToolName(key), - arguments: args && typeof args === "object" ? (args as Record) : { raw: payload }, + arguments: safeArguments, index, kind: "native", }; diff --git a/packages/ai/test/cursor-native-toolcall-json-safety.test.ts b/packages/ai/test/cursor-native-toolcall-json-safety.test.ts new file mode 100644 index 0000000000..654896cb82 --- /dev/null +++ b/packages/ai/test/cursor-native-toolcall-json-safety.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "bun:test"; +import { buildNativeToolCallBlock, cursorJsonSafeValueForTest } from "../src/providers/cursor"; + +/** + * Cursor native tool calls arrive as protobuf-es payloads carrying + * `$typeName` markers, `bigint` fields, and `Uint8Array` blobs. Those values + * must never leak into assistant toolCall `arguments`: staged managed + * snapshots, JSONL transcript persistence, and provider replay all require + * plain `JSON.stringify`-safe data (issue #4578 producer boundary). + */ +describe("cursor native toolCall JSON safety", () => { + it("converts protobuf payload values into plain JSON-safe data", () => { + const converted = cursorJsonSafeValueForTest({ + $typeName: "agent.v1.ShellToolCallArgs", + command: "ls -la", + fileOutputThresholdBytes: 4096n, + fileSize: BigInt(Number.MAX_SAFE_INTEGER) + 1n, + blob: Uint8Array.from([104, 105]), + nested: [{ $typeName: "agent.v1.Inner", durationMs: 12n }], + when: new Date(1755216000000), + }) as Record; + expect(converted).toEqual({ + command: "ls -la", + fileOutputThresholdBytes: 4096, + fileSize: "9007199254740992", + blob: Buffer.from("hi").toString("base64"), + nested: [{ durationMs: 12 }], + when: new Date(1755216000000).toISOString(), + }); + expect(JSON.parse(JSON.stringify(converted))).toEqual(converted); + }); + + it("collapses cycles and non-data leaves instead of throwing", () => { + const cyclic: Record = { fn: () => "x" }; + cyclic.self = cyclic; + const converted = cursorJsonSafeValueForTest(cyclic) as Record; + expect(converted).toEqual({ fn: null, self: null }); + }); + + it("bounds hostile graph traversal by node count and depth", () => { + const wide = Object.fromEntries(Array.from({ length: 10_050 }, (_, index) => [`key${index}`, index])); + const convertedWide = cursorJsonSafeValueForTest(wide) as Record; + expect(Object.keys(convertedWide).length).toBeLessThan(Object.keys(wide).length); + expect(JSON.parse(JSON.stringify(convertedWide))).toEqual(convertedWide); + + const deep: Record = {}; + let cursor = deep; + for (let index = 0; index < 500; index++) { + const next: Record = {}; + cursor.next = next; + cursor = next; + } + expect(() => JSON.stringify(cursorJsonSafeValueForTest(deep))).not.toThrow(); + }); + + it("contains unreadable payload objects at the provider boundary", () => { + const unreadable = new Proxy( + {}, + { + ownKeys() { + throw new Error("unreadable payload"); + }, + }, + ); + expect(cursorJsonSafeValueForTest(unreadable)).toBeNull(); + expect(buildNativeToolCallBlock({ shellToolCall: { args: unreadable } }, "call-proxy", 0)?.arguments).toEqual({ + raw: null, + }); + }); + + it("builds native toolCall blocks with JSON-serializable arguments", () => { + const block = buildNativeToolCallBlock( + { + shellToolCall: { + $typeName: "agent.v1.ShellToolCall", + args: { + $typeName: "agent.v1.ShellToolCallArgs", + command: "echo hello", + timeoutMs: 30000, + fileOutputThresholdBytes: 65536n, + }, + }, + }, + "call-1", + 0, + ); + expect(block).toMatchObject({ + type: "toolCall", + id: "call-1", + name: "bash", + arguments: { command: "echo hello", timeoutMs: 30000, fileOutputThresholdBytes: 65536 }, + }); + expect(JSON.parse(JSON.stringify(block?.arguments))).toEqual(block?.arguments); + }); + + it("wraps argument-less payloads as JSON-safe raw records", () => { + const block = buildNativeToolCallBlock( + { readLintsToolCall: { $typeName: "agent.v1.ReadLintsToolCall", sizeBytes: 12n } }, + "call-2", + 1, + ); + expect(block).toMatchObject({ + type: "toolCall", + id: "call-2", + name: "read_lints", + }); + expect(JSON.parse(JSON.stringify(block?.arguments))).toEqual(block?.arguments); + }); +});