-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat(langchain/createAgent): llmToolSelectorMiddleware implementation #9050
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,239 @@ | ||
| import { z } from "zod/v3"; | ||
| import { BaseChatModel } from "@langchain/core/language_models/chat_models"; | ||
| import type { InferInteropZodInput } from "@langchain/core/utils/types"; | ||
| import { BaseMessage, HumanMessage } from "@langchain/core/messages"; | ||
|
|
||
| import { createMiddleware } from "../middleware.js"; | ||
| import { initChatModel } from "../../../chat_models/universal.js"; | ||
| import type { ModelRequest } from "../types.js"; | ||
|
|
||
| /** | ||
| * Zod schema for tool selection structured output. | ||
| */ | ||
| const ToolSelectionSchema = z.object({ | ||
| selectedTools: z.array(z.string()).describe("List of selected tool names"), | ||
| }); | ||
|
|
||
| const DEFAULT_SYSTEM_PROMPT = | ||
| "Your goal is to select the most relevant tool for answering the user's query."; | ||
| const DEFAULT_INCLUDE_FULL_HISTORY = false; | ||
| const DEFAULT_MAX_RETRIES = 3; | ||
|
|
||
| /** | ||
| * Options for configuring the LLM Tool Selector middleware. | ||
| */ | ||
| export const LLMToolSelectorOptionsSchema = z.object({ | ||
| /** | ||
| * The language model to use for tool selection (default: the provided model from the agent options). | ||
| */ | ||
| model: z.string().or(z.instanceof(BaseChatModel)).optional(), | ||
| /** | ||
| * System prompt for the tool selection model. | ||
| */ | ||
| systemPrompt: z.string().default(DEFAULT_SYSTEM_PROMPT), | ||
| /** | ||
| * Maximum number of tools to select. | ||
| */ | ||
| maxTools: z.number().optional(), | ||
| /** | ||
| * Whether to include the full conversation history in the tool selection prompt. | ||
| */ | ||
| includeFullHistory: z.boolean().default(DEFAULT_INCLUDE_FULL_HISTORY), | ||
| /** | ||
| * Maximum number of retries if the model selects incorrect tools. | ||
| */ | ||
| maxRetries: z.number().default(DEFAULT_MAX_RETRIES), | ||
| }); | ||
| export type LLMToolSelectorConfig = InferInteropZodInput< | ||
| typeof LLMToolSelectorOptionsSchema | ||
| >; | ||
|
|
||
| /** | ||
| * Middleware for selecting tools using an LLM-based strategy. | ||
| * | ||
| * This middleware analyzes the user's query and available tools to select | ||
| * the most relevant tools for the task, reducing the cognitive load on the | ||
| * main model and improving response quality. | ||
| * | ||
| * @param options - Configuration options for the middleware | ||
| * @param options.model - The language model to use for tool selection (default: the provided model from the agent options). | ||
| * @param options.systemPrompt - System prompt for the tool selection model. | ||
| * @param options.maxTools - Maximum number of tools to select. | ||
| * @param options.includeFullHistory - Whether to include the full conversation history in the tool selection prompt. | ||
| * @param options.maxRetries - Maximum number of retries if the model selects incorrect tools. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { llmToolSelectorMiddleware } from "langchain/agents/middleware"; | ||
| * | ||
| * const middleware = llmToolSelectorMiddleware({ | ||
| * maxTools: 3, | ||
| * systemPrompt: "Select the most relevant tools for the user's query." | ||
| * }); | ||
| * | ||
| * const agent = createAgent({ | ||
| * model: "openai:gpt-4o", | ||
| * tools: [tool1, tool2, tool3, tool4, tool5], | ||
| * middleware: [middleware], | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export function llmToolSelectorMiddleware(options: LLMToolSelectorConfig) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not bigtool? Just curious
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I got a Python implementation from @eyurtsev and copied the name from there, (changed file name later on)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd prefer renaming to toolSelection.ts personally and put some different strategies here in the long run... unless we think that bigTool.ts is obvious |
||
| return createMiddleware({ | ||
| name: "LLMToolSelector", | ||
| contextSchema: LLMToolSelectorOptionsSchema, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're missing here an option to always include some tools. A user may want to make sure that some tools are always included (and these tools should be removed from the prompt; i.e., the model shouldn't necessarily know about them.) |
||
| async modifyModelRequest(request, _, runtime): Promise<ModelRequest> { | ||
| const model = runtime.context.model ?? options.model; | ||
| const maxTools = runtime.context.maxTools ?? options.maxTools; | ||
| const includeFullHistory = | ||
| runtime.context.includeFullHistory === DEFAULT_INCLUDE_FULL_HISTORY | ||
| ? options.includeFullHistory ?? runtime.context.includeFullHistory | ||
| : runtime.context.includeFullHistory ?? options.includeFullHistory; | ||
| const maxRetries = | ||
| runtime.context.maxRetries === DEFAULT_MAX_RETRIES | ||
| ? options.maxRetries ?? runtime.context.maxRetries | ||
| : runtime.context.maxRetries ?? options.maxRetries; | ||
| const defaultSystemPrompt = | ||
| runtime.context.systemPrompt === DEFAULT_SYSTEM_PROMPT | ||
| ? options.systemPrompt ?? runtime.context.systemPrompt | ||
| : runtime.context.systemPrompt ?? options.systemPrompt; | ||
|
Comment on lines
+86
to
+99
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the short term could these just be args to middleware? not fallback to runtime context spec? gives users 1 way to do things for now |
||
|
|
||
| /** | ||
| * If no tools available, return request unchanged | ||
| */ | ||
| if (!request.tools || request.tools.length === 0) { | ||
| return request; | ||
| } | ||
|
|
||
| /** | ||
| * Extract tool information | ||
| */ | ||
| const toolInfo = runtime.tools.map((tool) => ({ | ||
| name: tool.name as string, | ||
| description: tool.description, | ||
| tool, | ||
| })); | ||
|
|
||
| /** | ||
| * Build tool representation for the prompt | ||
| */ | ||
| const toolRepresentation = toolInfo | ||
| .map(({ name, description }) => `- ${name}: ${description}`) | ||
| .join("\n"); | ||
|
Comment on lines
+120
to
+122
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be configurable?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess this goes into prompt engineering - maybe there are ways you would want to represent tools in different ways, e.g. I could imagine some models like an XML description of a tool better? |
||
|
|
||
| /** | ||
| * Build system message | ||
| */ | ||
| let systemMessage = `You are an agent that can use the following tools:\n${toolRepresentation}\n${defaultSystemPrompt}`; | ||
|
|
||
| if (includeFullHistory) { | ||
| const userMessages = request.messages | ||
| .filter(HumanMessage.isInstance) | ||
| .map((msg: BaseMessage) => msg.content) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why are we only keeping HumanMessages for the full history? the full history is usually meant to include also AImessage and ToolMessage |
||
| .join("\n"); | ||
| systemMessage += `\nThe full conversation history is:\n${userMessages}`; | ||
| } | ||
|
|
||
| if (maxTools !== undefined) { | ||
| systemMessage += ` You can select up to ${maxTools} tools.`; | ||
| } | ||
|
|
||
| /** | ||
| * Get the latest user message | ||
| */ | ||
| const latestMessage = request.messages.at(-1); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe that there's a bug here -- the latest message may not be a user message (it may be a ToolMessage). |
||
| const userContent = | ||
| typeof latestMessage?.content === "string" | ||
| ? latestMessage?.content | ||
| : JSON.stringify(latestMessage?.content); | ||
|
|
||
| /** | ||
| * Create tool selection model | ||
| */ | ||
| const toolSelectionModel = !model | ||
| ? (request.model as BaseChatModel) | ||
| : typeof model === "string" | ||
| ? await initChatModel(model) | ||
| : model; | ||
|
|
||
| const validToolNames = toolInfo.map(({ name }) => name); | ||
| const structuredModel = await toolSelectionModel.withStructuredOutput( | ||
| ToolSelectionSchema | ||
| ); | ||
|
|
||
| let attempts = 0; | ||
| let selectedToolNames: string[] = [...validToolNames]; | ||
|
|
||
| while (attempts <= maxRetries) { | ||
| try { | ||
| const response = await structuredModel.invoke([ | ||
| { role: "system", content: systemMessage }, | ||
| { role: "user", content: userContent }, | ||
| ]); | ||
|
|
||
| selectedToolNames = response.selectedTools; | ||
|
|
||
| /** | ||
| * Validate that selected tools exist | ||
| */ | ||
| const invalidTools = selectedToolNames.filter( | ||
| (name) => !validToolNames.includes(name) | ||
| ); | ||
|
|
||
| if (selectedToolNames.length === 0) { | ||
| systemMessage += `\n\nNote: You have not selected any tools. Please select at least one tool.`; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This does not appear to be correct behavior. I think it's OK to select 0 tools if there are no relevant tools. |
||
| attempts++; | ||
| } else if ( | ||
| invalidTools.length === 0 && | ||
| maxTools && | ||
| selectedToolNames.length > maxTools | ||
| ) { | ||
| systemMessage += `\n\nNote: You have selected more tools than the maximum allowed. You can select up to ${maxTools} tools.`; | ||
| attempts++; | ||
| } else if (invalidTools.length === 0) { | ||
| /** | ||
| * Success | ||
| */ | ||
| break; | ||
| } else if (attempts < maxRetries) { | ||
| /** | ||
| * Retry with feedback about invalid tools | ||
| */ | ||
| systemMessage += `\n\nNote: The following tools are not available: ${invalidTools.join( | ||
| ", " | ||
| )}. Please select only from the available tools.`; | ||
| attempts++; | ||
| } else { | ||
| /** | ||
| * Filter out invalid tools on final attempt | ||
| */ | ||
| selectedToolNames = selectedToolNames.filter((name) => | ||
| validToolNames.includes(name) | ||
| ); | ||
| break; | ||
| } | ||
| } catch { | ||
| /** | ||
| * Fall back to using all tools | ||
| */ | ||
| if (attempts >= maxRetries) { | ||
| return request; | ||
| } | ||
| attempts++; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Filter tools based on selection | ||
| */ | ||
| const selectedTools = toolInfo | ||
| .filter(({ name }) => selectedToolNames.includes(name)) | ||
| .map(({ name }) => name); | ||
|
|
||
| return { | ||
| ...request, | ||
| tools: selectedTools, | ||
| }; | ||
| }, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we generate this dynamically so it's not a list of string, but a list of possible enums with descriptions?