diff --git a/libs/langchain/src/agents/nodes/tests/utils.test.ts b/libs/langchain/src/agents/nodes/tests/utils.test.ts new file mode 100644 index 000000000000..b66cdadaca08 --- /dev/null +++ b/libs/langchain/src/agents/nodes/tests/utils.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod/v3"; +import { withLangGraph } from "@langchain/langgraph/zod"; +import { initializeMiddlewareStates } from "../utils.js"; +import type { AgentMiddleware } from "../../middleware/types.js"; + +describe("initializeMiddlewareStates", () => { + it("should handle withLangGraph defaults", async () => { + // Create a middleware with a state schema that has a default + const middleware: AgentMiddleware = { + name: "TestMiddleware", + stateSchema: z.object({ + testField: withLangGraph(z.string(), { + default: () => "default-value", + }), + }), + }; + + // Call with empty state (like the test does) + const result = await initializeMiddlewareStates([middleware], {}); + + // Should apply the default value + expect(result).toEqual({ + testField: "default-value", + }); + }); + + it("should handle withLangGraph defaults with reducers", async () => { + // Create a middleware like the text editor middleware + const middleware: AgentMiddleware = { + name: "TestMiddleware", + stateSchema: z.object({ + fieldWithDefault: withLangGraph(z.string(), { + reducer: { + fn: (_left: string, right: string) => right, + }, + default: () => "default value", + }), + }), + }; + + // Call with empty state + const result = await initializeMiddlewareStates([middleware], {}); + + // Should apply the default value + expect(result).toEqual({ + fieldWithDefault: "default value", + }); + }); + + it("should throw error for truly required fields without defaults", async () => { + // Create a middleware with a required field (no default) + const middleware: AgentMiddleware = { + name: "TestMiddleware", + stateSchema: z.object({ + requiredField: z.string(), + }), + }; + + // Should throw because field is required and has no default + await expect(initializeMiddlewareStates([middleware], {})).rejects.toThrow( + /required/i + ); + }); +}); diff --git a/libs/langchain/src/agents/nodes/utils.ts b/libs/langchain/src/agents/nodes/utils.ts index 0a4d82401c6c..2f00460b356f 100644 --- a/libs/langchain/src/agents/nodes/utils.ts +++ b/libs/langchain/src/agents/nodes/utils.ts @@ -1,15 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { z } from "zod/v3"; import { type BaseMessage } from "@langchain/core/messages"; import { + getInteropZodObjectShape, interopSafeParseAsync, interopZodObjectMakeFieldsOptional, } from "@langchain/core/utils/types"; -import { type ZodIssue } from "zod/v3"; import { END } from "@langchain/langgraph"; +import { schemaMetaRegistry } from "@langchain/langgraph/zod"; +import { z } from "zod/v3"; -import type { JumpTo } from "../types.js"; import type { AgentMiddleware } from "../middleware/types.js"; +import type { JumpTo } from "../types.js"; /** * Helper function to initialize middleware state defaults. @@ -17,6 +18,10 @@ import type { AgentMiddleware } from "../middleware/types.js"; * * Private properties (starting with _) are automatically made optional since * users cannot provide them when invoking the agent. + * + * This function also checks the LangGraph schemaMetaRegistry for fields with + * default values defined via withLangGraph(), and applies those defaults when + * the fields are omitted from the input state. */ export async function initializeMiddlewareStates( middlewareList: readonly AgentMiddleware[], @@ -41,36 +46,67 @@ export async function initializeMiddlewareStates( } /** - * If safeParse fails, there are required public fields missing + * If safeParse fails, check if the missing fields have defaults in the + * schemaMetaRegistry (from withLangGraph). If they do, apply the defaults. + * Only throw an error for truly required fields (no defaults, not optional). */ - const requiredFields = parseResult.error.issues - .filter( - (issue: ZodIssue) => - issue.code === "invalid_type" && issue.message === "Required" - ) - .map( - (issue: ZodIssue) => ` - ${issue.path.join(".")}: ${issue.message}` - ) - .join("\n"); - - throw new Error( - `Middleware "${middleware.name}" has required state fields that must be initialized:\n` + - `${requiredFields}\n\n` + - `To fix this, either:\n` + - `1. Provide default values in your middleware's state schema using .default():\n` + - ` stateSchema: z.object({\n` + - ` myField: z.string().default("default value")\n` + - ` })\n\n` + - `2. Or make the fields optional using .optional():\n` + - ` stateSchema: z.object({\n` + - ` myField: z.string().optional()\n` + - ` })\n\n` + - `3. Or ensure you pass these values when invoking the agent:\n` + - ` agent.invoke({\n` + - ` messages: [...],\n` + - ` ${parseResult.error.issues[0]?.path.join(".")}: "value"\n` + - ` })` - ); + const shape = getInteropZodObjectShape(middleware.stateSchema); + const missingRequiredFields: string[] = []; + const fieldsWithDefaults: Record = {}; + + for (const issue of parseResult.error.issues) { + if (issue.code === "invalid_type" && issue.message === "Required") { + const fieldName = issue.path[0] as string; + const fieldSchema = shape[fieldName]; + + if (fieldSchema) { + // Check if this field has a default in the registry + const meta = schemaMetaRegistry.get(fieldSchema); + if (meta?.default) { + // Apply the default value + fieldsWithDefaults[fieldName] = meta.default(); + } else { + // No default found - this is truly required + missingRequiredFields.push( + ` - ${issue.path.join(".")}: ${issue.message}` + ); + } + } + } + } + + // If there are truly required fields (no defaults), throw an error + if (missingRequiredFields.length > 0) { + throw new Error( + `Middleware "${middleware.name}" has required state fields that must be initialized:\n` + + `${missingRequiredFields.join("\n")}\n\n` + + `To fix this, either:\n` + + `1. Provide default values in your middleware's state schema using withLangGraph():\n` + + ` import { withLangGraph } from "@langchain/langgraph/zod";\n` + + ` stateSchema: z.object({\n` + + ` myField: withLangGraph(z.string(), { default: () => "default value" })\n` + + ` })\n\n` + + `2. Or use Zod's .default():\n` + + ` stateSchema: z.object({\n` + + ` myField: z.string().default("default value")\n` + + ` })\n\n` + + `3. Or make the fields optional using .optional():\n` + + ` stateSchema: z.object({\n` + + ` myField: z.string().optional()\n` + + ` })\n\n` + + `4. Or ensure you pass these values when invoking the agent:\n` + + ` agent.invoke({\n` + + ` messages: [...],\n` + + ` ${missingRequiredFields[0] + ?.split(":")[0] + ?.trim() + .replace("- ", "")}: "value"\n` + + ` })` + ); + } + + // Merge the fields with applied defaults into middlewareStates + Object.assign(middlewareStates, fieldsWithDefaults); } } diff --git a/libs/langchain/src/agents/tests/middleware-defaults.test.ts b/libs/langchain/src/agents/tests/middleware-defaults.test.ts new file mode 100644 index 000000000000..9750b588a376 --- /dev/null +++ b/libs/langchain/src/agents/tests/middleware-defaults.test.ts @@ -0,0 +1,192 @@ +/** + * Tests for agent middleware state schemas with default values. + * + * These tests verify that fields with `withLangGraph` defaults are correctly: + * 1. Treated as optional in TypeScript types (no type errors when omitted) + * 2. Given their default values at runtime when omitted from invoke() + * 3. Still accept explicitly provided values + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod/v3"; +import { withLangGraph } from "@langchain/langgraph/zod"; +import { AIMessage, HumanMessage } from "@langchain/core/messages"; +import { createAgent } from "../index.js"; +import { createMiddleware } from "../middleware.js"; +import { FakeToolCallingChatModel } from "./utils.js"; + +describe("Middleware state with withLangGraph defaults", () => { + it("should make fields with withLangGraph defaults optional in invoke parameter", async () => { + // Create middleware with a field that has a default + const middlewareWithDefaults = createMiddleware({ + name: "TestMiddleware", + stateSchema: z.object({ + fieldWithDefault: withLangGraph(z.string(), { + default: () => "default-value", + }), + }), + }); + + const llm = new FakeToolCallingChatModel({ + responses: [new AIMessage({ id: "0", content: "Response" })], + }); + + const agent = createAgent({ + model: llm, + middleware: [middlewareWithDefaults], + }); + + // This should NOT have a type error - fieldWithDefault should be optional + // because it has a default value + const result1 = await agent.invoke({ + messages: [new HumanMessage("test")], + // fieldWithDefault is omitted - should be OK due to default + }); + + // Verify the default value was applied + expect(result1.fieldWithDefault).toBe("default-value"); + + // This should also work when providing the field + const result2 = await agent.invoke({ + messages: [new HumanMessage("test")], + fieldWithDefault: "custom-value", + }); + + // Verify the custom value was used + expect(result2.fieldWithDefault).toBe("custom-value"); + }); + + it("should make fields with withLangGraph reducer defaults optional in invoke parameter", async () => { + // Create middleware like the text editor middleware + const middlewareWithReducerDefaults = createMiddleware({ + name: "TestMiddleware", + stateSchema: z.object({ + fieldWithDefault: withLangGraph(z.string(), { + reducer: { + fn: (_left: string, right: string) => right, + }, + default: () => "default value", + }), + }), + }); + + const llm = new FakeToolCallingChatModel({ + responses: [new AIMessage({ id: "0", content: "Response" })], + }); + + const agent = createAgent({ + model: llm, + middleware: [middlewareWithReducerDefaults], + }); + + // This should NOT have a type error - fieldWithDefault should be optional + const result1 = await agent.invoke({ + messages: [new HumanMessage("test")], + // fieldWithDefault is omitted - should be OK due to default + }); + + // Verify the default value was applied + expect(result1.fieldWithDefault).toEqual("default value"); + + // This should also work when providing the field + const result2 = await agent.invoke({ + messages: [new HumanMessage("test")], + fieldWithDefault: "nondefault value", + }); + + // Verify the custom value was used + expect(result2.fieldWithDefault).toEqual("nondefault value"); + }); + + it("should require fields without defaults in invoke parameter", async () => { + // Create middleware with a required field (no default) + const middlewareWithRequired = createMiddleware({ + name: "TestMiddleware", + stateSchema: z.object({ + requiredField: z.string(), + }), + }); + + const llm = new FakeToolCallingChatModel({ + responses: [new AIMessage({ id: "0", content: "Response" })], + }); + + const agent = createAgent({ + model: llm, + middleware: [middlewareWithRequired], + }); + + await expect( + // @ts-expect-error - requiredField should be required + agent.invoke({ + messages: [new HumanMessage("test")], + // requiredField is omitted - should be a type error AND runtime error + }) + ).rejects.toThrow(/required/i); + + // This should work when providing the field + const result = await agent.invoke({ + messages: [new HumanMessage("test")], + requiredField: "value", + }); + + // Verify the provided value was used + expect(result.requiredField).toBe("value"); + }); + + it("should make fields optional when passed directly via stateSchema", async () => { + // When stateSchema is passed directly (not via middleware), it should work the same way + const llm = new FakeToolCallingChatModel({ + responses: [new AIMessage({ id: "0", content: "Response" })], + }); + + const agent = createAgent({ + model: llm, + stateSchema: z.object({ + fieldWithDefault: withLangGraph(z.string(), { + default: () => "default-value", + }), + }), + }); + + // This should NOT have a type error + const result = await agent.invoke({ + messages: [new HumanMessage("test")], + // fieldWithDefault is omitted - should be OK due to default + }); + + // Field should NOT appear in output since no node updated it + // (LangGraph only applies defaults when a field is actually written to) + expect(result.fieldWithDefault).toBeUndefined(); + expect(result.messages).toBeDefined(); + }); + + it("should make fields with reducer optional when passed directly via stateSchema", async () => { + // When stateSchema is passed directly (not via middleware), it should work the same way + const llm = new FakeToolCallingChatModel({ + responses: [new AIMessage({ id: "0", content: "Response" })], + }); + + const agent = createAgent({ + model: llm, + stateSchema: z.object({ + fieldWithDefault: withLangGraph(z.string(), { + reducer: { + fn: (_left: string, right: string) => right, + }, + default: () => "default-value", + }), + }), + }); + + // This should NOT have a type error + const result = await agent.invoke({ + messages: [new HumanMessage("test")], + // fieldWithDefault is omitted - should be OK due to default + }); + + // Field should NOT appear in output since no node updated it + // (LangGraph only applies defaults when a field is actually written to) + expect(result.fieldWithDefault).toBeUndefined(); + expect(result.messages).toBeDefined(); + }); +});