UNPKG

langchain

Version:
457 lines (455 loc) 18.5 kB
import { initChatModel } from "../../chat_models/universal.js"; import { MultipleStructuredOutputsError } from "../errors.js"; import { bindTools, hasToolCalls, isClientTool, validateLLMHasNoBoundTools } from "../utils.js"; import { RunnableCallable } from "../RunnableCallable.js"; import { mergeAbortSignals } from "./utils.js"; import { withAgentName } from "../withAgentName.js"; import { ProviderStrategy, ToolStrategy, transformResponseFormat } from "../responses.js"; import { AIMessage, SystemMessage, ToolMessage } from "@langchain/core/messages"; import { raceWithSignal } from "@langchain/core/runnables"; import { Command } from "@langchain/langgraph"; import { getSchemaDescription, interopParse, interopZodObjectPartial } from "@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 AIMessage.isInstance(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 RunnableCallable { #options; #systemMessage; #currentSystemMessage; 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 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. */ #getResponseFormat(model) { if (!this.#options.responseFormat) return void 0; const strategies = transformResponseFormat(this.#options.responseFormat, void 0, model); /** * we either define a list of provider strategies or a list of tool strategies */ const isProviderStrategy = strategies.every((format) => format instanceof ProviderStrategy); /** * Populate a list of structured tool info. */ if (!isProviderStrategy) return { type: "tool", tools: strategies.filter((format) => format instanceof ToolStrategy).reduce((acc, format) => { acc[format.name] = format; return acc; }, {}) }; return { type: "native", 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 && ToolMessage.isInstance(lastMessage) && lastMessage.name && this.#options.shouldReturnDirect.has(lastMessage.name)) /** * return directly without invoking the model again */ return { messages: [] }; const response = await this.#invokeModel(state, config); /** * if we were able to generate a structured response, return it */ if ("structuredResponse" in response) return { messages: [...state.messages, ...response.messages || []], structuredResponse: response.structuredResponse }; /** * if we need to direct the agent to the model, return the update */ if (response instanceof Command) return response; response.name = this.name; response.lc_kwargs.name = this.name; if (this.#areMoreStepsNeeded(state, response)) return { messages: [new AIMessage({ content: "Sorry, need more steps to process this request.", name: this.name, id: response.id })] }; return { messages: [response] }; } /** * 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 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 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. */ validateLLMHasNoBoundTools(request.model); const structuredResponseFormat = this.#getResponseFormat(request.model); const modelWithTools = await this.#bindTools(request.model, request, structuredResponseFormat); /** * prepend the system message to the messages if it is not empty */ const messages = [...this.#currentSystemMessage.text === "" ? [] : [this.#currentSystemMessage], ...request.messages]; const signal = mergeAbortSignals(this.#options.signal, config.signal); const response = await raceWithSignal(modelWithTools.invoke(messages, { ...config, signal }), signal); /** * 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] }; 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 toolStrategy = structuredResponseFormat.tools[toolCalls[0].name]; const toolMessageContent = toolStrategy?.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 [middleware, getMiddlewareState] = wrapperMiddleware[i]; if (middleware.wrapModelCall) { const innerHandler = wrappedHandler; const currentMiddleware = middleware; const currentGetState = getMiddlewareState; wrappedHandler = async (request) => { /** * Merge context with default context of middleware */ const context = currentMiddleware.contextSchema ? interopParse(currentMiddleware.contextSchema, lgConfig?.context || {}) : lgConfig?.context; /** * Create runtime */ const runtime = Object.freeze({ context, writer: lgConfig.writer, interrupt: lgConfig.interrupt, signal: lgConfig.signal }); /** * Create the request with state and runtime */ const requestWithStateAndRuntime = { ...request, state: { ...middleware.stateSchema ? interopParse(interopZodObjectPartial(middleware.stateSchema), state) : {}, ...currentGetState(), messages: state.messages }, runtime }; /** * Create handler that validates tools and calls the inner handler */ const handlerWithValidation = async (req) => { /** * Verify that the user didn't add any new tools. * We can't allow this as the ToolNode is already initiated with given tools. */ const modifiedTools = req.tools ?? []; const newTools = modifiedTools.filter((tool) => isClientTool(tool) && !this.#options.toolClasses.some((t) => t.name === tool.name)); if (newTools.length > 0) throw new Error(`You have added a new tool in "wrapModelCall" hook of middleware "${currentMiddleware.name}": ${newTools.map((tool) => tool.name).join(", ")}. This is not supported.`); /** * Verify that user has not added or modified a tool with the same name. * We can't allow this as the ToolNode is already initiated with given tools. */ const invalidTools = modifiedTools.filter((tool) => isClientTool(tool) && this.#options.toolClasses.every((t) => t !== tool)); if (invalidTools.length > 0) throw new Error(`You have modified a tool in "wrapModelCall" hook of middleware "${currentMiddleware.name}": ${invalidTools.map((tool) => tool.name).join(", ")}. This is not supported.`); let normalizedReq = req; const hasSystemPromptChanged = req.systemPrompt !== this.#currentSystemMessage.text; const hasSystemMessageChanged = req.systemMessage !== this.#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) { this.#currentSystemMessage = new SystemMessage({ content: [{ type: "text", text: req.systemPrompt }] }); normalizedReq = { ...req, systemPrompt: this.#currentSystemMessage.text, systemMessage: this.#currentSystemMessage }; } /** * If the systemMessage was changed, update the current system message */ if (hasSystemMessageChanged) { this.#currentSystemMessage = new SystemMessage({ ...req.systemMessage }); normalizedReq = { ...req, systemPrompt: this.#currentSystemMessage.text, systemMessage: this.#currentSystemMessage }; } return innerHandler(normalizedReq); }; if (!currentMiddleware.wrapModelCall) return handlerWithValidation(requestWithStateAndRuntime); try { const middlewareResponse = await currentMiddleware.wrapModelCall(requestWithStateAndRuntime, handlerWithValidation); /** * Validate that this specific middleware returned a valid AIMessage */ if (!isInternalModelResponse(middlewareResponse)) throw new Error(`Invalid response from "wrapModelCall" in middleware "${currentMiddleware.name}": expected AIMessage, got ${typeof middlewareResponse}`); return middlewareResponse; } catch (error) { /** * Add middleware context to error if not already added */ if (error instanceof Error && !error.message.includes(`middleware "${currentMiddleware.name}"`)) error.message = `Error in middleware "${currentMiddleware.name}": ${error.message}`; throw error; } }; } } /** * 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 */ this.#currentSystemMessage = this.#systemMessage; const initialRequest = { model, systemPrompt: this.#currentSystemMessage?.text, systemMessage: this.#currentSystemMessage, messages: state.messages, tools: this.#options.toolClasses, state, runtime: Object.freeze({ context: lgConfig?.context, writer: lgConfig.writer, interrupt: lgConfig.interrupt, signal: lgConfig.signal }) }; return wrappedHandler(initialRequest); } /** * 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 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 ToolMessage({ tool_call_id: toolCall.id ?? "", content: JSON.stringify(structuredResponse), name: toolCall.name }), new 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 MultipleStructuredOutputsError)) return new Command({ update: { messages: [response, new 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 Command({ update: { messages: [response, new 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 Command({ update: { messages: [response, new ToolMessage({ content, tool_call_id: toolCallId })] }, goto: AGENT_NODE_NAME }); } /** * Default: retry if we reach here */ return new Command({ update: { messages: [response, new ToolMessage({ content: error.message, tool_call_id: toolCallId })] }, goto: AGENT_NODE_NAME }); } #areMoreStepsNeeded(state, response) { const allToolsReturnDirect = 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 && 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: getSchemaDescription(structuredResponseFormat.strategy.schema), schema: structuredResponseFormat.strategy.schema, strict: resolvedStrict }; Object.assign(options, { response_format: { type: "json_schema", json_schema: jsonSchemaParams }, output_format: { type: "json_schema", schema: structuredResponseFormat.strategy.schema }, headers: { "anthropic-beta": "structured-outputs-2025-11-13" }, ls_structured_output_format: { kwargs: { method: "json_schema" }, schema: structuredResponseFormat.strategy.schema }, strict: resolvedStrict }); } /** * Bind tools to the model if they are not already bound. */ const modelWithTools = await bindTools(model, allTools, { ...options, ...preparedOptions?.modelSettings ?? {}, tool_choice: toolChoice }); /** * Create a model runnable with the prompt and agent name * Use current SystemMessage state (which may have been modified by middleware) */ const modelRunnable = this.#options.includeAgentName === "inline" ? withAgentName(modelWithTools, this.#options.includeAgentName) : modelWithTools; return modelRunnable; } getState() { const state = super.getState(); const origState = state && !(state instanceof Command) ? state : {}; return { messages: [], ...origState }; } }; //#endregion export { AGENT_NODE_NAME, AgentNode }; //# sourceMappingURL=AgentNode.js.map