Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/fix-zod-oom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@langchain/core": patch
---

fix(core): replace exported zod type references with structural duck-type interfaces to fix TypeScript OOM

Replaces all exported Zod type references (`z3.ZodType`, `z4.$ZodType`, etc.) in `@langchain/core`'s public API with minimal structural ("duck-type") interfaces. This prevents TypeScript from performing expensive deep structural comparisons (~3,400+ lines of mutually recursive generics) when downstream packages resolve a different Zod version than `@langchain/core`, which was causing OOM crashes and unresponsive language servers in monorepo setups.
5 changes: 5 additions & 0 deletions .changeset/nasty-cars-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"langchain": patch
---

support structured output (providerStrategy) for Google Gemini models in createAgent
51 changes: 16 additions & 35 deletions environment_tests/test-exports-vercel/next.config.js
Original file line number Diff line number Diff line change
@@ -1,40 +1,21 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
webpack: (config, { isServer, nextRuntime, webpack }) => {
// Handle node: protocol imports
const nodeImports = [
"node:async_hooks",
"node:fs",
"node:fs/promises",
"node:path",
];
nodeImports.forEach((nodeImport) => {
let moduleName = nodeImport.replace("node:", "");
// Special case for fs/promises - use fs instead since fs/promises isn't a valid webpack module
if (moduleName === "fs/promises") {
moduleName = "fs";
}
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(
new RegExp(`^${nodeImport.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`),
moduleName
)
);
});

// For client-side builds, provide fallbacks to disable Node.js modules
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
// Disable all Node.js modules that aren't available in browser/edge
async_hooks: false,
fs: false,
path: false,
typeorm: false,
};
}

return config;
// Turbopack is the default bundler in Next.js 16+.
// Stub out Node.js built-ins that are unavailable in the browser.
// The { browser: ... } conditional only applies the alias for client builds,
// leaving server/SSR/API builds with the real Node.js modules.
turbopack: {
resolveAlias: {
"node:async_hooks": { browser: "./src/empty.cjs" },
"node:fs": { browser: "./src/empty.cjs" },
"node:fs/promises": { browser: "./src/empty.cjs" },
"node:path": { browser: "./src/empty.cjs" },
async_hooks: { browser: "./src/empty.cjs" },
fs: { browser: "./src/empty.cjs" },
"fs/promises": { browser: "./src/empty.cjs" },
path: { browser: "./src/empty.cjs" },
typeorm: { browser: "./src/empty.cjs" },
},
},
};

Expand Down
6 changes: 3 additions & 3 deletions environment_tests/test-exports-vercel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "next lint"
"test": "next build"
},
"dependencies": {
"@langchain/anthropic": "workspace:*",
Expand All @@ -19,7 +19,7 @@
"@types/react-dom": "18.0.11",
"cheerio": "^1.1.2",
"eslint": "8.37.0",
"eslint-config-next": "14.2.28",
"eslint-config-next": "^16.1.7",
"langchain": "workspace:*",
"next": "^16.1.7",
"peggy": "^5.0.5",
Expand All @@ -29,4 +29,4 @@
"typeorm": "^0.3.25",
"typescript": "^5.0.0"
}
}
}
5 changes: 5 additions & 0 deletions environment_tests/test-exports-vercel/src/empty.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Empty stub for Node.js built-in modules that are unavailable in the browser.
// Used by turbopack resolveAlias in next.config.js.
// Must be CJS so Turbopack treats it as a dynamic module and does not
// statically validate named exports (e.g. `import { writeFile } from "fs"`).
module.exports = {};
76 changes: 76 additions & 0 deletions environment_tests/test-zod-compat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Zod Compatibility Tests

Regression tests for the zod version mismatch OOM bug (TS2589).

These tests verify that `@langchain/core` and `langchain` exported types
work correctly when consumers install different zod versions than what
the packages were built with.

## Test Scenarios

| Test | Consumer zod | What it tests |
| -------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
| `zod-v3` | `~3.25.76` | Core types + agent APIs with zod v3 |
| `zod-v4` | `^4` | Core types + agent APIs with zod v4 |
| `zod-mismatch` | `~3.25.76` (top-level) + `4.3.6` (nested in @langchain/core) | OOM regression — two different zod copies in the module tree |

## What's tested

Each test simulates a real consumer app using `@langchain/core` and `langchain`:

