Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ The remaining options are settable both ways, as a `langfuse.json` key or as the
- `trace_seed` derives deterministic trace ids, so a headless caller knows a run's trace id up front. Use a unique seed per session.
- `debug` logs verbosely to stderr, and defaults to `false`.
- `fail_on_error` fails the hook on upload errors instead of failing open, and defaults to `false`.
- `max_history_chars` caps how many characters of earlier turns each generation's input carries, and defaults to `200000`. The most recent messages are kept and older ones are replaced by a single "earlier message(s) omitted" marker, with the count recorded in the generation's `codex.history_omitted_messages` metadata. The current turn's own messages are never dropped. Raising it keeps more context in traces but costs memory in the hook on long sessions; `0` drops earlier turns entirely.

Everything the plugin traces is uploaded to Langfuse, including prompts and tool inputs and outputs, so do not enable it for sessions containing data you do not want stored there.

Expand Down
15 changes: 14 additions & 1 deletion plugins/tracing/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,23 @@ export const ConfigSchema = z.object({
trace_seed: z.string().optional(),
debug: z.boolean(),
fail_on_error: z.boolean(),
max_history_chars: z.number().int().nonnegative(),
});

export type Config = z.infer<typeof ConfigSchema>;

const PartialConfigSchema = ConfigSchema.partial();

const DEFAULTS: Pick<Config, "enabled" | "base_url" | "skill_tags" | "debug" | "fail_on_error"> = {
const DEFAULTS: Pick<
Config,
"enabled" | "base_url" | "skill_tags" | "debug" | "fail_on_error" | "max_history_chars"
> = {
enabled: false,
base_url: "https://cloud.langfuse.com",
skill_tags: true,
debug: false,
fail_on_error: false,
max_history_chars: 200_000,
};

const CodexAuthSchema = z
Expand All @@ -60,6 +65,11 @@ function parseBoolean(value: unknown): boolean | undefined {
return undefined;
}

function parseNonNegativeInt(value: unknown): number | undefined {
const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
return typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : undefined;
}

function parseTags(value: unknown): string[] | undefined {
if (Array.isArray(value)) return value.map(String);
if (typeof value !== "string" || value.trim().length === 0) return undefined;
Expand Down Expand Up @@ -115,6 +125,8 @@ async function readConfigFile(file: string): Promise<Partial<Config> | undefined
skill_tags: raw.skill_tags != null ? parseBoolean(raw.skill_tags) : undefined,
debug: raw.debug != null ? parseBoolean(raw.debug) : undefined,
fail_on_error: raw.fail_on_error != null ? parseBoolean(raw.fail_on_error) : undefined,
max_history_chars:
raw.max_history_chars != null ? parseNonNegativeInt(raw.max_history_chars) : undefined,
}),
);
} catch {
Expand Down Expand Up @@ -171,6 +183,7 @@ function readEnvConfig(env: Record<string, string | undefined>): Partial<Config>
trace_seed: env.LANGFUSE_CODEX_TRACE_SEED,
debug: parseBoolean(env.LANGFUSE_CODEX_DEBUG),
fail_on_error: parseBoolean(env.LANGFUSE_CODEX_FAIL_ON_ERROR),
max_history_chars: parseNonNegativeInt(env.LANGFUSE_CODEX_MAX_HISTORY_CHARS),
}),
);
}
Expand Down
82 changes: 69 additions & 13 deletions plugins/tracing/src/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,9 +367,68 @@ function turnHistoryMessages(turn: Turn): ChatMlMessage[] {
return messages;
}

function messageChars(message: ChatMlMessage): number {
let n = 0;
if (typeof message.content === "string") {
n += message.content.length;
} else if (Array.isArray(message.content)) {
for (const part of message.content) {
n += part.type === "text" ? part.text.length : part.image_url.url.length;
}
}
if (message.role === "assistant") {
for (const part of message.thinking ?? []) n += part.content.length;
for (const tc of message.tool_calls ?? []) {
n += tc.function.name.length + (tc.function.arguments?.length ?? 0);
}
}
return n;
}

type History = { messages: ChatMlMessage[]; omitted: number };

