Skip to content

Commit cf9a426

Browse files
authored
🛡️ fix: Guard HITL checkpoint size against MongoDB 16MB limit (danny-avila#14157)
* 🛡️ fix: Guard HITL checkpoint size against MongoDB 16MB limit A LangGraph HITL checkpoint embeds the whole serialized message history in a single BSON document, so a large conversation (inlined base64 media, big tool outputs, long history) can serialize past MongoDB's 16MB document ceiling. `MongoDBSaver.put` would then throw a raw `BSONObjectTooLarge` at pause time and the pause would be lost with no legible error. `LazyMongoSaver` now measures the serialized checkpoint on the persist path (rare HITL pauses only — the clean-exit common path is untouched): debug-logs the size, warns past a soft 8MB threshold, and throws a typed `CheckpointTooLargeError` before the doomed write past a 15MB hard limit (16MB minus headroom for the document's other fields). Thresholds are overridable via the constructor for testing. Adds integration coverage (real serde + mongodb-memory-server) for the under-threshold, soft-warn, and hard-reject cases. * 🧱 fix: Add explicit types for isolatedDeclarations build The production build (tsdown + rolldown-plugin-dts) compiles with --isolatedDeclarations, which requires explicit type annotations on exported bindings whose initializers it can't infer syntactically. `CHECKPOINT_HARD_LIMIT_BYTES` (arithmetic over two consts) tripped TS9010; annotate it and `CHECKPOINT_WARN_BYTES` as `number`. Verified with `tsc --isolatedDeclarations` over the package. * fix: Codex review — include metadata in the size guard + flush parked bookkeeping P1 (lost bookkeeping): `put` consumes the write anchor, then AWAITS assertCheckpointFitsDocument (checkpoint serialization). A bookkeeping-only putWrites dispatched in that window sees neither the anchor nor persistedIds, so it parks — and `put` never flushed it, dropping the marker (e.g. a completed Send-sibling's __no_writes__) so a resume re-executed the sibling. Extract flushBufferedBookkeeping (shared with the anchoring putWrites) and call it after super.put in the persist path. P2 (metadata ignored by guard): MongoDBSaver.put stores the serialized checkpoint AND metadata (plus metadata_search) in the SAME document, but the guard measured only the checkpoint — a just-under-limit checkpoint with large metadata fell through to a raw BSONObjectTooLarge. Measure checkpoint + metadata; the fixed headroom now only covers metadata_search/ids/framing. Two integration regressions added (both fail without the fix, pass with it): metadata-pushes-over-the-ceiling, and flush-during-the-serialization-window. * fix: count metadata_search (raw metadata copy) in the checkpoint size guard Codex follow-up: MongoDBSaver.put stores metadata a SECOND time as `metadata_search: metadata` — the whole raw metadata object as a queryable BSON subdocument in the same agent_checkpoints document. Measuring only checkpoint + serialized metadata undercounted by that raw copy, so a large metadata.writes payload could pass the 15 MB preflight while metadata_search pushed the actual BSON past 16 MB — the raw BSONObjectTooLarge the guard exists to prevent. Add mongoose.mongo.BSON.calculateObjectSize(metadata) for the metadata_search contribution (mongoose already imported; no new dep). Headroom now only covers ids + BSON framing. New integration test sizes a case where checkpoint + serialized metadata is under the limit but the raw metadata_search copy pushes it over — green (24/24). * ci: run packages/api agents integration specs (checkpointer) in CI The checkpointer.integration.spec.ts (durable HITL checkpointer vs a real in-process MongoDB) is a *.integration.spec.ts, which test:ci deliberately excludes — and cache-integration-tests.yml only covers cache/cluster/mcp/ stream, not src/agents/**. So it ran nowhere and its regressions guarded nothing. Add: - test:agents-integration script (jest over src/agents/*.integration.spec.ts, runInBand — mongodb-memory-server is in-process, no external service); - a dedicated agents-integration-tests.yml (mirrors the proven build setup, no Redis) triggered on packages/api/src/agents/** changes; - babel-plugin-replace-ts-export-assignment as a packages/api devDep: the spec imports @langchain/langgraph-checkpoint (whitelisted for babel transform, uses `export =`), whose transform needs this plugin — it was only present under client/node_modules, unresolvable from packages/api, so the suite couldn't load. 24/24 pass locally.
1 parent 988a14a commit cf9a426

5 files changed

Lines changed: 447 additions & 11 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Agents Integration Tests
2+
3+
# Runs the packages/api `src/agents/**` integration specs (e.g. the durable HITL
4+
# checkpointer against a real in-process MongoDB via mongodb-memory-server). These
5+
# are `*.integration.spec.ts`, which `test:ci` deliberately excludes — without this
6+
# job they run nowhere and their regressions guard nothing.
7+
on:
8+
pull_request:
9+
branches:
10+
- main
11+
- dev
12+
- dev-staging
13+
- release/*
14+
paths:
15+
- 'packages/api/src/agents/**'
16+
- 'packages/api/package.json'
17+
- '.github/workflows/agents-integration-tests.yml'
18+
19+
permissions:
20+
contents: read
21+
22+
jobs:
23+
agents_integration_tests:
24+
name: Integration Tests that use in-process MongoDB
25+
timeout-minutes: 20
26+
runs-on: ubuntu-latest
27+
28+
steps:
29+
- name: Checkout repository
30+
uses: actions/checkout@v4
31+
32+
- name: Use Node.js 24.16.0
33+
uses: actions/setup-node@v4
34+
with:
35+
node-version: '24.16.0'
36+
37+
- name: Restore node_modules cache
38+
id: cache-node-modules
39+
uses: actions/cache@v4
40+
with:
41+
path: |
42+
node_modules
43+
api/node_modules
44+
packages/api/node_modules
45+
packages/data-provider/node_modules
46+
packages/data-schemas/node_modules
47+
key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
48+
49+
- name: Install dependencies
50+
if: steps.cache-node-modules.outputs.cache-hit != 'true'
51+
run: npm ci
52+
53+
- name: Restore data-provider build cache
54+
id: cache-data-provider
55+
uses: actions/cache@v4
56+
with:
57+
path: packages/data-provider/dist
58+
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
59+
60+
- name: Build data-provider
61+
if: steps.cache-data-provider.outputs.cache-hit != 'true'
62+
run: npm run build:data-provider
63+
64+
- name: Restore data-schemas build cache
65+
id: cache-data-schemas
66+
uses: actions/cache@v4
67+
with:
68+
path: packages/data-schemas/dist
69+
key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
70+
71+
- name: Build data-schemas
72+
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
73+
run: npm run build:data-schemas
74+
75+
- name: Restore api build cache
76+
id: cache-api
77+
uses: actions/cache@v4
78+
with:
79+
path: packages/api/dist
80+
key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
81+
82+
- name: Build api
83+
if: steps.cache-api.outputs.cache-hit != 'true'
84+
run: npm run build:api
85+
86+
- name: Run agents integration tests (in-process MongoDB)
87+
working-directory: packages/api
88+
env:
89+
NODE_ENV: test
90+
run: npm run test:agents-integration

package-lock.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"test:cache-integration:stream": "jest --testPathPatterns=\"src/stream/.*\\.stream_integration\\.spec\\.ts$\" --coverage=false --runInBand --forceExit",
3434
"test:cache-integration": "npm run test:cache-integration:core && npm run test:cache-integration:cluster && npm run test:cache-integration:mcp && npm run test:cache-integration:stream",
3535
"test:s3-integration": "jest --testPathPatterns=\"src/storage/s3/.*\\.integration\\.spec\\.ts$\" --coverage=false --runInBand",
36+
"test:agents-integration": "jest --testPathPatterns=\"src/agents/.*\\.integration\\.spec\\.ts$\" --coverage=false --runInBand --forceExit",
3637
"verify": "npm run test:ci",
3738
"b:clean": "bun run rimraf dist",
3839
"b:build": "bun run b:clean && bun run tsdown",
@@ -77,6 +78,7 @@
7778
"@types/winston": "^2.4.4",
7879
"@types/yauzl": "^2.10.3",
7980
"aws-sdk-client-mock": "^4.1.0",
81+
"babel-plugin-replace-ts-export-assignment": "^0.0.2",
8082
"dedent": "^1.5.3",
8183
"get-stream": "^6.0.1",
8284
"jest": "^30.2.0",

packages/api/src/agents/checkpointer.integration.spec.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import mongoose from 'mongoose';
2+
import { logger } from '@librechat/data-schemas';
23
import { MongoMemoryServer } from 'mongodb-memory-server';
34
import { MongoDBSaver } from '@langchain/langgraph-checkpoint-mongodb';
45
import { emptyCheckpoint, ERROR, INTERRUPT } from '@langchain/langgraph-checkpoint';
56
import {
67
getAgentCheckpointer,
78
deleteAgentCheckpoint,
89
deleteAgentCheckpoints,
10+
LazyMongoSaver,
11+
CheckpointTooLargeError,
912
__resetCheckpointerForTests,
1013
} from './checkpointer';
1114

@@ -446,3 +449,197 @@ describe('LazyMongoSaver (lazy persistence — mongodb-memory-server)', () => {
446449
expect(effects).toEqual({ a: 1, c: 1 }); // siblings did NOT re-execute
447450
});
448451
});
452+
453+
describe('LazyMongoSaver checkpoint size guard (mongodb-memory-server integration)', () => {
454+
// Guards the single-document ceiling: a checkpoint embeds the whole message history, so a
455+
// large conversation can serialize past MongoDB's 16 MB limit. The guard measures the
456+
// serialized state on the persist path, WARNS past a soft threshold, and REJECTS past a hard
457+
// limit BEFORE the write — a typed CheckpointTooLargeError instead of a raw BSONObjectTooLarge.
458+
// Thresholds are shrunk here so payloads stay tiny while exercising the real serde + Mongo.
459+
const clientForSaver = () =>
460+
// mongoose vends the live MongoClient; the driver type resolves to a different `mongodb` copy
461+
// than checkpoint-mongodb's, so the cast mirrors buildMongoSaver in checkpointer.ts.
462+
mongoose.connection.getClient() as unknown as ConstructorParameters<
463+
typeof MongoDBSaver
464+
>[0]['client'];
465+
466+
const makeSaver = (overrides?: { warnBytes?: number; hardLimitBytes?: number }) =>
467+
new LazyMongoSaver({
468+
client: clientForSaver(),
469+
checkpointCollectionName: 'agent_checkpoints',
470+
checkpointWritesCollectionName: 'agent_checkpoint_writes',
471+
ttl: 3600,
472+
...overrides,
473+
});
474+
475+
/** Seed an interrupt anchor for a checkpoint whose serialized state is inflated to ~`payloadBytes`. */
476+
async function seedSizedInterrupt(saver: LazyMongoSaver, threadId: string, payloadBytes: number) {
477+
const checkpoint = emptyCheckpoint();
478+
checkpoint.channel_values = { messages: 'x'.repeat(payloadBytes) };
479+
await saver.putWrites(
480+
{ configurable: { thread_id: threadId, checkpoint_ns: '', checkpoint_id: checkpoint.id } },
481+
[[INTERRUPT, 'approve?']],
482+
'task-1',
483+
);
484+
const config = { configurable: { thread_id: threadId, checkpoint_ns: '' } };
485+
const metadata = { source: 'input' as const, step: -1, writes: null, parents: {} };
486+
return { checkpoint, config, metadata };
487+
}
488+
489+
afterEach(() => {
490+
jest.restoreAllMocks();
491+
});
492+
493+
it('persists a checkpoint under the soft threshold', async () => {
494+
const saver = makeSaver({ warnBytes: 5_000, hardLimitBytes: 50_000 });
495+
await saver.setup();
496+
const threadId = `convo-${new mongoose.Types.ObjectId().toString()}`;
497+
const { checkpoint, config, metadata } = await seedSizedInterrupt(saver, threadId, 100);
498+
499+
await saver.put(config, checkpoint, metadata);
500+
501+
expect(await saver.getTuple(readConfig(threadId))).toBeDefined();
502+
});
503+
504+
it('persists but WARNS when a checkpoint crosses the soft threshold', async () => {
505+
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger);
506+
const saver = makeSaver({ warnBytes: 500, hardLimitBytes: 50_000 });
507+
await saver.setup();
508+
const threadId = `convo-${new mongoose.Types.ObjectId().toString()}`;
509+
const { checkpoint, config, metadata } = await seedSizedInterrupt(saver, threadId, 2_000);
510+
511+
await saver.put(config, checkpoint, metadata);
512+
513+
expect(await saver.getTuple(readConfig(threadId))).toBeDefined();
514+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('soft threshold'));
515+
});
516+
517+
it('REFUSES to persist and throws CheckpointTooLargeError over the hard limit', async () => {
518+
jest.spyOn(logger, 'error').mockImplementation(() => logger);
519+
const saver = makeSaver({ warnBytes: 500, hardLimitBytes: 2_000 });
520+
await saver.setup();
521+
const threadId = `convo-${new mongoose.Types.ObjectId().toString()}`;
522+
const { checkpoint, config, metadata } = await seedSizedInterrupt(saver, threadId, 10_000);
523+
524+
await expect(saver.put(config, checkpoint, metadata)).rejects.toBeInstanceOf(
525+
CheckpointTooLargeError,
526+
);
527+
528+
// Nothing durable was written for that thread — getTuple reads the checkpoint document.
529+
expect(await saver.getTuple(readConfig(threadId))).toBeUndefined();
530+
expect(
531+
await mongoose.connection.db!.collection('agent_checkpoints').countDocuments({
532+
thread_id: threadId,
533+
}),
534+
).toBe(0);
535+
});
536+
537+
it('counts metadata toward the ceiling — refuses when the checkpoint is under but metadata pushes over', async () => {
538+
// MongoDBSaver stores the serialized checkpoint AND metadata in one document, so a
539+
// just-under-limit checkpoint with large metadata still overflows. The guard must catch it
540+
// as a typed CheckpointTooLargeError, not let it fall through to a raw BSONObjectTooLarge.
541+
jest.spyOn(logger, 'error').mockImplementation(() => logger);
542+
const saver = makeSaver({ warnBytes: 500, hardLimitBytes: 2_000 });
543+
await saver.setup();
544+
const threadId = `convo-${new mongoose.Types.ObjectId().toString()}`;
545+
546+
const checkpoint = emptyCheckpoint();
547+
checkpoint.channel_values = { messages: 'x'.repeat(400) }; // checkpoint alone well UNDER 2 KB
548+
await saver.putWrites(
549+
{ configurable: { thread_id: threadId, checkpoint_ns: '', checkpoint_id: checkpoint.id } },
550+
[[INTERRUPT, 'approve?']],
551+
'task-1',
552+
);
553+
const config = { configurable: { thread_id: threadId, checkpoint_ns: '' } };
554+
// Metadata alone pushes checkpoint + metadata over the 2 KB hard limit.
555+
const metadata = {
556+
source: 'input' as const,
557+
step: -1,
558+
writes: { payload: 'y'.repeat(4_000) },
559+
parents: {},
560+
};
561+
562+
await expect(saver.put(config, checkpoint, metadata)).rejects.toBeInstanceOf(
563+
CheckpointTooLargeError,
564+
);
565+
expect(await saver.getTuple(readConfig(threadId))).toBeUndefined();
566+
});
567+
568+
it('counts metadata_search (the raw metadata copy) — refuses when serialized-only would pass', async () => {
569+
// MongoDBSaver stores `metadata_search: metadata` — the WHOLE raw metadata a
570+
// SECOND time in the same document. Sized so checkpoint + serialized metadata is
571+
// UNDER the limit but adding the raw metadata_search copy pushes it over: the guard
572+
// must count that copy, else the doc slips the preflight and overflows on the wire.
573+
jest.spyOn(logger, 'error').mockImplementation(() => logger);
574+
const saver = makeSaver({ warnBytes: 500, hardLimitBytes: 6_000 });
575+
await saver.setup();
576+
const threadId = `convo-${new mongoose.Types.ObjectId().toString()}`;
577+
578+
const checkpoint = emptyCheckpoint();
579+
checkpoint.channel_values = { messages: 'x'.repeat(200) };
580+
await saver.putWrites(
581+
{ configurable: { thread_id: threadId, checkpoint_ns: '', checkpoint_id: checkpoint.id } },
582+
[[INTERRUPT, 'approve?']],
583+
'task-1',
584+
);
585+
const config = { configurable: { thread_id: threadId, checkpoint_ns: '' } };
586+
// ~3 KB metadata: checkpoint + serialized metadata (~3.2 KB) is under 6 KB, but the
587+
// raw metadata_search copy (~another 3 KB) pushes checkpoint+metadata+metadata_search
588+
// over 6 KB. The pre-fix guard (checkpoint + serialized metadata only) would pass here.
589+
const metadata = {
590+
source: 'loop' as const,
591+
step: 1,
592+
writes: { payload: 'y'.repeat(3_000) },
593+
parents: {},
594+
};
595+
596+
await expect(saver.put(config, checkpoint, metadata)).rejects.toBeInstanceOf(
597+
CheckpointTooLargeError,
598+
);
599+
expect(await saver.getTuple(readConfig(threadId))).toBeUndefined();
600+
});
601+
602+
it('flushes bookkeeping parked during the size-serialization window (not dropped on resume)', async () => {
603+
// `put` consumes the write anchor, then AWAITS `assertCheckpointFitsDocument` (serialization).
604+
// A bookkeeping-only putWrites dispatched in that window sees no anchor and no persisted
605+
// marker, so it parks — and must be flushed once the checkpoint persists, or a resume
606+
// re-executes the completed sibling.
607+
const NO_WRITES = '__no_writes__';
608+
const saver = makeSaver({ warnBytes: 5_000, hardLimitBytes: 50_000 });
609+
await saver.setup();
610+
const threadId = `convo-${new mongoose.Types.ObjectId().toString()}`;
611+
const checkpoint = emptyCheckpoint();
612+
const writeCfg = {
613+
configurable: { thread_id: threadId, checkpoint_ns: '', checkpoint_id: checkpoint.id },
614+
};
615+
await saver.putWrites(writeCfg, [[INTERRUPT, 'approve?']], 'task-gate');
616+
617+
// Pause the checkpoint serialization inside `put` so the bookkeeping batch lands mid-window.
618+
const serde = (saver as unknown as { serde: { dumpsTyped: (v: unknown) => unknown } }).serde;
619+
const realDumps = serde.dumpsTyped.bind(serde);
620+
let releaseGate!: () => void;
621+
const gate = new Promise<void>((resolve) => {
622+
releaseGate = resolve;
623+
});
624+
let paused = false;
625+
jest.spyOn(serde, 'dumpsTyped').mockImplementation(async (value: unknown) => {
626+
if (!paused) {
627+
paused = true;
628+
await gate; // hold inside assertCheckpointFitsDocument (anchor consumed, not yet persisted)
629+
}
630+
return realDumps(value);
631+
});
632+
633+
const config = { configurable: { thread_id: threadId, checkpoint_ns: '' } };
634+
const metadata = { source: 'input' as const, step: -1, writes: null, parents: {} };
635+
const putPromise = saver.put(config, checkpoint, metadata); // suspends at the gate
636+
await saver.putWrites(writeCfg, [[NO_WRITES, null]], 'task-sibling'); // parks in the window
637+
releaseGate();
638+
await putPromise;
639+
640+
const tuple = await saver.getTuple(readConfig(threadId));
641+
const channels = (tuple?.pendingWrites ?? []).map((w) => w[1]);
642+
expect(channels).toContain(INTERRUPT);
643+
expect(channels).toContain(NO_WRITES); // flushed, not dropped
644+
});
645+
});

0 commit comments

Comments
 (0)