- `tool()` with simple, nested, enum, and deeply-nested zod schemas
- `StructuredOutputParser.fromZodSchema()`
- `createMiddleware()` with `stateSchema` and `beforeAgent`/`beforeModel` hooks
- `createAgent()` — basic, with middleware, with `responseFormat`, with `stateSchema`, kitchen sink
- `toolStrategy()` and `providerStrategy()` with zod schemas

No internal type utilities are imported — only public consumer-facing APIs.
On `main` (before the fix), `zod-mismatch` will OOM. On this branch it passes.

## Running

```bash
# From monorepo root:
bash environment_tests/test-zod-compat/run.sh
```

The script:

1. Builds `langchain` (which also builds `@langchain/core`)
2. Packs both as tarballs
3. For each test: creates an isolated temp directory, installs both tarballs + zod + typescript
4. Runs `tsc --noEmit` with a 120s timeout and 512MB heap limit
5. Reports pass/fail

## How the mismatch test works

The `zod-v3` and `zod-v4` tests are straightforward: install one version
of zod at the top level and type-check against it.

The `zod-mismatch` test is different. After the normal npm install, the
run script forces a **different** zod version into a nested `node_modules`
inside `@langchain/core`:

```
node_modules/
zod/ <-- 3.25.76 (consumer's top-level copy)
@langchain/core/
node_modules/
zod/ <-- 4.3.6 (different copy, nested)
```

This means TypeScript resolves **two distinct copies** of zod's `.d.ts`
files. When `@langchain/core`'s internal `.d.ts` files `import` from `zod`,
TypeScript follows the Node module resolution and finds the nested v4 copy.
When the consumer's code `import`s from `zod`, it finds the top-level v3 copy.

If `@langchain/core`'s **exported** types referenced real zod types
(`z3.ZodType`, `z4.$ZodType`), TypeScript would need to check whether the
consumer's v3 zod type is assignable to core's v4 zod type (or vice versa).
Since the two copies are nominally different, TypeScript falls back to a full
structural comparison of ~3,400+ lines of mutually-recursive generics — OOM.

With the structural duck-type fix, `@langchain/core`'s exported `.d.ts`
files don't `import` from `zod` at all. The exported types are lightweight
interfaces (`ZodV3Like`, `ZodV4Like`, etc.) defined inline, so there is
nothing for TypeScript to structurally compare across the two copies.
139 changes: 139 additions & 0 deletions environment_tests/test-zod-compat/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
#
# Zod compatibility tests for @langchain/core and langchain
#
# Tests that exported types work correctly with:
# 1. zod@3.25.x (v3-only consumer)
# 2. zod@4.x (v4 consumer)
# 3. zod version mismatch (consumer has zod@3.25.x, core built with zod@4.x)
#
# Each test exercises both @langchain/core primitives (tool, StructuredOutputParser,
# InteropZodType) and langchain agent APIs (createAgent, createMiddleware,
# toolStrategy, providerStrategy).
#
# Each test creates an isolated directory, installs packages from local
# tarballs alongside a specific zod version, and runs `tsc --noEmit`.
#
# The "zod-mismatch" test is special: after the normal install, it forces a
# DIFFERENT zod version into @langchain/core's and langchain's nested
# node_modules. This means TypeScript resolves two distinct copies of zod's
# .d.ts files and must structurally compare them at every interop boundary.
# Before the fix, this caused OOM; after the fix, it's fast because the
# exported types are lightweight structural interfaces with no zod imports.
#
# Usage:
# ./environment_tests/test-zod-compat/run.sh
# # or from monorepo root:
# bash environment_tests/test-zod-compat/run.sh

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
MONOREPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
CORE_DIR="$MONOREPO_ROOT/libs/langchain-core"
LANGCHAIN_DIR="$MONOREPO_ROOT/libs/langchain"
WORK_DIR=$(mktemp -d)

# The consumer installs this version at the top level
CONSUMER_ZOD_VERSION="3.25.76"
# Force this version nested inside @langchain/core for the mismatch test
NESTED_ZOD_VERSION="4.3.6"

cleanup() {
echo "Cleaning up $WORK_DIR"
rm -rf "$WORK_DIR"
}
trap cleanup EXIT

echo "=== Building packages ==="
cd "$MONOREPO_ROOT"
pnpm build --filter langchain 2>&1 | tail -3

echo "=== Packing @langchain/core ==="
CORE_TARBALL=$(cd "$CORE_DIR" && pnpm pack --pack-destination "$WORK_DIR" 2>/dev/null | tail -1)
if [ ! -f "$CORE_TARBALL" ]; then
CORE_TARBALL=$(ls "$WORK_DIR"/langchain-core-*.tgz 2>/dev/null | head -1)
fi
echo "Core tarball: $CORE_TARBALL"

