Skip to content

Commit 94ad4c2

Browse files
fix(langchain/createAgent): improve interop between Zod v3 and v4 (#9029)
1 parent 5d5e24e commit 94ad4c2

File tree

23 files changed

+507
-242
lines changed

23 files changed

+507
-242
lines changed

.github/workflows/compatibility.yml

Lines changed: 36 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -45,43 +45,43 @@ jobs:
4545
echo "EOF" >> $GITHUB_OUTPUT
4646
4747
# LangChain
48-
langchain-latest-deps:
49-
runs-on: ubuntu-latest
50-
needs: get-changed-files
51-
if: (contains(needs.get-changed-files.outputs.changed_files, 'dependency_range_tests/scripts/langchain/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain-core/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/providers/langchain-openai/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain-textsplitters/'))
52-
steps:
53-
- uses: actions/checkout@v4
54-
- name: Setup pnpm
55-
uses: pnpm/[email protected]
56-
- name: Use Node.js ${{ env.NODE_VERSION }}
57-
uses: actions/setup-node@v4
58-
with:
59-
node-version: ${{ env.NODE_VERSION }}
60-
- name: Install dependencies
61-
run: pnpm install --frozen-lockfile
62-
- name: Build required workspace packages
63-
run: pnpm build --filter=@langchain/openai --filter=@langchain/anthropic --filter=@langchain/cohere --filter=@langchain/textsplitters
64-
- name: Test LangChain with latest deps
65-
run: docker compose -f dependency_range_tests/docker-compose.yml run langchain-latest-deps
48+
# langchain-latest-deps:
49+
# runs-on: ubuntu-latest
50+
# needs: get-changed-files
51+
# if: (contains(needs.get-changed-files.outputs.changed_files, 'dependency_range_tests/scripts/langchain/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain-core/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/providers/langchain-openai/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain-textsplitters/'))
52+
# steps:
53+
# - uses: actions/checkout@v4
54+
# - name: Setup pnpm
55+
# uses: pnpm/[email protected]
56+
# - name: Use Node.js ${{ env.NODE_VERSION }}
57+
# uses: actions/setup-node@v4
58+
# with:
59+
# node-version: ${{ env.NODE_VERSION }}
60+
# - name: Install dependencies
61+
# run: pnpm install --frozen-lockfile
62+
# - name: Build required workspace packages
63+
# run: pnpm build --filter=@langchain/openai --filter=@langchain/anthropic --filter=@langchain/cohere --filter=@langchain/textsplitters
64+
# - name: Test LangChain with latest deps
65+
# run: docker compose -f dependency_range_tests/docker-compose.yml run langchain-latest-deps
6666

67-
langchain-lowest-deps:
68-
runs-on: ubuntu-latest
69-
needs: get-changed-files
70-
if: (contains(needs.get-changed-files.outputs.changed_files, 'dependency_range_tests/scripts/langchain/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain/'))
71-
steps:
72-
- uses: actions/checkout@v4
73-
- name: Setup pnpm
74-
uses: pnpm/[email protected]
75-
- name: Use Node.js ${{ env.NODE_VERSION }}
76-
uses: actions/setup-node@v4
77-
with:
78-
node-version: ${{ env.NODE_VERSION }}
79-
- name: Install dependencies
80-
run: pnpm install --frozen-lockfile
81-
- name: Build required workspace packages
82-
run: pnpm build --filter=@langchain/openai --filter=@langchain/anthropic --filter=@langchain/cohere --filter=@langchain/textsplitters
83-
- name: Test LangChain with lowest deps
84-
run: docker compose -f dependency_range_tests/docker-compose.yml run langchain-lowest-deps
67+
# langchain-lowest-deps:
68+
# runs-on: ubuntu-latest
69+
# needs: get-changed-files
70+
# if: (contains(needs.get-changed-files.outputs.changed_files, 'dependency_range_tests/scripts/langchain/') || contains(needs.get-changed-files.outputs.changed_files, 'libs/langchain/'))
71+
# steps:
72+
# - uses: actions/checkout@v4
73+
# - name: Setup pnpm
74+
# uses: pnpm/[email protected]
75+
# - name: Use Node.js ${{ env.NODE_VERSION }}
76+
# uses: actions/setup-node@v4
77+
# with:
78+
# node-version: ${{ env.NODE_VERSION }}
79+
# - name: Install dependencies
80+
# run: pnpm install --frozen-lockfile
81+
# - name: Build required workspace packages
82+
# run: pnpm build --filter=@langchain/openai --filter=@langchain/anthropic --filter=@langchain/cohere --filter=@langchain/textsplitters
83+
# - name: Test LangChain with lowest deps
84+
# run: docker compose -f dependency_range_tests/docker-compose.yml run langchain-lowest-deps
8585

8686
# # Community
8787
# community-latest-deps:

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"https://json.schemastore.org/github-workflow.json": "./.github/workflows/deploy.yml"
1111
},
1212
"typescript.tsdk": "node_modules/typescript/lib",
13+
"typescript.experimental.useTsgo": true,
1314
"cSpell.words": ["AILLM", "Upstash"],
1415
"cSpell.enabledFileTypes": {
1516
"mdx": true,

libs/langchain-core/src/utils/types/zod.ts

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,29 @@ export type ZodObjectV3 = z3.ZodObject<any, any, any, any>;
2222

2323
export type ZodObjectV4 = z4.$ZodObject;
2424

25+
export type ZodDefaultV3<T extends z3.ZodTypeAny> = z3.ZodDefault<T>;
26+
export type ZodDefaultV4<T extends z4.SomeType> = z4.$ZodDefault<T>;
27+
export type ZodOptionalV3<T extends z3.ZodTypeAny> = z3.ZodOptional<T>;
28+
export type ZodOptionalV4<T extends z4.SomeType> = z4.$ZodOptional<T>;
29+
2530
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2631
export type InteropZodType<Output = any, Input = Output> =
2732
| z3.ZodType<Output, z3.ZodTypeDef, Input>
2833
| z4.$ZodType<Output, Input>;
2934

3035
export type InteropZodObject = ZodObjectV3 | ZodObjectV4;
36+
export type InteropZodDefault<T = InteropZodObjectShape> =
37+
T extends z3.ZodTypeAny
38+
? ZodDefaultV3<T>
39+
: T extends z4.SomeType
40+
? ZodDefaultV4<T>
41+
: never;
42+
export type InteropZodOptional<T = InteropZodObjectShape> =
43+
T extends z3.ZodTypeAny
44+
? ZodOptionalV3<T>
45+
: T extends z4.SomeType
46+
? ZodOptionalV4<T>
47+
: never;
3148

3249
export type InteropZodObjectShape<
3350
T extends InteropZodObject = InteropZodObject
@@ -178,7 +195,7 @@ export async function interopSafeParseAsync<T>(
178195
}
179196
}
180197
if (isZodSchemaV3(schema as z3.ZodType<Record<string, unknown>>)) {
181-
return schema.safeParse(input);
198+
return await schema.safeParseAsync(input);
182199
}
183200
throw new Error("Schema must be an instance of z3.ZodType or z4.$ZodType");
184201
}
@@ -198,10 +215,10 @@ export async function interopParseAsync<T>(
198215
input: unknown
199216
): Promise<T> {
200217
if (isZodSchemaV4(schema)) {
201-
return parse(schema, input);
218+
return await parseAsync(schema, input);
202219
}
203220
if (isZodSchemaV3(schema as z3.ZodType<Record<string, unknown>>)) {
204-
return schema.parse(input);
221+
return await schema.parseAsync(input);
205222
}
206223
throw new Error("Schema must be an instance of z3.ZodType or z4.$ZodType");
207224
}
@@ -780,3 +797,67 @@ export function interopZodTransformInputSchema(
780797

781798
throw new Error("Schema must be an instance of z3.ZodType or z4.$ZodType");
782799
}
800+
801+
/**
802+
* Creates a modified version of a Zod object schema where fields matching a predicate are made optional.
803+
* Supports both Zod v3 and v4 schemas and preserves the original schema version.
804+
*
805+
* @template T - The type of the Zod object schema.
806+
* @param {T} schema - The Zod object schema instance (either v3 or v4).
807+
* @param {(key: string, value: InteropZodType) => boolean} predicate - Function to determine which fields should be optional.
808+
* @returns {InteropZodObject} The modified Zod object schema.
809+
* @throws {Error} If the schema is not a Zod v3 or v4 object.
810+
*/
811+
export function interopZodObjectMakeFieldsOptional<T extends InteropZodObject>(
812+
schema: T,
813+
predicate: (key: string, value: InteropZodType) => boolean
814+
): InteropZodObject {
815+
if (isZodSchemaV3(schema)) {
816+
const shape = getInteropZodObjectShape(schema);
817+
const modifiedShape: Record<string, z3.ZodTypeAny> = {};
818+
819+
for (const [key, value] of Object.entries(shape)) {
820+
if (predicate(key, value)) {
821+
// Make this field optional using v3 methods
822+
modifiedShape[key] = (value as z3.ZodTypeAny).optional();
823+
} else {
824+
// Keep field as-is
825+
modifiedShape[key] = value;
826+
}
827+
}
828+
829+
// Use v3's extend method to create a new schema with the modified shape
830+
return schema.extend(modifiedShape as z3.ZodRawShape);
831+
}
832+
833+
if (isZodSchemaV4(schema)) {
834+
const shape = getInteropZodObjectShape(schema);
835+
const outputShape: Mutable<z4.$ZodShape> = { ...schema._zod.def.shape };
836+
837+
for (const [key, value] of Object.entries(shape)) {
838+
if (predicate(key, value)) {
839+
// Make this field optional using v4 methods
840+
outputShape[key] = new $ZodOptional({
841+
type: "optional" as const,
842+
innerType: value as z4.$ZodType,
843+
});
844+
}
845+
// Otherwise keep the field as-is (already in outputShape)
846+
}
847+
848+
const modifiedSchema = clone<ZodObjectV4>(schema, {
849+
...schema._zod.def,
850+
shape: outputShape,
851+
});
852+
853+
// Preserve metadata
854+
const meta = globalRegistry.get(schema);
855+
if (meta) globalRegistry.add(modifiedSchema, meta);
856+
857+
return modifiedSchema;
858+
}
859+
860+
throw new Error(
861+
"Schema must be an instance of z3.ZodObject or z4.$ZodObject"
862+
);
863+
}

