fix(codex): retain terminal facts in oversized trajectories (#126050)

* fix(codex): delegate trajectory bounding to host

* fix(trajectory): preserve compact terminal facts

* style: format release validation skill
This commit is contained in:
Peter Steinberger
2026-08-18 16:22:25 -07:00
committed by GitHub
parent 50720c3b8e
commit e2643afb2a
6 changed files with 236 additions and 226 deletions
-1
View File
@@ -245,7 +245,6 @@ extensions/codex/src/app-server/thread-lifecycle-run.ts 1
extensions/codex/src/app-server/thread-supervision.ts 2
extensions/codex/src/app-server/tool-abort-terminal-reason.ts 1
extensions/codex/src/app-server/tool-progress-normalization.ts 1
extensions/codex/src/app-server/trajectory.ts 3
extensions/codex/src/app-server/transcript-history-projection.ts 2
extensions/codex/src/app-server/transcript-mirror-attestation.ts 2
extensions/codex/src/app-server/transcript-mirror.ts 4
@@ -1,9 +1,12 @@
// Codex tests cover SQLite-only trajectory plugin behavior.
import fs from "node:fs";
import path from "node:path";
import { createAgentHarnessHostCapabilitiesForTest } from "openclaw/plugin-sdk/plugin-test-runtime";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
appendSqliteTrajectoryRuntimeEvents,
createTrajectoryRuntimeRecorderForTest,
exportTrajectoryBundleForTest,
loadSqliteTrajectoryRuntimeEvents,
type SqliteTrajectoryRuntimeEventForTest,
} from "openclaw/plugin-sdk/sqlite-runtime-testing";
@@ -84,7 +87,6 @@ function createMemoryBackedRecorder(params: {
} as never,
trajectory: host.trajectory,
tools: params.tools,
env: {},
});
return { events: host.events, recorder: expectTrajectoryRecorder(recorder) };
}
@@ -128,7 +130,6 @@ describe("Codex trajectory recorder", () => {
sessionId: "session-1",
model: { api: "responses" },
} as never,
env: {},
}),
).toBeNull();
});
@@ -157,7 +158,6 @@ describe("Codex trajectory recorder", () => {
sessionId: "session-1",
storePath,
}),
env: {},
});
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
@@ -172,20 +172,6 @@ describe("Codex trajectory recorder", () => {
).resolves.toEqual([expect.objectContaining({ type: "session.started" })]);
});
it("redacts secrets and keeps recorded strings UTF-16 safe", async () => {
const { events, recorder } = createMemoryBackedRecorder({ tmpDir: testWorkspace.dir });
recorder.recordEvent("model.output", {
text: `${"x".repeat(19_999)}😀`,
apiKey: "secret",
authorization: "Bearer sk-test-secret-token",
});
await recorder.flush();
expect(events[0]?.data?.text).toBe(`${"x".repeat(19_999)}`);
expect(events[0]?.data?.apiKey).toBe("<redacted>");
expect(events[0]?.data?.authorization).toBe("<redacted>");
});
it("records namespace dynamic tools as callable trajectory definitions", async () => {
const tools = [
{
@@ -218,32 +204,33 @@ describe("Codex trajectory recorder", () => {
]);
});
it("honors explicit disablement", () => {
const host = createMemoryTrajectoryFacade();
const recorder = createCodexTrajectoryRecorder({
cwd: testWorkspace.dir,
attempt: {
sessionFile: "agent:main:session-1",
sessionId: "session-1",
model: { api: "responses" },
} as never,
env: { OPENCLAW_TRAJECTORY: "0" },
trajectory: host.trajectory,
it("lets the host bound oversized Codex events without losing terminal facts", async () => {
const tmpDir = testWorkspace.dir;
const storePath = path.join(tmpDir, "sessions", "sessions.json");
const sessionTarget = {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
storePath,
};
await upsertSessionEntry({
agentId: sessionTarget.agentId,
sessionKey: sessionTarget.sessionKey,
storePath,
entry: { sessionId: sessionTarget.sessionId, updatedAt: 10 },
});
expect(recorder).toBeNull();
expect(host.events).toEqual([]);
});
it("preserves usage when truncating oversized model completion events", async () => {
const attempt = {
agentId: "main",
cwd: tmpDir,
workspaceDir: tmpDir,
sessionFile: sessionTarget.sessionKey,
sessionId: "session-1",
sessionKey: "agent:main:session-1",
runId: "run-1",
provider: "codex",
modelId: "gpt-5.4",
model: { api: "responses" },
} as never;
};
const usage = {
input: 384_954,
output: 5_624,
@@ -251,34 +238,190 @@ describe("Codex trajectory recorder", () => {
reasoningTokens: 2_038,
total: 724_402,
};
const { events, recorder } = createMemoryBackedRecorder({
tmpDir: testWorkspace.dir,
attempt,
const hostRecorder = createTrajectoryRuntimeRecorderForTest({
sessionId: sessionTarget.sessionId,
sessionKey: sessionTarget.sessionKey,
sessionTarget,
runId: attempt.runId,
provider: "openai",
modelId: attempt.modelId,
modelApi: "responses",
workspaceDir: tmpDir,
});
recordCodexTrajectoryCompletion(recorder, {
attempt,
threadId: "thread-1",
turnId: "turn-1",
timedOut: false,
result: {
terminal: { kind: "ok" },
attemptUsage: usage,
assistantTexts: ["done"],
messagesSnapshot: Array.from({ length: 20 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
if (!hostRecorder) {
throw new Error("Expected host trajectory recorder");
}
const host = await createAgentHarnessHostCapabilitiesForTest({
attempt: { ...attempt, trajectoryRecorder: hostRecorder } as never,
pluginId: "codex",
});
await recorder.flush();
const recorder = createCodexTrajectoryRecorder({
attempt: attempt as never,
cwd: tmpDir,
trajectory: host.capabilities.trajectory,
} as never);
const trajectoryRecorder = expectTrajectoryRecorder(recorder);
expect(events[0]?.data).toMatchObject({
try {
recordCodexTrajectoryContext(trajectoryRecorder, {
attempt: attempt as never,
cwd: tmpDir,
developerInstructions: `Bearer ${"s".repeat(40)} ${"x".repeat(40_000)}`,
prompt: "inspect",
tools: [
{
type: "function",
name: "huge_tool",
description: "x".repeat(40_000),
inputSchema: { type: "object" },
},
],
} as never);
trajectoryRecorder.recordEvent("tool.result", {
toolCallId: "call-1",
toolName: "huge_tool",
status: "completed",
authorization: `Bearer ${"t".repeat(40)}`,
output: `token=${"t".repeat(40)} ${"x".repeat(40_000)}`,
});
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt: attempt as never,
threadId: "thread-1",
turnId: "turn-1",
timedOut: true,
yieldDetected: true,
result: {
terminal: {
kind: "timeout",
phase: "prompt",
source: "runtime",
aborted: true,
failure: { source: "prompt", error: "terminal prompt error" },
},
attemptUsage: usage,
assistantTexts: ["done"],
// Twelve entries stay below the old plugin's post-sanitization cap,
// but exceed the host cap when forwarded without pre-shrinking.
messagesSnapshot: Array.from({ length: 12 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
recordCodexTrajectoryCompletion(trajectoryRecorder, {
attempt: attempt as never,
threadId: "thread-compact",
turnId: "turn-compact",
timedOut: true,
result: {
terminal: {
kind: "timeout",
phase: "prompt",
source: "runtime",
aborted: true,
failure: { source: "prompt", error: "compact prompt error" },
},
attemptUsage: usage,
assistantTexts: Array.from(
{ length: 12 },
(_value, index) => `assistant-${index} ${"x".repeat(32_000)}`,
),
messagesSnapshot: Array.from({ length: 12 }, (_value, index) => ({
role: index % 2 === 0 ? "user" : "assistant",
content: `message-${index} ${"x".repeat(32_000)}`,
})),
} as never,
});
await trajectoryRecorder.flush();
} finally {
host.close();
}
const events = await loadSqliteTrajectoryRuntimeEvents({
agentId: sessionTarget.agentId,
sessionId: sessionTarget.sessionId,
storePath,
});
const context = events.find((event) => event.type === "context.compiled");
const tool = events.find((event) => event.type === "tool.result");
const completion = events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-1",
);
const compactCompletion = events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-compact",
);
expect(context?.data).toMatchObject({
prompt: "inspect",
systemPrompt: {
truncated: true,
reason: "trajectory-field-size-limit",
},
tools: [
{
name: "huge_tool",
description: {
truncated: true,
reason: "trajectory-field-size-limit",
},
parameters: { type: "object" },
},
],
});
expect(JSON.stringify(context)).not.toContain("s".repeat(40));
expect(tool?.data).toMatchObject({
toolCallId: "call-1",
toolName: "huge_tool",
status: "completed",
output: {
truncated: true,
reason: "trajectory-field-size-limit",
},
});
expect(tool?.data?.authorization).toBeUndefined();
expect(JSON.stringify(tool)).not.toContain(`token=${"t".repeat(40)}`);
expect(completion?.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
threadId: "thread-1",
turnId: "turn-1",
timedOut: true,
yieldDetected: true,
aborted: true,
promptError: "terminal prompt error",
usage,
assistantTexts: ["done"],
});
expect(completion?.data?.messagesSnapshot).toBeUndefined();
expect(completion?.data?.droppedFields).toEqual(["messagesSnapshot"]);
expect(compactCompletion?.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
threadId: "thread-compact",
turnId: "turn-compact",
timedOut: true,
yieldDetected: false,
aborted: true,
promptError: "compact prompt error",
usage,
});
expect(events[0]?.data?.messagesSnapshot).toBeUndefined();
expect(events[0]?.data?.droppedFields).toContain("messagesSnapshot");
expect(compactCompletion?.data?.assistantTexts).toBeUndefined();
expect(compactCompletion?.data?.messagesSnapshot).toBeUndefined();
expect(compactCompletion?.data?.droppedFields).toEqual(["assistantTexts", "messagesSnapshot"]);
const bundle = await exportTrajectoryBundleForTest({
outputDir: path.join(tmpDir, "bundle"),
sessionTarget,
sessionId: sessionTarget.sessionId,
sessionKey: sessionTarget.sessionKey,
workspaceDir: tmpDir,
});
const exportedCompletion = bundle.events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-1",
);
const exportedCompactCompletion = bundle.events.find(
(event) => event.type === "model.completed" && event.data?.turnId === "turn-compact",
);
expect(exportedCompletion?.data).toEqual(completion?.data);
expect(exportedCompactCompletion?.data).toEqual(compactCompletion?.data);
});
});
+4 -166
View File
@@ -1,11 +1,6 @@
/**
* Records optional Codex runtime trajectory events with bounded, redacted
* context and completion payloads.
*/
/** Records optional Codex runtime trajectory events through the host recorder. */
import type { EmbeddedRunAttemptParamsV2 as EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { attemptTerminal, type EmbeddedRunAttemptResult } from "./attempt-terminal.js";
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
import { flattenCodexDynamicToolFunctions, type CodexDynamicToolSpec } from "./protocol.js";
/** Runtime trajectory recorder used by Codex run attempts and event projectors. */
@@ -21,123 +16,19 @@ type CodexTrajectoryInit = {
prompt?: string;
trajectory?: NonNullable<EmbeddedRunAttemptParams["hostCapabilities"]["trajectory"]> | null;
tools?: CodexDynamicToolSpec[];
env?: NodeJS.ProcessEnv;
};
const SENSITIVE_FIELD_RE = /(?:authorization|cookie|credential|key|password|passwd|secret|token)/iu;
const PRIVATE_PAYLOAD_FIELD_RE = /(?:image|screenshot|attachment|fileData|dataUri)/iu;
const AUTHORIZATION_VALUE_RE = /\b(Bearer|Basic)\s+[A-Za-z0-9+/._~=-]{8,}/giu;
const JWT_VALUE_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu;
const COOKIE_PAIR_RE = /\b([A-Za-z][A-Za-z0-9_.-]{1,64})=([A-Za-z0-9+/._~%=-]{16,})(?=;|\s|$)/gu;
const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
const TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS = ["usage", "promptCache"] as const;
type CodexTrajectoryEvent = Record<string, unknown> & {
data?: Record<string, unknown>;
type: string;
};
function boundedTrajectoryEvent(event: Record<string, unknown>): CodexTrajectoryEvent | undefined {
const line = JSON.stringify(event);
const bytes = Buffer.byteLength(line, "utf8");
if (bytes <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return event as CodexTrajectoryEvent;
}
const originalData =
event.data && typeof event.data === "object" && !Array.isArray(event.data)
? (event.data as Record<string, unknown>)
: {};
const originalDataKeys = Object.keys(originalData);
const preservedDataKeys = new Set<string>();
const baseData = {
truncated: true,
originalBytes: bytes,
limitBytes: TRAJECTORY_RUNTIME_EVENT_MAX_BYTES,
reason: "trajectory-event-size-limit",
};
const buildTruncatedEvent = (includeDroppedFields: boolean): CodexTrajectoryEvent | undefined => {
const data: Record<string, unknown> = { ...baseData };
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
if (preservedDataKeys.has(key)) {
data[key] = originalData[key];
}
}
if (includeDroppedFields) {
const droppedFields = originalDataKeys.filter((key) => !preservedDataKeys.has(key));
if (droppedFields.length > 0) {
data.droppedFields = droppedFields;
}
}
const truncatedEvent = { ...event, data };
const truncated = JSON.stringify(truncatedEvent);
if (Buffer.byteLength(truncated, "utf8") <= TRAJECTORY_RUNTIME_EVENT_MAX_BYTES) {
return truncatedEvent as CodexTrajectoryEvent;
}
return undefined;
};
let best = buildTruncatedEvent(true) ?? buildTruncatedEvent(false);
if (!best) {
return undefined;
}
for (const key of TRAJECTORY_RUNTIME_OVERSIZE_PRESERVED_DATA_KEYS) {
if (!Object.hasOwn(originalData, key)) {
continue;
}
preservedDataKeys.add(key);
const next = buildTruncatedEvent(true) ?? buildTruncatedEvent(false);
if (next) {
best = next;
continue;
}
preservedDataKeys.delete(key);
}
return best;
}
/** Creates a trajectory recorder when trajectory capture is enabled for the environment. */
/** Creates a trajectory recorder when the host exposes its capture capability. */
export function createCodexTrajectoryRecorder(
params: CodexTrajectoryInit,
): CodexTrajectoryRecorder | null {
const env = params.env ?? process.env;
const enabled = parseTrajectoryEnabled(env);
if (!enabled) {
return null;
}
if (!params.trajectory) {
return null;
}
const trajectory = params.trajectory;
let seq = 0;
const attribution = resolveCodexLocalRuntimeAttribution(params.attempt);
return {
recordEvent: (type, data) => {
const event = boundedTrajectoryEvent({
traceSchema: "openclaw-trajectory",
schemaVersion: 1,
traceId: params.attempt.sessionId,
source: "runtime",
type,
ts: new Date().toISOString(),
seq: (seq += 1),
sourceSeq: seq,
sessionId: params.attempt.sessionId,
sessionKey: params.attempt.sessionKey,
runId: params.attempt.runId,
workspaceDir: params.cwd,
provider: attribution.provider,
modelId: params.attempt.modelId,
modelApi: attribution.api,
data: data ? sanitizeValue(data) : undefined,
});
if (event) {
trajectory.recordEvent(event.type, event.data);
}
},
recordEvent: trajectory.recordEvent,
flush: trajectory.flush,
};
}
@@ -187,17 +78,6 @@ export function recordCodexTrajectoryCompletion(
});
}
function parseTrajectoryEnabled(env: NodeJS.ProcessEnv): boolean {
const value = env.OPENCLAW_TRAJECTORY?.trim().toLowerCase();
if (value === "1" || value === "true" || value === "yes" || value === "on") {
return true;
}
if (value === "0" || value === "false" || value === "no" || value === "off") {
return false;
}
return true;
}
function toTrajectoryToolDefinitions(
tools: readonly CodexDynamicToolSpec[] | undefined,
): Array<{ name: string; description?: string; parameters?: unknown }> | undefined {
@@ -214,55 +94,13 @@ function toTrajectoryToolDefinitions(
{
name,
description: tool.description,
parameters: sanitizeValue(tool.inputSchema),
parameters: tool.inputSchema,
},
];
})
.toSorted((left, right) => left.name.localeCompare(right.name));
}
function sanitizeValue(value: unknown, depth = 0, key = ""): unknown {
// Trajectory exports may leave the live process, so redact credentials and
// private payloads before passing events to the SQLite host recorder.
if (value == null || typeof value === "boolean" || typeof value === "number") {
return value;
}
if (typeof value === "string") {
if (SENSITIVE_FIELD_RE.test(key)) {
return "<redacted>";
}
if (value.startsWith("data:") && value.length > 256) {
return `<redacted data-uri ${value.slice(0, value.indexOf(",")).length} chars>`;
}
if (PRIVATE_PAYLOAD_FIELD_RE.test(key) && value.length > 256) {
return "<redacted payload>";
}
const redacted = redactSensitiveString(value);
return redacted.length > 20_000 ? `${truncateUtf16Safe(redacted, 20_000)}` : redacted;
}
if (depth >= 6) {
return "<truncated>";
}
if (Array.isArray(value)) {
return value.slice(0, 100).map((entry) => sanitizeValue(entry, depth + 1, key));
}
if (typeof value === "object") {
const next: Record<string, unknown> = {};
for (const [keyLocal, child] of Object.entries(value).slice(0, 100)) {
next[keyLocal] = sanitizeValue(child, depth + 1, keyLocal);
}
return next;
}
return JSON.stringify(value);
}
function redactSensitiveString(value: string): string {
return value
.replace(AUTHORIZATION_VALUE_RE, "$1 <redacted>")
.replace(JWT_VALUE_RE, "<redacted-jwt>")
.replace(COOKIE_PAIR_RE, "$1=<redacted>");
}
/** Converts arbitrary prompt errors into trajectory-safe text. */
export function normalizeCodexTrajectoryError(value: unknown): string | null {
if (!value) {
+2
View File
@@ -19,6 +19,8 @@ export {
loadSqliteTrajectoryRuntimeEvents,
type SqliteTrajectoryRuntimeScope,
} from "../trajectory/runtime-store.sqlite.js";
export { createTrajectoryRuntimeRecorder as createTrajectoryRuntimeRecorderForTest } from "../trajectory/runtime.js";
export { exportTrajectoryBundle as exportTrajectoryBundleForTest } from "../trajectory/export.js";
export { type TrajectoryEvent as SqliteTrajectoryRuntimeEventForTest } from "../trajectory/types.js";
export {
closeOpenClawAgentDatabasesForTest,
+18 -1
View File
@@ -391,9 +391,19 @@ describe("trajectory runtime", () => {
const runtimeRecorder = expectTrajectoryRuntimeRecorder(recorder);
runtimeRecorder.recordEvent("model.completed", {
threadId: "thread-compact",
turnId: "turn-compact",
timedOut: true,
yieldDetected: false,
aborted: true,
promptError: "terminal prompt error",
usage: oversizedUsage,
promptCache,
stopReason: "length",
assistantTexts: Array.from(
{ length: 12 },
(_value, index) => `assistant-${index} ${"x".repeat(32_000)}`,
),
messagesSnapshot: [{ role: "user", content: "x".repeat(32_000) }],
});
@@ -402,12 +412,19 @@ describe("trajectory runtime", () => {
expect(parsed.data).toMatchObject({
truncated: true,
reason: "trajectory-event-size-limit",
threadId: "thread-compact",
turnId: "turn-compact",
timedOut: true,
yieldDetected: false,
aborted: true,
promptError: "terminal prompt error",
promptCache,
stopReason: "length",
});
expect(parsed.data.usage).toBeUndefined();
expect(parsed.data.assistantTexts).toBeUndefined();
expect(parsed.data.droppedFields).toEqual(
expect.arrayContaining(["usage", "messagesSnapshot"]),
expect.arrayContaining(["usage", "assistantTexts", "messagesSnapshot"]),
);
expect(
Buffer.byteLength(expectDefined(writes[0], "writes[0] test invariant"), "utf8"),
+12 -1
View File
@@ -61,7 +61,18 @@ const TRAJECTORY_RUNTIME_OVERSIZE_DROP_FIRST_DATA_KEYS = [
"messages",
"systemPrompt",
] as const;
const OVERSIZE_PRESERVED_DATA_KEYS = ["stopReason", "usage", "promptCache", "prompt"] as const;
const OVERSIZE_PRESERVED_DATA_KEYS = [
"threadId",
"turnId",
"timedOut",
"yieldDetected",
"aborted",
"promptError",
"stopReason",
"usage",
"promptCache",
"prompt",
] as const;
type TrajectoryRuntimeWriterDiagnostics = QueuedFileWriterDiagnostics;