diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts index b1fc98142224..69edda619505 100644 --- a/src/trajectory/runtime.test.ts +++ b/src/trajectory/runtime.test.ts @@ -268,7 +268,7 @@ describe("trajectory runtime", () => { ); }); - it("bounds large runtime event fields before serialization", () => { + it("bounds oversized prompts before compact preservation", () => { const writes: string[] = []; const recorder = createTrajectoryRuntimeRecorder({ sessionId: "session-1", @@ -289,6 +289,7 @@ describe("trajectory runtime", () => { expect(writes).toHaveLength(1); const parsed = JSON.parse(expectDefined(writes[0], "writes[0] test invariant")); + expect(parsed.data.truncated).toBeUndefined(); expect(parsed.data.prompt.truncated).toBe(true); expect(parsed.data.prompt.reason).toBe("trajectory-field-size-limit"); expect( @@ -296,6 +297,36 @@ describe("trajectory runtime", () => { ).toBeLessThanOrEqual(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1); }); + it("keeps normal schema-v1 event payloads unchanged", () => { + const writes: string[] = []; + const recorder = createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: (line) => { + writes.push(line); + }, + flush: async () => undefined, + }, + }); + const data = { + prompt: "inspect", + systemPrompt: "system prompt", + messages: [{ role: "user", content: "inspect" }], + messagesSnapshot: [{ role: "assistant", content: "done" }], + imagesCount: 0, + }; + + expectTrajectoryRuntimeRecorder(recorder).recordEvent("prompt.submitted", data); + + const parsed = JSON.parse(expectDefined(writes[0], "writes[0] test invariant")); + expect(parsed.schemaVersion).toBe(1); + expect(parsed.data).toEqual(data); + expect(JSON.stringify(parsed.data)).toBe(JSON.stringify(data)); + expect(parsed.data.truncated).toBeUndefined(); + }); + it("preserves usage when truncating oversized runtime events", () => { const writes: string[] = []; const usage = { @@ -322,6 +353,8 @@ describe("trajectory runtime", () => { runtimeRecorder.recordEvent("model.completed", { usage, promptCache, + assistantTexts: ["done"], + finalPromptText: "inspect", messagesSnapshot: Array.from({ length: 12 }, (_value, index) => ({ role: index % 2 === 0 ? "user" : "assistant", content: `message-${index} ${"x".repeat(32_000)}`, @@ -336,9 +369,11 @@ describe("trajectory runtime", () => { reason: "trajectory-event-size-limit", usage, promptCache, + assistantTexts: ["done"], + finalPromptText: "inspect", }); expect(parsed.data.messagesSnapshot).toBeUndefined(); - expect(parsed.data.droppedFields).toContain("messagesSnapshot"); + expect(parsed.data.droppedFields).toEqual(["messagesSnapshot"]); expect( Buffer.byteLength(expectDefined(writes[0], "writes[0] test invariant"), "utf8"), ).toBeLessThanOrEqual(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1); @@ -385,6 +420,124 @@ describe("trajectory runtime", () => { ).toBeLessThanOrEqual(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1); }); + it("preserves the prompt when an oversized event drops inlined conversation state", () => { + const writes: string[] = []; + const prompt = "summarize the incident timeline for the deploy that failed"; + const recorder = createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: (line) => { + writes.push(line); + }, + flush: async () => undefined, + }, + }); + + const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder); + runtimeRecorder.recordEvent("context.compiled", { + prompt, + systemPrompt: "x".repeat(32_000), + messages: Array.from({ length: 12 }, (_value, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `message-${index} ${"x".repeat(32_000)}`, + })), + }); + + expect(writes).toHaveLength(1); + const parsed = JSON.parse(expectDefined(writes[0], "writes[0] test invariant")); + expect(parsed.data).toMatchObject({ + truncated: true, + reason: "trajectory-event-size-limit", + prompt, + systemPrompt: "x".repeat(32_000), + }); + expect(parsed.data.messages).toBeUndefined(); + expect(parsed.data.droppedFields).toEqual(["messages"]); + expect( + Buffer.byteLength(expectDefined(writes[0], "writes[0] test invariant"), "utf8"), + ).toBeLessThanOrEqual(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1); + }); + + it("drops repeated conversation fields in priority order", () => { + const writes: string[] = []; + const prompt = "summarize the incident timeline"; + const messageBlocks = Array.from({ length: 12 }, (_value, index) => ({ + role: index % 2 === 0 ? "user" : "assistant", + content: `message-${index} ${"x".repeat(32_000)}`, + })); + const recorder = createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: (line) => { + writes.push(line); + }, + flush: async () => undefined, + }, + }); + + expectTrajectoryRuntimeRecorder(recorder).recordEvent("context.compiled", { + prompt, + messagesSnapshot: messageBlocks, + messages: messageBlocks, + systemPrompt: "x".repeat(32_760), + tools: Array.from({ length: 7 }, (_value, index) => ({ + name: `tool-${index}`, + description: "x".repeat(32_760), + })), + }); + + const parsed = JSON.parse(expectDefined(writes[0], "writes[0] test invariant")); + expect(parsed.data.prompt).toBe(prompt); + expect(parsed.data.tools).toHaveLength(7); + expect(parsed.data.messagesSnapshot).toBeUndefined(); + expect(parsed.data.messages).toBeUndefined(); + expect(parsed.data.systemPrompt).toBeUndefined(); + expect(parsed.data.droppedFields).toEqual(["messagesSnapshot", "messages", "systemPrompt"]); + expect( + Buffer.byteLength(expectDefined(writes[0], "writes[0] test invariant"), "utf8"), + ).toBeLessThanOrEqual(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1); + }); + + it("preserves the prompt in the compact fallback for oversized events", () => { + const writes: string[] = []; + const prompt = "summarize the incident timeline for the deploy that failed"; + const recorder = createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: (line) => { + writes.push(line); + }, + flush: async () => undefined, + }, + }); + + expectTrajectoryRuntimeRecorder(recorder).recordEvent("context.compiled", { + prompt, + tools: Array.from({ length: 12 }, (_value, index) => ({ + name: `tool-${index}`, + description: "x".repeat(32_000), + })), + }); + + const parsed = JSON.parse(expectDefined(writes[0], "writes[0] test invariant")); + expect(parsed.data).toMatchObject({ + truncated: true, + reason: "trajectory-event-size-limit", + prompt, + }); + expect(parsed.data.tools).toBeUndefined(); + expect(parsed.data.droppedFields).toEqual(["tools"]); + expect( + Buffer.byteLength(expectDefined(writes[0], "writes[0] test invariant"), "utf8"), + ).toBeLessThanOrEqual(TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1); + }); + it("preserves usage on non-final oversized runtime completions", () => { const writes: string[] = []; const firstUsage = { diff --git a/src/trajectory/runtime.ts b/src/trajectory/runtime.ts index 84b84ad87dec..e4cdbc0056ba 100644 --- a/src/trajectory/runtime.ts +++ b/src/trajectory/runtime.ts @@ -50,7 +50,16 @@ const TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS = 64; const TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS = 64; const TRAJECTORY_RUNTIME_DATA_MAX_DEPTH = 6; const TRAJECTORY_RUNTIME_FINAL_PROMPT_MAX_BYTES = 4 * 1024; -const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const; +// Oversized events first shed repeated conversation state while keeping the +// rest of their schema-v1 payload. The compact fallback then preserves keys +// that remain useful even when every nonessential field must be dropped. +// sanitizeTrajectoryPayload bounds prompt strings before this limiter runs. +const TRAJECTORY_RUNTIME_OVERSIZE_DROP_FIRST_DATA_KEYS = [ + "messagesSnapshot", + "messages", + "systemPrompt", +] as const; +const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache", "prompt"] as const; type TrajectoryRuntimeWriterDiagnostics = QueuedFileWriterDiagnostics; @@ -84,6 +93,27 @@ function truncateOversizedTrajectoryEvent( limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, reason: "trajectory-event-size-limit", }; + const reducedData = { ...originalData }; + const reducedDroppedFields: string[] = []; + for (const key of TRAJECTORY_RUNTIME_OVERSIZE_DROP_FIRST_DATA_KEYS) { + if (!Object.hasOwn(reducedData, key)) { + continue; + } + delete reducedData[key]; + reducedDroppedFields.push(key); + const reduced = safeJsonStringify({ + ...event, + data: { + ...reducedData, + ...baseData, + droppedFields: reducedDroppedFields, + }, + }); + if (reduced && Buffer.byteLength(reduced, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) { + return reduced; + } + } + const buildTruncatedEventLine = (includeDroppedFields: boolean): string | undefined => { const data: Record = { ...baseData }; for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {