UNPKG

langchain

Version:
536 lines (535 loc) 23.5 kB
require("../../_virtual/_rolldown/runtime.cjs"); const require_chat_models_universal = require("../../chat_models/universal.cjs"); const require_model = require("../model.cjs"); const require_errors = require("../errors.cjs"); const require_utils = require("../utils.cjs"); const require_RunnableCallable = require("../RunnableCallable.cjs"); const require_utils$1 = require("./utils.cjs"); const require_withAgentName = require("../withAgentName.cjs"); const require_responses = require("../responses.cjs"); let _langchain_core_messages = require("@langchain/core/messages"); let _langchain_core_runnables = require("@langchain/core/runnables"); let _langchain_langgraph = require("@langchain/langgraph"); let _langchain_core_utils_types = require("@langchain/core/utils/types"); //#region src/agents/nodes/AgentNode.ts /** * Check if the response is an internal model response. * @param response - The response to check. * @returns True if the response is an internal model response, false otherwise. */ function isInternalModelResponse(response) { return _langchain_core_messages.AIMessage.isInstance(response) || (0, _langchain_langgraph.isCommand)(response) || typeof response === "object" && response !== null && "structuredResponse" in response && "messages" in response; } /** * The name of the agent node in the state graph. */ const AGENT_NODE_NAME = "model_request"; var AgentNode = class extends require_RunnableCallable.RunnableCallable { #options; #systemMessage; constructor(options) { super({ name: options.name ?? "model", func: (input, config) => this.#run(input, config) }); this.#options = options; this.#systemMessage = options.systemMessage; } /** * Returns response format primtivies based on given model and response format provided by the user. * * If the user selects a tool output: * - return a record of tools to extract structured output from the model's response * * if the user selects a native schema output or if the model supports JSON schema output: * - return a provider strategy to extract structured output from the model's response * * @param model - The model to get the response format for. * @returns The response format. */ async #getResponseFormat(model, responseFormat = this.#options.responseFormat) { if (!responseFormat) return; let resolvedModel; if (require_model.isConfigurableModel(model)) resolvedModel = await model._getModelInstance(); else if (typeof model !== "string") resolvedModel = model; const strategies = require_responses.transformResponseFormat(responseFormat, void 0, resolvedModel); if (strategies.length === 0) return; /** * Populate a list of structured tool info. */ if (!strategies.every((format) => format instanceof require_responses.ProviderStrategy)) return { type: "tool", tools: strategies.filter((format) => format instanceof require_responses.ToolStrategy).reduce((acc, format) => { acc[format.name] = format; return acc; }, {}) }; return { type: "native", /** * there can only be one provider strategy */ strategy: strategies[0] }; } async #run(state, config) { /** * Check if we just executed a returnDirect tool * If so, we should generate structured response (if needed) and stop */ const lastMessage = state.messages.at(-1); if (lastMessage && _langchain_core_messages.ToolMessage.isInstance(lastMessage) && lastMessage.name && this.#options.shouldReturnDirect.has(lastMessage.name)) return [new _langchain_langgraph.Command({ update: { messages: [] } })]; const { response, lastAiMessage, collectedCommands } = await this.#invokeModel(state, config); /** * structuredResponse — return as a plain state update dict (not a Command) * because the structuredResponse channel uses UntrackedValue(guard=true) * which only allows a single write per step. */ if (typeof response === "object" && response !== null && "structuredResponse" in response && "messages" in response) { const { structuredResponse, messages } = response; return { messages: [...state.messages, ...messages], structuredResponse }; } const commands = []; const aiMessage = _langchain_core_messages.AIMessage.isInstance(response) ? response : lastAiMessage; if (aiMessage) { aiMessage.name = this.name; aiMessage.lc_kwargs.name = this.name; if (this.#areMoreStepsNeeded(state, aiMessage)) commands.push(new _langchain_langgraph.Command({ update: { messages: [new _langchain_core_messages.AIMessage({ content: "Sorry, need more steps to process this request.", name: this.name, id: aiMessage.id })] } })); else commands.push(new _langchain_langgraph.Command({ update: { messages: [aiMessage] } })); } if ((0, _langchain_langgraph.isCommand)(response) && !collectedCommands.includes(response)) commands.push(response); commands.push(...collectedCommands); return commands; } /** * Derive the model from the options. * @param state - The state of the agent. * @param config - The config of the agent. * @returns The model. */ #deriveModel() { if (typeof this.#options.model === "string") return require_chat_models_universal.initChatModel(this.#options.model); if (this.#options.model) return this.#options.model; throw new Error("No model option was provided, either via `model` option."); } async #invokeModel(state, config, options = {}) { const model = await this.#deriveModel(); const lgConfig = config; /** * Create a local variable for current system message to avoid concurrency issues * Each invocation gets its own copy */ let currentSystemMessage = this.#systemMessage; /** * Shared tracking state for AIMessage and Command collection. * lastAiMessage tracks the effective AIMessage through the middleware chain. * collectedCommands accumulates Commands returned by middleware (not base handler). */ let lastAiMessage = null; const collectedCommands = []; /** * Create the base handler that performs the actual model invocation */ const baseHandler = async (request) => { /** * Check if the LLM already has bound tools and throw if it does. */ require_utils.validateLLMHasNoBoundTools(request.model); const structuredResponseFormat = await this.#getResponseFormat(request.model, request.responseFormat); const modelWithTools = await this.#bindTools(request.model, request, structuredResponseFormat); /** * prepend the system message to the messages if it is not empty */ const messages = [...currentSystemMessage.text === "" ? [] : [currentSystemMessage], ...request.messages]; const signal = require_utils$1.mergeAbortSignals(this.#options.signal, config.signal); const response = await (0, _langchain_core_runnables.raceWithSignal)(modelWithTools.invoke(messages, { ...config, signal }), signal); lastAiMessage = response; /** * if the user requests a native schema output, try to parse the response * and return the structured response if it is valid */ if (structuredResponseFormat?.type === "native") { const structuredResponse = structuredResponseFormat.strategy.parse(response); if (structuredResponse) return { structuredResponse, messages: [response] }; /** * If the model produced a terminal response (no tool calls) but the * output failed to satisfy the provider strategy's schema, throw an * informative error instead of silently exiting with * `structuredResponse: undefined`. If tool calls are present, the * agent loop continues and a subsequent terminal step will get * another chance to produce a valid structured response. */ if (!response.tool_calls || response.tool_calls.length === 0) throw new require_errors.StructuredOutputParsingError(typeof structuredResponseFormat.strategy.schema?.title === "string" ? structuredResponseFormat.strategy.schema.title : "providerStrategy", ["Model output did not satisfy the provided response schema."]); return response; } if (!structuredResponseFormat || !response.tool_calls) return response; const toolCalls = response.tool_calls.filter((call) => call.name in structuredResponseFormat.tools); /** * if there were not structured tool calls, we can return the response */ if (toolCalls.length === 0) return response; /** * if there were multiple structured tool calls, we should throw an error as this * scenario is not defined/supported. */ if (toolCalls.length > 1) return this.#handleMultipleStructuredOutputs(response, toolCalls, structuredResponseFormat); const toolMessageContent = structuredResponseFormat.tools[toolCalls[0].name]?.options?.toolMessageContent; return this.#handleSingleStructuredOutput(response, toolCalls[0], structuredResponseFormat, toolMessageContent ?? options.lastMessage); }; const wrapperMiddleware = this.#options.wrapModelCallHookMiddleware ?? []; let wrappedHandler = baseHandler; /** * Build composed handler from last to first so first middleware becomes outermost */ for (let i = wrapperMiddleware.length - 1; i >= 0; i--) { const middlewareEntry = wrapperMiddleware[i]; const middleware = Array.isArray(middlewareEntry) ? middlewareEntry[0] : middlewareEntry; if (middleware.wrapModelCall) { const innerHandler = wrappedHandler; const currentMiddleware = middleware; wrappedHandler = async (request) => { const baselineSystemMessage = currentSystemMessage; /** * Merge context with default context of middleware */ const context = currentMiddleware.contextSchema ? (0, _langchain_core_utils_types.interopParse)(currentMiddleware.contextSchema, lgConfig?.context || {}) : lgConfig?.context; /** * Create runtime */ const runtime = Object.freeze({ context, store: lgConfig.store, configurable: lgConfig.configurable, writer: lgConfig.writer, interrupt: lgConfig.interrupt, signal: lgConfig.signal }); /** * Create the request with state and runtime */ const requestWithStateAndRuntime = { ...request, state: { ...middleware.stateSchema ? (0, _langchain_core_utils_types.interopParse)(require_utils$1.toPartialZodObject(middleware.stateSchema), state) : {}, messages: state.messages }, runtime }; /** * Create handler that validates tools and calls the inner handler */ const handlerWithValidation = async (req) => { currentSystemMessage = baselineSystemMessage; /** * Validate tool modifications in wrapModelCall. * * Classify each client tool as either: * - "added": a genuinely new tool name not in the static toolClasses * - "replaced": same name as a registered tool but different instance * * Added tools are allowed when a wrapToolCall middleware exists to * handle their execution. Replaced tools are always rejected to * preserve ToolNode execution identity. */ const modifiedTools = req.tools ?? []; const registeredToolsByName = new Map(this.#options.toolClasses.filter(require_utils.isClientTool).map((t) => [t.name, t])); const addedClientTools = modifiedTools.filter((tool) => require_utils.isClientTool(tool) && !registeredToolsByName.has(tool.name)); const replacedClientTools = modifiedTools.filter((tool) => { if (!require_utils.isClientTool(tool)) return false; const original = registeredToolsByName.get(tool.name); return original != null && original !== tool; }); if (addedClientTools.length > 0) { if (!this.#options.middleware?.some((m) => m.wrapToolCall != null)) throw new Error(`You have added a new tool in "wrapModelCall" hook of middleware "${currentMiddleware.name}": ${addedClientTools.map((tool) => tool.name).join(", ")}. This is not supported unless a middleware provides a "wrapToolCall" handler to execute it.`); } if (replacedClientTools.length > 0) throw new Error(`You have modified a tool in "wrapModelCall" hook of middleware "${currentMiddleware.name}": ${replacedClientTools.map((tool) => tool.name).join(", ")}. This is not supported.`); let normalizedReq = req; const hasSystemPromptChanged = req.systemPrompt !== currentSystemMessage.text; const hasSystemMessageChanged = req.systemMessage !== currentSystemMessage; if (hasSystemPromptChanged && hasSystemMessageChanged) throw new Error("Cannot change both systemPrompt and systemMessage in the same request."); /** * Check if systemPrompt is a string was changed, if so create a new SystemMessage */ if (hasSystemPromptChanged) { currentSystemMessage = new _langchain_core_messages.SystemMessage({ content: [{ type: "text", text: req.systemPrompt }] }); normalizedReq = { ...req, systemPrompt: currentSystemMessage.text, systemMessage: currentSystemMessage }; } /** * If the systemMessage was changed, update the current system message */ if (hasSystemMessageChanged) { currentSystemMessage = new _langchain_core_messages.SystemMessage({ ...req.systemMessage }); normalizedReq = { ...req, systemPrompt: currentSystemMessage.text, systemMessage: currentSystemMessage }; } const innerHandlerResult = await innerHandler(normalizedReq); /** * Normalize Commands so middleware always sees AIMessage from handler(). * When an inner handler (base handler or nested middleware) returns a * Command (e.g. structured-output retry), substitute the tracked * lastAiMessage so the middleware sees an AIMessage, and collect the * raw Command so the framework can still propagate it (e.g. for retries). * * Only collect if not already present: Commands from inner middleware * are already tracked via the middleware validation layer (line ~627). */ if ((0, _langchain_langgraph.isCommand)(innerHandlerResult) && lastAiMessage) { if (!collectedCommands.includes(innerHandlerResult)) collectedCommands.push(innerHandlerResult); return lastAiMessage; } return innerHandlerResult; }; if (!currentMiddleware.wrapModelCall) return handlerWithValidation(requestWithStateAndRuntime); try { const middlewareResponse = await currentMiddleware.wrapModelCall(requestWithStateAndRuntime, handlerWithValidation); /** * Validate that this specific middleware returned a valid response */ if (!isInternalModelResponse(middlewareResponse)) throw new Error(`Invalid response from "wrapModelCall" in middleware "${currentMiddleware.name}": expected AIMessage or Command, got ${typeof middlewareResponse}`); if (_langchain_core_messages.AIMessage.isInstance(middlewareResponse)) lastAiMessage = middlewareResponse; else if ((0, _langchain_langgraph.isCommand)(middlewareResponse)) collectedCommands.push(middlewareResponse); return middlewareResponse; } catch (error) { throw require_errors.MiddlewareError.wrap(error, currentMiddleware.name); } }; } } /** * Execute the wrapped handler with the initial request * Reset current system prompt to initial state and convert to string using .text getter * for backwards compatibility with ModelRequest */ currentSystemMessage = this.#systemMessage; const initialRequest = { model, responseFormat: this.#options.responseFormat, systemPrompt: currentSystemMessage?.text, systemMessage: currentSystemMessage, messages: state.messages, tools: this.#options.toolClasses, state, runtime: Object.freeze({ context: lgConfig?.context, store: lgConfig.store, configurable: lgConfig.configurable, writer: lgConfig.writer, interrupt: lgConfig.interrupt, signal: lgConfig.signal }) }; return { response: await wrappedHandler(initialRequest), lastAiMessage, collectedCommands }; } /** * If the model returns multiple structured outputs, we need to handle it. * @param response - The response from the model * @param toolCalls - The tool calls that were made * @returns The response from the model */ #handleMultipleStructuredOutputs(response, toolCalls, responseFormat) { const multipleStructuredOutputsError = new require_errors.MultipleStructuredOutputsError(toolCalls.map((call) => call.name)); return this.#handleToolStrategyError(multipleStructuredOutputsError, response, toolCalls[0], responseFormat); } /** * If the model returns a single structured output, we need to handle it. * @param toolCall - The tool call that was made * @returns The structured response and a message to the LLM if needed */ #handleSingleStructuredOutput(response, toolCall, responseFormat, lastMessage) { const tool = responseFormat.tools[toolCall.name]; try { const structuredResponse = tool.parse(toolCall.args); return { structuredResponse, messages: [ response, new _langchain_core_messages.ToolMessage({ tool_call_id: toolCall.id ?? "", content: JSON.stringify(structuredResponse), name: toolCall.name }), new _langchain_core_messages.AIMessage(lastMessage ?? `Returning structured response: ${JSON.stringify(structuredResponse)}`) ] }; } catch (error) { return this.#handleToolStrategyError(error, response, toolCall, responseFormat); } } async #handleToolStrategyError(error, response, toolCall, responseFormat) { /** * Using the `errorHandler` option of the first `ToolStrategy` entry is sufficient here. * There is technically only one `ToolStrategy` entry in `structuredToolInfo` if the user * uses `toolStrategy` to define the response format. If the user applies a list of json * schema objects, these will be transformed into multiple `ToolStrategy` entries but all * with the same `handleError` option. */ const errorHandler = Object.values(responseFormat.tools).at(0)?.options?.handleError; const toolCallId = toolCall.id; if (!toolCallId) throw new Error("Tool call ID is required to handle tool output errors. Please provide a tool call ID."); /** * Default behavior: retry if `errorHandler` is undefined or truthy. * Only throw if explicitly set to `false`. */ if (errorHandler === false) throw error; /** * retry if: */ if (errorHandler === void 0 || typeof errorHandler === "boolean" && errorHandler || Array.isArray(errorHandler) && errorHandler.some((h) => h instanceof require_errors.MultipleStructuredOutputsError)) return new _langchain_langgraph.Command({ update: { messages: [response, new _langchain_core_messages.ToolMessage({ content: error.message, tool_call_id: toolCallId })] }, goto: AGENT_NODE_NAME }); /** * if `errorHandler` is a string, retry the tool call with given string */ if (typeof errorHandler === "string") return new _langchain_langgraph.Command({ update: { messages: [response, new _langchain_core_messages.ToolMessage({ content: errorHandler, tool_call_id: toolCallId })] }, goto: AGENT_NODE_NAME }); /** * if `errorHandler` is a function, retry the tool call with the function */ if (typeof errorHandler === "function") { const content = await errorHandler(error); if (typeof content !== "string") throw new Error("Error handler must return a string."); return new _langchain_langgraph.Command({ update: { messages: [response, new _langchain_core_messages.ToolMessage({ content, tool_call_id: toolCallId })] }, goto: AGENT_NODE_NAME }); } /** * Default: retry if we reach here */ return new _langchain_langgraph.Command({ update: { messages: [response, new _langchain_core_messages.ToolMessage({ content: error.message, tool_call_id: toolCallId })] }, goto: AGENT_NODE_NAME }); } #areMoreStepsNeeded(state, response) { const allToolsReturnDirect = _langchain_core_messages.AIMessage.isInstance(response) && response.tool_calls?.every((call) => this.#options.shouldReturnDirect.has(call.name)); const remainingSteps = "remainingSteps" in state ? state.remainingSteps : void 0; return Boolean(remainingSteps && (remainingSteps < 1 && allToolsReturnDirect || remainingSteps < 2 && require_utils.hasToolCalls(state.messages.at(-1)))); } async #bindTools(model, preparedOptions, structuredResponseFormat) { const options = {}; const structuredTools = Object.values(structuredResponseFormat && "tools" in structuredResponseFormat ? structuredResponseFormat.tools : {}); /** * Use tools from preparedOptions if provided, otherwise use default tools */ const allTools = [...preparedOptions?.tools ?? this.#options.toolClasses, ...structuredTools.map((toolStrategy) => toolStrategy.tool)]; /** * If there are structured tools, we need to set the tool choice to "any" * so that the model can choose to use a structured tool or not. */ const toolChoice = preparedOptions?.toolChoice || (structuredTools.length > 0 ? "any" : void 0); /** * check if the user requests a native schema output */ if (structuredResponseFormat?.type === "native") { const resolvedStrict = preparedOptions?.modelSettings?.strict ?? structuredResponseFormat?.strategy?.strict ?? true; const jsonSchemaParams = { name: structuredResponseFormat.strategy.schema?.name ?? "extract", description: (0, _langchain_core_utils_types.getSchemaDescription)(structuredResponseFormat.strategy.schema), schema: structuredResponseFormat.strategy.schema, strict: resolvedStrict }; Object.assign(options, { /** * OpenAI-style options * Used by ChatOpenAI, ChatXAI, and other OpenAI-compatible providers. */ response_format: { type: "json_schema", json_schema: jsonSchemaParams }, /** * Anthropic-style options */ outputConfig: { format: { type: "json_schema", schema: structuredResponseFormat.strategy.schema } }, /** * Google-style options * Used by ChatGoogle and other Gemini-based providers. */ responseSchema: structuredResponseFormat.strategy.schema, /** * for LangSmith structured output tracing */ ls_structured_output_format: { kwargs: { method: "json_schema" }, schema: structuredResponseFormat.strategy.schema }, /** * Don't force strict on tools: it makes Anthropic's combined grammar * "too complex for compilation", and only OpenAI Chat Completions needs * it (re-applied there). Honor an explicit override; else leave unset. */ strict: preparedOptions?.modelSettings?.strict }); } /** * Bind tools to the model if they are not already bound. */ const modelWithTools = await require_utils.bindTools(model, allTools, { ...options, ...preparedOptions?.modelSettings, tool_choice: toolChoice }); return this.#options.includeAgentName === "inline" ? require_withAgentName.withAgentName(modelWithTools, this.#options.includeAgentName) : modelWithTools; } /** * Returns internal bookkeeping state for StateManager, not graph output. * The return shape differs from the node's output type (Command). */ getState() { const state = super.getState(); return { messages: [], ...state && !(0, _langchain_langgraph.isCommand)(state) ? state : {} }; } }; //#endregion exports.AGENT_NODE_NAME = AGENT_NODE_NAME; exports.AgentNode = AgentNode; //# sourceMappingURL=AgentNode.cjs.map