echo "=== Packing langchain ==="
LANGCHAIN_TARBALL=$(cd "$LANGCHAIN_DIR" && pnpm pack --pack-destination "$WORK_DIR" 2>/dev/null | tail -1)
if [ ! -f "$LANGCHAIN_TARBALL" ]; then
LANGCHAIN_TARBALL=$(ls "$WORK_DIR"/langchain-[0-9]*.tgz 2>/dev/null | head -1)
fi
echo "Langchain tarball: $LANGCHAIN_TARBALL"

TESTS=("zod-v3" "zod-v4" "zod-mismatch")
PASS=0
FAIL=0

for test_name in "${TESTS[@]}"; do
echo ""
echo "=== Test: $test_name ==="
TEST_SRC="$SCRIPT_DIR/$test_name"
TEST_WORK="$WORK_DIR/$test_name"

mkdir -p "$TEST_WORK/src"
cp "$TEST_SRC/tsconfig.json" "$TEST_WORK/"
cp "$TEST_SRC/src/test.ts" "$TEST_WORK/src/"
cp "$TEST_SRC/package.json" "$TEST_WORK/"

cd "$TEST_WORK"

npm install --no-package-lock "$CORE_TARBALL" "$LANGCHAIN_TARBALL" 2>&1 | tail -3
npm install --no-package-lock 2>&1 | tail -3

# For the mismatch test: force a DIFFERENT zod version nested inside
# @langchain/core and langchain so TypeScript sees two distinct copies.
#
# node_modules/
# zod/ <-- 3.25.76 (consumer's copy)
# @langchain/
# core/
# node_modules/
# zod/ <-- 4.3.6 (different copy, as if core was published against v4)
# langchain/ <-- uses core's nested zod transitively via .d.ts references
if [ "$test_name" = "zod-mismatch" ]; then
echo "Forcing zod version mismatch..."
CORE_NESTED="$TEST_WORK/node_modules/@langchain/core/node_modules"
mkdir -p "$CORE_NESTED"
npm pack "zod@$NESTED_ZOD_VERSION" --pack-destination "$CORE_NESTED" 2>/dev/null
ZOD_TGZ=$(ls "$CORE_NESTED"/zod-*.tgz 2>/dev/null | head -1)
mkdir -p "$CORE_NESTED/zod"
tar xzf "$ZOD_TGZ" -C "$CORE_NESTED/zod" --strip-components=1
rm -f "$ZOD_TGZ"
fi

echo "Installed zod version(s):"
# Show the top-level (consumer) zod version
node -e "try{const p=require.resolve('zod');const j=require(require('path').join(require('path').dirname(p),'package.json'));console.log(' top-level zod:',j.version)}catch(e){console.log(' top-level zod: not found')}"
# Show @langchain/core's resolved zod (may differ if nested)
node -e "
const path = require('path');
try {
// Resolve zod from @langchain/core's perspective
const coreDir = path.dirname(require.resolve('@langchain/core/package.json'));
const zodPath = require.resolve('zod', { paths: [coreDir] });
const zodPkg = require(path.join(path.dirname(zodPath), 'package.json'));
console.log(' @langchain/core zod:', zodPkg.version);
} catch(e) { console.log(' @langchain/core zod: not found'); }
"

echo "Running tsc --noEmit (timeout 120s, max 512MB heap)..."
if timeout 120 node --max-old-space-size=512 ./node_modules/.bin/tsc --noEmit 2>&1; then
echo "PASS: $test_name"
PASS=$((PASS + 1))
else
echo "FAIL: $test_name"
FAIL=$((FAIL + 1))
fi
done

echo ""
echo "=== Results ==="
echo "Passed: $PASS / ${#TESTS[@]}"
echo "Failed: $FAIL / ${#TESTS[@]}"

if [ "$FAIL" -gt 0 ]; then
exit 1
fi
15 changes: 15 additions & 0 deletions environment_tests/test-zod-compat/zod-mismatch/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "test-zod-compat-mismatch",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Verify @langchain/core does not OOM when two different zod versions exist in the node_modules tree (consumer has v3 at top level, core has v4 nested)",
"scripts": {
"test": "tsc --noEmit"
},
"dependencies": {
"zod": "~3.25.76",
"typescript": "^5.9.3",
"@types/node": "^22"
}
}
Loading
Loading