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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 45 additions & 17 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -923,13 +923,20 @@ function losslessDetachedClone<T>(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.
Expand Down Expand Up @@ -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");
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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);
Expand Down
114 changes: 103 additions & 11 deletions packages/agent/test/managed-attempt-transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: () => {
Expand All @@ -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 () => {
Expand Down
1 change: 1 addition & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading