From f1b42567043dca8fa60ffbf313a9b08cf8ba753d Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Fri, 12 Sep 2025 09:41:08 -0700 Subject: [PATCH 01/16] feat(langchain): refine reactAgent --- examples/package.json | 2 +- libs/langchain/package.json | 2 +- .../src/agents/middlewareAgent/ReactAgent.ts | 153 ++++++++++++++---- .../src/agents/middlewareAgent/index.ts | 132 +++++++++++---- .../src/agents/middlewareAgent/middleware.ts | 24 ++- .../agents/middlewareAgent/nodes/AgentNode.ts | 6 +- .../middlewareAgent/nodes/middleware.ts | 50 ++---- .../src/agents/middlewareAgent/types.ts | 80 +++++---- pnpm-lock.yaml | 32 ++-- 9 files changed, 325 insertions(+), 156 deletions(-) diff --git a/examples/package.json b/examples/package.json index 35a441a5e672..6c28ec43b3b8 100644 --- a/examples/package.json +++ b/examples/package.json @@ -52,7 +52,7 @@ "@langchain/google-vertexai": "workspace:*", "@langchain/google-vertexai-web": "workspace:*", "@langchain/groq": "workspace:*", - "@langchain/langgraph": "alpha", + "@langchain/langgraph": "next", "@langchain/mistralai": "workspace:*", "@langchain/mongodb": "workspace:*", "@langchain/nomic": "workspace:*", diff --git a/libs/langchain/package.json b/libs/langchain/package.json index ab62aad2d3ed..232fad70bc1b 100644 --- a/libs/langchain/package.json +++ b/libs/langchain/package.json @@ -79,7 +79,7 @@ }, "dependencies": { "@langchain/textsplitters": "workspace:*", - "@langchain/langgraph": "^1.0.0-alpha", + "@langchain/langgraph": "next", "@langchain/langgraph-checkpoint": "^0.1.1", "js-yaml": "^4.1.0", "jsonpointer": "^5.0.1", diff --git a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts index 52ff92b1c0f2..4f165ab84d73 100644 --- a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts +++ b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts @@ -10,9 +10,9 @@ import { Send, Command, CompiledStateGraph, - type LangGraphRunnableConfig, } from "@langchain/langgraph"; import { ToolMessage, AIMessage } from "@langchain/core/messages"; +import { IterableReadableStream } from "@langchain/core/utils/stream"; import { createAgentAnnotationConditional } from "./annotation.js"; import { isClientTool, validateLLMHasNoBoundTools } from "../utils.js"; @@ -32,8 +32,9 @@ import { InferMiddlewareInputStates, BuiltInState, InferMiddlewareContextInputs, - IsAllOptional, InferContextInput, + InvokeConfiguration, + StreamConfiguration, } from "./types.js"; import { @@ -341,7 +342,7 @@ export class ReactAgent< } /** - * Get the compiled graph. + * Get the compiled {@link https://docs.langchain.com/oss/javascript/langgraph/use-graph-api | StateGraph}. */ get graph(): AgentGraph< StructuredResponseFormat, @@ -461,8 +462,7 @@ export class ReactAgent< // Only add defaults for keys that don't exist in current state for (const [key, value] of Object.entries(defaultStates)) { if (!(key in updatedState)) { - // @ts-expect-error - ToDo: fix type - updatedState[key as keyof InvokeStateParameter] = value; + updatedState[key as keyof typeof updatedState] = value; } } @@ -470,41 +470,126 @@ export class ReactAgent< } /** - * @inheritdoc + * Executes the agent with the given state and returns the final state after all processing. + * + * This method runs the agent's entire workflow synchronously, including: + * - Processing the input messages through any configured middleware + * - Calling the language model to generate responses + * - Executing any tool calls made by the model + * - Running all middleware hooks (beforeModel, afterModel, etc.) + * + * @param state - The initial state for the agent execution. Can be: + * - An object containing `messages` array and any middleware-specific state properties + * - A Command object for more advanced control flow + * + * @param config - Optional runtime configuration including: + * @param config.context - The context for the agent execution. + * @param config.configurable - LangGraph configuration options like `thread_id`, `run_id`, etc. + * @param config.store - The store for the agent execution for persisting state, see more in {@link https://docs.langchain.com/oss/javascript/langgraph/memory#memory-storage | Memory storage}. + * @param config.signal - An optional {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | `AbortSignal`} for the agent execution. + * @param config.recursionLimit - The recursion limit for the agent execution. + * + * @returns A Promise that resolves to the final agent state after execution completes. + * The returned state includes: + * - a `messages` property containing an array with all messages (input, AI responses, tool calls/results) + * - a `structuredResponse` property containing the structured response (if configured) + * - all state values defined in the middleware + * + * @example + * ```typescript + * const agent = new ReactAgent({ + * llm: myModel, + * tools: [calculator, webSearch], + * responseFormat: z.object({ + * weather: z.string(), + * }), + * }); + * + * const result = await agent.invoke({ + * messages: [{ role: "human", content: "What's the weather in Paris?" }] + * }); + * + * console.log(result.structuredResponse.weather); // outputs: "It's sunny and 75°F." + * ``` */ - get invoke() { + invoke( + state: InvokeStateParameter, + config?: InvokeConfiguration< + InferContextInput & + InferMiddlewareContextInputs + > + ) { type FullState = MergedAgentState; - type FullContext = InferContextInput & - InferMiddlewareContextInputs; - - // Create overloaded function type based on whether context has required fields - type InvokeFunction = IsAllOptional extends true - ? ( - state: InvokeStateParameter, - config?: LangGraphRunnableConfig - ) => Promise - : ( - state: InvokeStateParameter, - config?: LangGraphRunnableConfig - ) => Promise; - - const invokeFunc: InvokeFunction = async ( - state: InvokeStateParameter, - config?: LangGraphRunnableConfig - ): Promise => { - const initializedState = this.#initializeMiddlewareStates(state); - return this.#graph.invoke( - initializedState, - config as any - ) as Promise; - }; - - return invokeFunc; + const initializedState = this.#initializeMiddlewareStates(state); + return this.#graph.invoke( + initializedState, + config as unknown as InferContextInput & + InferMiddlewareContextInputs + ) as Promise; } /** - * ToDo(@christian-bromann): Add stream and streamEvents methods + * Executes the agent with streaming, returning an async iterable of events as they occur. + * + * This method runs the agent's workflow similar to `invoke`, but instead of waiting for + * completion, it streams events in real-time. This allows you to: + * - Display intermediate results to users as they're generated + * - Monitor the agent's progress through each step + * - Handle tool calls and results as they happen + * - Update UI with streaming responses from the LLM + * + * @param state - The initial state for the agent execution. Can be: + * - An object containing `messages` array and any middleware-specific state properties + * - A Command object for more advanced control flow + * + * @param config - Optional runtime configuration including: + * @param config.context - The context for the agent execution. + * @param config.configurable - LangGraph configuration options like `thread_id`, `run_id`, etc. + * @param config.store - The store for the agent execution for persisting state, see more in {@link https://docs.langchain.com/oss/javascript/langgraph/memory#memory-storage | Memory storage}. + * @param config.signal - An optional {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal | `AbortSignal`} for the agent execution. + * @param config.streamMode - The streaming mode for the agent execution, see more in {@link https://docs.langchain.com/oss/javascript/langgraph/streaming#supported-stream-modes | Supported stream modes}. + * @param config.recursionLimit - The recursion limit for the agent execution. + * + * @returns A Promise that resolves to an IterableReadableStream of events. + * Events include: + * - `on_chat_model_start`: When the LLM begins processing + * - `on_chat_model_stream`: Streaming tokens from the LLM + * - `on_chat_model_end`: When the LLM completes + * - `on_tool_start`: When a tool execution begins + * - `on_tool_end`: When a tool execution completes + * - `on_chain_start`: When middleware chains begin + * - `on_chain_end`: When middleware chains complete + * - And other LangGraph v2 stream events + * + * @example + * ```typescript + * const agent = new ReactAgent({ + * llm: myModel, + * tools: [calculator, webSearch] + * }); + * + * const stream = await agent.stream({ + * messages: [{ role: "human", content: "What's 2+2 and the weather in NYC?" }] + * }); + * + * for await (const event of stream) { + * // + * } + * ``` */ + async stream( + state: InvokeStateParameter, + config?: StreamConfiguration< + InferContextInput & + InferMiddlewareContextInputs + > + ): Promise> { + const initializedState = this.#initializeMiddlewareStates(state); + return this.#graph.streamEvents(initializedState, { + ...config, + version: "v2", + } as any) as IterableReadableStream; + } /** * Visualize the graph as a PNG image. diff --git a/libs/langchain/src/agents/middlewareAgent/index.ts b/libs/langchain/src/agents/middlewareAgent/index.ts index d205d8abe36e..2f7bf6867313 100644 --- a/libs/langchain/src/agents/middlewareAgent/index.ts +++ b/libs/langchain/src/agents/middlewareAgent/index.ts @@ -18,44 +18,122 @@ import type { import { ReactAgent } from "./ReactAgent.js"; /** - * Creates a StateGraph agent that relies on a chat model utilizing tool calling. + * Creates a production-ready ReAct (Reasoning + Acting) agent that combines language models with tools + * and middleware to create systems that can reason about tasks, decide which tools to use, and iteratively + * work towards solutions. * - * @example + * The agent follows the ReAct pattern, interleaving reasoning steps with tool calls to iteratively + * work towards solutions. It can handle multiple tool calls in sequence or parallel, maintain state + * across interactions, and provide auditable decision processes. + * + * ## Core Components + * + * ### Model + * The reasoning engine can be specified as: + * - **String identifier**: `"openai:gpt-4o"` for simple setup + * - **Model instance**: Configured model object for full control + * - **Dynamic function**: Select models at runtime based on state + * + * ### Tools + * Tools give agents the ability to take actions: + * - Pass an array of tools created with the `tool` function + * - Or provide a configured `ToolNode` for custom error handling + * + * ### Prompt + * Shape how your agent approaches tasks: + * - String for simple instructions + * - SystemMessage for structured prompts + * - Function for dynamic prompts based on state + * + * ### Middleware + * Middleware allows you to extend the agent's behavior: + * - Add pre/post-model processing for context injection or validation + * - Add dynamic control flows, e.g. terminate invocation or retries + * - Add human-in-the-loop capabilities + * - Add tool calls to the agent + * - Add tool results to the agent + * + * ## Advanced Features + * + * - **Structured Output**: Use `responseFormat` with a Zod schema to get typed responses + * - **Memory**: Extend the state schema to remember information across interactions + * - **Streaming**: Get real-time updates as the agent processes + * + * @param options - Configuration options for the agent + * @param options.llm - The language model as an instance of a chat model + * @param options.model - The language model as a string identifier, see more in {@link https://docs.langchain.com/oss/javascript/langchain/models#basic-usage | Models}. + * @param options.tools - Array of tools or configured ToolNode + * @param options.prompt - System instructions (string, SystemMessage, or function) + * @param options.responseFormat - Zod schema for structured output + * @param options.stateSchema - Custom state schema for memory + * @param options.middleware - Array of middleware for extending agent behavior, see more in {@link https://docs.langchain.com/oss/javascript/langchain/middleware | Middleware}. + * + * @returns A ReactAgent instance with `invoke` and `stream` methods + * + * @example Basic agent with tools * ```ts - * import { ChatOpenAI } from "@langchain/openai"; * import { createAgent, tool } from "langchain"; - * import { z } from "zod/v3"; + * import { z } from "zod"; + * + * const search = tool( + * ({ query }) => `Results for: ${query}`, + * { + * name: "search", + * description: "Search for information", + * schema: z.object({ + * query: z.string().describe("The search query"), + * }) + * } + * ); * - * const model = new ChatOpenAI({ - * model: "gpt-4o", + * const agent = createAgent({ + * llm: "openai:gpt-4o", + * tools: [search], * }); * - * const getWeather = tool((input) => { - * if (["sf", "san francisco"].includes(input.location.toLowerCase())) { - * return "It's 60 degrees and foggy."; - * } else { - * return "It's 90 degrees and sunny."; - * } - * }, { - * name: "get_weather", - * description: "Call to get the current weather.", - * schema: z.object({ - * location: z.string().describe("Location to get the weather for."), - * }) - * }) + * const result = await agent.invoke({ + * messages: [{ role: "user", content: "Search for ReAct agents" }], + * }); + * ``` + * + * @example Structured output + * ```ts + * import { createAgent } from "langchain"; + * import { z } from "zod"; + * + * const ContactInfo = z.object({ + * name: z.string(), + * email: z.string(), + * phone: z.string(), + * }); + * + * const agent = createAgent({ + * llm: "openai:gpt-4o", + * tools: [], + * responseFormat: ContactInfo, + * }); * - * const agent = createAgent({ llm: model, tools: [getWeather] }); + * const result = await agent.invoke({ + * messages: [{ + * role: "user", + * content: "Extract: John Doe, john@example.com, (555) 123-4567" + * }], + * }); * - * const inputs = { - * messages: [{ role: "user", content: "what is the weather in SF?" }], - * }; + * console.log(result.structuredResponse); + * // { name: 'John Doe', email: 'john@example.com', phone: '(555) 123-4567' } + * ``` * - * const stream = await agent.stream(inputs, { streamMode: "values" }); + * @example Streaming responses + * ```ts + * const stream = await agent.stream( + * { messages: [{ role: "user", content: "What's the weather?" }] }, + * { streamMode: "values" } + * ); * - * for await (const { messages } of stream) { - * console.log(messages); + * for await (const chunk of stream) { + * // ... * } - * // Returns the messages in the state at each step of execution * ``` */ // Overload 1: With responseFormat as single InteropZodType diff --git a/libs/langchain/src/agents/middlewareAgent/middleware.ts b/libs/langchain/src/agents/middlewareAgent/middleware.ts index e496a6a2ced9..85342cbe578b 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware.ts @@ -3,7 +3,6 @@ import { z } from "zod/v3"; import type { AgentMiddleware, Runtime, - Controls, MiddlewareResult, AgentBuiltInState, ModelRequest, @@ -51,6 +50,7 @@ export function createMiddleware< state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, runtime: Runtime< + TSchema, TContextSchema extends z.ZodObject ? z.infer : {} > ) => Promise | ModelRequest | void; @@ -58,11 +58,8 @@ export function createMiddleware< state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, runtime: Runtime< + TSchema, TContextSchema extends z.ZodObject ? z.infer : {} - >, - controls: Controls< - (TSchema extends z.ZodObject ? z.infer : {}) & - AgentBuiltInState > ) => | Promise< @@ -77,11 +74,8 @@ export function createMiddleware< state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, runtime: Runtime< + TSchema, TContextSchema extends z.ZodObject ? z.infer : {} - >, - controls: Controls< - (TSchema extends z.ZodObject ? z.infer : {}) & - AgentBuiltInState > ) => | Promise< @@ -101,17 +95,19 @@ export function createMiddleware< if (config.prepareModelRequest) { middleware.prepareModelRequest = async (options, state, runtime) => - Promise.resolve(config.prepareModelRequest!(options, state, runtime)); + Promise.resolve( + config.prepareModelRequest!(options, state, runtime as any) + ); } if (config.beforeModel) { - middleware.beforeModel = async (state, runtime, controls) => - Promise.resolve(config.beforeModel!(state, runtime, controls)); + middleware.beforeModel = async (state, runtime) => + Promise.resolve(config.beforeModel!(state, runtime as any)); } if (config.afterModel) { - middleware.afterModel = async (state, runtime, controls) => - Promise.resolve(config.afterModel!(state, runtime, controls)); + middleware.afterModel = async (state, runtime) => + Promise.resolve(config.afterModel!(state, runtime as any)); } return middleware; diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts b/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts index 6a4ea240e416..cf127d910ba3 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts @@ -541,10 +541,14 @@ export class AgentNode< }; // Create runtime - const runtime: Runtime = { + const runtime: Runtime = { toolCalls: parseToolCalls(state.messages), toolResults: parseToolResults(state.messages), context, + writer: config.writer, + interrupt: config.interrupt, + signal: config.signal, + terminate: (result) => ({ type: "terminate", result }), }; const result = await middleware.prepareModelRequest!( diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts b/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts index 69a92fe3d134..60a6c9db199a 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts @@ -6,7 +6,6 @@ import { LangGraphRunnableConfig, Command } from "@langchain/langgraph"; import { RunnableCallable } from "../../RunnableCallable.js"; import type { Runtime, - Controls, ControlAction, AgentMiddleware, MiddlewareResult, @@ -32,8 +31,7 @@ export abstract class MiddlewareNode< abstract runHook( state: TStateSchema, - config?: Runtime, - controls?: Controls + config?: Runtime ): Promise>; async invokeMiddleware( @@ -71,21 +69,13 @@ export abstract class MiddlewareNode< /** * ToDo: implement later */ - const runtime: Runtime = { + const runtime: Runtime = { toolCalls: parseToolCalls(state.messages), toolResults: parseToolResults(state.messages), context: filteredContext, - }; - - const controls: Controls = { - jumpTo: ( - target: "model" | "tools", - stateUpdate?: Partial - ): ControlAction => ({ - type: "jump", - target, - stateUpdate, - }), + writer: config?.writer, + interrupt: config?.interrupt, + signal: config?.signal, terminate: ( result?: Partial | Error ): ControlAction => { @@ -96,7 +86,7 @@ export abstract class MiddlewareNode< }, }; - const result = await this.runHook(state, runtime, controls); + const result = await this.runHook(state, runtime); /** * If result is undefined, return current state @@ -108,25 +98,17 @@ export abstract class MiddlewareNode< /** * If result is a control action, handle it */ - // if (typeof result === "object" && "type" in result) { - // // Handle control actions - // const action = result as ControlAction; - // if (action.type === "terminate") { - // if (action.error) { - // throw action.error; - // } - // return { ...state, ...(action.result || {}) }; - // } - - // if (action.type === "jump") { - // return new Command({ - // goto: action.target, - // update: { ...state, ...(action.stateUpdate || {}) }, - // }); - // } + if (typeof result === "object" && "type" in result) { + // Handle control actions + if (result.type === "terminate") { + if (result.error) { + throw result.error; + } + return { ...state, ...(result.result || {}) }; + } - // throw new Error(`Invalid control action: ${JSON.stringify(action)}`); - // } + throw new Error(`Invalid control action: ${JSON.stringify(result)}`); + } /** * If result is a state update, merge it with current state diff --git a/libs/langchain/src/agents/middlewareAgent/types.ts b/libs/langchain/src/agents/middlewareAgent/types.ts index 564bbfbf89f1..7b2c8c0aa6b6 100644 --- a/libs/langchain/src/agents/middlewareAgent/types.ts +++ b/libs/langchain/src/agents/middlewareAgent/types.ts @@ -5,7 +5,12 @@ import type { InteropZodType, InferInteropZodInput, } from "@langchain/core/utils/types"; -import type { LangGraphRunnableConfig, START } from "@langchain/langgraph"; +import type { + LangGraphRunnableConfig, + START, + PregelOptions, + Runtime as LangGraphRuntime, +} from "@langchain/langgraph"; import type { LanguageModelLike } from "@langchain/core/language_models/base"; import type { SystemMessage, BaseMessage } from "@langchain/core/messages"; @@ -115,26 +120,18 @@ export interface ModelRequest { /** * Runtime information available to middleware (readonly). */ -export interface Runtime { +export interface Runtime + extends Partial< + Omit, "context" | "configurable"> + > { readonly toolCalls: ToolCall[]; readonly toolResults: ToolResult[]; - // readonly tokenUsage: { - // readonly inputTokens: number; - // readonly outputTokens: number; - // readonly totalTokens: number; - // }; readonly context: TContext; - // readonly currentIteration: number; -} -/** - * Control flow interface for middleware. - */ -export interface Controls { - jumpTo( - target: "model" | "tools", - stateUpdate?: Partial - ): ControlAction; + /** + * Terminates the agent with an update to the state or throws an error. + * @param result - The result to terminate the agent with. + */ terminate(result?: Partial | Error): ControlAction; } @@ -142,7 +139,7 @@ export interface Controls { * Control action type returned by control methods. */ export type ControlAction = { - type: "jump" | "terminate" | "retry"; + type: "terminate"; target?: string; stateUpdate?: Partial; result?: any; @@ -315,11 +312,7 @@ export interface AgentMiddleware< beforeModel?( state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, - runtime: Runtime, - controls: Controls< - (TSchema extends z.ZodObject ? z.infer : {}) & - AgentBuiltInState - > + runtime: Runtime ): Promise< MiddlewareResult< Partial ? z.infer : {}> @@ -328,11 +321,7 @@ export interface AgentMiddleware< afterModel?( state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, - runtime: Runtime, - controls: Controls< - (TSchema extends z.ZodObject ? z.infer : {}) & - AgentBuiltInState - > + runtime: Runtime ): Promise< MiddlewareResult< Partial ? z.infer : {}> @@ -631,3 +620,38 @@ export type InternalAgentState< } & (StructuredResponseType extends ResponseFormatUndefined ? Record : { structuredResponse: StructuredResponseType }); + +export type InvokeConfiguration> = + Partial< + Pick< + PregelOptions, + | "configurable" + | "durability" + | "store" + | "cache" + | "signal" + | "recursionLimit" + | "maxConcurrency" + | "timeout" + > + > & { + context: ContextSchema; + }; + +export type StreamConfiguration> = + Partial< + Pick< + PregelOptions, + | "configurable" + | "durability" + | "store" + | "cache" + | "signal" + | "streamMode" + | "recursionLimit" + | "maxConcurrency" + | "timeout" + > + > & { + context: ContextSchema; + }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4e14cc84a1d..c871382ddd33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -195,8 +195,8 @@ importers: specifier: workspace:* version: link:../libs/providers/langchain-groq '@langchain/langgraph': - specifier: alpha - version: 1.0.0-alpha.0(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + specifier: next + version: 1.0.0-alpha.1(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) '@langchain/mistralai': specifier: workspace:* version: link:../libs/providers/langchain-mistralai @@ -608,8 +608,8 @@ importers: libs/langchain: dependencies: '@langchain/langgraph': - specifier: ^1.0.0-alpha - version: 1.0.0-alpha.0(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) + specifier: next + version: 1.0.0-alpha.1(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) '@langchain/langgraph-checkpoint': specifier: ^0.1.1 version: 0.1.1(@langchain/core@libs+langchain-core) @@ -5952,8 +5952,8 @@ packages: react-dom: optional: true - '@langchain/langgraph-sdk@0.0.112': - resolution: {integrity: sha512-/9W5HSWCqYgwma6EoOspL4BGYxGxeJP6lIquPSF4FA0JlKopaUv58ucZC3vAgdJyCgg6sorCIV/qg7SGpEcCLw==} + '@langchain/langgraph-sdk@0.1.4': + resolution: {integrity: sha512-pITGjh6ayNLgtJ8Ant2lyFZ/o94ePlrH8zNgMLeiDqdRDjoqSehW5k3SZucpG4n9U12qR/WtPXDzbyHa93ISbA==} peerDependencies: '@langchain/core': workspace:* react: ^18 || ^19 @@ -5976,8 +5976,8 @@ packages: zod-to-json-schema: optional: true - '@langchain/langgraph@1.0.0-alpha.0': - resolution: {integrity: sha512-cCYqWW3/aoU4V6oURiL5sd/uij1r0u/nJT/Po4L8C8HhFM07JlI0Eg92yfkP+Cy9T5gMwta5UX2GtOS3LuurGA==} + '@langchain/langgraph@1.0.0-alpha.1': + resolution: {integrity: sha512-CQzPdCXYo4b3rG1x95bHD93sFw11HgZANNfG3NDIDfxntpL4VFwUaZHKfKvsL3PWU30Cm+LDnXl8rZocgibcGQ==} engines: {node: '>=18'} peerDependencies: '@langchain/core': workspace:* @@ -20633,7 +20633,7 @@ snapshots: react: 19.0.0 react-dom: 18.3.1(react@19.0.0) - '@langchain/langgraph-sdk@0.0.112(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': + '@langchain/langgraph-sdk@0.1.4(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': dependencies: '@types/json-schema': 7.0.15 p-queue: 6.6.2 @@ -20657,11 +20657,11 @@ snapshots: - react - react-dom - '@langchain/langgraph@1.0.0-alpha.0(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': + '@langchain/langgraph@1.0.0-alpha.1(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': dependencies: '@langchain/core': link:libs/langchain-core '@langchain/langgraph-checkpoint': 0.1.1(@langchain/core@libs+langchain-core) - '@langchain/langgraph-sdk': 0.0.112(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0) + '@langchain/langgraph-sdk': 0.1.4(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0) uuid: 10.0.0 zod: 3.25.76 optionalDependencies: @@ -26202,7 +26202,7 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.1.6) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -26226,7 +26226,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1(supports-color@8.1.1) @@ -26241,14 +26241,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@5.1.6) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -26273,7 +26273,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From b2ef3b24f079da4c40ee6ab486c62bae75969694 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Mon, 15 Sep 2025 13:40:39 -0700 Subject: [PATCH 02/16] optionality in context --- .../src/agents/middlewareAgent/middleware.ts | 89 +++++- .../agents/middlewareAgent/middleware/hitl.ts | 49 ++-- .../middlewareAgent/nodes/AfterModalNode.ts | 20 +- .../agents/middlewareAgent/nodes/AgentNode.ts | 3 +- .../middlewareAgent/nodes/BeforeModalNode.ts | 20 +- .../middlewareAgent/nodes/middleware.ts | 7 +- .../src/agents/middlewareAgent/nodes/utils.ts | 32 +- .../tests/middleware.test-d.ts | 273 ++++++++++++++++-- .../middlewareAgent/tests/middleware.test.ts | 8 +- .../src/agents/middlewareAgent/types.ts | 181 ++++++++---- 10 files changed, 525 insertions(+), 157 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/middleware.ts b/libs/langchain/src/agents/middlewareAgent/middleware.ts index 85342cbe578b..9330d4815716 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware.ts @@ -40,7 +40,11 @@ import type { */ export function createMiddleware< TSchema extends z.ZodObject | undefined = undefined, - TContextSchema extends z.ZodObject | undefined = undefined + TContextSchema extends + | z.ZodObject + | z.ZodOptional> + | z.ZodDefault> + | undefined = undefined >(config: { name: string; stateSchema?: TSchema; @@ -50,16 +54,30 @@ export function createMiddleware< state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, runtime: Runtime< - TSchema, - TContextSchema extends z.ZodObject ? z.infer : {} + (TSchema extends z.ZodObject ? z.infer : {}) & + AgentBuiltInState, + TContextSchema extends z.ZodObject + ? z.infer + : TContextSchema extends z.ZodDefault> + ? z.infer + : TContextSchema extends z.ZodOptional> + ? Partial> + : never > ) => Promise | ModelRequest | void; beforeModel?: ( state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, runtime: Runtime< - TSchema, - TContextSchema extends z.ZodObject ? z.infer : {} + (TSchema extends z.ZodObject ? z.infer : {}) & + AgentBuiltInState, + TContextSchema extends z.ZodObject + ? z.infer + : TContextSchema extends z.ZodDefault> + ? z.infer + : TContextSchema extends z.ZodOptional> + ? Partial> + : never > ) => | Promise< @@ -74,8 +92,15 @@ export function createMiddleware< state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, runtime: Runtime< - TSchema, - TContextSchema extends z.ZodObject ? z.infer : {} + (TSchema extends z.ZodObject ? z.infer : {}) & + AgentBuiltInState, + TContextSchema extends z.ZodObject + ? z.infer + : TContextSchema extends z.ZodDefault> + ? z.infer + : TContextSchema extends z.ZodOptional> + ? Partial> + : never > ) => | Promise< @@ -96,18 +121,62 @@ export function createMiddleware< if (config.prepareModelRequest) { middleware.prepareModelRequest = async (options, state, runtime) => Promise.resolve( - config.prepareModelRequest!(options, state, runtime as any) + config.prepareModelRequest!( + options, + state, + runtime as Runtime< + (TSchema extends z.ZodObject ? z.infer : {}) & + AgentBuiltInState, + TContextSchema extends z.ZodObject + ? z.infer + : TContextSchema extends z.ZodDefault> + ? z.infer + : TContextSchema extends z.ZodOptional> + ? Partial> + : never + > + ) ); } if (config.beforeModel) { middleware.beforeModel = async (state, runtime) => - Promise.resolve(config.beforeModel!(state, runtime as any)); + Promise.resolve( + config.beforeModel!( + state, + runtime as Runtime< + (TSchema extends z.ZodObject ? z.infer : {}) & + AgentBuiltInState, + TContextSchema extends z.ZodObject + ? z.infer + : TContextSchema extends z.ZodDefault> + ? z.infer + : TContextSchema extends z.ZodOptional> + ? Partial> + : never + > + ) + ); } if (config.afterModel) { middleware.afterModel = async (state, runtime) => - Promise.resolve(config.afterModel!(state, runtime as any)); + Promise.resolve( + config.afterModel!( + state, + runtime as Runtime< + (TSchema extends z.ZodObject ? z.infer : {}) & + AgentBuiltInState, + TContextSchema extends z.ZodObject + ? z.infer + : TContextSchema extends z.ZodDefault> + ? z.infer + : TContextSchema extends z.ZodOptional> + ? Partial> + : never + > + ) + ); } return middleware; diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index 083d690ee595..7dce7f9050e1 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { z } from "zod/v3"; import { v4 as uuid } from "uuid"; -import { AIMessage, ToolMessage, isAIMessage } from "@langchain/core/messages"; +import { AIMessage, ToolMessage } from "@langchain/core/messages"; import { interrupt } from "@langchain/langgraph"; import { createMiddleware } from "../middleware.js"; @@ -18,17 +18,19 @@ interface ToolApprovalRequest { description?: string; } -const contextSchema = z.object({ - toolConfigs: z - .record( - z.object({ - requireApproval: z.boolean().optional(), - description: z.string().optional(), - }) - ) - .default({}), - messagePrefix: z.string().default("Tool execution requires approval"), -}); +const contextSchema = z + .object({ + toolConfigs: z + .record( + z.object({ + requireApproval: z.boolean().optional(), + description: z.string().optional(), + }) + ) + .default({}), + messagePrefix: z.string().default("Tool execution requires approval"), + }) + .optional(); /** * Creates a Human-in-the-Loop (HITL) middleware for tool approval and oversight. @@ -178,7 +180,7 @@ export function humanInTheLoopMiddleware( return createMiddleware({ name: "HumanInTheLoopMiddleware", contextSchema, - afterModel: async (state, runtime, controls) => { + afterModel: async (state, runtime) => { const config = { ...contextSchema.parse(options), ...runtime.context }; const { messages } = state; @@ -188,11 +190,22 @@ export function humanInTheLoopMiddleware( const lastMessage = messages[messages.length - 1]; - // Check if it's an AI message with tool calls - if (!isAIMessage(lastMessage) || !lastMessage.tool_calls?.length) { + /** + * Check if it's an AI message with tool calls + */ + if ( + !AIMessage.isInstance(lastMessage) || + !lastMessage.tool_calls?.length + ) { return; } + if (!config.toolConfigs) { + throw new Error("HumanInTheLoopMiddleware: toolConfigs is required"); + } + + const toolConfigs = config.toolConfigs; + // Separate tool calls that need interrupts from those that don't const interruptToolCalls: ToolCall[] = []; const autoApprovedToolCalls: ToolCall[] = []; @@ -205,7 +218,7 @@ export function humanInTheLoopMiddleware( args: toolCall.args, }; - const toolConfig = config.toolConfigs[normalizedToolCall.name]; + const toolConfig = toolConfigs[normalizedToolCall.name]; if (toolConfig?.requireApproval) { interruptToolCalls.push(normalizedToolCall); @@ -224,7 +237,7 @@ export function humanInTheLoopMiddleware( // Process tool calls that need interrupts const requests: ToolApprovalRequest[] = interruptToolCalls.map( (toolCall) => { - const toolConfig = config.toolConfigs[toolCall.name]; + const toolConfig = toolConfigs[toolCall.name]; const description = toolConfig?.description || `${config.messagePrefix}\n\nTool: ${ @@ -271,7 +284,7 @@ export function humanInTheLoopMiddleware( case "ignore": // Skip to end - terminate the agent - return controls.terminate(); + return runtime.terminate(state); case "response": { // Return manual tool response and jump back to model diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/AfterModalNode.ts b/libs/langchain/src/agents/middlewareAgent/nodes/AfterModalNode.ts index ba715622ba6f..d42f5a4f24e4 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/AfterModalNode.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/AfterModalNode.ts @@ -1,12 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { LangGraphRunnableConfig } from "@langchain/langgraph"; import { MiddlewareNode } from "./middleware.js"; -import type { - Controls, - AgentMiddleware, - MiddlewareResult, - Runtime, -} from "../types.js"; +import type { AgentMiddleware, MiddlewareResult, Runtime } from "../types.js"; /** * Node for executing a single middleware's afterModel hook. @@ -28,13 +23,10 @@ export class AfterModelNode< this.name = `AfterModelNode_${middleware.name}`; } - runHook( - state: TStateSchema, - runtime: Runtime, - controls: Controls - ) { - return this.middleware.afterModel!(state, runtime, controls) as Promise< - MiddlewareResult - >; + runHook(state: TStateSchema, runtime: Runtime) { + return this.middleware.afterModel!( + state, + runtime as Runtime + ) as Promise>; } } diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts b/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts index cf127d910ba3..d7631eb62aa3 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts @@ -44,7 +44,7 @@ import { transformResponseFormat, ToolStrategyError, } from "../../responses.js"; -import { parseToolCalls, parseToolResults } from "./utils.js"; +import { parseToolCalls } from "./utils.js"; type ResponseHandlerResult = | { @@ -543,7 +543,6 @@ export class AgentNode< // Create runtime const runtime: Runtime = { toolCalls: parseToolCalls(state.messages), - toolResults: parseToolResults(state.messages), context, writer: config.writer, interrupt: config.interrupt, diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/BeforeModalNode.ts b/libs/langchain/src/agents/middlewareAgent/nodes/BeforeModalNode.ts index 8f647c6398c9..ad876a77f941 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/BeforeModalNode.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/BeforeModalNode.ts @@ -1,12 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { RunnableConfig } from "@langchain/core/runnables"; import { MiddlewareNode } from "./middleware.js"; -import type { - Controls, - AgentMiddleware, - MiddlewareResult, - Runtime, -} from "../types.js"; +import type { AgentMiddleware, MiddlewareResult, Runtime } from "../types.js"; /** * Node for executing a single middleware's beforeModel hook. @@ -27,13 +22,10 @@ export class BeforeModelNode< }); } - runHook( - state: TStateSchema, - runtime: Runtime, - controls: Controls - ) { - return this.middleware.beforeModel!(state, runtime, controls) as Promise< - MiddlewareResult - >; + runHook(state: TStateSchema, runtime: Runtime) { + return this.middleware.beforeModel!( + state, + runtime as Runtime + ) as Promise>; } } diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts b/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts index 60a6c9db199a..672b6a83f83e 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts @@ -10,11 +10,7 @@ import type { AgentMiddleware, MiddlewareResult, } from "../types.js"; -import { - derivePrivateState, - parseToolCalls, - parseToolResults, -} from "./utils.js"; +import { derivePrivateState, parseToolCalls } from "./utils.js"; type NodeOutput> = | TStateSchema @@ -71,7 +67,6 @@ export abstract class MiddlewareNode< */ const runtime: Runtime = { toolCalls: parseToolCalls(state.messages), - toolResults: parseToolResults(state.messages), context: filteredContext, writer: config?.writer, interrupt: config?.interrupt, diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/utils.ts b/libs/langchain/src/agents/middlewareAgent/nodes/utils.ts index 5b5525048d9b..80c04d09e874 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/utils.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/utils.ts @@ -1,10 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { type BaseMessage, - type ToolMessage, - type AIMessage, - isToolMessage, - isAIMessage, + ToolMessage, + AIMessage, } from "@langchain/core/messages"; import { z, type ZodIssue, type ZodTypeAny } from "zod/v3"; @@ -121,19 +119,31 @@ export function derivePrivateState( } /** - * Parse out all tool calls from the messages + * Parse out all tool calls from the messages and populate the results * @param messages - The messages to parse * @returns The tool calls */ export function parseToolCalls(messages: BaseMessage[]): ToolCall[] { - return ( + const calls = messages .filter( - (message) => isAIMessage(message) && (message as AIMessage).tool_calls + (message) => + AIMessage.isInstance(message) && (message as AIMessage).tool_calls ) .map((message) => (message as AIMessage).tool_calls as ToolCall[]) - .flat() || [] - ); + .flat() || []; + + const results = parseToolResults(messages); + return calls.map((call) => { + const callResult = results.find((result) => result.id === call.id); + if (callResult) { + return { + ...call, + result: callResult.result, + }; + } + return call; + }); } /** @@ -141,9 +151,9 @@ export function parseToolCalls(messages: BaseMessage[]): ToolCall[] { * @param messages - The messages to parse * @returns The tool results */ -export function parseToolResults(messages: BaseMessage[]): ToolResult[] { +function parseToolResults(messages: BaseMessage[]): ToolResult[] { return messages - .filter((message) => isToolMessage(message)) + .filter((message) => ToolMessage.isInstance(message)) .map((message) => ({ id: (message as ToolMessage).tool_call_id, result: (message as ToolMessage).content, diff --git a/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts b/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts index 9ba7c14dafff..c3ce78871dd7 100644 --- a/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts +++ b/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts @@ -60,42 +60,257 @@ describe("middleware types", () => { }>(); }); - it("a middleware can define a context schema which can be a required property within the runnable config", async () => { - const middleware = createMiddleware({ - name: "Middleware", - contextSchema: z.object({ - customOptionalContextProp: z.string().default("default value"), - customRequiredContextProp: z.string(), - }), + describe("context schema", () => { + it("a middleware can define a context schema which can be a required property within the runnable config", async () => { + const middleware = createMiddleware({ + name: "Middleware", + contextSchema: z.object({ + customOptionalContextProp: z.string().default("default value"), + customRequiredContextProp: z.string(), + }), + }); + + const agent = createAgent({ + contextSchema: z.object({ + customAgentOptionalContextProp: z.string().default("default value"), + customAgentRequiredContextProp: z.string(), + }), + middleware: [middleware] as const, + tools: [], + model: "gpt-4", + }); + + const state = { + messages: [new HumanMessage("Hello, world!")], + }; + + await agent.invoke(state, { + context: { + customAgentRequiredContextProp: "123", + customRequiredContextProp: "456", + }, + }); + + await agent.invoke(state, { + context: { + customAgentRequiredContextProp: "123", + // @ts-expect-error defined as string + customRequiredContextProp: 456, + }, + }); }); - const agent = createAgent({ - contextSchema: z.object({ - customAgentOptionalContextProp: z.string().default("default value"), - customAgentRequiredContextProp: z.string(), - }), - middleware: [middleware] as const, - tools: [], - model: "gpt-4", + it("is required to pass in a context if a middleware has context schema that is not optional", async () => { + const middleware = createMiddleware({ + name: "Middleware", + contextSchema: z.object({ + customRequiredContextProp: z.string(), + }), + }); + + const agent = createAgent({ + middleware: [middleware] as const, + tools: [], + model: "gpt-4", + }); + + await agent.invoke( + { + messages: [new HumanMessage("Hello, world!")], + }, + // @ts-expect-error Property 'context' is missing + { + configurable: { + thread_id: "test-123", + }, + } + ); }); - const state = { - messages: [new HumanMessage("Hello, world!")], - }; + it("doesn't require users to pass in a context if a middleware has optional context schema", async () => { + const middleware = createMiddleware({ + name: "Middleware", + contextSchema: z + .object({ + customOptionalContextProp: z.string().default("default value"), + }) + .optional(), + }); + + const agent = createAgent({ + middleware: [middleware] as const, + tools: [], + model: "gpt-4", + }); + + await agent.invoke( + { + messages: [new HumanMessage("Hello, world!")], + }, + { + configurable: { + thread_id: "test-123", + }, + } + ); + }); + + it("doesn't require users to pass in a context if a middleware has context schema with defaults", async () => { + const middleware = createMiddleware({ + name: "Middleware", + contextSchema: z + .object({ + customDefaultContextProp: z.string().default("default value"), + customOptionalContextProp: z.string().optional(), + customRequiredContextProp: z.string(), + }) + .default({ + customRequiredContextProp: "default value", + }), + beforeModel: async (_state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf<{ + customDefaultContextProp: string; + customOptionalContextProp?: string; + customRequiredContextProp: string; + }>(); + }, + afterModel: async (_state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf<{ + customDefaultContextProp: string; + customOptionalContextProp?: string; + customRequiredContextProp: string; + }>(); + }, + prepareModelRequest: async (_request, _state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf<{ + customDefaultContextProp: string; + customOptionalContextProp?: string; + customRequiredContextProp: string; + }>(); + }, + }); + + const agent = createAgent({ + middleware: [middleware] as const, + tools: [], + model: "gpt-4", + }); - await agent.invoke(state, { - context: { - customAgentRequiredContextProp: "123", - customRequiredContextProp: "456", - }, + await agent.invoke( + { + messages: [new HumanMessage("Hello, world!")], + }, + { + configurable: { + thread_id: "test-123", + }, + } + ); }); - await agent.invoke(state, { - context: { - customAgentRequiredContextProp: "123", - // @ts-expect-error defined as string - customRequiredContextProp: 456, - }, + it("doesn't require users to pass in a context if a middleware has context schema as optional", async () => { + const middleware = createMiddleware({ + name: "Middleware", + contextSchema: z + .object({ + customOptionalContextProp: z.string().default("default value"), + }) + .optional(), + beforeModel: async (_state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf< + Partial< + | { + customOptionalContextProp: string; + } + | undefined + > + >(); + }, + afterModel: async (_state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf< + Partial< + | { + customOptionalContextProp: string; + } + | undefined + > + >(); + }, + prepareModelRequest: async (_request, _state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf< + Partial< + | { + customOptionalContextProp: string; + } + | undefined + > + >(); + }, + }); + + const agent = createAgent({ + middleware: [middleware] as const, + tools: [], + model: "gpt-4", + }); + + await agent.invoke( + { + messages: [new HumanMessage("Hello, world!")], + }, + { + configurable: { + thread_id: "test-123", + }, + } + ); + }); + + it("doesn't require users to pass in a context if all middleware context properties are optional", async () => { + const middleware = createMiddleware({ + name: "Middleware", + contextSchema: z.object({ + customDefaultContextProp: z.string().optional(), + }), + beforeModel: async (_state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf< + Partial<{ + customDefaultContextProp: string; + }> + >(); + }, + afterModel: async (_state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf< + Partial<{ + customDefaultContextProp: string; + }> + >(); + }, + prepareModelRequest: async (_request, _state, runtime) => { + expectTypeOf(runtime.context).toEqualTypeOf< + Partial<{ + customDefaultContextProp: string; + }> + >(); + }, + }); + + const agent = createAgent({ + middleware: [middleware] as const, + tools: [], + model: "gpt-4", + }); + + await agent.invoke( + { + messages: [new HumanMessage("Hello, world!")], + }, + { + configurable: { + thread_id: "test-123", + }, + } + ); }); }); }); diff --git a/libs/langchain/src/agents/middlewareAgent/tests/middleware.test.ts b/libs/langchain/src/agents/middlewareAgent/tests/middleware.test.ts index 56fd4ed5b0db..de25d93afbe7 100644 --- a/libs/langchain/src/agents/middlewareAgent/tests/middleware.test.ts +++ b/libs/langchain/src/agents/middlewareAgent/tests/middleware.test.ts @@ -170,8 +170,8 @@ describe("middleware", () => { }); const middleware = createMiddleware({ name: "middleware", - beforeModel: (_, __, controls) => { - return controls.terminate(new Error("middleware terminated")); + beforeModel: (_, runtime) => { + return runtime.terminate(new Error("middleware terminated")); }, }); const toolFn = vi.fn(); @@ -204,8 +204,8 @@ describe("middleware", () => { const middleware = createMiddleware({ name: "middleware", beforeModel, - afterModel: (_, __, controls) => { - return controls.terminate( + afterModel: (_, runtime) => { + return runtime.terminate( new Error("middleware terminated in afterModel") ); }, diff --git a/libs/langchain/src/agents/middlewareAgent/types.ts b/libs/langchain/src/agents/middlewareAgent/types.ts index 7b2c8c0aa6b6..e91b70074b77 100644 --- a/libs/langchain/src/agents/middlewareAgent/types.ts +++ b/libs/langchain/src/agents/middlewareAgent/types.ts @@ -59,6 +59,14 @@ export interface ToolCall { * The arguments that were passed to the tool. */ args: Record; + /** + * The result of the tool call. + */ + result?: unknown; + /** + * An optional error message if the tool call failed. + */ + error?: string; } /** @@ -117,23 +125,38 @@ export interface ModelRequest { tools: (ClientTool | ServerTool)[]; } +/** + * Type helper to check if TContext is an optional Zod schema + */ +type IsOptionalZodObject = T extends z.ZodOptional> + ? true + : false; + +type IsDefaultZodObject = T extends z.ZodDefault> + ? true + : false; + +type WithMaybeContext = undefined extends TContext + ? { readonly context?: TContext } + : IsOptionalZodObject extends true + ? { readonly context?: TContext } + : IsDefaultZodObject extends true + ? { readonly context?: TContext } + : { readonly context: TContext }; + /** * Runtime information available to middleware (readonly). */ -export interface Runtime - extends Partial< - Omit, "context" | "configurable"> - > { +export type Runtime = Partial< + Omit, "context" | "configurable"> +> & { readonly toolCalls: ToolCall[]; - readonly toolResults: ToolResult[]; - readonly context: TContext; - /** * Terminates the agent with an update to the state or throws an error. * @param result - The result to terminate the agent with. */ - terminate(result?: Partial | Error): ControlAction; -} + terminate(result: Partial | Error): ControlAction; +} & WithMaybeContext; /** * Control action type returned by control methods. @@ -248,7 +271,9 @@ export type InferMiddlewareContext> = export type InferMiddlewareContextInput< T extends AgentMiddleware > = T extends AgentMiddleware - ? C extends z.ZodObject + ? C extends z.ZodOptional + ? z.input | undefined + : C extends z.ZodObject ? z.input : {} : {}; @@ -268,6 +293,21 @@ export type InferMiddlewareContexts< : {} : {}; +/** + * Helper to merge two context types, preserving undefined unions + */ +type MergeContextTypes = [A] extends [undefined] + ? [B] extends [undefined] + ? undefined + : B | undefined + : [B] extends [undefined] + ? A | undefined + : [A] extends [B] + ? A + : [B] extends [A] + ? B + : A & B; + /** * Helper type to infer merged input context from an array of middleware (with optional defaults) */ @@ -278,7 +318,10 @@ export type InferMiddlewareContextInputs< : T extends readonly [infer First, ...infer Rest] ? First extends AgentMiddleware ? Rest extends readonly AgentMiddleware[] - ? InferMiddlewareContextInput & InferMiddlewareContextInputs + ? MergeContextTypes< + InferMiddlewareContextInput, + InferMiddlewareContextInputs + > : InferMiddlewareContextInput : {} : {}; @@ -288,7 +331,11 @@ export type InferMiddlewareContextInputs< */ export interface AgentMiddleware< TSchema extends z.ZodObject | undefined = undefined, - TContextSchema extends z.ZodObject | undefined = undefined, + TContextSchema extends + | z.ZodObject + | z.ZodOptional> + | z.ZodDefault> + | undefined = undefined, TFullContext = any > { stateSchema?: TSchema; @@ -569,14 +616,26 @@ export type CreateAgentParams< version?: "v1" | "v2"; }; +/** + * Helper type to check if a type is optional (includes undefined) + */ +type IsOptionalType = undefined extends T ? true : false; + +/** + * Extract non-undefined part of a union that includes undefined + */ +type ExtractNonUndefined = T extends undefined ? never : T; + /** * Helper type to check if all properties of a type are optional */ -export type IsAllOptional = T extends Record - ? {} extends T +export type IsAllOptional = IsOptionalType extends true + ? true + : ExtractNonUndefined extends Record + ? {} extends ExtractNonUndefined ? true : false - : true; + : IsOptionalType; /** * Helper type to extract input type from context schema (with optional defaults) @@ -589,15 +648,35 @@ export type InferContextInput< ? ToAnnotationRoot["State"] : {}; +/** + * Helper to check if ContextSchema is the default (AnyAnnotationRoot) + */ +type IsDefaultContext = [T] extends [AnyAnnotationRoot] + ? any extends T + ? true + : false + : false; + /** * Helper type to get the required config type based on context schema */ export type InferAgentConfig< ContextSchema extends AnyAnnotationRoot | InteropZodObject, TMiddleware extends readonly AgentMiddleware[] -> = IsAllOptional< - InferContextInput & InferMiddlewareContextInputs -> extends true +> = IsDefaultContext extends true + ? IsAllOptional> extends true + ? + | LangGraphRunnableConfig<{ + context?: InferMiddlewareContextInputs; + }> + | undefined + : LangGraphRunnableConfig<{ + context: InferMiddlewareContextInputs; + }> + : IsAllOptional< + InferContextInput & + InferMiddlewareContextInputs + > extends true ? | LangGraphRunnableConfig<{ context?: InferContextInput & @@ -621,37 +700,41 @@ export type InternalAgentState< ? Record : { structuredResponse: StructuredResponseType }); +/** + * Pregel options that are propagated to the agent + */ +type CreateAgentPregelOptions = + | "configurable" + | "durability" + | "store" + | "cache" + | "signal" + | "recursionLimit" + | "maxConcurrency" + | "timeout"; + export type InvokeConfiguration> = - Partial< - Pick< - PregelOptions, - | "configurable" - | "durability" - | "store" - | "cache" - | "signal" - | "recursionLimit" - | "maxConcurrency" - | "timeout" - > - > & { - context: ContextSchema; - }; + IsAllOptional extends true + ? Partial, CreateAgentPregelOptions>> & { + context?: Partial; + } + : Partial, CreateAgentPregelOptions>> & + WithMaybeContext; export type StreamConfiguration> = - Partial< - Pick< - PregelOptions, - | "configurable" - | "durability" - | "store" - | "cache" - | "signal" - | "streamMode" - | "recursionLimit" - | "maxConcurrency" - | "timeout" - > - > & { - context: ContextSchema; - }; + IsAllOptional extends true + ? Partial< + Pick< + PregelOptions, + CreateAgentPregelOptions | "streamMode" + > + > & { + context?: Partial; + } + : Partial< + Pick< + PregelOptions, + CreateAgentPregelOptions | "streamMode" + > + > & + WithMaybeContext; From 51dc3ccc2d161ac4b80b6b8537cb3b7679ac58e2 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Mon, 15 Sep 2025 15:23:00 -0700 Subject: [PATCH 03/16] support HITL with multiple tool calls --- libs/langchain/src/agents/interrupt.ts | 14 + .../agents/middlewareAgent/middleware/hitl.ts | 168 ++++++++-- .../middlewareAgent/middleware/index.ts | 2 +- .../middleware/tests/hitl.test.ts | 317 +++++++++++++----- .../src/agents/middlewareAgent/types.ts | 2 + libs/langchain/src/agents/tests/utils.ts | 2 +- libs/langchain/src/index.ts | 1 + 7 files changed, 378 insertions(+), 128 deletions(-) diff --git a/libs/langchain/src/agents/interrupt.ts b/libs/langchain/src/agents/interrupt.ts index d4ded7661301..28aba26d2e37 100644 --- a/libs/langchain/src/agents/interrupt.ts +++ b/libs/langchain/src/agents/interrupt.ts @@ -1,5 +1,19 @@ export { interrupt } from "@langchain/langgraph"; +/** + * Represents information about an interrupt. + */ +export interface Interrupt { + /** + * The ID of the interrupt. + */ + id: string; + /** + * The requests for human input. + */ + value: TValue; +} + /** * Configuration interface that defines what actions are allowed for a human interrupt. * This controls the available interaction options when the graph is paused for human input. diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index 7dce7f9050e1..5e7a9b59d5f7 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -6,12 +6,11 @@ import { interrupt } from "@langchain/langgraph"; import { createMiddleware } from "../middleware.js"; import type { ToolCall } from "../types.js"; -import { HumanResponse } from "../../interrupt.js"; /** * Interrupt request for tool approval */ -interface ToolApprovalRequest { +export interface ToolApprovalRequest { action: string; args: Record; toolCallId: string; @@ -206,12 +205,16 @@ export function humanInTheLoopMiddleware( const toolConfigs = config.toolConfigs; - // Separate tool calls that need interrupts from those that don't + /** + * Separate tool calls that need interrupts from those that don't + */ const interruptToolCalls: ToolCall[] = []; const autoApprovedToolCalls: ToolCall[] = []; for (const toolCall of lastMessage.tool_calls) { - // Ensure tool call has an ID + /** + * Ensure tool call has an ID + */ const normalizedToolCall: ToolCall = { id: toolCall.id || uuid(), name: toolCall.name, @@ -227,14 +230,16 @@ export function humanInTheLoopMiddleware( } } - // If no interrupts needed, return early + /** + * If no interrupts needed, return early + */ if (!interruptToolCalls.length) { return; } - const approvedToolCalls = [...autoApprovedToolCalls]; - - // Process tool calls that need interrupts + /** + * Process tool calls that need interrupts + */ const requests: ToolApprovalRequest[] = interruptToolCalls.map( (toolCall) => { const toolConfig = toolConfigs[toolCall.name]; @@ -253,13 +258,48 @@ export function humanInTheLoopMiddleware( } ); - // Interrupt and wait for human responses - const responses = (await interrupt(requests)) as HumanResponse[]; + /** + * Interrupt and wait for human responses + */ + const responses = (await interrupt( + requests + )) as HumanInTheLoopMiddlewareHumanResponse[]; + + /** + * double check that all interrupts have a response + */ + const missingResponses: string[] = []; + for (const toolCall of interruptToolCalls) { + if (!responses?.find((response) => response.id === toolCall.id)) { + missingResponses.push(toolCall.name); + } + } + if (missingResponses.length > 0) { + throw new Error( + `Missing responses for tool calls: ${missingResponses.join(", ")}` + ); + } + + const approvedToolCalls = [...autoApprovedToolCalls]; + const toolMessages: ToolMessage[] = []; + + /** + * Process responses + */ + for (const response of responses) { + const toolCall = interruptToolCalls.find( + (toolCall) => toolCall.id === response.id + ); - // Process responses - for (let i = 0; i < responses.length; i++) { - const response = responses[i]; - const toolCall = interruptToolCalls[i]; + if (!toolCall) { + throw new Error( + `Tool call "${ + response.id + }" not interrupted, interruptToolCalls: ${interruptToolCalls + .map((toolCall) => toolCall.id) + .join(", ")}` + ); + } switch (response.type) { case "accept": @@ -267,53 +307,111 @@ export function humanInTheLoopMiddleware( break; case "edit": - // For edit, args is an ActionRequest with updated args + /** + * For edit, args is an ActionRequest with updated args + */ if ( - response.args && + "args" in response && typeof response.args === "object" && - "args" in response.args + response.args !== null ) { approvedToolCalls.push({ ...toolCall, - args: ( - response.args as { action: string; args: Record } - ).args, + args: response.args, }); } break; case "ignore": - // Skip to end - terminate the agent - return runtime.terminate(state); + /** + * Skip to end - terminate the agent + */ + return runtime.terminate({ + ...state, + messages: [ + ...state.messages, + /** + * inject an artificial tool message to indicate that the tool was ignored + */ + new ToolMessage({ + content: `User ignored the tool call for ${toolCall.name} with id ${toolCall.id}`, + tool_call_id: toolCall.id, + }), + ], + }); case "response": { - // Return manual tool response and jump back to model - // For response, args is a string - const toolMessage = new ToolMessage({ - content: typeof response.args === "string" ? response.args : "", - tool_call_id: toolCall.id, - }); - return { - messages: [...state.messages, toolMessage], - jump_to: "model", - }; + /** + * Return manual tool response and jump back to model + * For response, args is a string + */ + toolMessages.push( + new ToolMessage({ + content: typeof response.args === "string" ? response.args : "", + tool_call_id: toolCall.id, + }) + ); + break; } default: throw new Error(`Unknown response type: ${(response as any).type}`); } } - // Update the last message with approved tool calls + /** + * Update the last message with approved tool calls + */ const updatedMessage = new AIMessage({ content: lastMessage.content, tool_calls: approvedToolCalls, id: lastMessage.id, }); - // Replace the last message with the updated one + /** + * Replace the last message with the updated one + */ return { - messages: [...state.messages.slice(0, -1), updatedMessage], + messages: [ + ...state.messages.slice(0, -1), + ...toolMessages, + updatedMessage, + ], }; }, }); } + +export interface HumanInTheLoopMiddlewareResponse { + id: string; +} + +export interface HumanInTheLoopMiddlewareAcceptResponse + extends HumanInTheLoopMiddlewareResponse { + type: "accept"; +} + +export interface HumanInTheLoopMiddlewareIgnoreResponse + extends HumanInTheLoopMiddlewareResponse { + type: "ignore"; +} + +export interface HumanInTheLoopMiddlewareResponseResponse + extends HumanInTheLoopMiddlewareResponse { + type: "response"; + args: unknown; +} + +export interface HumanInTheLoopMiddlewareEditResponse + extends HumanInTheLoopMiddlewareResponse { + type: "edit"; + args: unknown; +} + +/** + * The response provided by a human to an interrupt. + */ +export type HumanInTheLoopMiddlewareHumanResponse = + | HumanInTheLoopMiddlewareAcceptResponse + | HumanInTheLoopMiddlewareIgnoreResponse + | HumanInTheLoopMiddlewareResponseResponse + | HumanInTheLoopMiddlewareEditResponse; diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/index.ts b/libs/langchain/src/agents/middlewareAgent/middleware/index.ts index 415af2beb571..09a994704db0 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/index.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/index.ts @@ -2,6 +2,6 @@ export { summarizationMiddleware, countTokensApproximately, } from "./summarization.js"; -export { humanInTheLoopMiddleware } from "./hitl.js"; +export { humanInTheLoopMiddleware, type ToolApprovalRequest } from "./hitl.js"; export { anthropicPromptCachingMiddleware } from "./promptCaching.js"; export { dynamicSystemPromptMiddleware } from "./dynamicSystemPrompt.js"; diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts index 38a7f1d4aaed..ce44ed162a1d 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts @@ -1,69 +1,69 @@ import { z } from "zod/v3"; -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { tool } from "@langchain/core/tools"; import { HumanMessage, ToolMessage } from "@langchain/core/messages"; import { Command } from "@langchain/langgraph"; import { MemorySaver } from "@langchain/langgraph-checkpoint"; import { createAgent } from "../../index.js"; -import { humanInTheLoopMiddleware } from "../hitl.js"; +import { + humanInTheLoopMiddleware, + type ToolApprovalRequest, + type HumanInTheLoopMiddlewareHumanResponse, +} from "../hitl.js"; import { FakeToolCallingModel, _AnyIdHumanMessage, _AnyIdToolMessage, _AnyIdAIMessage, } from "../../../tests/utils.js"; +import type { Interrupt } from "../../../interrupt.js"; + +const writeFileFn = vi.fn( + async ({ filename, content }: { filename: string; content: string }) => { + return `Successfully wrote ${content.length} characters to ${filename}`; + } +); + +const calculatorFn = vi.fn( + async ({ a, b, operation }: { a: number; b: number; operation: string }) => { + switch (operation) { + case "add": + return `${a} + ${b} = ${a + b}`; + case "multiply": + return `${a} * ${b} = ${a * b}`; + default: + return "Unknown operation"; + } + } +); + +// Define tools +const calculateTool = tool(calculatorFn, { + name: "calculator", + description: "Perform basic math operations", + schema: z.object({ + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + operation: z.enum(["add", "multiply"]).describe("Math operation"), + }), +}); -describe("humanInTheLoopMiddleware", () => { - it("should auto-approve safe tools and interrupt for tools requiring approval", async () => { - // Mock tool functions - const calculatorFn = vi.fn( - async ({ - a, - b, - operation, - }: { - a: number; - b: number; - operation: string; - }) => { - switch (operation) { - case "add": - return `${a} + ${b} = ${a + b}`; - case "multiply": - return `${a} * ${b} = ${a * b}`; - default: - return "Unknown operation"; - } - } - ); - - const writeFileFn = vi.fn( - async ({ filename, content }: { filename: string; content: string }) => { - return `Successfully wrote ${content.length} characters to ${filename}`; - } - ); - - // Define tools - const calculateTool = tool(calculatorFn, { - name: "calculator", - description: "Perform basic math operations", - schema: z.object({ - a: z.number().describe("First number"), - b: z.number().describe("Second number"), - operation: z.enum(["add", "multiply"]).describe("Math operation"), - }), - }); +const writeFileTool = tool(writeFileFn, { + name: "write_file", + description: "Write content to a file", + schema: z.object({ + filename: z.string().describe("Name of the file"), + content: z.string().describe("Content to write"), + }), +}); - const writeFileTool = tool(writeFileFn, { - name: "write_file", - description: "Write content to a file", - schema: z.object({ - filename: z.string().describe("Name of the file"), - content: z.string().describe("Content to write"), - }), - }); +describe("humanInTheLoopMiddleware", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("should auto-approve safe tools and interrupt for tools requiring approval", async () => { // Configure HITL middleware const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { @@ -177,7 +177,7 @@ describe("humanInTheLoopMiddleware", () => { llm.index = 1; const resumedResult = await agent.invoke( new Command({ - resume: [{ type: "accept" }], + resume: [{ id: "call_2", type: "accept" }], }), config ); @@ -200,21 +200,6 @@ describe("humanInTheLoopMiddleware", () => { }); it("should handle edit response type", async () => { - const writeFileFn = vi.fn( - async ({ filename, content }: { filename: string; content: string }) => { - return `Successfully wrote ${content.length} characters to ${filename}`; - } - ); - - const writeFileTool = tool(writeFileFn, { - name: "write_file", - description: "Write content to a file", - schema: z.object({ - filename: z.string(), - content: z.string(), - }), - }); - const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { @@ -262,10 +247,11 @@ describe("humanInTheLoopMiddleware", () => { new Command({ resume: [ { + id: "call_1", type: "edit", args: { - action: "write_file", - args: { filename: "safe.txt", content: "Safe content" }, + filename: "safe.txt", + content: "Safe content", }, }, ], @@ -288,17 +274,6 @@ describe("humanInTheLoopMiddleware", () => { * is failing in dependency range tests */ it.skip("should handle ignore response type", async () => { - const writeFileFn = vi.fn(); - - const writeFileTool = tool(writeFileFn, { - name: "write_file", - description: "Write content to a file", - schema: z.object({ - filename: z.string(), - content: z.string(), - }), - }); - const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { @@ -344,7 +319,7 @@ describe("humanInTheLoopMiddleware", () => { // Resume with ignore const resumedResult = await agent.invoke( new Command({ - resume: [{ type: "ignore" }], + resume: [{ id: "call_1", type: "ignore" }], }), config ); @@ -359,17 +334,6 @@ describe("humanInTheLoopMiddleware", () => { }); it("should handle manual response type", async () => { - const writeFileFn = vi.fn(); - - const writeFileTool = tool(writeFileFn, { - name: "write_file", - description: "Write content to a file", - schema: z.object({ - filename: z.string(), - content: z.string(), - }), - }); - const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { @@ -417,6 +381,7 @@ describe("humanInTheLoopMiddleware", () => { new Command({ resume: [ { + id: "call_1", type: "response", args: "File operation not allowed in demo mode", }, @@ -437,4 +402,174 @@ describe("humanInTheLoopMiddleware", () => { "call_1" ); }); + + it("should allow to interrupt multiple tools at the same time", async () => { + const hitlMiddleware = humanInTheLoopMiddleware({ + toolConfigs: { + write_file: { + requireApproval: true, + description: "⚠️ File write operation requires approval", + }, + calculator: { + requireApproval: true, + }, + }, + }); + + // Create agent with mocked LLM + const llm = new FakeToolCallingModel({ + toolCalls: [ + // First call: calculator tool (auto-approved) + [ + { + id: "call_1", + name: "calculator", + args: { a: 42, b: 17, operation: "multiply" }, + }, + { + id: "call_2", + name: "write_file", + args: { filename: "greeting.txt", content: "Hello World" }, + }, + ], + ], + }); + + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + checkpointer, + prompt: + "You are a helpful assistant. Use the tools provided to help the user.", + tools: [calculateTool, writeFileTool], + middleware: [hitlMiddleware] as const, + }); + + const config = { + configurable: { + thread_id: "test-123", + }, + }; + + // Initial invocation + const initialResult = await agent.invoke( + { + messages: [ + new HumanMessage("Calculate 42 * 17 and write to greeting.txt"), + ], + }, + config + ); + + // not called due to interrupt + expect(calculatorFn).toHaveBeenCalledTimes(0); + expect(writeFileFn).toHaveBeenCalledTimes(0); + + const interruptRequest = initialResult.__interrupt__?.[0] as Interrupt< + ToolApprovalRequest[] + >; + const resume: HumanInTheLoopMiddlewareHumanResponse[] = + interruptRequest.value.map((request) => { + if (request.action === "calculator") { + return { id: request.toolCallId, type: "accept" }; + } else if (request.action === "write_file") { + return { + id: request.toolCallId, + type: "edit", + args: { filename: "safe.txt", content: "Safe content" }, + }; + } + + throw new Error(`Unknown action: ${request.action}`); + }); + + // Resume with approval + await agent.invoke(new Command({ resume }), config); + + // Verify tool was called + expect(calculatorFn).toHaveBeenCalledTimes(1); + expect(writeFileFn).toHaveBeenCalledTimes(1); + expect(writeFileFn).toHaveBeenCalledWith( + { + filename: "safe.txt", + content: "Safe content", + }, + expect.anything() + ); + expect(calculatorFn).toHaveBeenCalledWith( + { + a: 42, + b: 17, + operation: "multiply", + }, + expect.anything() + ); + }); + + it("should throw if not all tool calls have a response", async () => { + const hitlMiddleware = humanInTheLoopMiddleware({ + toolConfigs: { + write_file: { + requireApproval: true, + description: "⚠️ File write operation requires approval", + }, + calculator: { + requireApproval: true, + }, + }, + }); + + // Create agent with mocked LLM + const llm = new FakeToolCallingModel({ + toolCalls: [ + // First call: calculator tool (auto-approved) + [ + { + id: "call_1", + name: "calculator", + args: { a: 42, b: 17, operation: "multiply" }, + }, + { + id: "call_2", + name: "write_file", + args: { filename: "greeting.txt", content: "Hello World" }, + }, + ], + ], + }); + + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + checkpointer, + prompt: + "You are a helpful assistant. Use the tools provided to help the user.", + tools: [calculateTool, writeFileTool], + middleware: [hitlMiddleware] as const, + }); + + const config = { + configurable: { + thread_id: "test-123", + }, + }; + + // Initial invocation + await agent.invoke( + { + messages: [ + new HumanMessage("Calculate 42 * 17 and write to greeting.txt"), + ], + }, + config + ); + + // Resume with approval + await expect(() => + agent.invoke( + new Command({ resume: [{ id: "call_2", type: "ignore" }] }), + config + ) + ).rejects.toThrow("Missing responses for tool calls: calculator"); + }); }); diff --git a/libs/langchain/src/agents/middlewareAgent/types.ts b/libs/langchain/src/agents/middlewareAgent/types.ts index e91b70074b77..55be47ae35ae 100644 --- a/libs/langchain/src/agents/middlewareAgent/types.ts +++ b/libs/langchain/src/agents/middlewareAgent/types.ts @@ -34,6 +34,7 @@ import type { ResponseFormatUndefined, JsonSchemaFormat, } from "../responses.js"; +import type { Interrupt } from "../interrupt.js"; import type { ToolNode } from "../nodes/ToolNode.js"; import type { ClientTool, ServerTool, AgentRuntime } from "../types.js"; @@ -41,6 +42,7 @@ export type N = typeof START | "model_request" | "tools"; export interface BuiltInState { messages: BaseMessage[]; + __interrupt__?: Interrupt[]; } /** diff --git a/libs/langchain/src/agents/tests/utils.ts b/libs/langchain/src/agents/tests/utils.ts index 168e1f2af143..6d31d3986195 100644 --- a/libs/langchain/src/agents/tests/utils.ts +++ b/libs/langchain/src/agents/tests/utils.ts @@ -244,7 +244,7 @@ export class FakeToolCallingChatModel extends BaseChatModel { withStructuredOutput< RunOutput extends Record = Record - >(_: unknown) { + >(_: unknown): Runnable { if (!this.structuredResponse) { throw new Error("No structured response provided"); } diff --git a/libs/langchain/src/index.ts b/libs/langchain/src/index.ts index 0e82f40e0c15..24e714ba751a 100644 --- a/libs/langchain/src/index.ts +++ b/libs/langchain/src/index.ts @@ -42,6 +42,7 @@ export { type HumanInterruptConfig, type ActionRequest, type HumanResponse, + type Interrupt, } from "./agents/index.js"; /** From 629d618f3d37e0a653beb9d3996470fd5e73ecc0 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 16 Sep 2025 15:54:25 -0700 Subject: [PATCH 04/16] more edge cases and integration tests --- .../src/agents/middlewareAgent/ReactAgent.ts | 193 +++++++++-- .../src/agents/middlewareAgent/annotation.ts | 5 + .../agents/middlewareAgent/middleware/hitl.ts | 83 +++-- .../middleware/tests/hitl.int.test.ts | 325 ++++++++++++++++++ .../src/agents/middlewareAgent/types.ts | 9 + 5 files changed, 558 insertions(+), 57 deletions(-) create mode 100644 libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts diff --git a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts index 4f165ab84d73..3c40d670dc31 100644 --- a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts +++ b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts @@ -270,6 +270,7 @@ export class ReactAgent< if (afterModelNodes.length > 0 && lastAfterModelNode) { allNodeWorkflows.addEdge("model_request", lastAfterModelNode.name); } else { + // If no afterModel nodes, connect model_request directly to model paths const modelPaths = this.#getModelPaths(toolClasses.filter(isClientTool)); if (modelPaths.length === 1) { allNodeWorkflows.addEdge("model_request", modelPaths[0]); @@ -290,19 +291,20 @@ export class ReactAgent< ); } - // Connect first afterModel node (last to execute) to model paths + // Connect first afterModel node (last to execute) to model paths with jumpTo support if (afterModelNodes.length > 0) { const firstAfterModelNode = afterModelNodes[0].name; - const modelPaths = this.#getModelPaths(toolClasses.filter(isClientTool)); - if (modelPaths.length === 1) { - allNodeWorkflows.addEdge(firstAfterModelNode, modelPaths[0]); - } else { - allNodeWorkflows.addConditionalEdges( - firstAfterModelNode, - this.#createModelRouter(), - modelPaths - ); - } + const modelPaths = this.#getModelPaths( + toolClasses.filter(isClientTool), + true + ); + + // Use afterModel router which includes jumpTo logic + allNodeWorkflows.addConditionalEdges( + firstAfterModelNode, + this.#createAfterModelRouter(toolClasses.filter(isClientTool)), + modelPaths + ); } /** @@ -355,21 +357,54 @@ export class ReactAgent< /** * Get possible edge destinations from model node. * @param toolClasses names of tools to call + * @param includeModelRequest whether to include model_request as a valid path (for jumpTo routing) * @returns list of possible edge destinations */ #getModelPaths( - toolClasses: (ClientTool | ServerTool)[] - ): ("tools" | typeof END)[] { - const paths: ("tools" | typeof END)[] = []; + toolClasses: (ClientTool | ServerTool)[], + includeModelRequest: boolean = false + ): ("tools" | "model_request" | typeof END)[] { + const paths: ("tools" | "model_request" | typeof END)[] = []; if (toolClasses.length > 0) { paths.push("tools"); } + if (includeModelRequest) { + paths.push("model_request"); + } + paths.push(END); return paths; } + /** + * Create routing function for tools node conditional edges. + */ + #createToolsRouter(shouldReturnDirect: Set) { + /** + * ToDo: fix type + */ + return (state: any) => { + const messages = state.messages; + const lastMessage = messages[messages.length - 1]; + + // Check if we just executed a returnDirect tool + if ( + ToolMessage.isInstance(lastMessage) && + lastMessage.name && + shouldReturnDirect.has(lastMessage.name) + ) { + // If we have a response format, route to agent to generate structured response + // Otherwise, return directly + return this.options.responseFormat ? "model_request" : END; + } + + // For non-returnDirect tools, always route back to agent + return "model_request"; + }; + } + /** * Create routing function for model node conditional edges. */ @@ -377,10 +412,7 @@ export class ReactAgent< /** * determine if the agent should continue or not */ - /** - * ToDo: fix type - */ - return (state: any) => { + return (state: BuiltInState) => { const messages = state.messages; const lastMessage = messages.at(-1); @@ -392,6 +424,17 @@ export class ReactAgent< return END; } + // Check if all tool calls are for structured response extraction + const hasOnlyStructuredResponseCalls = lastMessage.tool_calls.every( + (toolCall) => toolCall.name.startsWith("extract-") + ); + + if (hasOnlyStructuredResponseCalls) { + // If all tool calls are for structured response extraction, go to END + // The AgentNode will handle these internally and return the structured response + return END; + } + /** * The tool node processes a single message. */ @@ -400,38 +443,116 @@ export class ReactAgent< } /** - * Route to tools node + * Route to tools node (filter out any structured response tool calls) */ - return lastMessage.tool_calls.map( + const regularToolCalls = lastMessage.tool_calls.filter( + (toolCall) => !toolCall.name.startsWith("extract-") + ); + + if (regularToolCalls.length === 0) { + return END; + } + + return regularToolCalls.map( (toolCall) => new Send("tools", { ...state, lg_tool_call: toolCall }) ); }; } /** - * Create routing function for tools node conditional edges. + * Create routing function for jumpTo functionality after afterModel hooks. + * + * This router checks if the `jumpTo` property is set in the state after afterModel middleware + * execution. If set, it routes to the specified target ("model_request" or "tools"). + * If not set, it falls back to the normal model routing logic for afterModel context. + * + * The jumpTo property is automatically cleared after use to prevent infinite loops. + * + * @param toolClasses - Available tool classes for validation + * @returns Router function that handles jumpTo logic and normal routing */ - #createToolsRouter(shouldReturnDirect: Set) { - /** - * ToDo: fix type - */ - return (state: any) => { + #createAfterModelRouter(toolClasses: (ClientTool | ServerTool)[]) { + const hasStructuredResponse = Boolean(this.options.responseFormat); + + return (state: BuiltInState) => { + // First, check if we just processed a structured response + // If so, ignore any existing jumpTo and go to END const messages = state.messages; - const lastMessage = messages[messages.length - 1]; + const lastMessage = messages.at(-1); + if ( + AIMessage.isInstance(lastMessage) && + (!lastMessage.tool_calls || lastMessage.tool_calls.length === 0) + ) { + return END; + } + + // Check if jumpTo is set in the state + if (state.jumpTo) { + const jumpTarget = state.jumpTo; + + // Validate that the jump target is available + if (jumpTarget === "tools" && toolClasses.length === 0) { + // If trying to jump to tools but no tools are available, go to END + return END; + } + + return jumpTarget; + } + + // check if there are pending tool calls + const toolMessages = messages.filter(ToolMessage.isInstance); + const lastAiMessage = messages.filter(AIMessage.isInstance).at(-1); + const pendingToolCalls = lastAiMessage?.tool_calls?.filter( + (call) => !toolMessages.some((m) => m.tool_call_id === call.id) + ); + if (pendingToolCalls && pendingToolCalls.length > 0) { + return pendingToolCalls.map( + (toolCall) => new Send("tools", { ...state, lg_tool_call: toolCall }) + ); + } + + // if we exhausted all tool calls, but still have no structured response tool calls, + // go back to model_request + const hasStructuredResponseCalls = lastAiMessage?.tool_calls?.some( + (toolCall) => toolCall.name.startsWith("extract-") + ); - // Check if we just executed a returnDirect tool if ( - ToolMessage.isInstance(lastMessage) && - lastMessage.name && - shouldReturnDirect.has(lastMessage.name) + pendingToolCalls && + pendingToolCalls.length === 0 && + !hasStructuredResponseCalls && + hasStructuredResponse ) { - // If we have a response format, route to agent to generate structured response - // Otherwise, return directly - return this.options.responseFormat ? "model_request" : END; + return "model_request"; } - // For non-returnDirect tools, always route back to agent - return "model_request"; + if ( + !AIMessage.isInstance(lastMessage) || + !lastMessage.tool_calls || + lastMessage.tool_calls.length === 0 + ) { + return END; + } + + // Check if all tool calls are for structured response extraction + const hasOnlyStructuredResponseCalls = lastMessage.tool_calls.every( + (toolCall) => toolCall.name.startsWith("extract-") + ); + + // Check if there are any regular tool calls (non-structured response) + const hasRegularToolCalls = lastMessage.tool_calls.some( + (toolCall) => !toolCall.name.startsWith("extract-") + ); + + if (hasOnlyStructuredResponseCalls || !hasRegularToolCalls) { + return END; + } + + /** + * For routing from afterModel nodes, always use simple string paths + * The Send API is handled at the model_request node level + */ + return "tools"; }; } diff --git a/libs/langchain/src/agents/middlewareAgent/annotation.ts b/libs/langchain/src/agents/middlewareAgent/annotation.ts index c7410c44c5de..a2441ab64269 100644 --- a/libs/langchain/src/agents/middlewareAgent/annotation.ts +++ b/libs/langchain/src/agents/middlewareAgent/annotation.ts @@ -18,6 +18,7 @@ type MergedAnnotationSpec< TMiddleware extends readonly AgentMiddleware[] > = { messages: BinaryOperatorAggregate; + jumpTo: LastValue<"model_request" | "tools" | undefined>; } & (T extends ResponseFormatUndefined ? {} : { structuredResponse: LastValue }) & @@ -35,6 +36,10 @@ export function createAgentAnnotationConditional< reducer: messagesStateReducer, default: () => [], }), + jumpTo: Annotation<"model_request" | "tools" | undefined>({ + reducer: (x: any, y: any) => y ?? x, + default: () => undefined, + }), }; // Add middleware state properties to the annotation diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index 5e7a9b59d5f7..c2067d1359e1 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -38,6 +38,37 @@ const contextSchema = z * capabilities before execution. It enables selective approval workflows where certain tools * require human intervention while others can execute automatically. * + * A invocation result that has been interrupted by the middleware will have a `__interrupt__` + * property that contains the interrupt request. You can loop over the request to determine + * which tools were interrupted, and how to handle them separately. + * + * ```ts + * import { type ToolApprovalRequest, type HumanInTheLoopMiddlewareHumanResponse } from "langchain/middleware"; + * import { type Interrupt } from "langchain"; + * + * const result = await agent.invoke(request); + * const interruptRequest = initialResult.__interrupt__?.[0] as Interrupt< + * ToolApprovalRequest[] + * >; + * const resume: HumanInTheLoopMiddlewareHumanResponse[] = + * interruptRequest.value.map((request) => { + * if (request.action === "calculator") { + * return { id: request.toolCallId, type: "accept" }; + * } else if (request.action === "write_file") { + * return { + * id: request.toolCallId, + * type: "edit", + * args: { filename: "safe.txt", content: "Safe content" }, + * }; + * } + * + * throw new Error(`Unknown action: ${request.action}`); + * }); + * + * // Resume with approval + * await agent.invoke(new Command({ resume }), config); + * ``` + * * ## Features * * - **Selective Tool Approval**: Configure which tools require human approval @@ -182,12 +213,11 @@ export function humanInTheLoopMiddleware( afterModel: async (state, runtime) => { const config = { ...contextSchema.parse(options), ...runtime.context }; const { messages } = state; - if (!messages.length) { return; } - const lastMessage = messages[messages.length - 1]; + const lastMessage = messages.at(-1); /** * Check if it's an AI message with tool calls @@ -196,7 +226,19 @@ export function humanInTheLoopMiddleware( !AIMessage.isInstance(lastMessage) || !lastMessage.tool_calls?.length ) { - return; + // Clear any existing jumpTo property since there are no tool calls to process + return { jumpTo: undefined }; + } + + // Filter out structured response extraction tool calls (they start with "extract-") + const regularToolCalls = lastMessage.tool_calls.filter( + (toolCall) => !toolCall.name.startsWith("extract-") + ); + + // If all tool calls are structured response extractions, return early + if (regularToolCalls.length === 0) { + // Clear any existing jumpTo property since we're not processing any real tools + return { jumpTo: undefined }; } if (!config.toolConfigs) { @@ -211,7 +253,7 @@ export function humanInTheLoopMiddleware( const interruptToolCalls: ToolCall[] = []; const autoApprovedToolCalls: ToolCall[] = []; - for (const toolCall of lastMessage.tool_calls) { + for (const toolCall of regularToolCalls) { /** * Ensure tool call has an ID */ @@ -234,7 +276,8 @@ export function humanInTheLoopMiddleware( * If no interrupts needed, return early */ if (!interruptToolCalls.length) { - return; + // Clear any existing jumpTo property since no interrupts are needed + return { jumpTo: undefined }; } /** @@ -286,11 +329,10 @@ export function humanInTheLoopMiddleware( /** * Process responses */ - for (const response of responses) { - const toolCall = interruptToolCalls.find( - (toolCall) => toolCall.id === response.id - ); - + for (const [i, response] of Object.entries(responses)) { + const toolCall = interruptToolCalls[ + i as keyof typeof interruptToolCalls + ] as ToolCall; if (!toolCall) { throw new Error( `Tool call "${ @@ -359,23 +401,22 @@ export function humanInTheLoopMiddleware( } /** - * Update the last message with approved tool calls + * Replace the tool calls with the approved tool calls */ - const updatedMessage = new AIMessage({ - content: lastMessage.content, - tool_calls: approvedToolCalls, - id: lastMessage.id, - }); + if (AIMessage.isInstance(lastMessage)) { + lastMessage.tool_calls = lastMessage.tool_calls?.map((tc) => { + const approvedToolCall = approvedToolCalls.find( + (atc) => atc.id === tc.id + ); + return approvedToolCall ?? tc; + }); + } /** * Replace the last message with the updated one */ return { - messages: [ - ...state.messages.slice(0, -1), - ...toolMessages, - updatedMessage, - ], + messages: [...state.messages, ...toolMessages], }; }, }); diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts new file mode 100644 index 000000000000..bdd86e4cf54d --- /dev/null +++ b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts @@ -0,0 +1,325 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { z } from "zod"; +import { describe, it, expect } from "vitest"; +import { ChatOpenAI } from "@langchain/openai"; +import { HumanMessage, AIMessage, ToolMessage } from "@langchain/core/messages"; +import { MemorySaver } from "@langchain/langgraph-checkpoint"; +import { Command } from "@langchain/langgraph"; + +import { tool } from "@langchain/core/tools"; +import { createAgent } from "../../index.js"; +import { humanInTheLoopMiddleware } from "../hitl.js"; + +const calculator = tool( + ({ a, b, operation }: { a: number; b: number; operation: string }) => { + switch (operation) { + case "add": + return `${a} + ${b} = ${a + b}`; + case "multiply": + return `${a} * ${b} = ${a * b}`; + default: + return "Unknown operation"; + } + }, + { + name: "calculator", + description: "Perform basic math operations", + schema: z.object({ + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + operation: z.enum(["add", "multiply"]).describe("Math operation"), + }), + } +); + +const nameGenerator = tool( + () => { + return `Thomas`; + }, + { + name: "name_generator", + description: "Generates a random name", + schema: z.object({}), + } +); + +const llm = new ChatOpenAI({ model: "gpt-4o" }); +const thread = { + configurable: { + thread_id: "test-123", + }, +}; + +describe("humanInTheLoopMiddleware", () => { + describe("with structured output", () => { + it("should accept tool calls", async () => { + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + middleware: [ + humanInTheLoopMiddleware({ + toolConfigs: { + calculator: { + requireApproval: true, + }, + }, + }), + ] as const, + tools: [calculator], + responseFormat: z.object({ + result: z.number().describe("The result of the calculation"), + }), + checkpointer, + }); + + const result = await agent.invoke( + { + messages: [new HumanMessage("Calculate 42 * 17")], + }, + thread + ); + + expect(result.messages).toHaveLength(2); + expect(HumanMessage.isInstance(result.messages[0])).toBe(true); + expect(AIMessage.isInstance(result.messages[1])).toBe(true); + expect(result).toHaveProperty("__interrupt__"); + expect(result.__interrupt__).toHaveLength(1); + expect(result.__interrupt__?.[0]).toHaveProperty("value"); + expect(result.__interrupt__?.[0].value).toHaveLength(1); + + const interruptRequests = result.__interrupt__?.[0].value as any; + expect(interruptRequests[0]).toHaveProperty("action"); + expect(interruptRequests[0].action).toBe("calculator"); + expect(interruptRequests[0]).toHaveProperty("args"); + + expect(result).not.toHaveProperty("structuredResponse"); + + const resume = await agent.invoke( + new Command({ + resume: [{ id: interruptRequests[0].toolCallId, type: "accept" }], + }), + thread + ); + expect(resume).toHaveProperty("structuredResponse"); + expect(resume.structuredResponse).toEqual({ result: 714 }); + }); + + /** + * This test is skipped because we see the model re-running tool calls + * due to the fact that the tool args update changes context. + */ + it.skip("should edit tool calls", async () => { + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + middleware: [ + humanInTheLoopMiddleware({ + toolConfigs: { + calculator: { + requireApproval: true, + }, + }, + }), + ] as const, + tools: [calculator], + responseFormat: z.object({ + result: z.number().describe("The result of the calculation"), + }), + checkpointer, + }); + + const result = await agent.invoke( + { + messages: [new HumanMessage("Calculate 42 * 17")], + }, + thread + ); + + expect(result.messages).toHaveLength(2); + expect(HumanMessage.isInstance(result.messages[0])).toBe(true); + expect(AIMessage.isInstance(result.messages[1])).toBe(true); + expect(result).toHaveProperty("__interrupt__"); + expect(result.__interrupt__).toHaveLength(1); + expect(result.__interrupt__?.[0]).toHaveProperty("value"); + expect(result.__interrupt__?.[0].value).toHaveLength(1); + + const interruptRequests = result.__interrupt__?.[0].value as any; + expect(interruptRequests[0]).toHaveProperty("action"); + expect(interruptRequests[0].action).toBe("calculator"); + expect(interruptRequests[0]).toHaveProperty("args"); + + expect(result).not.toHaveProperty("structuredResponse"); + + const resume = await agent.invoke( + new Command({ + resume: [ + { + id: interruptRequests[0].toolCallId, + type: "edit", + args: { ...interruptRequests[0].args, operation: "add" }, + }, + ], + }), + thread + ); + expect(resume).toHaveProperty("structuredResponse"); + expect(resume.structuredResponse).toEqual({ + result: expect.toBeOneOf([59, 714]), + }); + }); + + it("should ignore tool calls", async () => { + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + middleware: [ + humanInTheLoopMiddleware({ + toolConfigs: { + calculator: { + requireApproval: true, + }, + }, + }), + ] as const, + tools: [calculator], + responseFormat: z.object({ + result: z.number().describe("The result of the calculation"), + }), + checkpointer, + }); + + const result = await agent.invoke( + { + messages: [new HumanMessage("Calculate 42 * 17")], + }, + thread + ); + + const interruptRequests = result.__interrupt__?.[0].value as any; + const resume = await agent.invoke( + new Command({ + resume: [ + { + id: interruptRequests[0].toolCallId, + type: "ignore", + }, + ], + }), + thread + ); + expect(resume.messages).toHaveLength(4); + const lastToolMessage = resume.messages + .filter(ToolMessage.isInstance) + .at(-1); + expect(lastToolMessage?.content).toMatch( + /User ignored the tool call for calculator with id/ + ); + expect(resume).not.toHaveProperty("structuredResponse"); + }); + + it("should respond to tool calls", async () => { + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + middleware: [ + humanInTheLoopMiddleware({ + toolConfigs: { + calculator: { + requireApproval: true, + }, + }, + }), + ] as const, + tools: [calculator], + responseFormat: z.object({ + result: z.number().describe("The result of the calculation"), + }), + checkpointer, + }); + + const result = await agent.invoke( + { + messages: [new HumanMessage("What is 123 + 456?")], + }, + thread + ); + + const interruptRequests = result.__interrupt__?.[0].value as any; + const resume = await agent.invoke( + new Command({ + resume: [ + { + id: interruptRequests[0].toolCallId, + type: "response", + args: "The calculation result is 500 (custom override)", + }, + ], + }), + thread + ); + + /** + * we never know if the model will return 500 or 579 + */ + expect([500, 579].includes(resume.structuredResponse.result)).toBe(true); + }); + + it("should respond with structured response for approved tool calls and custom response", async () => { + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + middleware: [ + humanInTheLoopMiddleware({ + toolConfigs: { + calculator: { + requireApproval: true, + }, + name_generator: { + requireApproval: true, + }, + }, + }), + ] as const, + tools: [calculator, nameGenerator], + responseFormat: z.object({ + result: z.number().describe("The result of the calculation"), + name: z.string().describe("A name of a person"), + }), + checkpointer, + }); + + const result = await agent.invoke( + { + messages: [ + new HumanMessage( + "What is 123 + 456? And give me a name of a person?" + ), + ], + }, + thread + ); + + const interruptRequests = result.__interrupt__?.[0].value as any; + const resume = await agent.invoke( + new Command({ + resume: [ + { + id: interruptRequests[0].toolCallId, + type: "response", + args: "The calculation result is 500 (custom override)", + }, + { + id: interruptRequests[1].toolCallId, + type: "accept", + }, + ], + }), + thread + ); + expect(resume.structuredResponse).toEqual({ + result: expect.toBeOneOf([500, 579]), + name: "Thomas", + }); + }); + }); +}); diff --git a/libs/langchain/src/agents/middlewareAgent/types.ts b/libs/langchain/src/agents/middlewareAgent/types.ts index 55be47ae35ae..5e7b0fe01463 100644 --- a/libs/langchain/src/agents/middlewareAgent/types.ts +++ b/libs/langchain/src/agents/middlewareAgent/types.ts @@ -43,6 +43,15 @@ export type N = typeof START | "model_request" | "tools"; export interface BuiltInState { messages: BaseMessage[]; __interrupt__?: Interrupt[]; + /** + * Optional property to control routing after afterModel middleware execution. + * When set by middleware, the agent will jump to the specified node instead of + * following normal routing logic. The property is automatically cleared after use. + * + * - "model_request": Jump back to the model for another LLM call + * - "tools": Jump to tool execution (requires tools to be available) + */ + jumpTo?: "model_request" | "tools"; } /** From ffc47850b520c2879fde1de3405f50efa81d896e Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 17 Sep 2025 16:10:11 -0700 Subject: [PATCH 05/16] HITL improvements --- .../src/agents/middlewareAgent/annotation.ts | 6 +- .../agents/middlewareAgent/middleware/hitl.ts | 425 ++++++++++-------- .../middlewareAgent/middleware/index.ts | 7 +- .../middleware/tests/hitl.int.test.ts | 180 ++++---- .../middleware/tests/hitl.test.ts | 214 ++++++--- .../middlewareAgent/nodes/middleware.ts | 6 +- 6 files changed, 466 insertions(+), 372 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/annotation.ts b/libs/langchain/src/agents/middlewareAgent/annotation.ts index a2441ab64269..93847b929fab 100644 --- a/libs/langchain/src/agents/middlewareAgent/annotation.ts +++ b/libs/langchain/src/agents/middlewareAgent/annotation.ts @@ -37,7 +37,11 @@ export function createAgentAnnotationConditional< default: () => [], }), jumpTo: Annotation<"model_request" | "tools" | undefined>({ - reducer: (x: any, y: any) => y ?? x, + /** + * Since `jumpTo` acts as a control command, we only want + * to apply it if explicitly set. + */ + reducer: (_x: any, y: any) => y, default: () => undefined, }), }; diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index c2067d1359e1..3111fd55308b 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -1,33 +1,118 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { z } from "zod/v3"; -import { v4 as uuid } from "uuid"; import { AIMessage, ToolMessage } from "@langchain/core/messages"; import { interrupt } from "@langchain/langgraph"; import { createMiddleware } from "../middleware.js"; -import type { ToolCall } from "../types.js"; + +const ToolConfigSchema = z.object({ + allowAccept: z.boolean().optional(), + allowEdit: z.boolean().optional(), + allowRespond: z.boolean().optional(), + description: z.string().optional(), +}); + +type ToolConfigSchema = z.input; +type ToolCall = NonNullable[number]; /** - * Interrupt request for tool approval + * Configuration that defines what actions are allowed for a human interrupt. + * This controls the available interaction options when the graph is paused for human input. */ -export interface ToolApprovalRequest { +export interface HumanInTheLoopConfig + extends Omit {} + +/** + * Represents a request with a name and arguments. + */ +export interface ActionRequest { + /** + * The type or name of action being requested (e.g., "add_numbers") + */ action: string; + /** + * Key-value pairs of arguments needed for the action (e.g., {"a": 1, "b": 2}) + */ args: Record; - toolCallId: string; +} + +/** + * Represents an interrupt triggered by the graph that requires human intervention. + * + * @param actionRequest - The specific action being requested from the human + * @param config - Configuration defining what response types are allowed + * @param description - Optional detailed description of what input is needed + * + * @example + * ```ts + * const hitlRequest: HumanInTheLoopRequest = { + * actionRequest: { action: "Approve XYZ action", args: { ... } }, + * config: { allowAccept: true, allowEdit: true, allowRespond: true }, + * description: "Please review the command before execution" + * }; + * response = interrupt([request])[0] + * ``` + */ +export interface HumanInTheLoopRequest { + actionRequest: ActionRequest; + config: HumanInTheLoopConfig; + description?: string; +} + +/** + * Response when a human approves the action. + */ +export interface AcceptPayload { + type: "accept"; +} + +/** + * Response when a human rejects the action. + */ +export interface ResponsePayload { + type: "response"; + args?: string; +} + +/** + * Response when a human edits the action. + */ +export interface EditPayload { + type: "edit"; + args: ActionRequest; +} + +export type HumanInTheLoopMiddlewareHumanResponse = + | AcceptPayload + | ResponsePayload + | EditPayload; + +/** + * Configuration for a tool requiring human in the loop. + */ +export interface ToolConfig extends HumanInTheLoopConfig { + /** + * The description attached to the request for human input + */ description?: string; } const contextSchema = z .object({ - toolConfigs: z - .record( - z.object({ - requireApproval: z.boolean().optional(), - description: z.string().optional(), - }) - ) - .default({}), - messagePrefix: z.string().default("Tool execution requires approval"), + /** + * Mapping of tool name to allowed actions. + * If a tool doesn't have an entry, it's auto-approved by default. + * + * - `true` -> all actions allowed + * - `false` -> indicates that the tool is auto-approved. + * - `ToolConfig` -> indicates the specific actions allowed for this tool. + */ + toolConfigs: z.record(z.union([z.boolean(), ToolConfigSchema])).default({}), + /** + * The prefix to use when constructing action requests. + * This is used to provide context about the tool call and the action being requested. + */ + descriptionPrefix: z.string().default("Tool execution requires approval"), }) .optional(); @@ -100,13 +185,14 @@ const contextSchema = z * * const hitlMiddleware = humanInTheLoopMiddleware({ * toolConfigs: { + * // Interrupt write_file tool and allow edits or accepts * "write_file": { - * requireApproval: true, + * allowEdit: true, + * allowAccept: true, * description: "⚠️ File write operation requires approval" * }, - * "read_file": { - * requireApproval: false // Safe operation, no approval needed - * } + * // Auto-approve read_file tool + * "read_file": false * } * }); * @@ -120,6 +206,7 @@ const contextSchema = z * @example * Handling approval requests * ```typescript + * import { type HumanInTheLoopRequest, type Interrupt } from "langchain"; * import { Command } from "@langchain/langgraph"; * * // Initial agent invocation @@ -128,15 +215,14 @@ const contextSchema = z * }, config); * * // Check if agent is paused for approval - * const state = await agent.graph.getState(config); - * if (state.next?.length > 0) { - * // Get interrupt details - * const task = state.tasks?.[0]; - * const requests = task?.interrupts?.[0]?.value; + * if (result.__interrupt__) { + * const interruptRequest = initialResult.__interrupt__?.[0] as Interrupt< + * HumanInTheLoopRequest[] + * >; * * // Show tool call details to user - * console.log("Tool:", requests[0].action); - * console.log("Args:", requests[0].args); + * console.log("Tool:", interruptRequest.value[0].actionRequest); + * console.log("Allowed actions:", interruptRequest.value[0].config); * * // Resume with approval * await agent.invoke( @@ -161,12 +247,13 @@ const contextSchema = z * }) * * // Skip tool and terminate agent - * new Command({ resume: [{ type: "ignore" }] }) + * new Command({ resume: [{ type: "response" }] }) * * // Provide manual response * new Command({ * resume: [{ * type: "response", + * // this must be a string * args: "File operation not allowed in demo mode" * }] * }) @@ -211,193 +298,166 @@ export function humanInTheLoopMiddleware( name: "HumanInTheLoopMiddleware", contextSchema, afterModel: async (state, runtime) => { - const config = { ...contextSchema.parse(options), ...runtime.context }; + const config = contextSchema.parse({ ...options, ...runtime.context }); + if (!config) { + return; + } + const { messages } = state; if (!messages.length) { return; } - const lastMessage = messages.at(-1); - /** - * Check if it's an AI message with tool calls + * Don't do anything if the last message isn't an AI message with tool calls. */ + const lastMessage = messages.at(-1); if ( !AIMessage.isInstance(lastMessage) || !lastMessage.tool_calls?.length ) { - // Clear any existing jumpTo property since there are no tool calls to process - return { jumpTo: undefined }; - } - - // Filter out structured response extraction tool calls (they start with "extract-") - const regularToolCalls = lastMessage.tool_calls.filter( - (toolCall) => !toolCall.name.startsWith("extract-") - ); - - // If all tool calls are structured response extractions, return early - if (regularToolCalls.length === 0) { - // Clear any existing jumpTo property since we're not processing any real tools - return { jumpTo: undefined }; + return; } if (!config.toolConfigs) { throw new Error("HumanInTheLoopMiddleware: toolConfigs is required"); } - const toolConfigs = config.toolConfigs; + // Resolve per-tool configs (boolean true -> all actions allowed; false -> auto-approve) + const resolvedToolConfigs: Record = {}; + for (const [toolName, toolConfig] of Object.entries(config.toolConfigs)) { + if (typeof toolConfig === "boolean") { + if (toolConfig === true) { + resolvedToolConfigs[toolName] = { + allowAccept: true, + allowEdit: true, + allowRespond: true, + }; + } + } else { + resolvedToolConfigs[toolName] = toolConfig; + } + } - /** - * Separate tool calls that need interrupts from those that don't - */ const interruptToolCalls: ToolCall[] = []; const autoApprovedToolCalls: ToolCall[] = []; - for (const toolCall of regularToolCalls) { - /** - * Ensure tool call has an ID - */ - const normalizedToolCall: ToolCall = { - id: toolCall.id || uuid(), - name: toolCall.name, - args: toolCall.args, - }; - - const toolConfig = toolConfigs[normalizedToolCall.name]; - - if (toolConfig?.requireApproval) { - interruptToolCalls.push(normalizedToolCall); + for (const toolCall of lastMessage.tool_calls) { + if (toolCall.name in resolvedToolConfigs) { + interruptToolCalls.push(toolCall); } else { - autoApprovedToolCalls.push(normalizedToolCall); + autoApprovedToolCalls.push(toolCall); } } /** - * If no interrupts needed, return early + * No interrupt tool calls, so we can just return. */ if (!interruptToolCalls.length) { - // Clear any existing jumpTo property since no interrupts are needed - return { jumpTo: undefined }; + return; } - /** - * Process tool calls that need interrupts - */ - const requests: ToolApprovalRequest[] = interruptToolCalls.map( + const hitlRequests: HumanInTheLoopRequest[] = interruptToolCalls.map( (toolCall) => { - const toolConfig = toolConfigs[toolCall.name]; + const toolConfig = resolvedToolConfigs[toolCall.name]!; const description = - toolConfig?.description || - `${config.messagePrefix}\n\nTool: ${ + toolConfig.description || + `${config.descriptionPrefix}\n\nTool: ${ toolCall.name }\nArgs: ${JSON.stringify(toolCall.args, null, 2)}`; - return { - action: toolCall.name, - args: toolCall.args, - toolCallId: toolCall.id, + actionRequest: { action: toolCall.name, args: toolCall.args }, + config: toolConfig, description, }; } ); - /** - * Interrupt and wait for human responses - */ const responses = (await interrupt( - requests + hitlRequests )) as HumanInTheLoopMiddlewareHumanResponse[]; - /** - * double check that all interrupts have a response - */ - const missingResponses: string[] = []; - for (const toolCall of interruptToolCalls) { - if (!responses?.find((response) => response.id === toolCall.id)) { - missingResponses.push(toolCall.name); - } - } - if (missingResponses.length > 0) { + if (responses.length !== interruptToolCalls.length) { throw new Error( - `Missing responses for tool calls: ${missingResponses.join(", ")}` + `Number of human responses (${responses.length}) does not match number of hanging tool calls (${interruptToolCalls.length}).` ); } - const approvedToolCalls = [...autoApprovedToolCalls]; - const toolMessages: ToolMessage[] = []; + const approvedToolCalls: ToolCall[] = [...autoApprovedToolCalls]; + const artificialToolMessages: ToolMessage[] = []; - /** - * Process responses - */ - for (const [i, response] of Object.entries(responses)) { - const toolCall = interruptToolCalls[ - i as keyof typeof interruptToolCalls - ] as ToolCall; - if (!toolCall) { - throw new Error( - `Tool call "${ - response.id - }" not interrupted, interruptToolCalls: ${interruptToolCalls - .map((toolCall) => toolCall.id) - .join(", ")}` - ); + for (const [i, response] of responses.entries()) { + const toolCall = interruptToolCalls[i]!; + const toolConfig = resolvedToolConfigs[toolCall.name]!; + + if (response.type === "accept" && toolConfig?.allowAccept) { + approvedToolCalls.push(toolCall); + continue; } - switch (response.type) { - case "accept": - approvedToolCalls.push(toolCall); - break; - - case "edit": - /** - * For edit, args is an ActionRequest with updated args - */ - if ( - "args" in response && - typeof response.args === "object" && - response.args !== null - ) { - approvedToolCalls.push({ - ...toolCall, - args: response.args, - }); - } - break; - - case "ignore": - /** - * Skip to end - terminate the agent - */ - return runtime.terminate({ - ...state, - messages: [ - ...state.messages, - /** - * inject an artificial tool message to indicate that the tool was ignored - */ - new ToolMessage({ - content: `User ignored the tool call for ${toolCall.name} with id ${toolCall.id}`, - tool_call_id: toolCall.id, - }), - ], - }); - - case "response": { - /** - * Return manual tool response and jump back to model - * For response, args is a string - */ - toolMessages.push( - new ToolMessage({ - content: typeof response.args === "string" ? response.args : "", - tool_call_id: toolCall.id, - }) + if (response.type === "edit" && toolConfig?.allowEdit) { + const edited = response.args; + approvedToolCalls.push({ + id: toolCall.id, + name: edited.action, + args: edited.args, + }); + continue; + } + + if (response.type === "response" && toolConfig?.allowRespond) { + const content = + response.args ?? + `User rejected the tool call for \`${toolCall.name}\` with id ${toolCall.id}`; + + /** + * Providing a meaningful error message for this case that should never happen. + */ + if (!toolCall.id) { + throw new Error( + `Can't provide custom tool response for tool call without an ID: ${toolCall.name}!\n` + + "This use case is not expected to happen, please report this as a bug." + ); + } + + /** + * ToolMessage expects a string, so we need to throw an error if it's not a string + * as we currently have no way to proper type responses from users through the + * Command object. + */ + if (typeof content !== "string") { + throw new Error( + `Tool call response for "${ + toolCall.name + }" must be a string, got ${typeof content}` ); - break; } - default: - throw new Error(`Unknown response type: ${(response as any).type}`); + + artificialToolMessages.push( + new ToolMessage({ + content, + name: toolCall.name, + tool_call_id: toolCall.id, + status: "error", + }) + ); + continue; } + + const allowedActions = [ + toolConfig?.allowAccept && "accept", + toolConfig?.allowEdit && "edit", + toolConfig?.allowRespond && "response", + ] + .filter(Boolean) + .join('", "'); + throw new Error( + `Unexpected human response: ${JSON.stringify( + response + )}. Response action '${response.type}' is not allowed for tool '${ + toolCall.name + }'. Expected one of: "${allowedActions}", based on the tool's configuration.` + ); } /** @@ -412,47 +472,14 @@ export function humanInTheLoopMiddleware( }); } - /** - * Replace the last message with the updated one - */ + if (approvedToolCalls.length > 0) { + return { messages: [...state.messages, ...artificialToolMessages] }; + } + return { - messages: [...state.messages, ...toolMessages], + jumpTo: "model_request", + messages: [...state.messages, ...artificialToolMessages], }; }, }); } - -export interface HumanInTheLoopMiddlewareResponse { - id: string; -} - -export interface HumanInTheLoopMiddlewareAcceptResponse - extends HumanInTheLoopMiddlewareResponse { - type: "accept"; -} - -export interface HumanInTheLoopMiddlewareIgnoreResponse - extends HumanInTheLoopMiddlewareResponse { - type: "ignore"; -} - -export interface HumanInTheLoopMiddlewareResponseResponse - extends HumanInTheLoopMiddlewareResponse { - type: "response"; - args: unknown; -} - -export interface HumanInTheLoopMiddlewareEditResponse - extends HumanInTheLoopMiddlewareResponse { - type: "edit"; - args: unknown; -} - -/** - * The response provided by a human to an interrupt. - */ -export type HumanInTheLoopMiddlewareHumanResponse = - | HumanInTheLoopMiddlewareAcceptResponse - | HumanInTheLoopMiddlewareIgnoreResponse - | HumanInTheLoopMiddlewareResponseResponse - | HumanInTheLoopMiddlewareEditResponse; diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/index.ts b/libs/langchain/src/agents/middlewareAgent/middleware/index.ts index 09a994704db0..979bd863b7fa 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/index.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/index.ts @@ -2,6 +2,11 @@ export { summarizationMiddleware, countTokensApproximately, } from "./summarization.js"; -export { humanInTheLoopMiddleware, type ToolApprovalRequest } from "./hitl.js"; +export { + humanInTheLoopMiddleware, + type HumanInTheLoopRequest, + type HumanInTheLoopMiddlewareHumanResponse, + type ActionRequest, +} from "./hitl.js"; export { anthropicPromptCachingMiddleware } from "./promptCaching.js"; export { dynamicSystemPromptMiddleware } from "./dynamicSystemPrompt.js"; diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts index bdd86e4cf54d..5b101876730d 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.int.test.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { describe, it, expect } from "vitest"; import { ChatOpenAI } from "@langchain/openai"; -import { HumanMessage, AIMessage, ToolMessage } from "@langchain/core/messages"; +import { HumanMessage, AIMessage } from "@langchain/core/messages"; import { MemorySaver } from "@langchain/langgraph-checkpoint"; import { Command } from "@langchain/langgraph"; @@ -59,9 +59,7 @@ describe("humanInTheLoopMiddleware", () => { middleware: [ humanInTheLoopMiddleware({ toolConfigs: { - calculator: { - requireApproval: true, - }, + calculator: true, }, }), ] as const, @@ -88,15 +86,39 @@ describe("humanInTheLoopMiddleware", () => { expect(result.__interrupt__?.[0].value).toHaveLength(1); const interruptRequests = result.__interrupt__?.[0].value as any; - expect(interruptRequests[0]).toHaveProperty("action"); - expect(interruptRequests[0].action).toBe("calculator"); - expect(interruptRequests[0]).toHaveProperty("args"); + expect(interruptRequests).toMatchInlineSnapshot(` + [ + { + "actionRequest": { + "action": "calculator", + "args": { + "a": 42, + "b": 17, + "operation": "multiply", + }, + }, + "config": { + "allowAccept": true, + "allowEdit": true, + "allowRespond": true, + }, + "description": "Tool execution requires approval + + Tool: calculator + Args: { + "a": 42, + "b": 17, + "operation": "multiply" + }", + }, + ] + `); expect(result).not.toHaveProperty("structuredResponse"); const resume = await agent.invoke( new Command({ - resume: [{ id: interruptRequests[0].toolCallId, type: "accept" }], + resume: [{ type: "accept" }], }), thread ); @@ -105,58 +127,69 @@ describe("humanInTheLoopMiddleware", () => { }); /** - * This test is skipped because we see the model re-running tool calls - * due to the fact that the tool args update changes context. + * This test has to be retried because the model sometimes may + * rerun the tool due to the edit. */ - it.skip("should edit tool calls", async () => { + it("should edit tool calls", { retry: 3 }, async () => { const checkpointer = new MemorySaver(); + const draftEmailTool = tool( + () => { + return "Draft email"; + }, + { + name: "draft_email", + description: "Drafts an email", + schema: z.object({ + message: z.string(), + to: z.array(z.string()), + subject: z.string(), + }), + } + ); const agent = createAgent({ llm, middleware: [ humanInTheLoopMiddleware({ toolConfigs: { - calculator: { - requireApproval: true, - }, + draft_email: true, }, }), ] as const, - tools: [calculator], + tools: [draftEmailTool], responseFormat: z.object({ - result: z.number().describe("The result of the calculation"), + success: z + .boolean() + .describe("Whether the email was drafted successfully"), }), checkpointer, }); const result = await agent.invoke( { - messages: [new HumanMessage("Calculate 42 * 17")], + messages: [ + new HumanMessage("Draft an email to John Doe, saying hello"), + ], }, thread ); - expect(result.messages).toHaveLength(2); - expect(HumanMessage.isInstance(result.messages[0])).toBe(true); - expect(AIMessage.isInstance(result.messages[1])).toBe(true); - expect(result).toHaveProperty("__interrupt__"); - expect(result.__interrupt__).toHaveLength(1); - expect(result.__interrupt__?.[0]).toHaveProperty("value"); - expect(result.__interrupt__?.[0].value).toHaveLength(1); - + const editedMessage = + "Hello John Doe,\n\nI hope this message finds you well! Just wanted to say hello.\n\nBest regards,\nHans Claasen"; const interruptRequests = result.__interrupt__?.[0].value as any; - expect(interruptRequests[0]).toHaveProperty("action"); - expect(interruptRequests[0].action).toBe("calculator"); - expect(interruptRequests[0]).toHaveProperty("args"); - - expect(result).not.toHaveProperty("structuredResponse"); - const resume = await agent.invoke( new Command({ resume: [ { - id: interruptRequests[0].toolCallId, type: "edit", - args: { ...interruptRequests[0].args, operation: "add" }, + args: { + action: "draft_email", + args: { + ...interruptRequests[0].actionRequest.args, + message: editedMessage, + to: ["john.doe@example.com"], + subject: "Hello", + }, + }, }, ], }), @@ -164,57 +197,23 @@ describe("humanInTheLoopMiddleware", () => { ); expect(resume).toHaveProperty("structuredResponse"); expect(resume.structuredResponse).toEqual({ - result: expect.toBeOneOf([59, 714]), - }); - }); - - it("should ignore tool calls", async () => { - const checkpointer = new MemorySaver(); - const agent = createAgent({ - llm, - middleware: [ - humanInTheLoopMiddleware({ - toolConfigs: { - calculator: { - requireApproval: true, - }, - }, - }), - ] as const, - tools: [calculator], - responseFormat: z.object({ - result: z.number().describe("The result of the calculation"), - }), - checkpointer, + success: true, }); - const result = await agent.invoke( + const firstAIMessage = resume.messages.find( + AIMessage.isInstance + ) as AIMessage; + expect(firstAIMessage.tool_calls).toEqual([ { - messages: [new HumanMessage("Calculate 42 * 17")], + id: expect.any(String), + name: "draft_email", + args: { + message: editedMessage, + to: ["john.doe@example.com"], + subject: "Hello", + }, }, - thread - ); - - const interruptRequests = result.__interrupt__?.[0].value as any; - const resume = await agent.invoke( - new Command({ - resume: [ - { - id: interruptRequests[0].toolCallId, - type: "ignore", - }, - ], - }), - thread - ); - expect(resume.messages).toHaveLength(4); - const lastToolMessage = resume.messages - .filter(ToolMessage.isInstance) - .at(-1); - expect(lastToolMessage?.content).toMatch( - /User ignored the tool call for calculator with id/ - ); - expect(resume).not.toHaveProperty("structuredResponse"); + ]); }); it("should respond to tool calls", async () => { @@ -224,9 +223,7 @@ describe("humanInTheLoopMiddleware", () => { middleware: [ humanInTheLoopMiddleware({ toolConfigs: { - calculator: { - requireApproval: true, - }, + calculator: true, }, }), ] as const, @@ -237,19 +234,17 @@ describe("humanInTheLoopMiddleware", () => { checkpointer, }); - const result = await agent.invoke( + await agent.invoke( { messages: [new HumanMessage("What is 123 + 456?")], }, thread ); - const interruptRequests = result.__interrupt__?.[0].value as any; const resume = await agent.invoke( new Command({ resume: [ { - id: interruptRequests[0].toolCallId, type: "response", args: "The calculation result is 500 (custom override)", }, @@ -271,12 +266,8 @@ describe("humanInTheLoopMiddleware", () => { middleware: [ humanInTheLoopMiddleware({ toolConfigs: { - calculator: { - requireApproval: true, - }, - name_generator: { - requireApproval: true, - }, + calculator: true, + name_generator: true, }, }), ] as const, @@ -288,7 +279,7 @@ describe("humanInTheLoopMiddleware", () => { checkpointer, }); - const result = await agent.invoke( + await agent.invoke( { messages: [ new HumanMessage( @@ -299,17 +290,14 @@ describe("humanInTheLoopMiddleware", () => { thread ); - const interruptRequests = result.__interrupt__?.[0].value as any; const resume = await agent.invoke( new Command({ resume: [ { - id: interruptRequests[0].toolCallId, type: "response", args: "The calculation result is 500 (custom override)", }, { - id: interruptRequests[1].toolCallId, type: "accept", }, ], diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts index ce44ed162a1d..ad0c8f5e93fa 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/tests/hitl.test.ts @@ -8,7 +8,7 @@ import { MemorySaver } from "@langchain/langgraph-checkpoint"; import { createAgent } from "../../index.js"; import { humanInTheLoopMiddleware, - type ToolApprovalRequest, + type HumanInTheLoopRequest, type HumanInTheLoopMiddlewareHumanResponse, } from "../hitl.js"; import { @@ -68,12 +68,10 @@ describe("humanInTheLoopMiddleware", () => { const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { - requireApproval: true, + allowAccept: true, description: "⚠️ File write operation requires approval", }, - calculator: { - requireApproval: false, - }, + calculator: false, }, }); @@ -167,17 +165,30 @@ describe("humanInTheLoopMiddleware", () => { expect(task.interrupts.length).toBe(1); const requests = task.interrupts[0].value; - expect(requests[0].action).toBe("write_file"); - expect(requests[0].args).toEqual({ - filename: "greeting.txt", - content: "Hello World", - }); + expect(requests).toMatchInlineSnapshot(` + [ + { + "actionRequest": { + "action": "write_file", + "args": { + "content": "Hello World", + "filename": "greeting.txt", + }, + }, + "config": { + "allowAccept": true, + "description": "⚠️ File write operation requires approval", + }, + "description": "⚠️ File write operation requires approval", + }, + ] + `); // Resume with approval llm.index = 1; const resumedResult = await agent.invoke( new Command({ - resume: [{ id: "call_2", type: "accept" }], + resume: [{ type: "accept" }], }), config ); @@ -202,9 +213,7 @@ describe("humanInTheLoopMiddleware", () => { it("should handle edit response type", async () => { const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { - write_file: { - requireApproval: true, - }, + write_file: true, }, }); @@ -247,11 +256,10 @@ describe("humanInTheLoopMiddleware", () => { new Command({ resume: [ { - id: "call_1", type: "edit", args: { - filename: "safe.txt", - content: "Safe content", + action: "write_file", + args: { filename: "safe.txt", content: "Safe content" }, }, }, ], @@ -270,14 +278,11 @@ describe("humanInTheLoopMiddleware", () => { ); }); - /** - * is failing in dependency range tests - */ - it.skip("should handle ignore response type", async () => { + it("should handle manual response type", async () => { const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { - requireApproval: true, + allowRespond: true, }, }, }); @@ -288,7 +293,7 @@ describe("humanInTheLoopMiddleware", () => { { id: "call_1", name: "write_file", - args: { filename: "ignored.txt", content: "Ignored content" }, + args: { filename: "manual.txt", content: "Manual content" }, }, ], ], @@ -304,22 +309,27 @@ describe("humanInTheLoopMiddleware", () => { const config = { configurable: { - thread_id: "test-ignore", + thread_id: "test-manual", }, }; // Initial invocation await agent.invoke( { - messages: [new HumanMessage("Write to ignored file")], + messages: [new HumanMessage("Write to manual file")], }, config ); - // Resume with ignore + // Resume with manual response const resumedResult = await agent.invoke( new Command({ - resume: [{ id: "call_1", type: "ignore" }], + resume: [ + { + type: "response", + args: "File operation not allowed in demo mode", + }, + ], }), config ); @@ -327,17 +337,21 @@ describe("humanInTheLoopMiddleware", () => { // Verify tool was NOT called expect(writeFileFn).not.toHaveBeenCalled(); - // Verify agent terminated - expect(resumedResult.messages.at(-1)?.content).toBe( - "Write to ignored file" + // Verify manual response was added + const { messages } = resumedResult; + expect(messages[messages.length - 1].content).toBe( + "File operation not allowed in demo mode" + ); + expect((messages[messages.length - 1] as ToolMessage).tool_call_id).toBe( + "call_1" ); }); - it("should handle manual response type", async () => { + it("should throw if response is not a string", async () => { const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { - requireApproval: true, + allowRespond: true, }, }, }); @@ -377,29 +391,23 @@ describe("humanInTheLoopMiddleware", () => { ); // Resume with manual response - const resumedResult = await agent.invoke( - new Command({ - resume: [ - { - id: "call_1", - type: "response", - args: "File operation not allowed in demo mode", - }, - ], - }), - config - ); - - // Verify tool was NOT called - expect(writeFileFn).not.toHaveBeenCalled(); - - // Verify manual response was added - const { messages } = resumedResult; - expect(messages[messages.length - 1].content).toBe( - "File operation not allowed in demo mode" - ); - expect((messages[messages.length - 1] as ToolMessage).tool_call_id).toBe( - "call_1" + await expect(() => + agent.invoke( + new Command({ + resume: [ + { + type: "response", + args: { + action: "write_file", + args: "File operation not allowed in demo mode", + }, + }, + ], + }), + config + ) + ).rejects.toThrow( + 'Tool call response for "write_file" must be a string, got object' ); }); @@ -407,12 +415,10 @@ describe("humanInTheLoopMiddleware", () => { const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { - requireApproval: true, + allowEdit: true, description: "⚠️ File write operation requires approval", }, - calculator: { - requireApproval: true, - }, + calculator: true, }, }); @@ -466,21 +472,23 @@ describe("humanInTheLoopMiddleware", () => { expect(writeFileFn).toHaveBeenCalledTimes(0); const interruptRequest = initialResult.__interrupt__?.[0] as Interrupt< - ToolApprovalRequest[] + HumanInTheLoopRequest[] >; const resume: HumanInTheLoopMiddlewareHumanResponse[] = - interruptRequest.value.map((request) => { - if (request.action === "calculator") { - return { id: request.toolCallId, type: "accept" }; - } else if (request.action === "write_file") { + interruptRequest.value.map(({ actionRequest }) => { + if (actionRequest.action === "calculator") { + return { type: "accept" }; + } else if (actionRequest.action === "write_file") { return { - id: request.toolCallId, type: "edit", - args: { filename: "safe.txt", content: "Safe content" }, + args: { + action: "write_file", + args: { filename: "safe.txt", content: "Safe content" }, + }, }; } - throw new Error(`Unknown action: ${request.action}`); + throw new Error(`Unknown action: ${actionRequest.action}`); }); // Resume with approval @@ -510,12 +518,10 @@ describe("humanInTheLoopMiddleware", () => { const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { - requireApproval: true, + allowEdit: true, description: "⚠️ File write operation requires approval", }, - calculator: { - requireApproval: true, - }, + calculator: true, }, }); @@ -570,6 +576,70 @@ describe("humanInTheLoopMiddleware", () => { new Command({ resume: [{ id: "call_2", type: "ignore" }] }), config ) - ).rejects.toThrow("Missing responses for tool calls: calculator"); + ).rejects.toThrow( + "Number of human responses (1) does not match number of hanging tool calls (2)." + ); + }); + + it("should not allow me to approve if I don't have allowAccept", async () => { + const hitlMiddleware = humanInTheLoopMiddleware({ + toolConfigs: { + write_file: { + allowEdit: true, + description: "⚠️ File write operation requires approval", + }, + }, + }); + + // Create agent with mocked LLM + const llm = new FakeToolCallingModel({ + toolCalls: [ + // First call: calculator tool (auto-approved) + [ + { + id: "call_1", + name: "calculator", + args: { a: 42, b: 17, operation: "multiply" }, + }, + { + id: "call_2", + name: "write_file", + args: { filename: "greeting.txt", content: "Hello World" }, + }, + ], + ], + }); + + const checkpointer = new MemorySaver(); + const agent = createAgent({ + llm, + checkpointer, + prompt: + "You are a helpful assistant. Use the tools provided to help the user.", + tools: [writeFileTool], + middleware: [hitlMiddleware] as const, + }); + + const config = { + configurable: { + thread_id: "test-123", + }, + }; + + // Initial invocation + await agent.invoke( + { + messages: [ + new HumanMessage("Calculate 42 * 17 and write to greeting.txt"), + ], + }, + config + ); + + await expect(() => + agent.invoke(new Command({ resume: [{ type: "accept" }] }), config) + ).rejects.toThrow( + 'Unexpected human response: {"type":"accept"}. Response action \'accept\' is not allowed for tool \'write_file\'. Expected one of: "edit", based on the tool\'s configuration.' + ); }); }); diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts b/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts index 672b6a83f83e..236fe33d5158 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/middleware.ts @@ -87,7 +87,7 @@ export abstract class MiddlewareNode< * If result is undefined, return current state */ if (!result) { - return state; + return { ...state, jumpTo: undefined }; } /** @@ -99,7 +99,7 @@ export abstract class MiddlewareNode< if (result.error) { throw result.error; } - return { ...state, ...(result.result || {}) }; + return { ...state, ...(result.result || {}), jumpTo: result?.jumpTo }; } throw new Error(`Invalid control action: ${JSON.stringify(result)}`); @@ -108,7 +108,7 @@ export abstract class MiddlewareNode< /** * If result is a state update, merge it with current state */ - return { ...state, ...result }; + return { ...state, ...result, jumpTo: result.jumpTo }; } get nodeOptions(): { From b1a77162f7971a8bcd752cf50f7251a3e88b4cd5 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 17 Sep 2025 16:33:00 -0700 Subject: [PATCH 06/16] validate jump targets --- .../src/agents/middlewareAgent/ReactAgent.ts | 24 +++++++++++++++---- .../agents/middlewareAgent/middleware/hitl.ts | 2 +- .../src/agents/middlewareAgent/types.ts | 2 +- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts index 3c40d670dc31..2e15e7fc123c 100644 --- a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts +++ b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts @@ -357,7 +357,7 @@ export class ReactAgent< /** * Get possible edge destinations from model node. * @param toolClasses names of tools to call - * @param includeModelRequest whether to include model_request as a valid path (for jumpTo routing) + * @param includeModelRequest whether to include "model_request" as a valid path (for jumpTo routing) * @returns list of possible edge destinations */ #getModelPaths( @@ -490,13 +490,29 @@ export class ReactAgent< if (state.jumpTo) { const jumpTarget = state.jumpTo; - // Validate that the jump target is available - if (jumpTarget === "tools" && toolClasses.length === 0) { + // If jumpTo is "model", go to model_request node + if (jumpTarget === "model") { + return "model_request"; + } + + // If jumpTo is "tools", go to tools node + if (jumpTarget === "tools") { // If trying to jump to tools but no tools are available, go to END + if (toolClasses.length === 0) { + return END; + } + + return "tools"; + } + + // If jumpTo is END, go to END + if (jumpTarget === END) { return END; } - return jumpTarget; + throw new Error( + `Invalid jump target: ${jumpTarget}, must be "model" or "tools".` + ); } // check if there are pending tool calls diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index 3111fd55308b..3a4f3cdd9c7c 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -477,7 +477,7 @@ export function humanInTheLoopMiddleware( } return { - jumpTo: "model_request", + jumpTo: "model", messages: [...state.messages, ...artificialToolMessages], }; }, diff --git a/libs/langchain/src/agents/middlewareAgent/types.ts b/libs/langchain/src/agents/middlewareAgent/types.ts index 5e7b0fe01463..11f1a09a555a 100644 --- a/libs/langchain/src/agents/middlewareAgent/types.ts +++ b/libs/langchain/src/agents/middlewareAgent/types.ts @@ -51,7 +51,7 @@ export interface BuiltInState { * - "model_request": Jump back to the model for another LLM call * - "tools": Jump to tool execution (requires tools to be available) */ - jumpTo?: "model_request" | "tools"; + jumpTo?: "model" | "tools"; } /** From 3588a204e23dfb2830ade696af0a92a0e522fe22 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 17 Sep 2025 16:37:18 -0700 Subject: [PATCH 07/16] prepareModelRequest -> modifyModelRequest --- .../src/agents/middlewareAgent/ReactAgent.ts | 8 ++++---- .../src/agents/middlewareAgent/middleware.ts | 10 +++++----- .../middlewareAgent/middleware/promptCaching.ts | 2 +- .../src/agents/middlewareAgent/nodes/AgentNode.ts | 14 +++++++------- .../middlewareAgent/tests/middleware.test-d.ts | 6 +++--- libs/langchain/src/agents/middlewareAgent/types.ts | 4 ++-- .../src/agents/tests/reactAgent.int.test.ts | 8 ++++---- 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts index 2e15e7fc123c..a34aab06b8f2 100644 --- a/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts +++ b/libs/langchain/src/agents/middlewareAgent/ReactAgent.ts @@ -157,7 +157,7 @@ export class ReactAgent< // Generate node names for middleware nodes that have hooks const beforeModelNodes: { index: number; name: string }[] = []; const afterModelNodes: { index: number; name: string }[] = []; - const prepareModelRequestHookMiddleware: [ + const modifyModelRequestHookMiddleware: [ AgentMiddleware, /** * ToDo: better type to get the state of middleware @@ -197,8 +197,8 @@ export class ReactAgent< ); } - if (m.prepareModelRequest) { - prepareModelRequestHookMiddleware.push([ + if (m.modifyModelRequest) { + modifyModelRequestHookMiddleware.push([ m, () => ({ ...beforeModelNode?.getState(), @@ -224,7 +224,7 @@ export class ReactAgent< toolClasses, shouldReturnDirect, signal: this.options.signal, - prepareModelRequestHookMiddleware, + modifyModelRequestHookMiddleware, }), AgentNode.nodeOptions ); diff --git a/libs/langchain/src/agents/middlewareAgent/middleware.ts b/libs/langchain/src/agents/middlewareAgent/middleware.ts index 9330d4815716..ed4b3dba3edf 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware.ts @@ -15,7 +15,7 @@ import type { * @param config.name - The name of the middleware * @param config.stateSchema - The schema of the middleware state * @param config.contextSchema - The schema of the middleware context - * @param config.prepareModelRequest - The function to prepare the model request + * @param config.modifyModelRequest - The function to prepare the model request * @param config.beforeModel - The function to run before the model call * @param config.afterModel - The function to run after the model call * @returns A middleware instance @@ -49,7 +49,7 @@ export function createMiddleware< name: string; stateSchema?: TSchema; contextSchema?: TContextSchema; - prepareModelRequest?: ( + modifyModelRequest?: ( options: ModelRequest, state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, @@ -118,10 +118,10 @@ export function createMiddleware< contextSchema: config.contextSchema, }; - if (config.prepareModelRequest) { - middleware.prepareModelRequest = async (options, state, runtime) => + if (config.modifyModelRequest) { + middleware.modifyModelRequest = async (options, state, runtime) => Promise.resolve( - config.prepareModelRequest!( + config.modifyModelRequest!( options, state, runtime as Runtime< diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts index 8c6889297156..eb42b24b5115 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts @@ -143,7 +143,7 @@ export function anthropicPromptCachingMiddleware( return createMiddleware({ name: "PromptCachingMiddleware", contextSchema, - prepareModelRequest: (options, state, runtime) => { + modifyModelRequest: (options, state, runtime) => { /** * If the runtime values match the schema default values, use the middleware option * values otherwise use the runtime values. This allows to apply general configurations diff --git a/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts b/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts index d7631eb62aa3..bde7467ad6fd 100644 --- a/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts +++ b/libs/langchain/src/agents/middlewareAgent/nodes/AgentNode.ts @@ -72,7 +72,7 @@ export interface AgentNodeOptions< toolClasses: (ClientTool | ServerTool)[]; shouldReturnDirect: Set; signal?: AbortSignal; - prepareModelRequestHookMiddleware?: [ + modifyModelRequestHookMiddleware?: [ AgentMiddleware, () => any ][]; @@ -223,7 +223,7 @@ export class AgentNode< const model = await this.#deriveModel(state, config); /** - * Execute prepareModelRequest hooks from beforeModelNodes + * Execute modifyModelRequest hooks from beforeModelNodes */ const preparedOptions = await this.#executePrepareModelRequestHooks( model, @@ -509,8 +509,8 @@ export class AgentNode< config: LangGraphRunnableConfig ): Promise { if ( - !this.#options.prepareModelRequestHookMiddleware || - this.#options.prepareModelRequestHookMiddleware.length === 0 + !this.#options.modifyModelRequestHookMiddleware || + this.#options.modifyModelRequestHookMiddleware.length === 0 ) { return undefined; } @@ -531,8 +531,8 @@ export class AgentNode< tools: [], }; - // Execute prepareModelRequest hooks from all middleware - const middlewareList = this.#options.prepareModelRequestHookMiddleware; + // Execute modifyModelRequest hooks from all middleware + const middlewareList = this.#options.modifyModelRequestHookMiddleware; for (const [middleware, getMiddlewareState] of middlewareList) { // Merge context with default context of middleware const context = { @@ -550,7 +550,7 @@ export class AgentNode< terminate: (result) => ({ type: "terminate", result }), }; - const result = await middleware.prepareModelRequest!( + const result = await middleware.modifyModelRequest!( currentOptions, { messages: state.messages, diff --git a/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts b/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts index c3ce78871dd7..dae27dae9e75 100644 --- a/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts +++ b/libs/langchain/src/agents/middlewareAgent/tests/middleware.test-d.ts @@ -181,7 +181,7 @@ describe("middleware types", () => { customRequiredContextProp: string; }>(); }, - prepareModelRequest: async (_request, _state, runtime) => { + modifyModelRequest: async (_request, _state, runtime) => { expectTypeOf(runtime.context).toEqualTypeOf<{ customDefaultContextProp: string; customOptionalContextProp?: string; @@ -236,7 +236,7 @@ describe("middleware types", () => { > >(); }, - prepareModelRequest: async (_request, _state, runtime) => { + modifyModelRequest: async (_request, _state, runtime) => { expectTypeOf(runtime.context).toEqualTypeOf< Partial< | { @@ -286,7 +286,7 @@ describe("middleware types", () => { }> >(); }, - prepareModelRequest: async (_request, _state, runtime) => { + modifyModelRequest: async (_request, _state, runtime) => { expectTypeOf(runtime.context).toEqualTypeOf< Partial<{ customDefaultContextProp: string; diff --git a/libs/langchain/src/agents/middlewareAgent/types.ts b/libs/langchain/src/agents/middlewareAgent/types.ts index 11f1a09a555a..db43794ba453 100644 --- a/libs/langchain/src/agents/middlewareAgent/types.ts +++ b/libs/langchain/src/agents/middlewareAgent/types.ts @@ -361,7 +361,7 @@ export interface AgentMiddleware< * @param runtime - Runtime context and metadata * @returns Modified options or undefined to pass through */ - prepareModelRequest?( + modifyModelRequest?( request: ModelRequest, state: (TSchema extends z.ZodObject ? z.infer : {}) & AgentBuiltInState, @@ -481,7 +481,7 @@ export type CreateAgentParams< * Prior to `v0.2.46`, the prompt was set using `stateModifier` / `messagesModifier` parameters. * This is now deprecated and will be removed in a future release. * - * Cannot be used together with `prepareModelRequest`. + * Cannot be used together with `modifyModelRequest`. */ prompt?: SystemMessage | string; diff --git a/libs/langchain/src/agents/tests/reactAgent.int.test.ts b/libs/langchain/src/agents/tests/reactAgent.int.test.ts index 364ca06ace4a..700b7c687330 100644 --- a/libs/langchain/src/agents/tests/reactAgent.int.test.ts +++ b/libs/langchain/src/agents/tests/reactAgent.int.test.ts @@ -263,7 +263,7 @@ describe("createAgent Integration Tests", () => { }); }); - describe("prepareModelRequest", () => { + describe("modifyModelRequest", () => { it("should allow middleware to update model, messages and systemMessage", async () => { // Setup mocked fetch functions for both providers const openAIFetchMock = vi.fn((url, options) => fetch(url, options)); @@ -289,7 +289,7 @@ describe("createAgent Integration Tests", () => { // Create middleware that will change the model and messages const modelSwitchMiddleware = createMiddleware({ name: "modelSwitcher", - prepareModelRequest: async (_request, _state, _runtime) => { + modifyModelRequest: async (_request, _state, _runtime) => { // Create a new ChatAnthropic instance const anthropicModel = new ChatAnthropic({ model: "claude-opus-4-20250514", @@ -400,7 +400,7 @@ Please provide a clear, direct, and authoritative answer, as this information wi expect(cachedTokens).toBe(1195); }); - it("can change tools and toolChoice in prepareModelRequest", async () => { + it("can change tools and toolChoice in modifyModelRequest", async () => { // Setup mocked fetch for OpenAI const openAIFetchMock = vi.fn(); @@ -434,7 +434,7 @@ Please provide a clear, direct, and authoritative answer, as this information wi // Create middleware that adds tools and sets toolChoice const toolsMiddleware = { name: "toolsModifier", - prepareModelRequest: async () => { + modifyModelRequest: async () => { // Add tools dynamically const tools = [weatherTool, newsTool]; From 7ad0c69fdc40d7129afdadcd4c9f2b80f8e7ffe7 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 17 Sep 2025 16:44:22 -0700 Subject: [PATCH 08/16] allow interrupt type imports from the same place --- .../agents/middlewareAgent/middleware/hitl.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index 3a4f3cdd9c7c..bef5bb6b6a08 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -15,6 +15,20 @@ const ToolConfigSchema = z.object({ type ToolConfigSchema = z.input; type ToolCall = NonNullable[number]; +/** + * Represents information about an interrupt. + */ +export interface Interrupt { + /** + * The ID of the interrupt. + */ + id: string; + /** + * The requests for human input. + */ + value: TValue; +} + /** * Configuration that defines what actions are allowed for a human interrupt. * This controls the available interaction options when the graph is paused for human input. @@ -206,7 +220,7 @@ const contextSchema = z * @example * Handling approval requests * ```typescript - * import { type HumanInTheLoopRequest, type Interrupt } from "langchain"; + * import { type HumanInTheLoopRequest, type Interrupt } from "langchain/middleware"; * import { Command } from "@langchain/langgraph"; * * // Initial agent invocation From 77223760718dfe8dada465e20306595b54b0fc4f Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Wed, 17 Sep 2025 18:44:48 -0700 Subject: [PATCH 09/16] improve typedoc --- examples/src/createAgent/middleware/hitl.ts | 6 +- .../agents/middlewareAgent/middleware/hitl.ts | 67 +++++++++++++------ 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/examples/src/createAgent/middleware/hitl.ts b/examples/src/createAgent/middleware/hitl.ts index 07fb362e0787..c213e3da598e 100644 --- a/examples/src/createAgent/middleware/hitl.ts +++ b/examples/src/createAgent/middleware/hitl.ts @@ -54,12 +54,10 @@ const writeFileTool = tool( const hitlMiddleware = humanInTheLoopMiddleware({ toolConfigs: { write_file: { - requireApproval: true, + allowAccept: true, description: "⚠️ File write operation requires approval", }, - calculator: { - requireApproval: false, // Math is safe - }, + calculator: false, // Math is safe }, }); diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index bef5bb6b6a08..46701eb0a3c1 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -6,9 +6,21 @@ import { interrupt } from "@langchain/langgraph"; import { createMiddleware } from "../middleware.js"; const ToolConfigSchema = z.object({ + /** + * Whether the human can approve the current action without changes + */ allowAccept: z.boolean().optional(), + /** + * Whether the human can reject the current action with feedback + */ allowEdit: z.boolean().optional(), + /** + * Whether the human can approve the current action with edited content + */ allowRespond: z.boolean().optional(), + /** + * The description attached to the request for human input + */ description: z.string().optional(), }); @@ -30,32 +42,31 @@ export interface Interrupt { } /** - * Configuration that defines what actions are allowed for a human interrupt. - * This controls the available interaction options when the graph is paused for human input. + * Configuration that defines which reviewer response types are permitted during a human interrupt. + * These flags control what the human reviewer may do (e.g., accept/edit/respond), + * not the tool action the agent has requested. */ export interface HumanInTheLoopConfig extends Omit {} /** - * Represents a request with a name and arguments. + * Describes the agent-requested tool action (name and arguments). + * This is what the AI intends to execute, subject to human review. */ export interface ActionRequest { /** - * The type or name of action being requested (e.g., "add_numbers") + * The tool/action name requested by the agent (e.g., "send_email"). */ action: string; /** - * Key-value pairs of arguments needed for the action (e.g., {"a": 1, "b": 2}) + * Arguments for the requested tool call (e.g., {"a": 1, "b": 2}). */ args: Record; } /** - * Represents an interrupt triggered by the graph that requires human intervention. - * - * @param actionRequest - The specific action being requested from the human - * @param config - Configuration defining what response types are allowed - * @param description - Optional detailed description of what input is needed + * Represents an interrupt triggered by the graph that requires human intervention + * to approve, edit, or respond to an agent-requested tool action. * * @example * ```ts @@ -68,20 +79,30 @@ export interface ActionRequest { * ``` */ export interface HumanInTheLoopRequest { + /** + * The agent-requested tool action to be reviewed. + */ actionRequest: ActionRequest; + /** + * Which reviewer responses are allowed (accept/edit/respond). + */ config: HumanInTheLoopConfig; + /** + * Optional human-facing description shown in the approval prompt. + */ description?: string; } /** - * Response when a human approves the action. + * Response when a human approves the agent-requested action. */ export interface AcceptPayload { type: "accept"; } /** - * Response when a human rejects the action. + * Response when a human provides a manual response instead of executing + * the agent-requested action. */ export interface ResponsePayload { type: "response"; @@ -89,7 +110,7 @@ export interface ResponsePayload { } /** - * Response when a human edits the action. + * Response when a human edits the agent-requested action (tool name and/or args). */ export interface EditPayload { type: "edit"; @@ -106,7 +127,7 @@ export type HumanInTheLoopMiddlewareHumanResponse = */ export interface ToolConfig extends HumanInTheLoopConfig { /** - * The description attached to the request for human input + * Human-facing description shown in the approval request. */ description?: string; } @@ -114,17 +135,21 @@ export interface ToolConfig extends HumanInTheLoopConfig { const contextSchema = z .object({ /** - * Mapping of tool name to allowed actions. + * Mapping of tool name to allowed reviewer responses. * If a tool doesn't have an entry, it's auto-approved by default. * - * - `true` -> all actions allowed - * - `false` -> indicates that the tool is auto-approved. - * - `ToolConfig` -> indicates the specific actions allowed for this tool. + * - `true` -> pause for approval and allow accept/edit/respond + * - `false` -> auto-approve (no human review) + * - `ToolConfig` -> explicitly specify which reviewer responses are allowed for this tool */ toolConfigs: z.record(z.union([z.boolean(), ToolConfigSchema])).default({}), /** - * The prefix to use when constructing action requests. - * This is used to provide context about the tool call and the action being requested. + * Prefix used when constructing human-facing approval messages. + * Provides context about the tool call being reviewed; does not change the underlying action. + * + * Note: This prefix is only applied for tools that do not provide a custom + * `description` via their {@link ToolConfig}. If a tool specifies a custom + * `description`, that per-tool text is used and this prefix is ignored. */ descriptionPrefix: z.string().default("Tool execution requires approval"), }) @@ -187,7 +212,7 @@ const contextSchema = z * @param options.toolConfigs - Per-tool configuration mapping tool names to their settings * @param options.toolConfigs[toolName].requireApproval - Whether the tool requires human approval * @param options.toolConfigs[toolName].description - Custom approval message for the tool - * @param options.messagePrefix - Default prefix for approval messages (default: "Tool execution requires approval") + * @param options.messagePrefix - Default prefix for approval messages (default: "Tool execution requires approval"). Only used for tools that do not define a custom `description` in their ToolConfig. * * @returns A middleware instance that can be passed to `createAgent` * From ef2414f4b4faa2fb78783e103b2c2c42b1cbee50 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 18 Sep 2025 09:09:12 -0700 Subject: [PATCH 10/16] typedoc update --- .../src/agents/middlewareAgent/middleware/hitl.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts index 46701eb0a3c1..52e867a6e0b4 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/hitl.ts @@ -210,7 +210,9 @@ const contextSchema = z * * @param options - Configuration options for the middleware * @param options.toolConfigs - Per-tool configuration mapping tool names to their settings - * @param options.toolConfigs[toolName].requireApproval - Whether the tool requires human approval + * @param options.toolConfigs[toolName].allowAccept - Whether the human can approve the current action without changes + * @param options.toolConfigs[toolName].allowEdit - Whether the human can reject the current action with feedback + * @param options.toolConfigs[toolName].allowRespond - Whether the human can approve the current action with edited content * @param options.toolConfigs[toolName].description - Custom approval message for the tool * @param options.messagePrefix - Default prefix for approval messages (default: "Tool execution requires approval"). Only used for tools that do not define a custom `description` in their ToolConfig. * @@ -304,14 +306,14 @@ const contextSchema = z * const hitlMiddleware = humanInTheLoopMiddleware({ * toolConfigs: { * "execute_sql": { - * requireApproval: true, + * allowAccept: true, + * allowEdit: true, + * allowRespond: true, * description: "🚨 SQL query requires DBA approval\nPlease review for safety and performance" * }, - * "read_schema": { - * requireApproval: false // Reading metadata is safe - * }, + * "read_schema": false // Reading metadata is safe * "delete_records": { - * requireApproval: true, + * allowAccept: true, * description: "⛔ DESTRUCTIVE OPERATION - Requires manager approval" * } * }, From 61e7caeb7e530190c0f41ce09ed655cfb6ab2c28 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 18 Sep 2025 09:25:09 -0700 Subject: [PATCH 11/16] fix(mcp-adapters): use createAgent for examples and update tests (#8976) --- internal/eslint/package.json | 4 +- .../src/load/import_map.ts | 30 ++ libs/langchain-core/src/messages/index.ts | 11 +- .../langchain-mcp-adapters/.gitignore | 5 +- .../langchain-mcp-adapters/README.md | 11 +- .../__tests__/fixtures/dummy-stdio-server.ts | 48 --- .../langchain-mcp-adapters/__tests__/mocks.ts | 90 ----- .../langchain-mcp-adapters/eslint.config.ts | 10 +- .../langchain-mcp-adapters/examples/README.md | 22 +- .../examples/calculator_server_shttp_sse.ts | 10 +- .../examples/calculator_sse_shttp_example.ts | 3 +- .../examples/filesystem_langgraph_example.ts | 10 +- .../firecrawl_custom_config_example.ts | 5 +- .../firecrawl_multiple_servers_example.ts | 5 +- .../examples/langgraph_example.ts | 3 +- .../examples/tsconfig.json | 11 + .../langchain-mcp-adapters/package.json | 12 +- .../@modelcontextprotocol/sdk/client/index.ts | 32 ++ .../@modelcontextprotocol/sdk/client/sse.ts | 16 + .../@modelcontextprotocol/sdk/client/stdio.ts | 18 + .../sdk/client/streamableHttp.ts | 19 + .../tests}/client.basic.test.ts | 31 +- .../tests}/client.comprehensive.test.ts | 33 +- .../tests/client.test.ts} | 159 ++++---- .../tests}/fixtures/dummy-http-server.ts | 38 +- .../src/tests/fixtures/dummy-stdio-server.ts | 53 +++ .../{__tests__ => src/tests}/tools.test.ts | 35 +- .../langchain-mcp-adapters/tsconfig.cjs.json | 9 - .../tsconfig.examples.json | 18 - .../langchain-mcp-adapters/tsconfig.json | 6 +- .../langchain-mcp-adapters/vitest.config.ts | 2 +- pnpm-lock.yaml | 354 +++--------------- 32 files changed, 435 insertions(+), 678 deletions(-) delete mode 100644 libs/providers/langchain-mcp-adapters/__tests__/fixtures/dummy-stdio-server.ts delete mode 100644 libs/providers/langchain-mcp-adapters/__tests__/mocks.ts create mode 100644 libs/providers/langchain-mcp-adapters/examples/tsconfig.json create mode 100644 libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/index.ts create mode 100644 libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/sse.ts create mode 100644 libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/stdio.ts create mode 100644 libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/streamableHttp.ts rename libs/providers/langchain-mcp-adapters/{__tests__ => src/tests}/client.basic.test.ts (94%) rename libs/providers/langchain-mcp-adapters/{__tests__ => src/tests}/client.comprehensive.test.ts (94%) rename libs/providers/langchain-mcp-adapters/{__tests__/client.int.test.ts => src/tests/client.test.ts} (94%) rename libs/providers/langchain-mcp-adapters/{__tests__ => src/tests}/fixtures/dummy-http-server.ts (89%) create mode 100644 libs/providers/langchain-mcp-adapters/src/tests/fixtures/dummy-stdio-server.ts rename libs/providers/langchain-mcp-adapters/{__tests__ => src/tests}/tools.test.ts (92%) delete mode 100644 libs/providers/langchain-mcp-adapters/tsconfig.cjs.json delete mode 100644 libs/providers/langchain-mcp-adapters/tsconfig.examples.json diff --git a/internal/eslint/package.json b/internal/eslint/package.json index 77c0492408de..eead00973ef0 100644 --- a/internal/eslint/package.json +++ b/internal/eslint/package.json @@ -4,8 +4,8 @@ "description": "Shared ESLint configuration for LangChain.js projects", "author": "LangChain", "license": "MIT", - "main": "./index.ts", - "types": "./index.ts", + "main": "./src/index.ts", + "types": "./src/index.ts", "type": "module", "keywords": [ "eslint", diff --git a/libs/langchain-community/src/load/import_map.ts b/libs/langchain-community/src/load/import_map.ts index 798ceef97423..7a3d01d3d151 100644 --- a/libs/langchain-community/src/load/import_map.ts +++ b/libs/langchain-community/src/load/import_map.ts @@ -82,7 +82,37 @@ export * as llms__togetherai from "../llms/togetherai.js"; export * as llms__watsonx_ai from "../llms/watsonx_ai.js"; export * as llms__writer from "../llms/writer.js"; export * as llms__yandex from "../llms/yandex.js"; +export * as llms__layerup_security from "../llms/layerup_security.js"; +export * as vectorstores__analyticdb from "../vectorstores/analyticdb.js"; +export * as vectorstores__astradb from "../vectorstores/astradb.js"; +export * as vectorstores__azion_edgesql from "../vectorstores/azion_edgesql.js"; +export * as vectorstores__azure_aisearch from "../vectorstores/azure_aisearch.js"; +export * as vectorstores__azure_cosmosdb from "../vectorstores/azure_cosmosdb.js"; +export * as vectorstores__cassandra from "../vectorstores/cassandra.js"; +export * as vectorstores__chroma from "../vectorstores/chroma.js"; +export * as vectorstores__clickhouse from "../vectorstores/clickhouse.js"; +export * as vectorstores__closevector__node from "../vectorstores/closevector/node.js"; +export * as vectorstores__closevector__web from "../vectorstores/closevector/web.js"; +export * as vectorstores__cloudflare_vectorize from "../vectorstores/cloudflare_vectorize.js"; +export * as vectorstores__convex from "../vectorstores/convex.js"; export * as vectorstores__couchbase_search from "../vectorstores/couchbase_search.js"; +export * as vectorstores__elasticsearch from "../vectorstores/elasticsearch.js"; +export * as vectorstores__faiss from "../vectorstores/faiss.js"; +export * as vectorstores__googlevertexai from "../vectorstores/googlevertexai.js"; +export * as vectorstores__hnswlib from "../vectorstores/hnswlib.js"; +export * as vectorstores__hanavector from "../vectorstores/hanavector.js"; +export * as vectorstores__lancedb from "../vectorstores/lancedb.js"; +export * as vectorstores__libsql from "../vectorstores/libsql.js"; +export * as vectorstores__mariadb from "../vectorstores/mariadb.js"; +export * as vectorstores__milvus from "../vectorstores/milvus.js"; +export * as vectorstores__momento_vector_index from "../vectorstores/momento_vector_index.js"; +export * as vectorstores__mongodb_atlas from "../vectorstores/mongodb_atlas.js"; +export * as vectorstores__myscale from "../vectorstores/myscale.js"; +export * as vectorstores__neo4j_vector from "../vectorstores/neo4j_vector.js"; +export * as vectorstores__neon from "../vectorstores/neon.js"; +export * as vectorstores__opensearch from "../vectorstores/opensearch.js"; +export * as vectorstores__pgvector from "../vectorstores/pgvector.js"; +export * as vectorstores__pinecone from "../vectorstores/pinecone.js"; export * as vectorstores__prisma from "../vectorstores/prisma.js"; export * as vectorstores__qdrant from "../vectorstores/qdrant.js"; export * as vectorstores__redis from "../vectorstores/redis.js"; diff --git a/libs/langchain-core/src/messages/index.ts b/libs/langchain-core/src/messages/index.ts index 6eaa8dbcb75a..6770d7b467e5 100644 --- a/libs/langchain-core/src/messages/index.ts +++ b/libs/langchain-core/src/messages/index.ts @@ -9,16 +9,7 @@ export * from "./transformers.js"; export * from "./metadata.js"; export * from "./message.js"; export * from "./modifier.js"; -// TODO: Use a star export when we deprecate the -// existing "ToolCall" type in "base.js". -export { - type ToolMessageFields, - ToolMessage, - ToolMessageChunk, - type InvalidToolCall, - isToolMessage, - isToolMessageChunk, -} from "./tool.js"; +export * from "./tool.js"; // This is an old export for backwards compatibility with existing multimodal content blocks // TODO: remove this in v2 diff --git a/libs/providers/langchain-mcp-adapters/.gitignore b/libs/providers/langchain-mcp-adapters/.gitignore index 5d8889b41cda..185e5b2d7cf0 100644 --- a/libs/providers/langchain-mcp-adapters/.gitignore +++ b/libs/providers/langchain-mcp-adapters/.gitignore @@ -1,9 +1,6 @@ -index.cjs -index.js -index.d.ts -index.d.cts node_modules dist .yarn .env .eslintcache +examples/filesystem_test diff --git a/libs/providers/langchain-mcp-adapters/README.md b/libs/providers/langchain-mcp-adapters/README.md index 9c72844af603..c3c93b5f3383 100644 --- a/libs/providers/langchain-mcp-adapters/README.md +++ b/libs/providers/langchain-mcp-adapters/README.md @@ -41,9 +41,9 @@ npm install @langchain/mcp-adapters The library allows you to connect to one or more MCP servers and load tools from them, without needing to manage your own MCP client instances. ```ts -import { MultiServerMCPClient } from "@langchain/mcp-adapters"; +import { createAgent } from "langchain"; import { ChatOpenAI } from "@langchain/openai"; -import { createReactAgent } from "@langchain/langgraph/prebuilt"; +import { MultiServerMCPClient } from "@langchain/mcp-adapters"; // Create client and connect to server const client = new MultiServerMCPClient({ @@ -129,7 +129,7 @@ const model = new ChatOpenAI({ }); // Create the React agent -const agent = createReactAgent({ +const agent = createAgent({ llm: model, tools, }); @@ -166,8 +166,9 @@ export OPENAI_API_KEY= ```ts import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +import { createAgent } from "langchain"; import { ChatOpenAI } from "@langchain/openai"; -import { createReactAgent } from "@langchain/langgraph/prebuilt"; import { loadMcpTools } from "@langchain/mcp-adapters"; // Initialize the ChatOpenAI model @@ -202,7 +203,7 @@ try { }); // Create and run the agent - const agent = createReactAgent({ llm: model, tools }); + const agent = createAgent({ llm: model, tools }); const agentResponse = await agent.invoke({ messages: [{ role: "user", content: "what's (3 + 5) x 12?" }], }); diff --git a/libs/providers/langchain-mcp-adapters/__tests__/fixtures/dummy-stdio-server.ts b/libs/providers/langchain-mcp-adapters/__tests__/fixtures/dummy-stdio-server.ts deleted file mode 100644 index e839ea5bf4c3..000000000000 --- a/libs/providers/langchain-mcp-adapters/__tests__/fixtures/dummy-stdio-server.ts +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env node - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import { z } from 'zod'; - -// Get server name from command line arguments -const serverName = process.argv[2] || 'dummy-server'; - -const server = new McpServer({ name: serverName, version: '1.0.0' }); - -// Add test tools that capture request metadata -server.tool( - 'test_tool', - 'A test tool that echoes input and metadata', - { input: z.string() }, - async ({ input }, extra) => { - return { - content: [{ - type: 'text', - text: JSON.stringify({ - input, - meta: extra._meta, - serverName - }) - }], - }; - } -); - -// Add a tool that can check environment variables -server.tool( - 'check_env', - 'Check environment variable', - { varName: z.string() }, - async ({ varName }) => { - return { - content: [{ - type: 'text', - text: process.env[varName] || 'NOT_SET' - }], - }; - } -); - -// Connect to stdio transport -const transport = new StdioServerTransport(); -await server.connect(transport); \ No newline at end of file diff --git a/libs/providers/langchain-mcp-adapters/__tests__/mocks.ts b/libs/providers/langchain-mcp-adapters/__tests__/mocks.ts deleted file mode 100644 index 3b1619dc670b..000000000000 --- a/libs/providers/langchain-mcp-adapters/__tests__/mocks.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { vi } from "vitest"; - -// Set up mocks for external modules -vi.mock("@modelcontextprotocol/sdk/client/index.js", () => { - const clientPrototype = { - connect: vi.fn().mockReturnValue(Promise.resolve()), - listTools: vi.fn().mockReturnValue( - Promise.resolve({ - tools: [ - { - name: "tool1", - description: "Test tool 1", - inputSchema: { type: "object", properties: {} }, - }, - { - name: "tool2", - description: "Test tool 2", - inputSchema: { type: "object", properties: {} }, - }, - ], - }) - ), - callTool: vi - .fn() - .mockReturnValue( - Promise.resolve({ content: [{ type: "text", text: "result" }] }) - ), - close: vi.fn().mockImplementation(() => Promise.resolve()), - tools: [], // Add the tools property - }; - const Client = vi.fn().mockImplementation(() => clientPrototype); - Client.prototype = clientPrototype; - return { - Client, - }; -}); - -vi.mock("@modelcontextprotocol/sdk/client/stdio.js", () => { - const stdioClientTransportPrototype = { - connect: vi.fn().mockReturnValue(Promise.resolve()), - send: vi.fn().mockReturnValue(Promise.resolve()), - close: vi.fn().mockReturnValue(Promise.resolve()), - }; - const StdioClientTransport = vi.fn().mockImplementation((config) => { - return { - ...stdioClientTransportPrototype, - config, - }; - }); - StdioClientTransport.prototype = stdioClientTransportPrototype; - return { - StdioClientTransport, - }; -}); - -vi.mock("@modelcontextprotocol/sdk/client/sse.js", () => { - const sseClientTransportPrototype = { - connect: vi.fn().mockReturnValue(Promise.resolve()), - send: vi.fn().mockReturnValue(Promise.resolve()), - close: vi.fn().mockReturnValue(Promise.resolve()), - }; - const SSEClientTransport = vi.fn().mockImplementation((config) => { - return { - ...sseClientTransportPrototype, - config, - }; - }); - SSEClientTransport.prototype = sseClientTransportPrototype; - return { - SSEClientTransport, - }; -}); - -vi.mock("@modelcontextprotocol/sdk/client/streamableHttp.js", () => { - const streamableHTTPClientTransportPrototype = { - connect: vi.fn().mockReturnValue(Promise.resolve()), - send: vi.fn().mockReturnValue(Promise.resolve()), - close: vi.fn().mockReturnValue(Promise.resolve()), - }; - const StreamableHTTPClientTransport = vi.fn().mockImplementation((config) => { - return { - ...streamableHTTPClientTransportPrototype, - config, - }; - }); - StreamableHTTPClientTransport.prototype = streamableHTTPClientTransportPrototype; - return { - StreamableHTTPClientTransport, - }; -}); diff --git a/libs/providers/langchain-mcp-adapters/eslint.config.ts b/libs/providers/langchain-mcp-adapters/eslint.config.ts index 7a5736286833..63960fd26bc3 100644 --- a/libs/providers/langchain-mcp-adapters/eslint.config.ts +++ b/libs/providers/langchain-mcp-adapters/eslint.config.ts @@ -1,5 +1,9 @@ +import path from "node:path"; +import url from "node:url"; import { langchainConfig, type ConfigArray } from "@langchain/eslint"; +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + const config: ConfigArray = [ ...langchainConfig, { @@ -7,8 +11,8 @@ const config: ConfigArray = [ files: ["examples/**/*.ts"], languageOptions: { parserOptions: { - project: "./tsconfig.examples.json", - tsconfigRootDir: import.meta.dirname, + project: "./examples/tsconfig.json", + tsconfigRootDir: __dirname, }, }, rules: { @@ -22,7 +26,7 @@ const config: ConfigArray = [ languageOptions: { parserOptions: { project: "./tsconfig.tests.json", - tsconfigRootDir: import.meta.dirname, + tsconfigRootDir: __dirname, }, }, }, diff --git a/libs/providers/langchain-mcp-adapters/examples/README.md b/libs/providers/langchain-mcp-adapters/examples/README.md index 1ba2de9d010f..f821384ec1aa 100644 --- a/libs/providers/langchain-mcp-adapters/examples/README.md +++ b/libs/providers/langchain-mcp-adapters/examples/README.md @@ -5,8 +5,8 @@ This directory contains examples demonstrating how to use the `@langchain/mcp-ad ## Running the Examples ```bash -# Build all examples -pnpm build:examples +# type check examples +pnpm lint:examples # Run specific example cd examples && npx -y tsx firecrawl_custom_config_example.ts @@ -14,22 +14,28 @@ cd examples && npx -y tsx firecrawl_custom_config_example.ts ## Example Descriptions -### Filesystem LangGraph Example (`filesystem_langgraph_example.ts`) +### Filesystem LangGraph Example (`filesystem_langgraph_example.ts`) + Demonstrates using the Filesystem MCP server with LangGraph to create a structured workflow for complex file operations. The example creates a graph-based agent that can perform various file operations like creating multiple files, reading files, creating directory structures, and organizing files. -### Firecrawl - Custom Configuration (`firecrawl_custom_config_example.ts`) +### Firecrawl - Custom Configuration (`firecrawl_custom_config_example.ts`) + Shows how to initialize the Firecrawl MCP server with a custom configuration. The example sets up a connection to Firecrawl using SSE transport, loads tools from the server, and creates a React agent to perform web scraping tasks and find news about artificial intelligence. -### Firecrawl - Multiple Servers (`firecrawl_multiple_servers_example.ts`) +### Firecrawl - Multiple Servers (`firecrawl_multiple_servers_example.ts`) + Demonstrates how to use multiple MCP servers simultaneously by configuring both Firecrawl for web scraping and a Math server for calculations. The example creates a React agent that can use tools from both servers to answer queries involving both math calculations and web content retrieval. -### LangGraph - Complex Config (`langgraph_complex_config_example.ts`) +### LangGraph - Complex Config (`langgraph_complex_config_example.ts`) + Illustrates using different configuration files to set up connections to MCP servers, with a focus on the Math server. This example shows how to parse JSON configuration files, connect to a Math server directly, and create a LangGraph workflow that can perform mathematical operations using MCP tools. -### LangGraph - Simple Config (`langgraph_example.ts`) +### LangGraph - Simple Config (`langgraph_example.ts`) + Shows a straightforward integration of LangGraph with MCP tools, creating a flexible agent workflow. The example demonstrates how to set up a graph-based structure with separate nodes for LLM reasoning and tool execution, with conditional routing between nodes based on whether tool calls are needed. -### Launching a Containerized MCP Server (`mcp_over_docker_example.ts`) +### Launching a Containerized MCP Server (`mcp_over_docker_example.ts`) + Shows how to run an MCP server inside a Docker container. This example configures a connection to a containerized Filesystem MCP server with appropriate volume mounting, demonstrating how to use Docker to isolate and run MCP servers while still allowing file operations. ## Requirements diff --git a/libs/providers/langchain-mcp-adapters/examples/calculator_server_shttp_sse.ts b/libs/providers/langchain-mcp-adapters/examples/calculator_server_shttp_sse.ts index 34d7a3b8e599..658867d5ed65 100644 --- a/libs/providers/langchain-mcp-adapters/examples/calculator_server_shttp_sse.ts +++ b/libs/providers/langchain-mcp-adapters/examples/calculator_server_shttp_sse.ts @@ -19,7 +19,7 @@ export async function main() { "add", "Adds two numbers together", calcSchema, - async ({ a, b }: { a: number; b: number }, extra) => { + async ({ a, b }: { a: number; b: number }) => { return { content: [{ type: "text", text: `${a + b}` }], }; @@ -30,7 +30,7 @@ export async function main() { "subtract", "Subtracts two numbers", calcSchema, - async ({ a, b }: { a: number; b: number }, extra) => { + async ({ a, b }: { a: number; b: number }) => { return { content: [{ type: "text", text: `${a - b}` }] }; } ); @@ -39,7 +39,7 @@ export async function main() { "multiply", "Multiplies two numbers", calcSchema, - async ({ a, b }: { a: number; b: number }, extra) => { + async ({ a, b }: { a: number; b: number }) => { return { content: [{ type: "text", text: `${a * b}` }] }; } ); @@ -48,7 +48,7 @@ export async function main() { "divide", "Divides two numbers", calcSchema, - async ({ a, b }: { a: number; b: number }, extra) => { + async ({ a, b }: { a: number; b: number }) => { return { content: [{ type: "text", text: `${a / b}` }] }; } ); @@ -134,7 +134,7 @@ export async function main() { app.delete("/mcp", handleSessionRequest); // Legacy SSE endpoint for older clients - app.get("/sse", async (req, res) => { + app.get("/sse", async (_req, res) => { // Create SSE transport for legacy clients const transport = new SSEServerTransport("/messages", res); transports.sse[transport.sessionId] = transport; diff --git a/libs/providers/langchain-mcp-adapters/examples/calculator_sse_shttp_example.ts b/libs/providers/langchain-mcp-adapters/examples/calculator_sse_shttp_example.ts index b0a1d2e9f475..30d9d65f368f 100644 --- a/libs/providers/langchain-mcp-adapters/examples/calculator_sse_shttp_example.ts +++ b/libs/providers/langchain-mcp-adapters/examples/calculator_sse_shttp_example.ts @@ -22,7 +22,6 @@ import { HumanMessage, AIMessage, SystemMessage, - isHumanMessage, } from "@langchain/core/messages"; import dotenv from "dotenv"; @@ -102,7 +101,7 @@ these tools to answer the user's questions.`; // Add system message if it's the first call let { messages } = state; - if (messages.length === 1 && isHumanMessage(messages[0])) { + if (messages.length === 1 && HumanMessage.isInstance(messages[0])) { messages = [new SystemMessage(systemMessage), ...messages]; } diff --git a/libs/providers/langchain-mcp-adapters/examples/filesystem_langgraph_example.ts b/libs/providers/langchain-mcp-adapters/examples/filesystem_langgraph_example.ts index d3838f6177d9..147738eddf03 100644 --- a/libs/providers/langchain-mcp-adapters/examples/filesystem_langgraph_example.ts +++ b/libs/providers/langchain-mcp-adapters/examples/filesystem_langgraph_example.ts @@ -17,13 +17,7 @@ import { START, MessagesAnnotation, } from "@langchain/langgraph"; -import { ToolNode } from "@langchain/langgraph/prebuilt"; -import { - HumanMessage, - AIMessage, - SystemMessage, - isHumanMessage, -} from "@langchain/core/messages"; +import { HumanMessage, AIMessage, SystemMessage, ToolNode } from "langchain"; import dotenv from "dotenv"; import fs from "fs"; import path from "path"; @@ -105,7 +99,7 @@ For reading multiple files, you can use the read_multiple_files tool.`; // Add system message if it's the first call let { messages } = state; - if (messages.length === 1 && isHumanMessage(messages[0])) { + if (messages.length === 1 && HumanMessage.isInstance(messages[0])) { messages = [new SystemMessage(systemMessage), ...messages]; } diff --git a/libs/providers/langchain-mcp-adapters/examples/firecrawl_custom_config_example.ts b/libs/providers/langchain-mcp-adapters/examples/firecrawl_custom_config_example.ts index eda2789285e5..611ebc8b9edc 100644 --- a/libs/providers/langchain-mcp-adapters/examples/firecrawl_custom_config_example.ts +++ b/libs/providers/langchain-mcp-adapters/examples/firecrawl_custom_config_example.ts @@ -6,9 +6,8 @@ */ import { ChatOpenAI } from "@langchain/openai"; -import { HumanMessage } from "@langchain/core/messages"; +import { HumanMessage, createAgent } from "langchain"; import dotenv from "dotenv"; -import { createReactAgent } from "@langchain/langgraph/prebuilt"; // MCP client imports import { type ClientConfig, MultiServerMCPClient } from "../src/index.js"; @@ -65,7 +64,7 @@ async function runExample() { }); // Create a React agent using LangGraph's createReactAgent - const agent = createReactAgent({ + const agent = createAgent({ llm: model, tools: firecrawlTools, }); diff --git a/libs/providers/langchain-mcp-adapters/examples/firecrawl_multiple_servers_example.ts b/libs/providers/langchain-mcp-adapters/examples/firecrawl_multiple_servers_example.ts index ca05dbebfde4..e3a103951245 100644 --- a/libs/providers/langchain-mcp-adapters/examples/firecrawl_multiple_servers_example.ts +++ b/libs/providers/langchain-mcp-adapters/examples/firecrawl_multiple_servers_example.ts @@ -6,8 +6,7 @@ */ import { ChatOpenAI } from "@langchain/openai"; -import { createReactAgent } from "@langchain/langgraph/prebuilt"; -import { HumanMessage } from "@langchain/core/messages"; +import { HumanMessage, createAgent } from "langchain"; import dotenv from "dotenv"; import { ClientConfig, MultiServerMCPClient } from "../src/index.js"; @@ -81,7 +80,7 @@ async function runExample() { console.log("\n=== CREATING REACT AGENT ==="); // Create the React agent - const agent = createReactAgent({ + const agent = createAgent({ llm: model, tools: mcpTools, }); diff --git a/libs/providers/langchain-mcp-adapters/examples/langgraph_example.ts b/libs/providers/langchain-mcp-adapters/examples/langgraph_example.ts index d6ebc1def6b5..47016d01213b 100644 --- a/libs/providers/langchain-mcp-adapters/examples/langgraph_example.ts +++ b/libs/providers/langchain-mcp-adapters/examples/langgraph_example.ts @@ -29,8 +29,7 @@ import { START, MessagesAnnotation, } from "@langchain/langgraph"; -import { ToolNode } from "@langchain/langgraph/prebuilt"; -import { HumanMessage, AIMessage, BaseMessage } from "@langchain/core/messages"; +import { HumanMessage, AIMessage, BaseMessage, ToolNode } from "langchain"; import dotenv from "dotenv"; // MCP client imports diff --git a/libs/providers/langchain-mcp-adapters/examples/tsconfig.json b/libs/providers/langchain-mcp-adapters/examples/tsconfig.json new file mode 100644 index 000000000000..f95dc36a2adf --- /dev/null +++ b/libs/providers/langchain-mcp-adapters/examples/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "../", + "declaration": false, + "strict": true, + "noEmit": true // we just want type checking - no need to output for inclusion in the published module + }, + "include": ["./**/*", "../src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/libs/providers/langchain-mcp-adapters/package.json b/libs/providers/langchain-mcp-adapters/package.json index 07dae1017f00..a9f57dd90390 100644 --- a/libs/providers/langchain-mcp-adapters/package.json +++ b/libs/providers/langchain-mcp-adapters/package.json @@ -15,16 +15,16 @@ "url": "https://github.com/langchain-ai/langchainjs/issues" }, "scripts": { - "build": "run-s \"build:main\" \"build:examples\"", + "build": "run-s build:main", "build:main": "pnpm --filter @langchain/build compile @langchain/mcp-adapters", - "build:examples": "tsc -p tsconfig.examples.json", "clean": "rm -rf dist/ dist-cjs/ .turbo/", "format": "prettier --config .prettierrc --write \"src/**/*.ts\" \"examples/**/*.ts\"", "format:check": "prettier --config .prettierrc --check \"src\" \"examples/**/*.ts\"", - "lint": "pnpm lint:eslint && pnpm lint:dpdm", + "lint": "run-s lint:eslint lint:dpdm lint:examples", "lint:dpdm": "dpdm --skip-dynamic-imports circular --exit-code circular:1 --no-warning --no-tree src/**/*.ts examples/**/*.ts", "lint:eslint": "eslint --cache src/ examples/", "lint:fix": "pnpm lint:eslint --fix && pnpm lint:dpdm", + "lint:examples": "tsc -p ./examples/tsconfig.json", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest" @@ -51,7 +51,7 @@ "@eslint/js": "^9.21.0", "@langchain/core": "workspace:*", "@langchain/eslint": "workspace:*", - "@langchain/langgraph": "^0.2.62", + "@langchain/langgraph": "next", "@langchain/openai": "workspace:*", "@tsconfig/recommended": "^1.0.8", "@types/debug": "^4.1.12", @@ -61,10 +61,10 @@ "dotenv": "^16.4.7", "dpdm": "^3.14.0", "eslint": "^9.34.0", - "eslint-plugin-vitest": "^0.5.4", "eventsource": "^3.0.6", "express": "^5.1.0", - "npm-run-all": "^4.1.5", + "langchain": "workspace:*", + "npm-run-all2": "^8.0.4", "prettier": "^2.8.3", "release-it": "^17.6.0", "rollup": "^4.39.0", diff --git a/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/index.ts b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/index.ts new file mode 100644 index 000000000000..ac56c0294858 --- /dev/null +++ b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/index.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { vi } from "vitest"; + +const clientPrototype = { + connect: vi.fn().mockReturnValue(Promise.resolve()), + listTools: vi.fn().mockReturnValue( + Promise.resolve({ + tools: [ + { + name: "tool1", + description: "Test tool 1", + inputSchema: { type: "object", properties: {} }, + }, + { + name: "tool2", + description: "Test tool 2", + inputSchema: { type: "object", properties: {} }, + }, + ], + }) + ), + callTool: vi + .fn() + .mockReturnValue( + Promise.resolve({ content: [{ type: "text", text: "result" }] }) + ), + close: vi.fn().mockImplementation(() => Promise.resolve()), + tools: [], // Add the tools property +}; +export const Client: any = vi.fn().mockImplementation(() => clientPrototype); +Client.prototype = clientPrototype; diff --git a/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/sse.ts b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/sse.ts new file mode 100644 index 000000000000..c642c778137b --- /dev/null +++ b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/sse.ts @@ -0,0 +1,16 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { vi } from "vitest"; + +const sseClientTransportPrototype = { + connect: vi.fn().mockReturnValue(Promise.resolve()), + send: vi.fn().mockReturnValue(Promise.resolve()), + close: vi.fn().mockReturnValue(Promise.resolve()), +}; +export const SSEClientTransport: any = vi.fn().mockImplementation((config) => { + return { + ...sseClientTransportPrototype, + config, + }; +}); +SSEClientTransport.prototype = sseClientTransportPrototype; diff --git a/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/stdio.ts b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/stdio.ts new file mode 100644 index 000000000000..5caf5ff37b51 --- /dev/null +++ b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/stdio.ts @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { vi } from "vitest"; + +const stdioClientTransportPrototype = { + connect: vi.fn().mockReturnValue(Promise.resolve()), + send: vi.fn().mockReturnValue(Promise.resolve()), + close: vi.fn().mockReturnValue(Promise.resolve()), +}; +export const StdioClientTransport: any = vi + .fn() + .mockImplementation((config) => { + return { + ...stdioClientTransportPrototype, + config, + }; + }); +StdioClientTransport.prototype = stdioClientTransportPrototype; diff --git a/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/streamableHttp.ts b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/streamableHttp.ts new file mode 100644 index 000000000000..5023ba715a05 --- /dev/null +++ b/libs/providers/langchain-mcp-adapters/src/tests/__mocks__/@modelcontextprotocol/sdk/client/streamableHttp.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// eslint-disable-next-line import/no-extraneous-dependencies +import { vi } from "vitest"; + +const streamableHTTPClientTransportPrototype = { + connect: vi.fn().mockReturnValue(Promise.resolve()), + send: vi.fn().mockReturnValue(Promise.resolve()), + close: vi.fn().mockReturnValue(Promise.resolve()), +}; +export const StreamableHTTPClientTransport: any = vi + .fn() + .mockImplementation((config) => { + return { + ...streamableHTTPClientTransportPrototype, + config, + }; + }); +StreamableHTTPClientTransport.prototype = + streamableHTTPClientTransportPrototype; diff --git a/libs/providers/langchain-mcp-adapters/__tests__/client.basic.test.ts b/libs/providers/langchain-mcp-adapters/src/tests/client.basic.test.ts similarity index 94% rename from libs/providers/langchain-mcp-adapters/__tests__/client.basic.test.ts rename to libs/providers/langchain-mcp-adapters/src/tests/client.basic.test.ts index 487d5d91da40..94fb1cce935f 100644 --- a/libs/providers/langchain-mcp-adapters/__tests__/client.basic.test.ts +++ b/libs/providers/langchain-mcp-adapters/src/tests/client.basic.test.ts @@ -9,20 +9,28 @@ import { } from "vitest"; import { ZodError } from "zod/v3"; -import "./mocks.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -const { MultiServerMCPClient, MCPClientError } = await import( - "../src/client.js" +import { MultiServerMCPClient, MCPClientError } from "../client.js"; + +vi.mock( + "@modelcontextprotocol/sdk/client/index.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/index.js") ); -const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); -const { StdioClientTransport } = await import( - "@modelcontextprotocol/sdk/client/stdio.js" +vi.mock( + "@modelcontextprotocol/sdk/client/stdio.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/stdio.js") ); -const { SSEClientTransport } = await import( - "@modelcontextprotocol/sdk/client/sse.js" +vi.mock( + "@modelcontextprotocol/sdk/client/sse.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/sse.js") ); -const { StreamableHTTPClientTransport } = await import( - "@modelcontextprotocol/sdk/client/streamableHttp.js" +vi.mock( + "@modelcontextprotocol/sdk/client/streamableHttp.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/streamableHttp.js") ); describe("MultiServerMCPClient", () => { @@ -78,7 +86,6 @@ describe("MultiServerMCPClient", () => { test("should have a compile time error and a runtime error when the config is invalid", () => { expect(() => { - // eslint-disable-next-line no-new new MultiServerMCPClient({ "test-server": { // @ts-expect-error shouldn't match type constraints here @@ -452,7 +459,6 @@ describe("MultiServerMCPClient", () => { describe("streamable HTTP transport", () => { test("should throw when streamable HTTP config is missing required fields", () => { expect(() => { - // eslint-disable-next-line no-new new MultiServerMCPClient({ // @ts-expect-error missing url field "test-server": { @@ -465,7 +471,6 @@ describe("MultiServerMCPClient", () => { test("should throw when streamable HTTP URL is invalid", () => { expect(() => { - // eslint-disable-next-line no-new new MultiServerMCPClient({ "test-server": { transport: "http", diff --git a/libs/providers/langchain-mcp-adapters/__tests__/client.comprehensive.test.ts b/libs/providers/langchain-mcp-adapters/src/tests/client.comprehensive.test.ts similarity index 94% rename from libs/providers/langchain-mcp-adapters/__tests__/client.comprehensive.test.ts rename to libs/providers/langchain-mcp-adapters/src/tests/client.comprehensive.test.ts index f3dc1a49ea83..608121bd67c6 100644 --- a/libs/providers/langchain-mcp-adapters/__tests__/client.comprehensive.test.ts +++ b/libs/providers/langchain-mcp-adapters/src/tests/client.comprehensive.test.ts @@ -1,23 +1,31 @@ import { vi, describe, test, expect, beforeEach, type Mock } from "vitest"; import { ZodError } from "zod/v3"; -import type { Connection } from "../src/types.js"; - -import "./mocks.js"; // Import modules after mocking -const { StdioClientTransport } = await import( - "@modelcontextprotocol/sdk/client/stdio.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; + +import type { Connection } from "../types.js"; +import { MultiServerMCPClient, MCPClientError } from "../client.js"; + +vi.mock( + "@modelcontextprotocol/sdk/client/index.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/index.js") ); -const { SSEClientTransport } = await import( - "@modelcontextprotocol/sdk/client/sse.js" +vi.mock( + "@modelcontextprotocol/sdk/client/stdio.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/stdio.js") ); -const { StreamableHTTPClientTransport } = await import( - "@modelcontextprotocol/sdk/client/streamableHttp.js" +vi.mock( + "@modelcontextprotocol/sdk/client/sse.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/sse.js") ); -const { MultiServerMCPClient, MCPClientError } = await import( - "../src/client.js" +vi.mock( + "@modelcontextprotocol/sdk/client/streamableHttp.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/streamableHttp.js") ); -const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); beforeEach(() => { vi.clearAllMocks(); @@ -101,7 +109,6 @@ describe("MultiServerMCPClient", () => { // Should throw error during initialization expect(() => { - // eslint-disable-next-line no-new new MultiServerMCPClient(config); }).toThrow(ZodError); }); diff --git a/libs/providers/langchain-mcp-adapters/__tests__/client.int.test.ts b/libs/providers/langchain-mcp-adapters/src/tests/client.test.ts similarity index 94% rename from libs/providers/langchain-mcp-adapters/__tests__/client.int.test.ts rename to libs/providers/langchain-mcp-adapters/src/tests/client.test.ts index 77bffbaa7911..8a3bc68aa102 100644 --- a/libs/providers/langchain-mcp-adapters/__tests__/client.int.test.ts +++ b/libs/providers/langchain-mcp-adapters/src/tests/client.test.ts @@ -3,19 +3,13 @@ import { Server } from "node:http"; import { join } from "node:path"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; -import type { - Base64ContentBlock, - DataContentBlock, - MessageContentComplex, - MessageContentText, - StandardAudioBlock, - MessageContentImageUrl, -} from "@langchain/core/messages"; -import type { ToolCall } from "@langchain/core/dist/messages/tool.js"; +import type { ContentBlock } from "@langchain/core/messages"; +import type { ToolCall } from "@langchain/core/messages"; import type { StructuredToolInterface } from "@langchain/core/tools"; -import { MultiServerMCPClient } from "../src/client.js"; + import { createDummyHttpServer } from "./fixtures/dummy-http-server.js"; -import { type ClientConfig } from "../src/types.js"; +import { MultiServerMCPClient } from "../client.js"; +import { type ClientConfig } from "../types.js"; // Manages dummy MCP servers for testing class TestMCPServers { @@ -700,10 +694,10 @@ describe("MultiServerMCPClient Integration Tests", () => { expires_in: 3600, }; }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -764,10 +758,10 @@ describe("MultiServerMCPClient Integration Tests", () => { expires_in: 3600, }; }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -832,10 +826,10 @@ describe("MultiServerMCPClient Integration Tests", () => { expires_in: 3600, }; }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -894,10 +888,10 @@ describe("MultiServerMCPClient Integration Tests", () => { async tokens() { return undefined; // No tokens available }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -957,10 +951,10 @@ describe("MultiServerMCPClient Integration Tests", () => { async tokens() { throw new Error("OAuth provider error: Failed to retrieve tokens"); }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -1025,10 +1019,10 @@ describe("MultiServerMCPClient Integration Tests", () => { expires_in: 3600, }; }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -1118,10 +1112,10 @@ describe("MultiServerMCPClient Integration Tests", () => { expires_in: 3600, }; }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -1213,10 +1207,10 @@ describe("MultiServerMCPClient Integration Tests", () => { expires_in: 3600, }; }, - async saveTokens(tokens: OAuthTokens) { + async saveTokens(_tokens: OAuthTokens) { // Mock implementation }, - async saveCodeVerifier(codeVerifier: string) { + async saveCodeVerifier(_codeVerifier: string) { // Mock implementation }, async codeVerifier() { @@ -1461,26 +1455,23 @@ describe("MultiServerMCPClient Integration Tests", () => { // Expect content to be an array of MessageContentComplex or DataContentBlock expect(Array.isArray(content)).toBe(true); - const contentArray = content as ( - | MessageContentComplex - | DataContentBlock - )[]; + const contentArray = content as ContentBlock[]; const textBlock = contentArray.find( (c) => c.type === "text" - ) as MessageContentText; + ) as ContentBlock.Text; expect(textBlock).toBeDefined(); expect(textBlock.text).toContain("Audio input was: test audio input"); expect(textBlock.text).toContain("http-audio-test"); const audioBlock = contentArray.find( (c) => c.type === "audio" - ) as StandardAudioBlock & Base64ContentBlock; + ) as ContentBlock.Multimodal.Audio; expect(audioBlock).toBeDefined(); expect(audioBlock.source_type).toBe("base64"); expect(audioBlock.mime_type).toBe("audio/wav"); expect(typeof audioBlock.data).toBe("string"); - expect(audioBlock.data.length).toBeGreaterThan(10); + expect(audioBlock.data?.length).toBeGreaterThan(10); } finally { await client.close(); } @@ -1531,20 +1522,17 @@ describe("MultiServerMCPClient Integration Tests", () => { args: toolInput, }); expect(imgArtifact).toEqual([]); - const imgContentArray = imgContent as ( - | MessageContentComplex - | DataContentBlock - )[]; + const imgContentArray = imgContent as ContentBlock[]; const imgTextBlock = imgContentArray.find( (c) => c.type === "text" - ) as MessageContentText; + ) as ContentBlock.Text; expect(imgTextBlock.text).toContain( "Image input was: test standard blocks" ); const imgBlock = imgContentArray.find( (c) => c.type === "image" - ) as Base64ContentBlock; + ) as ContentBlock.Multimodal.Data; expect(imgBlock.source_type).toBe("base64"); expect(imgBlock.mime_type).toBe("image/png"); expect(typeof imgBlock.data).toBe("string"); @@ -1557,20 +1545,17 @@ describe("MultiServerMCPClient Integration Tests", () => { args: toolInput, }); expect(audioArtifact).toEqual([]); - const audioContentArray = audioContent as ( - | MessageContentComplex - | DataContentBlock - )[]; + const audioContentArray = audioContent as ContentBlock[]; const audioTextBlock = audioContentArray.find( (c) => c.type === "text" - ) as MessageContentText; + ) as ContentBlock.Text; expect(audioTextBlock.text).toContain( "Audio input was: test standard blocks" ); const audioBlock = audioContentArray.find( (c) => c.type === "audio" - ) as StandardAudioBlock & Base64ContentBlock; + ) as ContentBlock.Multimodal.Audio; expect(audioBlock.source_type).toBe("base64"); expect(audioBlock.mime_type).toBe("audio/wav"); } finally { @@ -1610,26 +1595,21 @@ describe("MultiServerMCPClient Integration Tests", () => { args: toolInput, }); expect(imgArtifact).toEqual([]); - const imgContentArray = imgContent as ( - | MessageContentComplex - | DataContentBlock - )[]; + const imgContentArray = imgContent as ContentBlock[]; const imgTextBlock = imgContentArray.find( (c) => c.type === "text" - ) as MessageContentText; + ) as ContentBlock.Text; expect(imgTextBlock.text).toContain( "Image input was: test standard blocks" ); // Check for legacy image_url format const imgUrlBlock = imgContentArray.find( (c) => c.type === "image_url" - ) as MessageContentImageUrl; + ) as ContentBlock.Multimodal.Data; expect(imgUrlBlock).toBeDefined(); - const imageUrl = - typeof imgUrlBlock.image_url === "string" - ? imgUrlBlock.image_url - : imgUrlBlock.image_url.url; + // @ts-expect-error image_url is unknown + const imageUrl = imgUrlBlock.image_url?.url; expect(imageUrl).toMatch(/^data:image\/png;base64,/); // Audio should still use StandardAudioBlock @@ -1642,13 +1622,10 @@ describe("MultiServerMCPClient Integration Tests", () => { args: toolInput, }); expect(audioArtifact).toEqual([]); - const audioContentArray = audioContent as ( - | MessageContentComplex - | DataContentBlock - )[]; + const audioContentArray = audioContent as ContentBlock[]; const audioBlock = audioContentArray.find( (c) => c.type === "audio" - ) as StandardAudioBlock & Base64ContentBlock; + ) as ContentBlock.Multimodal.Audio; expect(audioBlock.source_type).toBe("base64"); expect(audioBlock.mime_type).toBe("audio/wav"); } finally { @@ -1715,14 +1692,16 @@ describe("MultiServerMCPClient Integration Tests", () => { expect(Array.isArray(imgContentResult)).toBe(true); expect(imgArtifact).toEqual([]); - const imgContentArray = imgContentResult as MessageContentComplex[]; + const imgContentArray = imgContentResult as ContentBlock[]; expect(imgContentArray).toHaveLength(2); expect(imgContentArray).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "text", - text: expect.stringContaining(imageToolInput.args.input), + text: expect.stringContaining( + imageToolInput.args.input as string + ), }), expect.objectContaining({ type: "image_url", @@ -1737,15 +1716,19 @@ describe("MultiServerMCPClient Integration Tests", () => { await resourceTool.invoke(resourceToolInput); if (typeof resContentResult === "string") { - expect(resContentResult).toContain(resourceToolInput.args.input); + expect(resContentResult).toContain( + resourceToolInput.args.input as string + ); } else { expect(Array.isArray(resContentResult)).toBe(true); - const resContentArray = resContentResult as MessageContentComplex[]; + const resContentArray = resContentResult as ContentBlock[]; expect(resContentArray).toHaveLength(1); expect(resContentArray[0]).toEqual( expect.objectContaining({ type: "text", - text: expect.stringContaining(resourceToolInput.args.input), + text: expect.stringContaining( + resourceToolInput.args.input as string + ), }) ); } @@ -1800,7 +1783,9 @@ describe("MultiServerMCPClient Integration Tests", () => { expect.arrayContaining([ expect.objectContaining({ type: "text", - text: expect.stringContaining(imageToolInput.args.input), + text: expect.stringContaining( + imageToolInput.args.input as string + ), }), expect.objectContaining({ type: "image", @@ -1818,7 +1803,9 @@ describe("MultiServerMCPClient Integration Tests", () => { expect.arrayContaining([ expect.objectContaining({ type: "text", - text: expect.stringContaining(resourceToolInput.args.input), + text: expect.stringContaining( + resourceToolInput.args.input as string + ), }), expect.objectContaining({ type: "resource", @@ -1835,7 +1822,9 @@ describe("MultiServerMCPClient Integration Tests", () => { expect.arrayContaining([ expect.objectContaining({ type: "text", - text: expect.stringContaining(audioToolInput.args.input), + text: expect.stringContaining( + audioToolInput.args.input as string + ), }), expect.objectContaining({ type: "audio", @@ -1878,13 +1867,15 @@ describe("MultiServerMCPClient Integration Tests", () => { await imageTool.invoke(imageToolInput); expect(imgArtifact).toEqual([]); expect(Array.isArray(imgContent)).toBe(true); - const imgContentArray = imgContent as MessageContentComplex[]; + const imgContentArray = imgContent as ContentBlock[]; expect(imgContentArray).toHaveLength(2); expect(imgContentArray).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "text", - text: expect.stringContaining(imageToolInput.args.input), + text: expect.stringContaining( + imageToolInput.args.input as string + ), }), expect.objectContaining({ type: "image_url", @@ -1899,16 +1890,15 @@ describe("MultiServerMCPClient Integration Tests", () => { await resourceTool.invoke(resourceToolInput); expect(resArtifact).toEqual([]); expect(Array.isArray(resContent)).toBe(true); - const resContentArray = resContent as ( - | MessageContentComplex - | DataContentBlock - )[]; + const resContentArray = resContent as ContentBlock[]; expect(resContentArray).toHaveLength(2); expect(resContentArray).toEqual( expect.arrayContaining([ expect.objectContaining({ type: "text", - text: expect.stringContaining(resourceToolInput.args.input), + text: expect.stringContaining( + resourceToolInput.args.input as string + ), }), expect.objectContaining({ type: "file", @@ -1959,7 +1949,7 @@ describe("MultiServerMCPClient Integration Tests", () => { if (typeof imgContent === "string") { expect(imgContent).toContain(imageToolInput.args.input); } else { - const imgContentArray = imgContent as MessageContentComplex[]; + const imgContentArray = imgContent as ContentBlock[]; expect(imgContentArray).toHaveLength(1); expect(imgContentArray[0]).toEqual( expect.objectContaining({ type: "text" }) @@ -1975,10 +1965,7 @@ describe("MultiServerMCPClient Integration Tests", () => { const { content: resContent, artifact: resArtifact } = await resourceTool.invoke(resourceToolInput); expect(resArtifact).toEqual([]); - const resContentArray = resContent as ( - | MessageContentComplex - | DataContentBlock - )[]; + const resContentArray = resContent as ContentBlock[]; expect(resContentArray).toHaveLength(2); expect(resContentArray).toEqual( expect.arrayContaining([ @@ -2026,7 +2013,7 @@ describe("MultiServerMCPClient Integration Tests", () => { const { content: imgContent, artifact: imgArtifact } = await imageTool.invoke(imageToolInput); - const imgContentArray = imgContent as MessageContentComplex[]; + const imgContentArray = imgContent as ContentBlock[]; expect(imgContentArray).toHaveLength(1); expect(imgContentArray[0]).toEqual({ image_url: { @@ -2037,7 +2024,7 @@ describe("MultiServerMCPClient Integration Tests", () => { expect(imgArtifact).toHaveLength(1); expect(imgArtifact[0]).toEqual({ type: "text", - text: expect.stringContaining(imageToolInput.args.input), + text: expect.stringContaining(imageToolInput.args.input as string), }); const { content: resContent, artifact: resArtifact } = @@ -2094,7 +2081,7 @@ describe("MultiServerMCPClient Integration Tests", () => { if (typeof imgContent === "string") { expect(imgContent).toContain(imageToolInput.args.input); } else if (Array.isArray(imgContent)) { - const imgContentArray = imgContent as DataContentBlock[]; + const imgContentArray = imgContent as ContentBlock[]; expect(imgContentArray).toHaveLength(1); expect(imgContentArray[0]).toEqual( expect.objectContaining({ type: "text", source_type: "text" }) @@ -2114,7 +2101,7 @@ describe("MultiServerMCPClient Integration Tests", () => { const { content: audioContent, artifact: audioArtifact } = await audioTool.invoke(audioToolInput); expect(audioArtifact).toEqual([]); - const audioContentArray = audioContent as DataContentBlock[]; + const audioContentArray = audioContent as ContentBlock[]; expect(audioContentArray).toHaveLength(2); expect(audioContentArray).toEqual( expect.arrayContaining([ @@ -2132,7 +2119,7 @@ describe("MultiServerMCPClient Integration Tests", () => { if (typeof resContent === "string") { expect(resContent).toContain(resourceToolInput.args.input); } else if (Array.isArray(resContent)) { - const resContentArray = resContent as DataContentBlock[]; + const resContentArray = resContent as ContentBlock[]; expect(resContentArray).toHaveLength(1); expect(resContentArray[0]).toEqual( expect.objectContaining({ type: "text", source_type: "text" }) diff --git a/libs/providers/langchain-mcp-adapters/__tests__/fixtures/dummy-http-server.ts b/libs/providers/langchain-mcp-adapters/src/tests/fixtures/dummy-http-server.ts similarity index 89% rename from libs/providers/langchain-mcp-adapters/__tests__/fixtures/dummy-http-server.ts rename to libs/providers/langchain-mcp-adapters/src/tests/fixtures/dummy-http-server.ts index 99a2001e0c1f..3b85b78962f5 100644 --- a/libs/providers/langchain-mcp-adapters/__tests__/fixtures/dummy-http-server.ts +++ b/libs/providers/langchain-mcp-adapters/src/tests/fixtures/dummy-http-server.ts @@ -1,14 +1,14 @@ -import { randomUUID } from 'node:crypto'; +import { randomUUID } from "node:crypto"; // it's in dev dependencies - not sure why eslint gets mad here. // eslint-disable-next-line import/no-extraneous-dependencies -import express from 'express'; +import express, { type Express } from "express"; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; -import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; -import { z } from 'zod'; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; export function createDummyHttpServer( name: string, @@ -18,7 +18,7 @@ export function createDummyHttpServer( supportSSEFallback?: boolean; disableStreamableHttp?: boolean; } -) { +): Express { const server = new McpServer({ name, version: "1.0.0" }); // Store captured headers per session @@ -73,7 +73,7 @@ export function createDummyHttpServer( { headerName: z.string() }, async ({ headerName }, extra) => { // Get headers for this session - const sessionId = extra.sessionId || 'default'; + const sessionId = extra.sessionId || "default"; const headers = sessionHeaders[sessionId] || {}; return { content: [ @@ -94,10 +94,11 @@ export function createDummyHttpServer( { input: z.string().describe("Some input string for the audio tool"), }, - async ({ input }, extra) => { + async ({ input }) => { // Static base64 encoded minimal WAV file (1-byte silent audio) // This is a valid WAV file: RIFF header, WAVE format, fmt chunk (PCM, 44100Hz, 1 channel, 16-bit), data chunk (1 byte of 0x00) - const base64Audio = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA"; + const base64Audio = + "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA"; return { content: [ @@ -122,9 +123,10 @@ export function createDummyHttpServer( { input: z.string().describe("Some input string for the image tool"), }, - async ({ input }, extra) => { + async ({ input }) => { // Static base64 encoded minimal PNG file (1x1 black pixel) - const base64Image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + const base64Image = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; return { content: [ @@ -148,7 +150,7 @@ export function createDummyHttpServer( { input: z.string().describe("Some input string for the resource tool"), }, - async ({ input }, extra) => { + async ({ input }) => { return { content: [ { @@ -182,9 +184,11 @@ export function createDummyHttpServer( const captureHeaders = (req: express.Request, sessionId: string) => { if (options.testHeaders) { sessionHeaders[sessionId] = {}; - Object.keys(req.headers).forEach(key => { + Object.keys(req.headers).forEach((key) => { if (req.headers[key]) { - sessionHeaders[sessionId][key.toLowerCase()] = String(req.headers[key]); + sessionHeaders[sessionId][key.toLowerCase()] = String( + req.headers[key] + ); } }); } @@ -276,7 +280,7 @@ export function createDummyHttpServer( const transport = new SSEServerTransport("/messages", res); transports.sse[transport.sessionId] = transport; - + // Capture headers for SSE session captureHeaders(req, transport.sessionId); diff --git a/libs/providers/langchain-mcp-adapters/src/tests/fixtures/dummy-stdio-server.ts b/libs/providers/langchain-mcp-adapters/src/tests/fixtures/dummy-stdio-server.ts new file mode 100644 index 000000000000..9405cb9cc63b --- /dev/null +++ b/libs/providers/langchain-mcp-adapters/src/tests/fixtures/dummy-stdio-server.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; + +// Get server name from command line arguments +const serverName = process.argv[2] || "dummy-server"; + +const server = new McpServer({ name: serverName, version: "1.0.0" }); + +// Add test tools that capture request metadata +server.tool( + "test_tool", + "A test tool that echoes input and metadata", + { input: z.string() }, + async ({ input }, extra) => { + return { + content: [ + { + type: "text", + text: JSON.stringify({ + input, + meta: extra._meta, + serverName, + }), + }, + ], + }; + } +); + +// Add a tool that can check environment variables +server.tool( + "check_env", + "Check environment variable", + { varName: z.string() }, + async ({ varName }) => { + return { + content: [ + { + type: "text", + // eslint-disable-next-line no-process-env + text: process.env[varName] || "NOT_SET", + }, + ], + }; + } +); + +// Connect to stdio transport +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/libs/providers/langchain-mcp-adapters/__tests__/tools.test.ts b/libs/providers/langchain-mcp-adapters/src/tests/tools.test.ts similarity index 92% rename from libs/providers/langchain-mcp-adapters/__tests__/tools.test.ts rename to libs/providers/langchain-mcp-adapters/src/tests/tools.test.ts index 5493a63af7d1..dbbccd908370 100644 --- a/libs/providers/langchain-mcp-adapters/__tests__/tools.test.ts +++ b/libs/providers/langchain-mcp-adapters/src/tests/tools.test.ts @@ -15,7 +15,24 @@ import type { ToolMessage, } from "@langchain/core/messages"; -const { loadMcpTools } = await import("../src/tools.js"); +import { loadMcpTools } from "../tools.js"; + +vi.mock( + "@modelcontextprotocol/sdk/client/index.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/index.js") +); +vi.mock( + "@modelcontextprotocol/sdk/client/stdio.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/stdio.js") +); +vi.mock( + "@modelcontextprotocol/sdk/client/sse.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/sse.js") +); +vi.mock( + "@modelcontextprotocol/sdk/client/streamableHttp.js", + () => import("./__mocks__/@modelcontextprotocol/sdk/client/streamableHttp.js") +); // Create a mock client describe("Simplified Tool Adapter Tests", () => { @@ -122,14 +139,12 @@ describe("Simplified Tool Adapter Tests", () => { "It is currently 70 degrees and cloudy in New York." ); - expect(mockClient.callTool).toHaveBeenCalledWith( - { - arguments: { - city: "New York", - }, - name: "weather", - } - ); + expect(mockClient.callTool).toHaveBeenCalledWith({ + arguments: { + city: "New York", + }, + name: "weather", + }); }); test("should load tool with no input parameters", async () => { @@ -148,7 +163,7 @@ describe("Simplified Tool Adapter Tests", () => { }) ); - mockClient.callTool.mockImplementation((params) => { + mockClient.callTool.mockImplementation((_params) => { return Promise.resolve({ content: [ { diff --git a/libs/providers/langchain-mcp-adapters/tsconfig.cjs.json b/libs/providers/langchain-mcp-adapters/tsconfig.cjs.json deleted file mode 100644 index a67a84ea490e..000000000000 --- a/libs/providers/langchain-mcp-adapters/tsconfig.cjs.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "module": "commonjs", - "moduleResolution": "node10", - "declaration": false - }, - "exclude": ["node_modules", "dist", "docs", "**/tests"] -} diff --git a/libs/providers/langchain-mcp-adapters/tsconfig.examples.json b/libs/providers/langchain-mcp-adapters/tsconfig.examples.json deleted file mode 100644 index 39cf3381bf93..000000000000 --- a/libs/providers/langchain-mcp-adapters/tsconfig.examples.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": false, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "sourceMap": true, - "downlevelIteration": true, - "noEmit": true, // we just want type checking - no need to output for inclusion in the published module - "resolveJsonModule": true - }, - "include": ["examples/**/*", "src/**/*"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] -} diff --git a/libs/providers/langchain-mcp-adapters/tsconfig.json b/libs/providers/langchain-mcp-adapters/tsconfig.json index 28efdc26da6a..8a92672fe634 100644 --- a/libs/providers/langchain-mcp-adapters/tsconfig.json +++ b/libs/providers/langchain-mcp-adapters/tsconfig.json @@ -3,9 +3,9 @@ "compilerOptions": { "outDir": "dist", "rootDir": "./src", - "target": "ES2021", - "lib": ["ES2021", "ES2022.Object", "DOM"], - "module": "ES2020", + "target": "ES2022", + "lib": ["ES2022", "ES2022.Object", "DOM"], + "module": "ES2022", "moduleResolution": "bundler", "esModuleInterop": true, "declaration": true, diff --git a/libs/providers/langchain-mcp-adapters/vitest.config.ts b/libs/providers/langchain-mcp-adapters/vitest.config.ts index 9dcebf5b6a8f..cbc2ca373435 100644 --- a/libs/providers/langchain-mcp-adapters/vitest.config.ts +++ b/libs/providers/langchain-mcp-adapters/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["**/__tests__/**/*.test.ts"], + include: ["**/*.test.ts"], coverage: { provider: "v8", reporter: ["text", "lcov"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c871382ddd33..5458fde0a985 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2629,8 +2629,8 @@ importers: specifier: workspace:* version: link:../../../internal/eslint '@langchain/langgraph': - specifier: ^0.2.62 - version: 0.2.74(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76)) + specifier: next + version: 1.0.0-alpha.1(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76) '@langchain/openai': specifier: workspace:* version: link:../langchain-openai @@ -2658,18 +2658,18 @@ importers: eslint: specifier: ^9.34.0 version: 9.35.0(jiti@2.5.1) - eslint-plugin-vitest: - specifier: ^0.5.4 - version: 0.5.4(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.0)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)) eventsource: specifier: ^3.0.6 version: 3.0.7 express: specifier: ^5.1.0 version: 5.1.0 - npm-run-all: - specifier: ^4.1.5 - version: 4.1.5 + langchain: + specifier: workspace:* + version: link:../../langchain + npm-run-all2: + specifier: ^8.0.4 + version: 8.0.4 prettier: specifier: ^2.8.3 version: 2.8.8 @@ -5926,32 +5926,12 @@ packages: peerDependencies: '@langchain/core': workspace:* - '@langchain/langgraph-checkpoint@0.0.18': - resolution: {integrity: sha512-IS7zJj36VgY+4pf8ZjsVuUWef7oTwt1y9ylvwu0aLuOn1d0fg05Om9DLm3v2GZ2Df6bhLV1kfWAM0IAl9O5rQQ==} - engines: {node: '>=18'} - peerDependencies: - '@langchain/core': workspace:* - '@langchain/langgraph-checkpoint@0.1.1': resolution: {integrity: sha512-h2bP0RUikQZu0Um1ZUPErQLXyhzroJqKRbRcxYRTAh49oNlsfeq4A3K4YEDRbGGuyPZI/Jiqwhks1wZwY73AZw==} engines: {node: '>=18'} peerDependencies: '@langchain/core': workspace:* - '@langchain/langgraph-sdk@0.0.105': - resolution: {integrity: sha512-3DD1W1wnbP48807qq+5gY248mFcwwNGqKdmZt05P3zeLpfP5Sfm6ELzVvqHGpr+qumP0yGRZs/7qArYGXRRfcQ==} - peerDependencies: - '@langchain/core': workspace:* - react: ^18 || ^19 - react-dom: ^18 || ^19 - peerDependenciesMeta: - '@langchain/core': - optional: true - react: - optional: true - react-dom: - optional: true - '@langchain/langgraph-sdk@0.1.4': resolution: {integrity: sha512-pITGjh6ayNLgtJ8Ant2lyFZ/o94ePlrH8zNgMLeiDqdRDjoqSehW5k3SZucpG4n9U12qR/WtPXDzbyHa93ISbA==} peerDependencies: @@ -5966,16 +5946,6 @@ packages: react-dom: optional: true - '@langchain/langgraph@0.2.74': - resolution: {integrity: sha512-oHpEi5sTZTPaeZX1UnzfM2OAJ21QGQrwReTV6+QnX7h8nDCBzhtipAw1cK616S+X8zpcVOjgOtJuaJhXa4mN8w==} - engines: {node: '>=18'} - peerDependencies: - '@langchain/core': workspace:* - zod-to-json-schema: ^3.x - peerDependenciesMeta: - zod-to-json-schema: - optional: true - '@langchain/langgraph@1.0.0-alpha.1': resolution: {integrity: sha512-CQzPdCXYo4b3rG1x95bHD93sFw11HgZANNfG3NDIDfxntpL4VFwUaZHKfKvsL3PWU30Cm+LDnXl8rZocgibcGQ==} engines: {node: '>=18'} @@ -8209,10 +8179,6 @@ packages: resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/scope-manager@7.18.0': - resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} - engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/scope-manager@8.38.0': resolution: {integrity: sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -8261,10 +8227,6 @@ packages: resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/types@7.18.0': - resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} - engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/types@8.38.0': resolution: {integrity: sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -8282,15 +8244,6 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@7.18.0': - resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - '@typescript-eslint/typescript-estree@8.38.0': resolution: {integrity: sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -8309,12 +8262,6 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@7.18.0': - resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} - engines: {node: ^18.18.0 || >=20.0.0} - peerDependencies: - eslint: ^8.56.0 - '@typescript-eslint/utils@8.38.0': resolution: {integrity: sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -8333,10 +8280,6 @@ packages: resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@typescript-eslint/visitor-keys@7.18.0': - resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} - engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/visitor-keys@8.38.0': resolution: {integrity: sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -9972,10 +9915,6 @@ packages: cross-fetch@4.1.0: resolution: {integrity: sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==} - cross-spawn@6.0.6: - resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} - engines: {node: '>=4.8'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -10693,19 +10632,6 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-vitest@0.5.4: - resolution: {integrity: sha512-um+odCkccAHU53WdKAw39MY61+1x990uXjSPguUCq3VcEHdqJrOb8OTMrbYlY6f9jAKx7x98kLVlIe3RJeJqoQ==} - engines: {node: ^18.0.0 || >= 20.0.0} - peerDependencies: - '@typescript-eslint/eslint-plugin': '*' - eslint: ^8.57.0 || ^9.0.0 - vitest: '*' - peerDependenciesMeta: - '@typescript-eslint/eslint-plugin': - optional: true - vitest: - optional: true - eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} @@ -11591,9 +11517,6 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - hosted-git-info@2.8.9: - resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} - hpagent@1.2.0: resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} engines: {node: '>=14'} @@ -12457,6 +12380,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-parse-even-better-errors@4.0.0: + resolution: {integrity: sha512-lR4MXjGNgkJc7tkQ97kb2nuEMnNCyU//XYVH0MKTGcXEiSudQ5MKGKen3C5QubYy0vmq+JGitUg92uuywGEwIA==} + engines: {node: ^18.17.0 || >=20.5.0} + json-schema-to-typescript@13.1.2: resolution: {integrity: sha512-17G+mjx4nunvOpkPvcz7fdwUwYCEwyH8vR3Ym3rFiQ8uzAL3go+c1306Kk7iGRk8HuXBXqy+JJJmpYl0cvOllw==} engines: {node: '>=12.0.0'} @@ -13440,9 +13367,6 @@ packages: nice-grpc@2.1.12: resolution: {integrity: sha512-J1n4Wg+D3IhRhGQb+iqh2OpiM0GzTve/kf2lnlW4S+xczmIEd0aHUDV1OsJ5a3q8GSTqJf+s4Rgg1M8uJltarw==} - nice-try@1.0.5: - resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} - node-abi@3.75.0: resolution: {integrity: sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==} engines: {node: '>=10'} @@ -13535,9 +13459,6 @@ packages: engines: {node: '>=6'} hasBin: true - normalize-package-data@2.5.0: - resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} - normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -13554,9 +13475,13 @@ packages: resolution: {integrity: sha512-SsYhhigh+jOv06QOiFynOQATPMl96CspWDIL3Q5klzp4eaZ1dYaPI3ELoly80G1K0jf730u3ItvfwskzPKK41g==} engines: {node: '>=12'} - npm-run-all@4.1.5: - resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} - engines: {node: '>= 4'} + npm-normalize-package-bin@4.0.0: + resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} + engines: {node: ^18.17.0 || >=20.5.0} + + npm-run-all2@8.0.4: + resolution: {integrity: sha512-wdbB5My48XKp2ZfJUlhnLVihzeuA1hgBnqB2J9ahV77wLS+/YAJAlN8I+X3DIFIPZ3m5L7nplmlbhNiFDmXRDA==} + engines: {node: ^20.5.0 || >=22.0.0, npm: '>= 10'} hasBin: true npm-run-path@4.0.1: @@ -14008,10 +13933,6 @@ packages: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} - path-key@2.0.1: - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} - engines: {node: '>=4'} - path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -14034,10 +13955,6 @@ packages: resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} engines: {node: '>=16'} - path-type@3.0.0: - resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} - engines: {node: '>=4'} - path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -14151,11 +14068,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pidtree@0.3.1: - resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} - engines: {node: '>=0.10'} - hasBin: true - pidtree@0.6.0: resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} engines: {node: '>=0.10'} @@ -14576,9 +14488,9 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - read-pkg@3.0.0: - resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} - engines: {node: '>=4'} + read-package-json-fast@4.0.0: + resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} + engines: {node: ^18.17.0 || >=20.5.0} readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -14963,10 +14875,6 @@ packages: resolution: {integrity: sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==} engines: {node: '>=12'} - semver@5.7.2: - resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} - hasBin: true - semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -15046,18 +14954,10 @@ packages: resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@1.2.0: - resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} - engines: {node: '>=0.10.0'} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} - shebang-regex@1.0.0: - resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} - engines: {node: '>=0.10.0'} - shebang-regex@3.0.0: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} @@ -15189,18 +15089,6 @@ packages: sparse-bitfield@3.0.3: resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.21: - resolution: {integrity: sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==} - split-ca@1.0.1: resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} @@ -15340,10 +15228,6 @@ packages: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} - string.prototype.padend@3.1.6: - resolution: {integrity: sha512-XZpspuSB7vJWhvJc9DLSlrXl1mcA2BdoY5jjnS135ydXqLoqhs96JjDtCkjJEQHvfqZIp9hBuBMgI589peyx9Q==} - engines: {node: '>= 0.4'} - string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} @@ -15693,12 +15577,6 @@ packages: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} - ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' - ts-api-utils@2.1.0: resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} engines: {node: '>=18.12'} @@ -16304,9 +16182,6 @@ packages: resolution: {integrity: sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==} engines: {node: '>=0.10.0'} - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -16527,10 +16402,6 @@ packages: resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} engines: {node: '>= 0.4'} - which@1.3.1: - resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} - hasBin: true - which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -16541,6 +16412,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -20612,27 +20488,11 @@ snapshots: - encoding optional: true - '@langchain/langgraph-checkpoint@0.0.18(@langchain/core@libs+langchain-core)': - dependencies: - '@langchain/core': link:libs/langchain-core - uuid: 10.0.0 - '@langchain/langgraph-checkpoint@0.1.1(@langchain/core@libs+langchain-core)': dependencies: '@langchain/core': link:libs/langchain-core uuid: 10.0.0 - '@langchain/langgraph-sdk@0.0.105(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': - dependencies: - '@types/json-schema': 7.0.15 - p-queue: 6.6.2 - p-retry: 4.6.2 - uuid: 9.0.1 - optionalDependencies: - '@langchain/core': link:libs/langchain-core - react: 19.0.0 - react-dom: 18.3.1(react@19.0.0) - '@langchain/langgraph-sdk@0.1.4(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)': dependencies: '@types/json-schema': 7.0.15 @@ -20644,19 +20504,6 @@ snapshots: react: 19.0.0 react-dom: 18.3.1(react@19.0.0) - '@langchain/langgraph@0.2.74(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))': - dependencies: - '@langchain/core': link:libs/langchain-core - '@langchain/langgraph-checkpoint': 0.0.18(@langchain/core@libs+langchain-core) - '@langchain/langgraph-sdk': 0.0.105(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0) - uuid: 10.0.0 - zod: 3.25.76 - optionalDependencies: - zod-to-json-schema: 3.24.6(zod@3.25.76) - transitivePeerDependencies: - - react - - react-dom - '@langchain/langgraph@1.0.0-alpha.1(@langchain/core@libs+langchain-core)(react-dom@18.3.1(react@19.0.0))(react@19.0.0)(zod-to-json-schema@3.24.6(zod@3.25.76))(zod@3.25.76)': dependencies: '@langchain/core': link:libs/langchain-core @@ -23105,11 +22952,6 @@ snapshots: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - '@typescript-eslint/scope-manager@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/scope-manager@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 @@ -23166,8 +23008,6 @@ snapshots: '@typescript-eslint/types@5.62.0': {} - '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/types@8.38.0': {} '@typescript-eslint/types@8.43.0': {} @@ -23186,21 +23026,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.18.0(typescript@5.8.3)': - dependencies: - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.1(supports-color@8.1.1) - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 1.4.3(typescript@5.8.3) - optionalDependencies: - typescript: 5.8.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.38.0(typescript@5.8.3)': dependencies: '@typescript-eslint/project-service': 8.38.0(typescript@5.8.3) @@ -23248,17 +23073,6 @@ snapshots: - supports-color - typescript - '@typescript-eslint/utils@7.18.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)': - dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.35.0(jiti@2.5.1)) - '@typescript-eslint/scope-manager': 7.18.0 - '@typescript-eslint/types': 7.18.0 - '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) - eslint: 9.35.0(jiti@2.5.1) - transitivePeerDependencies: - - supports-color - - typescript - '@typescript-eslint/utils@8.38.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.35.0(jiti@2.5.1)) @@ -23286,11 +23100,6 @@ snapshots: '@typescript-eslint/types': 5.62.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@7.18.0': - dependencies: - '@typescript-eslint/types': 7.18.0 - eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.38.0': dependencies: '@typescript-eslint/types': 8.38.0 @@ -25371,14 +25180,6 @@ snapshots: transitivePeerDependencies: - encoding - cross-spawn@6.0.6: - dependencies: - nice-try: 1.0.5 - path-key: 2.0.1 - semver: 5.7.2 - shebang-command: 1.2.0 - which: 1.3.1 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -26377,16 +26178,6 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-vitest@0.5.4(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.17.0)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1)): - dependencies: - '@typescript-eslint/utils': 7.18.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.8.3) - eslint: 9.35.0(jiti@2.5.1) - optionalDependencies: - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.17.0)(jiti@2.5.1)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.1) - transitivePeerDependencies: - - supports-color - - typescript - eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 @@ -27626,8 +27417,6 @@ snapshots: hookable@5.5.3: {} - hosted-git-info@2.8.9: {} - hpagent@1.2.0: {} html-encoding-sniffer@3.0.0: @@ -28922,6 +28711,8 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-parse-even-better-errors@4.0.0: {} + json-schema-to-typescript@13.1.2: dependencies: '@bcherny/json-schema-ref-parser': 10.0.5-fork @@ -29946,8 +29737,6 @@ snapshots: abort-controller-x: 0.4.3 nice-grpc-common: 2.0.2 - nice-try@1.0.5: {} - node-abi@3.75.0: dependencies: semver: 7.7.2 @@ -30069,13 +29858,6 @@ snapshots: dependencies: abbrev: 1.1.1 - normalize-package-data@2.5.0: - dependencies: - hosted-git-info: 2.8.9 - resolve: 1.22.10 - semver: 5.7.2 - validate-npm-package-license: 3.0.4 - normalize-path@3.0.0: {} normalize-range@0.1.2: {} @@ -30089,17 +29871,18 @@ snapshots: transitivePeerDependencies: - encoding - npm-run-all@4.1.5: + npm-normalize-package-bin@4.0.0: {} + + npm-run-all2@8.0.4: dependencies: - ansi-styles: 3.2.1 - chalk: 2.4.2 - cross-spawn: 6.0.6 + ansi-styles: 6.2.1 + cross-spawn: 7.0.6 memorystream: 0.3.1 - minimatch: 3.1.2 - pidtree: 0.3.1 - read-pkg: 3.0.0 + picomatch: 4.0.3 + pidtree: 0.6.0 + read-package-json-fast: 4.0.0 shell-quote: 1.8.3 - string.prototype.padend: 3.1.6 + which: 5.0.0 npm-run-path@4.0.1: dependencies: @@ -30677,8 +30460,6 @@ snapshots: path-is-absolute@1.0.1: {} - path-key@2.0.1: {} - path-key@3.1.1: {} path-key@4.0.0: {} @@ -30694,10 +30475,6 @@ snapshots: path-to-regexp@8.2.0: {} - path-type@3.0.0: - dependencies: - pify: 3.0.0 - path-type@4.0.0: {} path-type@5.0.0: {} @@ -30806,8 +30583,6 @@ snapshots: picomatch@4.0.3: {} - pidtree@0.3.1: {} - pidtree@0.6.0: {} pify@2.3.0: {} @@ -31268,11 +31043,10 @@ snapshots: dependencies: pify: 2.3.0 - read-pkg@3.0.0: + read-package-json-fast@4.0.0: dependencies: - load-json-file: 4.0.0 - normalize-package-data: 2.5.0 - path-type: 3.0.0 + json-parse-even-better-errors: 4.0.0 + npm-normalize-package-bin: 4.0.0 readable-stream@2.3.8: dependencies: @@ -31847,8 +31621,6 @@ snapshots: dependencies: semver: 7.7.2 - semver@5.7.2: {} - semver@6.3.1: {} semver@7.5.1: @@ -31997,16 +31769,10 @@ snapshots: '@img/sharp-win32-ia32': 0.34.3 '@img/sharp-win32-x64': 0.34.3 - shebang-command@1.2.0: - dependencies: - shebang-regex: 1.0.0 - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 - shebang-regex@1.0.0: {} - shebang-regex@3.0.0: {} shell-quote@1.8.3: {} @@ -32163,20 +31929,6 @@ snapshots: dependencies: memory-pager: 1.5.0 - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.21 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.21 - - spdx-license-ids@3.0.21: {} - split-ca@1.0.1: {} split2@4.2.0: {} @@ -32339,13 +32091,6 @@ snapshots: set-function-name: 2.0.2 side-channel: 1.1.0 - string.prototype.padend@3.1.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-object-atoms: 1.1.1 - string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 @@ -32756,10 +32501,6 @@ snapshots: triple-beam@1.4.1: {} - ts-api-utils@1.4.3(typescript@5.8.3): - dependencies: - typescript: 5.8.3 - ts-api-utils@2.1.0(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -33514,11 +33255,6 @@ snapshots: vali-date@1.0.0: {} - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - validate-npm-package-name@5.0.1: {} vary@1.1.2: {} @@ -33955,10 +33691,6 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 - which@1.3.1: - dependencies: - isexe: 2.0.0 - which@2.0.2: dependencies: isexe: 2.0.0 @@ -33967,6 +33699,10 @@ snapshots: dependencies: isexe: 3.1.1 + which@5.0.0: + dependencies: + isexe: 3.1.1 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 From f34b9e86bc55b397814632fd60c01ae57cc4a43d Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 18 Sep 2025 10:28:41 -0700 Subject: [PATCH 12/16] consolidate tests --- .../middleware/promptCaching.ts | 18 +- .../tests/promptCaching.int.test.ts | 118 +++++++ .../tests/reactAgent.int.test.ts | 305 +++++++++++++++++ .../src/agents/tests/reactAgent.int.test.ts | 323 +----------------- .../langchain-mcp-adapters/package.json | 6 +- 5 files changed, 445 insertions(+), 325 deletions(-) create mode 100644 libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts create mode 100644 libs/langchain/src/agents/middlewareAgent/tests/reactAgent.int.test.ts diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts index eb42b24b5115..38d0481aa0ce 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts @@ -1,5 +1,7 @@ import { z } from "zod/v3"; import { ContentBlock } from "@langchain/core/messages"; + +import { ConfigurableModel } from "../../../chat_models/universal.js"; import { createMiddleware } from "../middleware.js"; const DEFAULT_ENABLE_CACHING = true; @@ -165,11 +167,23 @@ export function anthropicPromptCachingMiddleware( middlewareOptions?.minMessagesToCache; // Skip if caching is disabled - if (!enableCaching) { + if (!enableCaching || !options.model) { return undefined; } - if (options.model?.getName() !== "ChatAnthropic") { + if ( + !options.model + /** + * user passes in a ChatAnthropic instance + */ || + (options.model.getName() !== "ChatAnthropic" && + /** + * user passes in a model via string, e.g. "anthropic:claude-3-5-sonnet" + */ + "_defaultConfig" in options.model && + (options.model as ConfigurableModel)._defaultConfig?.modelProvider !== + "anthropic") + ) { throw new Error( "Prompt caching is only supported for Anthropic models" ); diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts b/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts new file mode 100644 index 000000000000..155232c47998 --- /dev/null +++ b/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from "vitest"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { HumanMessage, AIMessage } from "@langchain/core/messages"; +import { ChatAnthropic } from "@langchain/anthropic"; + +import { anthropicPromptCachingMiddleware } from "../promptCaching.js"; +import { createAgent } from "../../index.js"; + +describe("anthropicPromptCachingMiddleware", () => { + it("should allow middleware to update model, messages and systemMessage", async () => { + // Setup mocked fetch functions for both providers + const anthropicResponse = vi.fn((res) => res.clone()); + const anthropicFetchMock = vi.fn((url, options) => + fetch(url, options).then(anthropicResponse) + ); + const anthropicModel = new ChatAnthropic({ + model: "claude-opus-4-20250514", + temperature: 0.7, + maxTokens: 500, + topP: 0.95, + clientOptions: { + fetch: anthropicFetchMock, + }, + }); + + // Create a simple tool for testing + const simpleTool = tool( + async (input: { query: string }) => { + return `Tool response for: ${input.query}`; + }, + { + name: "simpleTool", + schema: z.object({ + query: z.string().describe("The query to process"), + }), + description: "A simple tool for testing", + } + ); + + const messages = [ + new HumanMessage(`I'm working on a comprehensive educational project about European capitals and their historical significance. I need detailed and accurate information for my research, which will be published in an upcoming textbook for high school students studying world geography and history. + + Before I ask my specific question, let me provide extensive context about the subject matter: France is a Western European country with a rich history spanning over millennia. It has been a major cultural, political, and economic power throughout history. The country has played a pivotal role in the development of Western civilization, from the Renaissance through the Enlightenment to modern times. The nation's influence extends far beyond its borders, shaping global politics, culture, and intellectual thought for centuries. + + France is renowned worldwide for its extraordinary contributions to art, science, philosophy, cuisine, fashion, and literature. The country has produced countless influential figures including philosophers like René Descartes, Voltaire, Jean-Jacques Rousseau, and Simone de Beauvoir; scientists like Marie Curie, Louis Pasteur, Blaise Pascal, and Henri Poincaré; writers like Victor Hugo, Marcel Proust, Albert Camus, and Marguerite Duras; and artists like Claude Monet, Auguste Rodin, Edgar Degas, and Henri Matisse. The French Revolution of 1789 had a profound and lasting impact on the development of modern democratic ideals, human rights, and political philosophy across the globe. + + The country is administratively divided into several distinct regions, each with its own unique culture, dialect, culinary traditions, and historical significance. From the aromatic lavender fields of Provence to the world-famous vineyards of Bordeaux, from the glamorous beaches of the Côte d'Azur to the majestic peaks of the Alps and Pyrenees, France offers incredible geographical and cultural diversity. The Loire Valley is known for its magnificent châteaux, Brittany for its Celtic heritage, Normandy for its D-Day beaches and apple orchards, and Alsace for its unique Franco-German culture. + + France's economy is one of the world's largest, with strong sectors in aerospace, automotive, luxury goods, tourism, and agriculture. The country is famous for its haute cuisine, which UNESCO recognized as an Intangible Cultural Heritage of Humanity. French wines, cheeses, and pastries are celebrated globally. The French language itself has been a lingua franca of diplomacy, culture, and international relations for centuries, and continues to be one of the working languages of many international organizations. + + The educational system in France has produced numerous Nobel laureates, Fields Medal winners, and other distinguished scholars. French universities like the Sorbonne have been centers of learning since the Middle Ages. The country's commitment to arts and culture is evident in its numerous world-class museums, including the Louvre, Musée d'Orsay, and Centre Pompidou, as well as its vibrant theater, cinema, and music scenes. + + In terms of governance, France is a unitary semi-presidential republic with a strong democratic tradition. The country is a founding member of the European Union and plays a crucial role in European and global politics. It maintains significant cultural and economic ties with francophone countries around the world through organizations like La Francophonie. + + Now, for my comprehensive educational project that requires accurate and reliable information about European capitals, I need to know the answer to this fundamental question: What is the capital of France? + + Please provide a clear, direct, and authoritative answer, as this information will be used in an educational context for students learning about European geography, and accuracy is of paramount importance for their academic development.`), + new HumanMessage("What is the capital of France?"), + ]; + + // Create agent with OpenAI model string and the middleware + const agent = createAgent({ + llm: anthropicModel, + tools: [simpleTool], + prompt: "You are a geography expert.", + middleware: [ + anthropicPromptCachingMiddleware({ + ttl: "5m", + minMessagesToCache: 1, + }), + ] as const, + }); + + // Invoke the agent + const result = await agent.invoke({ + messages, + }); + + // Verify that Anthropic was called (not OpenAI) + expect(anthropicFetchMock).toHaveBeenCalled(); + + // Verify the request to Anthropic includes our model settings + const anthropicCall = anthropicFetchMock.mock.calls[0]; + const requestBody = JSON.parse(anthropicCall[1].body); + + // Check that cache_control was passed through + expect(requestBody.messages).toHaveLength(2); + expect(requestBody.messages.at(-1).content[0]).toEqual({ + type: "text", + text: expect.stringContaining("What is the capital of France?"), + cache_control: { + type: "ephemeral", + ttl: "5m", + }, + }); + + // Check that the system message was updated + expect(requestBody.system).toBe("You are a geography expert."); + + // The response should be about France, not Tokyo weather + expect(result.messages).toBeDefined(); + expect(result.messages.length).toBeGreaterThan(0); + + // Find the AI response message + const aiResponse = result.messages.find((msg) => AIMessage.isInstance(msg)); + expect(aiResponse).toBeDefined(); + + // validate raw response has cache_control set + const rawResponse = await anthropicResponse.mock.calls[0][0].json(); + // tokens will be already cached when the same test is run again within a short time period + // so we expect them to be either in the creation or read bucket + const cachedTokens = + rawResponse.usage.cache_creation_input_tokens || + rawResponse.usage.cache_read_input_tokens; + expect(cachedTokens).toBe(1227); + }); +}); diff --git a/libs/langchain/src/agents/middlewareAgent/tests/reactAgent.int.test.ts b/libs/langchain/src/agents/middlewareAgent/tests/reactAgent.int.test.ts new file mode 100644 index 000000000000..4ad28315d76b --- /dev/null +++ b/libs/langchain/src/agents/middlewareAgent/tests/reactAgent.int.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi } from "vitest"; +import { tool } from "@langchain/core/tools"; +import { z } from "zod"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { ChatOpenAI } from "@langchain/openai"; +import { + HumanMessage, + SystemMessage, + AIMessage, +} from "@langchain/core/messages"; + +import { createMiddleware, createAgent } from "../index.js"; + +describe("modifyModelRequest", () => { + it("should allow middleware to update model, messages and systemMessage", async () => { + // Setup mocked fetch functions for both providers + const openAIFetchMock = vi.fn((url, options) => fetch(url, options)); + const anthropicResponse = vi.fn((res) => res.clone()); + const anthropicFetchMock = vi.fn((url, options) => + fetch(url, options).then(anthropicResponse) + ); + + // Create a simple tool for testing + const simpleTool = tool( + async (input: { query: string }) => { + return `Tool response for: ${input.query}`; + }, + { + name: "simpleTool", + schema: z.object({ + query: z.string().describe("The query to process"), + }), + description: "A simple tool for testing", + } + ); + + // Create middleware that will change the model and messages + const modelSwitchMiddleware = createMiddleware({ + name: "modelSwitcher", + modifyModelRequest: async (_request, _state, _runtime) => { + // Create a new ChatAnthropic instance + const anthropicModel = new ChatAnthropic({ + model: "claude-opus-4-20250514", + temperature: 0.7, + maxTokens: 500, + topP: 0.95, + clientOptions: { + fetch: anthropicFetchMock, + }, + }); + + // Change the messages to ask a completely different question + const newMessages = [ + new HumanMessage(`I'm working on a comprehensive educational project about European capitals and their historical significance. I need detailed and accurate information for my research, which will be published in an upcoming textbook for high school students studying world geography and history. + +Before I ask my specific question, let me provide extensive context about the subject matter: France is a Western European country with a rich history spanning over millennia. It has been a major cultural, political, and economic power throughout history. The country has played a pivotal role in the development of Western civilization, from the Renaissance through the Enlightenment to modern times. The nation's influence extends far beyond its borders, shaping global politics, culture, and intellectual thought for centuries. + +France is renowned worldwide for its extraordinary contributions to art, science, philosophy, cuisine, fashion, and literature. The country has produced countless influential figures including philosophers like René Descartes, Voltaire, Jean-Jacques Rousseau, and Simone de Beauvoir; scientists like Marie Curie, Louis Pasteur, Blaise Pascal, and Henri Poincaré; writers like Victor Hugo, Marcel Proust, Albert Camus, and Marguerite Duras; and artists like Claude Monet, Auguste Rodin, Edgar Degas, and Henri Matisse. The French Revolution of 1789 had a profound and lasting impact on the development of modern democratic ideals, human rights, and political philosophy across the globe. + +The country is administratively divided into several distinct regions, each with its own unique culture, dialect, culinary traditions, and historical significance. From the aromatic lavender fields of Provence to the world-famous vineyards of Bordeaux, from the glamorous beaches of the Côte d'Azur to the majestic peaks of the Alps and Pyrenees, France offers incredible geographical and cultural diversity. The Loire Valley is known for its magnificent châteaux, Brittany for its Celtic heritage, Normandy for its D-Day beaches and apple orchards, and Alsace for its unique Franco-German culture. + +France's economy is one of the world's largest, with strong sectors in aerospace, automotive, luxury goods, tourism, and agriculture. The country is famous for its haute cuisine, which UNESCO recognized as an Intangible Cultural Heritage of Humanity. French wines, cheeses, and pastries are celebrated globally. The French language itself has been a lingua franca of diplomacy, culture, and international relations for centuries, and continues to be one of the working languages of many international organizations. + +The educational system in France has produced numerous Nobel laureates, Fields Medal winners, and other distinguished scholars. French universities like the Sorbonne have been centers of learning since the Middle Ages. The country's commitment to arts and culture is evident in its numerous world-class museums, including the Louvre, Musée d'Orsay, and Centre Pompidou, as well as its vibrant theater, cinema, and music scenes. + +In terms of governance, France is a unitary semi-presidential republic with a strong democratic tradition. The country is a founding member of the European Union and plays a crucial role in European and global politics. It maintains significant cultural and economic ties with francophone countries around the world through organizations like La Francophonie. + +Now, for my comprehensive educational project that requires accurate and reliable information about European capitals, I need to know the answer to this fundamental question: What is the capital of France? + +Please provide a clear, direct, and authoritative answer, as this information will be used in an educational context for students learning about European geography, and accuracy is of paramount importance for their academic development.`), + ]; + + // Return partial ModelRequest - tools will be merged from original request + return { + model: anthropicModel, + messages: newMessages, + systemMessage: new SystemMessage("You are a geography expert."), + toolChoice: "none", + tools: [], + }; + }, + }); + + // Create agent with OpenAI model string and the middleware + const agent = createAgent({ + model: "gpt-4o-mini", + tools: [simpleTool], + middleware: [modelSwitchMiddleware], + }); + + // Invoke the agent + const result = await agent.invoke({ + messages: [new HumanMessage("What's the weather in Tokyo?")], + }); + + // Verify that Anthropic was called (not OpenAI) + expect(anthropicFetchMock).toHaveBeenCalled(); + expect(openAIFetchMock).not.toHaveBeenCalled(); + + // Verify the request to Anthropic includes our model settings + const anthropicCall = anthropicFetchMock.mock.calls[0]; + const requestBody = JSON.parse(anthropicCall[1].body); + + // Check that model settings were propagated + expect(requestBody.temperature).toBe(0.7); + expect(requestBody.max_tokens).toBe(500); + expect(requestBody.top_p).toBe(0.95); + // Check that messages were updated + expect(requestBody.messages).toHaveLength(1); + expect(requestBody.messages[0]).toEqual({ + role: "user", + content: expect.stringContaining( + "I'm working on a comprehensive educational" + ), + }); + + // Check that the system message was updated + expect(requestBody.system).toBe("You are a geography expert."); + + // The response should be about France, not Tokyo weather + expect(result.messages).toBeDefined(); + expect(result.messages.length).toBeGreaterThan(0); + + // Find the AI response message + const aiResponse = result.messages.find((msg) => msg.type === "ai"); + expect(aiResponse).toBeDefined(); + // The response should mention Paris or France, not Tokyo or weather + const responseContent = aiResponse?.content?.toString().toLowerCase() || ""; + expect(responseContent).toMatch(/paris|france/i); + expect(responseContent).not.toMatch(/tokyo|weather/i); + }); + + it("can change tools and toolChoice in modifyModelRequest", async () => { + // Setup mocked fetch for OpenAI + const openAIFetchMock = vi.fn(); + + // Create tools that will be added by middleware + const weatherTool = tool( + async (input: { location: string }) => { + return `Weather in ${input.location}: Sunny, 72°F`; + }, + { + name: "getWeather", + schema: z.object({ + location: z.string().describe("The location to get weather for"), + }), + description: "Get the current weather for a location", + } + ); + + const newsTool = tool( + async (input: { topic: string }) => { + return `Latest news on ${input.topic}: Breaking developments...`; + }, + { + name: "getNews", + schema: z.object({ + topic: z.string().describe("The topic to get news about"), + }), + description: "Get the latest news on a topic", + } + ); + + // Create middleware that adds tools and sets toolChoice + const toolsMiddleware = { + name: "toolsModifier", + modifyModelRequest: async () => { + // Add tools dynamically + const tools = [weatherTool, newsTool]; + + // Set toolChoice to force specific tool + return { + tools, + toolChoice: { + type: "function" as const, + function: { + name: "getWeather", + }, + }, + }; + }, + }; + + // Create OpenAI model initially without any tools + const openAIModel = new ChatOpenAI({ + model: "gpt-4", + temperature: 0, + configuration: { + fetch: openAIFetchMock, + }, + }); + + // Mock the OpenAI response with proper headers + openAIFetchMock.mockImplementation(async () => { + // Return a proper Response-like object + return { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers({ + "content-type": "application/json", + }), + json: async () => ({ + id: "chatcmpl-test", + object: "chat.completion", + created: Date.now(), + model: "gpt-4", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_test123", + type: "function", + function: { + name: "getWeather", + arguments: JSON.stringify({ location: "New York" }), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { + prompt_tokens: 50, + completion_tokens: 20, + total_tokens: 70, + }, + }), + text: async () => "", + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + clone: () => ({}), + body: null, + bodyUsed: false, + }; + }); + + // Create agent with the middleware + const agent = createAgent({ + llm: openAIModel, + // No tools provided initially + tools: [], + middleware: [toolsMiddleware], + }); + + // Invoke the agent + const result = await agent.invoke({ + messages: [new HumanMessage("What's the weather in New York?")], + }); + + // Verify the OpenAI API was called with the correct tools and tool_choice + expect(openAIFetchMock).toHaveBeenCalledOnce(); + const [, options] = openAIFetchMock.mock.calls[0]; + const requestBody = JSON.parse(options.body); + + // Check that tools were added + expect(requestBody.tools).toHaveLength(2); + expect(requestBody.tools[0]).toMatchObject({ + type: "function", + function: { + name: "getWeather", + description: "Get the current weather for a location", + parameters: { + type: "object", + properties: { + location: { + type: "string", + description: "The location to get weather for", + }, + }, + required: ["location"], + additionalProperties: false, + }, + }, + }); + expect(requestBody.tools[1]).toMatchObject({ + type: "function", + function: { + name: "getNews", + description: "Get the latest news on a topic", + }, + }); + + // Check that tool_choice was set correctly + expect(requestBody.tool_choice).toEqual({ + type: "function", + function: { name: "getWeather" }, + }); + + // Verify the result contains the tool call + const aiResponse = result.messages[result.messages.length - 1]; + expect(aiResponse).toBeInstanceOf(AIMessage); + expect((aiResponse as AIMessage).tool_calls).toHaveLength(1); + expect((aiResponse as AIMessage).tool_calls?.[0]).toMatchObject({ + name: "getWeather", + args: { location: "New York" }, + id: "call_test123", + }); + }); +}); diff --git a/libs/langchain/src/agents/tests/reactAgent.int.test.ts b/libs/langchain/src/agents/tests/reactAgent.int.test.ts index 700b7c687330..81c50385dc27 100644 --- a/libs/langchain/src/agents/tests/reactAgent.int.test.ts +++ b/libs/langchain/src/agents/tests/reactAgent.int.test.ts @@ -2,16 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { ChatAnthropic } from "@langchain/anthropic"; import { ChatOpenAI } from "@langchain/openai"; import { tool } from "@langchain/core/tools"; -import { - HumanMessage, - isHumanMessage, - SystemMessage, - AIMessage, -} from "@langchain/core/messages"; +import { HumanMessage } from "@langchain/core/messages"; import z from "zod/v3"; -import { createAgent, providerStrategy, createMiddleware } from "../index.js"; -import { anthropicPromptCachingMiddleware } from "../middlewareAgent/middleware/promptCaching.js"; +import { createAgent, providerStrategy } from "../index.js"; describe("createAgent Integration Tests", () => { const toolMock = vi.fn(async (input: { city: string }) => { @@ -174,7 +168,7 @@ describe("createAgent Integration Tests", () => { const lastMessage = state.messages.at(-1); // Modify the query - if (lastMessage && isHumanMessage(lastMessage)) { + if (lastMessage && HumanMessage.isInstance(lastMessage)) { state.messages[state.messages.length - 1].content = "What is 15 multiplied by 8?"; } @@ -262,315 +256,4 @@ describe("createAgent Integration Tests", () => { expect(response.someOptionalNumber).toBe(undefined); }); }); - - describe("modifyModelRequest", () => { - it("should allow middleware to update model, messages and systemMessage", async () => { - // Setup mocked fetch functions for both providers - const openAIFetchMock = vi.fn((url, options) => fetch(url, options)); - const anthropicResponse = vi.fn((res) => res.clone()); - const anthropicFetchMock = vi.fn((url, options) => - fetch(url, options).then(anthropicResponse) - ); - - // Create a simple tool for testing - const simpleTool = tool( - async (input: { query: string }) => { - return `Tool response for: ${input.query}`; - }, - { - name: "simpleTool", - schema: z.object({ - query: z.string().describe("The query to process"), - }), - description: "A simple tool for testing", - } - ); - - // Create middleware that will change the model and messages - const modelSwitchMiddleware = createMiddleware({ - name: "modelSwitcher", - modifyModelRequest: async (_request, _state, _runtime) => { - // Create a new ChatAnthropic instance - const anthropicModel = new ChatAnthropic({ - model: "claude-opus-4-20250514", - temperature: 0.7, - maxTokens: 500, - topP: 0.95, - clientOptions: { - fetch: anthropicFetchMock, - }, - }); - - // Change the messages to ask a completely different question - const newMessages = [ - new HumanMessage(`I'm working on a comprehensive educational project about European capitals and their historical significance. I need detailed and accurate information for my research, which will be published in an upcoming textbook for high school students studying world geography and history. - -Before I ask my specific question, let me provide extensive context about the subject matter: France is a Western European country with a rich history spanning over millennia. It has been a major cultural, political, and economic power throughout history. The country has played a pivotal role in the development of Western civilization, from the Renaissance through the Enlightenment to modern times. The nation's influence extends far beyond its borders, shaping global politics, culture, and intellectual thought for centuries. - -France is renowned worldwide for its extraordinary contributions to art, science, philosophy, cuisine, fashion, and literature. The country has produced countless influential figures including philosophers like René Descartes, Voltaire, Jean-Jacques Rousseau, and Simone de Beauvoir; scientists like Marie Curie, Louis Pasteur, Blaise Pascal, and Henri Poincaré; writers like Victor Hugo, Marcel Proust, Albert Camus, and Marguerite Duras; and artists like Claude Monet, Auguste Rodin, Edgar Degas, and Henri Matisse. The French Revolution of 1789 had a profound and lasting impact on the development of modern democratic ideals, human rights, and political philosophy across the globe. - -The country is administratively divided into several distinct regions, each with its own unique culture, dialect, culinary traditions, and historical significance. From the aromatic lavender fields of Provence to the world-famous vineyards of Bordeaux, from the glamorous beaches of the Côte d'Azur to the majestic peaks of the Alps and Pyrenees, France offers incredible geographical and cultural diversity. The Loire Valley is known for its magnificent châteaux, Brittany for its Celtic heritage, Normandy for its D-Day beaches and apple orchards, and Alsace for its unique Franco-German culture. - -France's economy is one of the world's largest, with strong sectors in aerospace, automotive, luxury goods, tourism, and agriculture. The country is famous for its haute cuisine, which UNESCO recognized as an Intangible Cultural Heritage of Humanity. French wines, cheeses, and pastries are celebrated globally. The French language itself has been a lingua franca of diplomacy, culture, and international relations for centuries, and continues to be one of the working languages of many international organizations. - -The educational system in France has produced numerous Nobel laureates, Fields Medal winners, and other distinguished scholars. French universities like the Sorbonne have been centers of learning since the Middle Ages. The country's commitment to arts and culture is evident in its numerous world-class museums, including the Louvre, Musée d'Orsay, and Centre Pompidou, as well as its vibrant theater, cinema, and music scenes. - -In terms of governance, France is a unitary semi-presidential republic with a strong democratic tradition. The country is a founding member of the European Union and plays a crucial role in European and global politics. It maintains significant cultural and economic ties with francophone countries around the world through organizations like La Francophonie. - -Now, for my comprehensive educational project that requires accurate and reliable information about European capitals, I need to know the answer to this fundamental question: What is the capital of France? - -Please provide a clear, direct, and authoritative answer, as this information will be used in an educational context for students learning about European geography, and accuracy is of paramount importance for their academic development.`), - ]; - - // Return partial ModelRequest - tools will be merged from original request - return { - model: anthropicModel, - messages: newMessages, - systemMessage: new SystemMessage("You are a geography expert."), - toolChoice: "none", - tools: [], - }; - }, - }); - - // Create agent with OpenAI model string and the middleware - const agent = createAgent({ - model: "gpt-4o-mini", - tools: [simpleTool], - middleware: [ - modelSwitchMiddleware, - anthropicPromptCachingMiddleware({ - ttl: "5m", - minMessagesToCache: 1, - }), - ] as const, - }); - - // Invoke the agent - const result = await agent.invoke({ - messages: [new HumanMessage("What's the weather in Tokyo?")], - }); - - // Verify that Anthropic was called (not OpenAI) - expect(anthropicFetchMock).toHaveBeenCalled(); - expect(openAIFetchMock).not.toHaveBeenCalled(); - - // Verify the request to Anthropic includes our model settings - const anthropicCall = anthropicFetchMock.mock.calls[0]; - const requestBody = JSON.parse(anthropicCall[1].body); - - // Check that model settings were propagated - expect(requestBody.temperature).toBe(0.7); - expect(requestBody.max_tokens).toBe(500); - expect(requestBody.top_p).toBe(0.95); - // Check that cache_control was passed through - expect(requestBody.messages).toHaveLength(1); - expect(requestBody.messages.at(-1).content[0]).toEqual({ - type: "text", - text: expect.stringContaining("What is the capital of France?"), - cache_control: { - type: "ephemeral", - ttl: "5m", - }, - }); - - // Check that the system message was updated - expect(requestBody.system).toBe("You are a geography expert."); - - // The response should be about France, not Tokyo weather - expect(result.messages).toBeDefined(); - expect(result.messages.length).toBeGreaterThan(0); - - // Find the AI response message - const aiResponse = result.messages.find((msg) => msg.type === "ai"); - expect(aiResponse).toBeDefined(); - // The response should mention Paris or France, not Tokyo or weather - const responseContent = - aiResponse?.content?.toString().toLowerCase() || ""; - expect(responseContent).toMatch(/paris|france/i); - expect(responseContent).not.toMatch(/tokyo|weather/i); - - // validate raw response has cache_control set - const rawResponse = await anthropicResponse.mock.calls[0][0].json(); - // tokens will be already cached when the same test is run again within a short time period - // so we expect them to be either in the creation or read bucket - const cachedTokens = - rawResponse.usage.cache_creation_input_tokens || - rawResponse.usage.cache_read_input_tokens; - expect(cachedTokens).toBe(1195); - }); - - it("can change tools and toolChoice in modifyModelRequest", async () => { - // Setup mocked fetch for OpenAI - const openAIFetchMock = vi.fn(); - - // Create tools that will be added by middleware - const weatherTool = tool( - async (input: { location: string }) => { - return `Weather in ${input.location}: Sunny, 72°F`; - }, - { - name: "getWeather", - schema: z.object({ - location: z.string().describe("The location to get weather for"), - }), - description: "Get the current weather for a location", - } - ); - - const newsTool = tool( - async (input: { topic: string }) => { - return `Latest news on ${input.topic}: Breaking developments...`; - }, - { - name: "getNews", - schema: z.object({ - topic: z.string().describe("The topic to get news about"), - }), - description: "Get the latest news on a topic", - } - ); - - // Create middleware that adds tools and sets toolChoice - const toolsMiddleware = { - name: "toolsModifier", - modifyModelRequest: async () => { - // Add tools dynamically - const tools = [weatherTool, newsTool]; - - // Set toolChoice to force specific tool - return { - tools, - toolChoice: { - type: "function" as const, - function: { - name: "getWeather", - }, - }, - }; - }, - }; - - // Create OpenAI model initially without any tools - const openAIModel = new ChatOpenAI({ - model: "gpt-4", - temperature: 0, - configuration: { - fetch: openAIFetchMock, - }, - }); - - // Mock the OpenAI response with proper headers - openAIFetchMock.mockImplementation(async () => { - // Return a proper Response-like object - return { - ok: true, - status: 200, - statusText: "OK", - headers: new Headers({ - "content-type": "application/json", - }), - json: async () => ({ - id: "chatcmpl-test", - object: "chat.completion", - created: Date.now(), - model: "gpt-4", - choices: [ - { - index: 0, - message: { - role: "assistant", - content: null, - tool_calls: [ - { - id: "call_test123", - type: "function", - function: { - name: "getWeather", - arguments: JSON.stringify({ location: "New York" }), - }, - }, - ], - }, - finish_reason: "tool_calls", - }, - ], - usage: { - prompt_tokens: 50, - completion_tokens: 20, - total_tokens: 70, - }, - }), - text: async () => "", - arrayBuffer: async () => new ArrayBuffer(0), - blob: async () => new Blob(), - clone: () => ({}), - body: null, - bodyUsed: false, - }; - }); - - // Create agent with the middleware - const agent = createAgent({ - llm: openAIModel, - // No tools provided initially - tools: [], - middleware: [toolsMiddleware], - }); - - // Invoke the agent - const result = await agent.invoke({ - messages: [new HumanMessage("What's the weather in New York?")], - }); - - // Verify the OpenAI API was called with the correct tools and tool_choice - expect(openAIFetchMock).toHaveBeenCalledOnce(); - const [, options] = openAIFetchMock.mock.calls[0]; - const requestBody = JSON.parse(options.body); - - // Check that tools were added - expect(requestBody.tools).toHaveLength(2); - expect(requestBody.tools[0]).toMatchObject({ - type: "function", - function: { - name: "getWeather", - description: "Get the current weather for a location", - parameters: { - type: "object", - properties: { - location: { - type: "string", - description: "The location to get weather for", - }, - }, - required: ["location"], - additionalProperties: false, - }, - }, - }); - expect(requestBody.tools[1]).toMatchObject({ - type: "function", - function: { - name: "getNews", - description: "Get the latest news on a topic", - }, - }); - - // Check that tool_choice was set correctly - expect(requestBody.tool_choice).toEqual({ - type: "function", - function: { name: "getWeather" }, - }); - - // Verify the result contains the tool call - const aiResponse = result.messages[result.messages.length - 1]; - expect(aiResponse).toBeInstanceOf(AIMessage); - expect((aiResponse as AIMessage).tool_calls).toHaveLength(1); - expect((aiResponse as AIMessage).tool_calls?.[0]).toMatchObject({ - name: "getWeather", - args: { location: "New York" }, - id: "call_test123", - }); - }); - }); }); diff --git a/libs/providers/langchain-mcp-adapters/package.json b/libs/providers/langchain-mcp-adapters/package.json index a9f57dd90390..79978e4adac1 100644 --- a/libs/providers/langchain-mcp-adapters/package.json +++ b/libs/providers/langchain-mcp-adapters/package.json @@ -15,16 +15,16 @@ "url": "https://github.com/langchain-ai/langchainjs/issues" }, "scripts": { - "build": "run-s build:main", + "build": "run-s build:main build:examples", "build:main": "pnpm --filter @langchain/build compile @langchain/mcp-adapters", + "build:examples": "tsc -p ./examples/tsconfig.json", "clean": "rm -rf dist/ dist-cjs/ .turbo/", "format": "prettier --config .prettierrc --write \"src/**/*.ts\" \"examples/**/*.ts\"", "format:check": "prettier --config .prettierrc --check \"src\" \"examples/**/*.ts\"", - "lint": "run-s lint:eslint lint:dpdm lint:examples", + "lint": "run-s lint:eslint lint:dpdm", "lint:dpdm": "dpdm --skip-dynamic-imports circular --exit-code circular:1 --no-warning --no-tree src/**/*.ts examples/**/*.ts", "lint:eslint": "eslint --cache src/ examples/", "lint:fix": "pnpm lint:eslint --fix && pnpm lint:dpdm", - "lint:examples": "tsc -p ./examples/tsconfig.json", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest" From 668709bae5ba10e9d3f764ff5fe80b6742dbee59 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 18 Sep 2025 10:34:20 -0700 Subject: [PATCH 13/16] cr --- .../src/agents/middlewareAgent/middleware/promptCaching.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts index 38d0481aa0ce..df4482eacc2d 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts @@ -172,10 +172,10 @@ export function anthropicPromptCachingMiddleware( } if ( - !options.model + !options.model || /** * user passes in a ChatAnthropic instance - */ || + */ (options.model.getName() !== "ChatAnthropic" && /** * user passes in a model via string, e.g. "anthropic:claude-3-5-sonnet" From b7948c5002c20874ccec4b739c3c7704453d2d01 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 18 Sep 2025 10:36:14 -0700 Subject: [PATCH 14/16] Create three-cats-sin.md --- .changeset/three-cats-sin.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/three-cats-sin.md diff --git a/.changeset/three-cats-sin.md b/.changeset/three-cats-sin.md new file mode 100644 index 000000000000..2f57467a6d01 --- /dev/null +++ b/.changeset/three-cats-sin.md @@ -0,0 +1,12 @@ +--- +langchain: patch +"@langchain/mcp-adapters": patch +--- + +Further refinements and improvements to createAgent with middleware: + +- updated createAgent symbol documentation +- improved type handling for invoke and stream invocation parameter +- acknowledge middleware context schema's that are optional or have default values as actually optional +- improvements to HITL middleware: support for multiple tool interrupts at the same time +- updates `@langchain/mcp-adapters` examples to use the new createAgent primitive from langchain From 04fc794507e2d53d894e84ac25521f2ee1c473ac Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 18 Sep 2025 10:49:08 -0700 Subject: [PATCH 15/16] fix test --- .../middlewareAgent/middleware/dynamicSystemPrompt.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/dynamicSystemPrompt.ts b/libs/langchain/src/agents/middlewareAgent/middleware/dynamicSystemPrompt.ts index cb4b1ac86eb9..ae1587ea6db0 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/dynamicSystemPrompt.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/dynamicSystemPrompt.ts @@ -43,15 +43,15 @@ import type { Runtime, AgentBuiltInState } from "../types.js"; export function dynamicSystemPromptMiddleware( fn: ( state: AgentBuiltInState, - runtime: Runtime + runtime: Runtime ) => string | Promise ) { return createMiddleware({ name: "DynamicSystemPromptMiddleware", - prepareModelRequest: async (options, state, runtime) => { + modifyModelRequest: async (options, state, runtime) => { const system = await fn( state as AgentBuiltInState, - runtime as Runtime + runtime as Runtime ); if (typeof system !== "string") { From d1a2366ac8133106e9e0f9f0eaa1094698eb0e92 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 18 Sep 2025 11:29:19 -0700 Subject: [PATCH 16/16] better tests --- .../middleware/promptCaching.ts | 19 +- .../tests/promptCaching.int.test.ts | 173 +++++++++++++----- .../middleware/tests/promptCaching.test.ts | 42 +++-- 3 files changed, 157 insertions(+), 77 deletions(-) diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts index df4482eacc2d..9ef84fb831af 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/promptCaching.ts @@ -171,19 +171,12 @@ export function anthropicPromptCachingMiddleware( return undefined; } - if ( - !options.model || - /** - * user passes in a ChatAnthropic instance - */ - (options.model.getName() !== "ChatAnthropic" && - /** - * user passes in a model via string, e.g. "anthropic:claude-3-5-sonnet" - */ - "_defaultConfig" in options.model && - (options.model as ConfigurableModel)._defaultConfig?.modelProvider !== - "anthropic") - ) { + const isAnthropicModel = + options.model.getName() === "ChatAnthropic" || + (options.model.getName() === "ConfigurableModel" && + (options.model as ConfigurableModel)._defaultConfig?.modelProvider === + "anthropic"); + if (!isAnthropicModel) { throw new Error( "Prompt caching is only supported for Anthropic models" ); diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts b/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts index 155232c47998..12e6131c8721 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.int.test.ts @@ -1,64 +1,104 @@ -import { describe, it, expect, vi } from "vitest"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, type MockInstance } from "vitest"; import { tool } from "@langchain/core/tools"; import { z } from "zod"; import { HumanMessage, AIMessage } from "@langchain/core/messages"; -import { ChatAnthropic } from "@langchain/anthropic"; +import { + ChatAnthropic, + type AnthropicInput, + // @ts-expect-error - instances is mocked + instances, +} from "@langchain/anthropic"; import { anthropicPromptCachingMiddleware } from "../promptCaching.js"; import { createAgent } from "../../index.js"; +/** + * Mock the Anthropic module to return a ChatAnthropicMock instance + */ +vi.mock("@langchain/anthropic", async (origModule) => { + const { ChatAnthropic } = (await origModule()) as any; + const instances: ChatAnthropicMock[] = []; + class ChatAnthropicMock extends ChatAnthropic { + anthropicResponse: MockInstance; + anthropicFetchMock: MockInstance; + + constructor(params: AnthropicInput) { + const anthropicResponse = vi.fn((res) => res.clone()); + const anthropicFetchMock = vi.fn((url, options) => + fetch(url, options).then(anthropicResponse) + ); + super({ + ...params, + clientOptions: { + ...params.clientOptions, + fetch: anthropicFetchMock, + }, + }); + + this.anthropicResponse = anthropicResponse; + this.anthropicFetchMock = anthropicFetchMock; + + instances.push(this); + } + + get mocks() { + return { + anthropicResponse: this.anthropicResponse, + anthropicFetchMock: this.anthropicFetchMock, + }; + } + } + return { + ChatAnthropic: ChatAnthropicMock, + instances, + }; +}); + +// Create a simple tool for testing +const simpleTool = tool( + async (input: { query: string }) => { + return `Tool response for: ${input.query}`; + }, + { + name: "simpleTool", + schema: z.object({ + query: z.string().describe("The query to process"), + }), + description: "A simple tool for testing", + } +); + +const messages = [ + new HumanMessage(`I'm working on a comprehensive educational project about European capitals and their historical significance. I need detailed and accurate information for my research, which will be published in an upcoming textbook for high school students studying world geography and history. + + Before I ask my specific question, let me provide extensive context about the subject matter: France is a Western European country with a rich history spanning over millennia. It has been a major cultural, political, and economic power throughout history. The country has played a pivotal role in the development of Western civilization, from the Renaissance through the Enlightenment to modern times. The nation's influence extends far beyond its borders, shaping global politics, culture, and intellectual thought for centuries. + + France is renowned worldwide for its extraordinary contributions to art, science, philosophy, cuisine, fashion, and literature. The country has produced countless influential figures including philosophers like René Descartes, Voltaire, Jean-Jacques Rousseau, and Simone de Beauvoir; scientists like Marie Curie, Louis Pasteur, Blaise Pascal, and Henri Poincaré; writers like Victor Hugo, Marcel Proust, Albert Camus, and Marguerite Duras; and artists like Claude Monet, Auguste Rodin, Edgar Degas, and Henri Matisse. The French Revolution of 1789 had a profound and lasting impact on the development of modern democratic ideals, human rights, and political philosophy across the globe. + + The country is administratively divided into several distinct regions, each with its own unique culture, dialect, culinary traditions, and historical significance. From the aromatic lavender fields of Provence to the world-famous vineyards of Bordeaux, from the glamorous beaches of the Côte d'Azur to the majestic peaks of the Alps and Pyrenees, France offers incredible geographical and cultural diversity. The Loire Valley is known for its magnificent châteaux, Brittany for its Celtic heritage, Normandy for its D-Day beaches and apple orchards, and Alsace for its unique Franco-German culture. + + France's economy is one of the world's largest, with strong sectors in aerospace, automotive, luxury goods, tourism, and agriculture. The country is famous for its haute cuisine, which UNESCO recognized as an Intangible Cultural Heritage of Humanity. French wines, cheeses, and pastries are celebrated globally. The French language itself has been a lingua franca of diplomacy, culture, and international relations for centuries, and continues to be one of the working languages of many international organizations. + + The educational system in France has produced numerous Nobel laureates, Fields Medal winners, and other distinguished scholars. French universities like the Sorbonne have been centers of learning since the Middle Ages. The country's commitment to arts and culture is evident in its numerous world-class museums, including the Louvre, Musée d'Orsay, and Centre Pompidou, as well as its vibrant theater, cinema, and music scenes. + + In terms of governance, France is a unitary semi-presidential republic with a strong democratic tradition. The country is a founding member of the European Union and plays a crucial role in European and global politics. It maintains significant cultural and economic ties with francophone countries around the world through organizations like La Francophonie. + + Now, for my comprehensive educational project that requires accurate and reliable information about European capitals, I need to know the answer to this fundamental question: What is the capital of France? + + Please provide a clear, direct, and authoritative answer, as this information will be used in an educational context for students learning about European geography, and accuracy is of paramount importance for their academic development.`), + new HumanMessage("What is the capital of France?"), +]; + describe("anthropicPromptCachingMiddleware", () => { it("should allow middleware to update model, messages and systemMessage", async () => { - // Setup mocked fetch functions for both providers - const anthropicResponse = vi.fn((res) => res.clone()); - const anthropicFetchMock = vi.fn((url, options) => - fetch(url, options).then(anthropicResponse) - ); const anthropicModel = new ChatAnthropic({ model: "claude-opus-4-20250514", temperature: 0.7, maxTokens: 500, topP: 0.95, - clientOptions: { - fetch: anthropicFetchMock, - }, }); - // Create a simple tool for testing - const simpleTool = tool( - async (input: { query: string }) => { - return `Tool response for: ${input.query}`; - }, - { - name: "simpleTool", - schema: z.object({ - query: z.string().describe("The query to process"), - }), - description: "A simple tool for testing", - } - ); - - const messages = [ - new HumanMessage(`I'm working on a comprehensive educational project about European capitals and their historical significance. I need detailed and accurate information for my research, which will be published in an upcoming textbook for high school students studying world geography and history. - - Before I ask my specific question, let me provide extensive context about the subject matter: France is a Western European country with a rich history spanning over millennia. It has been a major cultural, political, and economic power throughout history. The country has played a pivotal role in the development of Western civilization, from the Renaissance through the Enlightenment to modern times. The nation's influence extends far beyond its borders, shaping global politics, culture, and intellectual thought for centuries. - - France is renowned worldwide for its extraordinary contributions to art, science, philosophy, cuisine, fashion, and literature. The country has produced countless influential figures including philosophers like René Descartes, Voltaire, Jean-Jacques Rousseau, and Simone de Beauvoir; scientists like Marie Curie, Louis Pasteur, Blaise Pascal, and Henri Poincaré; writers like Victor Hugo, Marcel Proust, Albert Camus, and Marguerite Duras; and artists like Claude Monet, Auguste Rodin, Edgar Degas, and Henri Matisse. The French Revolution of 1789 had a profound and lasting impact on the development of modern democratic ideals, human rights, and political philosophy across the globe. - - The country is administratively divided into several distinct regions, each with its own unique culture, dialect, culinary traditions, and historical significance. From the aromatic lavender fields of Provence to the world-famous vineyards of Bordeaux, from the glamorous beaches of the Côte d'Azur to the majestic peaks of the Alps and Pyrenees, France offers incredible geographical and cultural diversity. The Loire Valley is known for its magnificent châteaux, Brittany for its Celtic heritage, Normandy for its D-Day beaches and apple orchards, and Alsace for its unique Franco-German culture. - - France's economy is one of the world's largest, with strong sectors in aerospace, automotive, luxury goods, tourism, and agriculture. The country is famous for its haute cuisine, which UNESCO recognized as an Intangible Cultural Heritage of Humanity. French wines, cheeses, and pastries are celebrated globally. The French language itself has been a lingua franca of diplomacy, culture, and international relations for centuries, and continues to be one of the working languages of many international organizations. - - The educational system in France has produced numerous Nobel laureates, Fields Medal winners, and other distinguished scholars. French universities like the Sorbonne have been centers of learning since the Middle Ages. The country's commitment to arts and culture is evident in its numerous world-class museums, including the Louvre, Musée d'Orsay, and Centre Pompidou, as well as its vibrant theater, cinema, and music scenes. - - In terms of governance, France is a unitary semi-presidential republic with a strong democratic tradition. The country is a founding member of the European Union and plays a crucial role in European and global politics. It maintains significant cultural and economic ties with francophone countries around the world through organizations like La Francophonie. - - Now, for my comprehensive educational project that requires accurate and reliable information about European capitals, I need to know the answer to this fundamental question: What is the capital of France? - - Please provide a clear, direct, and authoritative answer, as this information will be used in an educational context for students learning about European geography, and accuracy is of paramount importance for their academic development.`), - new HumanMessage("What is the capital of France?"), - ]; - // Create agent with OpenAI model string and the middleware const agent = createAgent({ llm: anthropicModel, @@ -77,6 +117,9 @@ describe("anthropicPromptCachingMiddleware", () => { messages, }); + const { anthropicFetchMock, anthropicResponse } = (anthropicModel as any) + .mocks; + // Verify that Anthropic was called (not OpenAI) expect(anthropicFetchMock).toHaveBeenCalled(); @@ -111,8 +154,42 @@ describe("anthropicPromptCachingMiddleware", () => { // tokens will be already cached when the same test is run again within a short time period // so we expect them to be either in the creation or read bucket const cachedTokens = - rawResponse.usage.cache_creation_input_tokens || - rawResponse.usage.cache_read_input_tokens; - expect(cachedTokens).toBe(1227); + rawResponse.usage.cache_read_input_tokens || + rawResponse.usage.cache_creation_input_tokens; + expect(cachedTokens).toBeGreaterThan(1200); + expect(cachedTokens).toBeLessThan(1400); + }); + + it("should work when model is passed in as string", async () => { + // Create agent with OpenAI model string and the middleware + const agent = createAgent({ + model: "anthropic:claude-opus-4-20250514", + tools: [simpleTool], + prompt: "You are a geography expert.", + middleware: [ + anthropicPromptCachingMiddleware({ + ttl: "5m", + minMessagesToCache: 1, + }), + ] as const, + }); + + // Invoke the agent + await agent.invoke({ + messages, + }); + + const { anthropicResponse } = (instances.pop() as any).mocks; + + // validate raw response has cache_control set + const rawResponse = await anthropicResponse.mock.calls[0][0].json(); + // tokens will be already cached when the same test is run again within a short time period + // so we expect them to be either in the creation or read bucket + const cachedTokens = + rawResponse.usage.cache_read_input_tokens || + rawResponse.usage.cache_creation_input_tokens; + + expect(cachedTokens).toBeGreaterThan(1200); + expect(cachedTokens).toBeLessThan(1400); }); }); diff --git a/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.test.ts b/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.test.ts index 2eb7e4491293..3bff9e57b8c2 100644 --- a/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.test.ts +++ b/libs/langchain/src/agents/middlewareAgent/middleware/tests/promptCaching.test.ts @@ -5,6 +5,8 @@ import { AIMessage, SystemMessage, } from "@langchain/core/messages"; +import { ChatOpenAI } from "@langchain/openai"; + import { anthropicPromptCachingMiddleware } from "../promptCaching.js"; import { createAgent } from "../../index.js"; @@ -112,26 +114,34 @@ describe("anthropicPromptCachingMiddleware", () => { expect(lastMessage.content[0]).not.toHaveProperty("cache_control"); }); - it("should throw error for non-Anthropic models", async () => { - const mockNonAnthropicModel = createMockAnthropicModel(); - mockNonAnthropicModel.getName = () => "openai"; // Not Anthropic - const middleware = anthropicPromptCachingMiddleware(); + describe("non-Anthropic models", () => { + it("should throw error if pass in a non-Anthropic chat instance", async () => { + const middleware = anthropicPromptCachingMiddleware(); - const agent = createAgent({ - llm: mockNonAnthropicModel as any, - middleware: [middleware] as const, + const agent = createAgent({ + llm: new ChatOpenAI({ model: "gpt-4o" }), + middleware: [middleware] as const, + }); + + // Should throw error + await expect(agent.invoke({ messages: [] })).rejects.toThrow( + "Prompt caching is only supported for Anthropic models" + ); }); - const messages = [ - new HumanMessage("Hello"), - new AIMessage("Hi there!"), - new HumanMessage("How are you?"), - ]; + it("should throw error if pass in a non-Anthropic model via string", async () => { + const middleware = anthropicPromptCachingMiddleware(); - // Should throw error - await expect(agent.invoke({ messages })).rejects.toThrow( - "Prompt caching is only supported for Anthropic models" - ); + const agent = createAgent({ + model: "openai:gpt-4o", + middleware: [middleware] as const, + }); + + // Should throw error + await expect(agent.invoke({ messages: [] })).rejects.toThrow( + "Prompt caching is only supported for Anthropic models" + ); + }); }); it("should include system message in message count", async () => {