mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 10:51:33 +00:00
fix(trajectory): preserve prompts in oversized events (#116220)
Preserve ordinary schema-v1 trajectory payloads while shedding bulky duplicate conversation fields only after an event exceeds the runtime byte cap. Keep the bounded prompt in compact fallback records.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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<string, unknown> = { ...baseData };
|
||||
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
|
||||
|
||||
Reference in New Issue
Block a user