libs/langchain/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@
6161
"vitest": "^3.2.4"
6262
},
6363
"peerDependencies": {
64-
"@langchain/core": "^1.0.0-alpha.3 <2.0.0",
64+
"@langchain/core": "^1.0.0-alpha.5 <2.0.0",
6565
"cheerio": "*",
6666
"peggy": "^3.0.2",
6767
"typeorm": "*"

libs/langchain/src/agents/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export {
4343
} from "./responses.js";
4444
export { createMiddleware } from "./middlewareAgent/index.js";
4545
export type { AgentMiddleware } from "./middlewareAgent/types.js";
46-
46+
export type { ReactAgent } from "./middlewareAgent/ReactAgent.js";
4747
/**
4848
* Agents combine language models with tools to create systems that can reason
4949
* about tasks, decide which tools to use, and iteratively work towards solutions.

libs/langchain/src/agents/middlewareAgent/ReactAgent.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,10 @@ type MergedAgentState<
5454
| ResponseFormatUndefined,
5555
TMiddleware extends readonly AgentMiddleware<any, any, any>[]
5656
> = (StructuredResponseFormat extends ResponseFormatUndefined
57-
? BuiltInState
58-
: BuiltInState & { structuredResponse: StructuredResponseFormat }) &
57+
? Omit<BuiltInState, "jumpTo">
58+
: Omit<BuiltInState, "jumpTo"> & {
59+
structuredResponse: StructuredResponseFormat;
60+
}) &
5961
InferMiddlewareStates<TMiddleware>;
6062

6163
type InvokeStateParameter<
@@ -90,7 +92,11 @@ export class ReactAgent<
9092
ContextSchema extends
9193
| AnyAnnotationRoot
9294
| InteropZodObject = AnyAnnotationRoot,
93-
TMiddleware extends readonly AgentMiddleware<any, any, any>[] = []
95+
TMiddleware extends readonly AgentMiddleware<
96+
any,
97+
any,
98+
any
99+
>[] = readonly AgentMiddleware<any, any, any>[]
94100
> {
95101
#graph: AgentGraph<StructuredResponseFormat, ContextSchema, TMiddleware>;
96102

@@ -104,6 +110,14 @@ export class ReactAgent<
104110
(Array.isArray(options.tools) ? options.tools : options.tools?.tools) ??
105111
[];
106112

113+
/**
114+
* append tools from middleware
115+
*/
116+
const middlewareTools = (this.options.middleware
117+
?.filter((m) => m.tools)
118+
.flatMap((m) => m.tools) ?? []) as (ClientTool | ServerTool)[];
119+
toolClasses.push(...middlewareTools);
120+
107121
/**
108122
* If any of the tools are configured to return_directly after running,
109123
* our graph needs to check if these were called
@@ -662,9 +676,9 @@ export class ReactAgent<
662676
/**
663677
* Initialize middleware states if not already present in the input state.
664678
*/
665-
#initializeMiddlewareStates(
679+
async #initializeMiddlewareStates(
666680
state: InvokeStateParameter<TMiddleware>
667-
): InvokeStateParameter<TMiddleware> {
681+
): Promise<InvokeStateParameter<TMiddleware>> {
668682
if (
669683
!this.options.middleware ||
670684
this.options.middleware.length === 0 ||
@@ -674,7 +688,7 @@ export class ReactAgent<
674688
return state;
675689
}
676690

677-
const defaultStates = initializeMiddlewareStates(
691+
const defaultStates = await initializeMiddlewareStates(
678692
this.options.middleware,
679693
state
680694
);
@@ -736,15 +750,15 @@ export class ReactAgent<
736750
* console.log(result.structuredResponse.weather); // outputs: "It's sunny and 75°F."
737751
* ```
738752
*/
739-
invoke(
753+
async invoke(
740754
state: InvokeStateParameter<TMiddleware>,
741755
config?: InvokeConfiguration<
742756
InferContextInput<ContextSchema> &
743757
InferMiddlewareContextInputs<TMiddleware>
744758
>
745759
) {
746760
type FullState = MergedAgentState<StructuredResponseFormat, TMiddleware>;
747-
const initializedState = this.#initializeMiddlewareStates(state);
761+
const initializedState = await this.#initializeMiddlewareStates(state);
748762
return this.#graph.invoke(
749763
initializedState,
750764
config as unknown as InferContextInput<ContextSchema> &
@@ -808,7 +822,7 @@ export class ReactAgent<
808822
InferMiddlewareContextInputs<TMiddleware>
809823
>
810824
): Promise<IterableReadableStream<any>> {
811-
const initializedState = this.#initializeMiddlewareStates(state);
825+
const initializedState = await this.#initializeMiddlewareStates(state);
812826
return this.#graph.streamEvents(initializedState, {
813827
...config,
814828
version: "v2",

0 commit comments

Comments
 (0)