/**
* The messages of the turns before `turnIndex`, newest first until `budget`
* characters are used, with a marker standing in for everything older. Every
* generation's input is serialized onto its own span, so an unbounded history
* grows memory with steps × session size and long sessions exhaust the heap.
*/
function boundedHistory(
turnMessages: readonly ChatMlMessage[][],
turnIndex: number,
budget: number,
): History {
const kept: ChatMlMessage[] = [];
let total = 0;
let used = 0;
let full = false;
for (let t = turnIndex - 1; t >= 0; t--) {
const messages = turnMessages[t];
total += messages.length;
for (let m = messages.length - 1; m >= 0 && !full; m--) {
const size = messageChars(messages[m]);
if (used + size > budget) {
full = true;
} else {
used += size;
kept.push(messages[m]);
}
}
}
const omitted = total - kept.length;
kept.reverse();
if (omitted > 0) {
kept.unshift({
role: "system",
content: `[${omitted} earlier message(s) omitted from this trace to stay within max_history_chars]`,
});
}
return { messages: kept, omitted };
}

function generationInput(
systemMessage: string | undefined,
historyPrefix: ChatMlMessage[],
historyPrefix: readonly ChatMlMessage[],
turn: Turn,
stepIndex: number,
): ChatMlMessage[] | undefined {
Expand Down Expand Up @@ -434,7 +493,7 @@ async function emitTurn(
seenThreadIds: Set<string>;
unannouncedSubagents?: SubagentRollout[];
inheritableTurnIds?: ReadonlySet<string>;
historyPrefix?: ChatMlMessage[];
history?: History;
},
): Promise<void> {
const isSubagent = sessionMeta.isSubagentThread === true || ctx.parentObservation != null;
Expand Down Expand Up @@ -485,7 +544,8 @@ async function emitTurn(
let failure: unknown;
try {
const systemMessage = systemPromptText(turn.systemPrompt);
const historyPrefix = ctx.historyPrefix ?? [];
const historyPrefix = ctx.history?.messages ?? [];
const omittedHistory = ctx.history?.omitted ?? 0;
const spawnObservations = new Map<string, LangfuseObservation>();

for (let i = 0; i < turn.steps.length; i++) {
Expand All @@ -506,6 +566,7 @@ async function emitTurn(
metadata: {
"codex.step_index": i,
"codex.reasoning_effort": turn.reasoningEffort,
...(omittedHistory > 0 ? { "codex.history_omitted_messages": omittedHistory } : {}),
},
},
{
Expand Down Expand Up @@ -643,14 +704,9 @@ export async function convertRollout(
): Promise<string[]> {
const { sessionMeta, turns } = parseSession(await loadSession(rolloutFile));

const historyPrefixes: ChatMlMessage[][] = [];
{
const seen: ChatMlMessage[] = [];
for (const turn of turns) {
historyPrefixes.push([...seen]);
seen.push(...turnHistoryMessages(turn));
}
}
const turnMessages = turns.map(turnHistoryMessages);
const historyFor = (turnIndex: number): History =>
boundedHistory(turnMessages, turnIndex, options.config.max_history_chars);
debugLog(`parsed ${turns.length} turn(s) from ${path.basename(rolloutFile)}`);

const subagentIndex =
Expand Down Expand Up @@ -716,7 +772,7 @@ export async function convertRollout(
seenThreadIds,
unannouncedSubagents: subagentsFor(turnIndex),
inheritableTurnIds,
historyPrefix: historyPrefixes[turnIndex],
history: historyFor(turnIndex),
});
}
return [];
Expand Down Expand Up @@ -753,7 +809,7 @@ export async function convertRollout(
seenThreadIds,
unannouncedSubagents: subagentsFor(turnIndex),
inheritableTurnIds,
historyPrefix: historyPrefixes[turnIndex],
history: historyFor(turnIndex),
});

if (attached) {
Expand Down
30 changes: 30 additions & 0 deletions plugins/tracing/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,4 +249,34 @@ describe("getConfig", () => {
});
expect(fromEnv.fail_on_error).toBe(false);
});

it("parses max_history_chars from config and environment, ignoring invalid values", async () => {
const defaults = await getConfig({ home: emptyHome(), cwd: emptyHome(), env: {} });
expect(defaults.max_history_chars).toBe(200_000);

const home = makeTmpHome({
rel: ".codex/langfuse.json",
contents: { public_key: "pk-lf-kept", max_history_chars: 5000 },
});
const fromFile = await getConfig({ home, cwd: emptyHome(), env: {} });
expect(fromFile.max_history_chars).toBe(5000);

const fromEnv = await getConfig({
home,
cwd: emptyHome(),
env: { LANGFUSE_CODEX_MAX_HISTORY_CHARS: "0" },
});
expect(fromEnv.max_history_chars).toBe(0);

const invalid = await getConfig({
home: makeTmpHome({
rel: ".codex/langfuse.json",
contents: { public_key: "pk-lf-kept", max_history_chars: -1 },
}),
cwd: emptyHome(),
env: { LANGFUSE_CODEX_MAX_HISTORY_CHARS: "lots" },
});
expect(invalid.max_history_chars).toBe(200_000);
expect(invalid.public_key).toBe("pk-lf-kept");
});
});
1 change: 1 addition & 0 deletions plugins/tracing/test/instrumentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const baseConfig: Config = {
skill_tags: true,
debug: false,
fail_on_error: false,
max_history_chars: 200_000,
};

const fixturesRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures/sessions");
Expand Down
1 change: 1 addition & 0 deletions plugins/tracing/test/trace-attached-ownership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const baseConfig: Config = {
skill_tags: true,
debug: false,
fail_on_error: false,
max_history_chars: 200_000,
};

const fixturesRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures/sessions");
Expand Down
1 change: 1 addition & 0 deletions plugins/tracing/test/trace-seed-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const baseConfig: Config = {
skill_tags: true,
debug: false,
fail_on_error: false,
max_history_chars: 200_000,
trace_seed: "ci-run-42",
};

Expand Down
70 changes: 70 additions & 0 deletions plugins/tracing/test/trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const baseConfig: Config = {
skill_tags: true,
debug: false,
fail_on_error: false,
max_history_chars: 200_000,
};

const fixturesRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures/sessions");
Expand Down Expand Up @@ -271,6 +272,75 @@ describe("convertRollout", () => {
fs.rmSync(dir, { recursive: true, force: true });
});

it("keeps only the most recent history within max_history_chars", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lf-codex-hist-cap-"));
const day = path.join(dir, "sessions", "2026", "06", "03");
fs.mkdirSync(day, { recursive: true });
const file = path.join(day, "rollout-history-cap.jsonl");
const ts = (n: number): string => `2026-06-03T09:00:${String(n).padStart(2, "0")}.000Z`;
const turn = (n: number, id: string, prompt: string, answer: string): unknown[] => [
{ timestamp: ts(n), type: "event_msg", payload: { type: "task_started", turn_id: id } },
{ timestamp: ts(n), type: "event_msg", payload: { type: "user_message", message: prompt } },
{
timestamp: ts(n + 1),
type: "response_item",
payload: {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: answer }],
},
},
{ timestamp: ts(n + 1), type: "event_msg", payload: { type: "token_count", info: {} } },
{ timestamp: ts(n + 2), type: "event_msg", payload: { type: "task_complete", turn_id: id } },
];
fs.writeFileSync(
file,
[
{ timestamp: ts(0), type: "session_meta", payload: { id: "sess-hist-cap" } },
...turn(1, "t1", "First question", "First answer"),
...turn(4, "t2", "Second question", "Second answer"),
...turn(7, "t3", "Third question", "Third answer"),
]
.map((l) => JSON.stringify(l))
.join("\n"),
);

await convertAndMark(file, {
config: { ...baseConfig, max_history_chars: 30 },
stoppedTurnId: "t3",
});

const generations = exporter
.getFinishedSpans()
.filter((s) => obsType(s) === "generation")
.sort((a, b) => startMs(a) - startMs(b));
expect(generations).toHaveLength(3);

expect(JSON.parse(attr(generations[1], "langfuse.observation.input"))).toEqual([
{ role: "user", content: "First question" },
{ role: "assistant", content: "First answer" },
{ role: "user", content: "Second question" },
]);
expect(
attr(generations[1], "langfuse.observation.metadata.codex.history_omitted_messages"),
).toBe("");

expect(JSON.parse(attr(generations[2], "langfuse.observation.input"))).toEqual([
{
role: "system",
content: "[2 earlier message(s) omitted from this trace to stay within max_history_chars]",
},
{ role: "user", content: "Second question" },
{ role: "assistant", content: "Second answer" },
{ role: "user", content: "Third question" },
]);
expect(
attr(generations[2], "langfuse.observation.metadata.codex.history_omitted_messages"),
).toBe("2");

fs.rmSync(dir, { recursive: true, force: true });
});

it("sends attached images as multimodal content and the loaded tools on the call", async () => {
const URI = "data:image/png;base64,iVBORw0KGgo=";
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lf-codex-media-"));
Expand Down
1 change: 1 addition & 0 deletions plugins/tracing/test/turn-emit-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const baseConfig: Config = {
skill_tags: true,
debug: false,
fail_on_error: false,
max_history_chars: 200_000,
};

const line = (ts: string, type: string, payload: Record<string, unknown>) =>
Expand Down
Loading