langchain
Version:
Typescript bindings for langchain
331 lines (329 loc) • 12.1 kB
JavaScript
import { isBaseChatModel, isConfigurableModel } from "./model.js";
import { MultipleToolsBoundError } from "./errors.js";
import { PROMPT_RUNNABLE_NAME } from "./constants.js";
import { AIMessage, AIMessageChunk, SystemMessage, ToolMessage } from "@langchain/core/messages";
import { Runnable, RunnableBinding, RunnableLambda, RunnableSequence } from "@langchain/core/runnables";
import { isCommand } from "@langchain/langgraph";
//#region src/agents/utils.ts
const NAME_PATTERN = /<name>(.*?)<\/name>/s;
const CONTENT_PATTERN = /<content>(.*?)<\/content>/s;
/**
* Attach formatted agent names to the messages passed to and from a language model.
*
* This is useful for making a message history with multiple agents more coherent.
*
* NOTE: agent name is consumed from the message.name field.
* If you're using an agent built with createAgent, name is automatically set.
* If you're building a custom agent, make sure to set the name on the AI message returned by the LLM.
*
* @param message - Message to add agent name formatting to
* @returns Message with agent name formatting
*
* @internal
*/
function _addInlineAgentName(message) {
if (!AIMessage.isInstance(message) || AIMessageChunk.isInstance(message)) return message;
if (!message.name) return message;
const { name } = message;
if (typeof message.content === "string") return new AIMessage({
...message.lc_kwargs,
content: `<name>${name}</name><content>${message.content}</content>`,
name: void 0
});
const updatedContent = [];
let textBlockCount = 0;
for (const contentBlock of message.content) if (typeof contentBlock === "string") {
textBlockCount += 1;
updatedContent.push(`<name>${name}</name><content>${contentBlock}</content>`);
} else if (typeof contentBlock === "object" && "type" in contentBlock && contentBlock.type === "text") {
textBlockCount += 1;
updatedContent.push({
...contentBlock,
text: `<name>${name}</name><content>${contentBlock.text}</content>`
});
} else updatedContent.push(contentBlock);
if (!textBlockCount) updatedContent.unshift({
type: "text",
text: `<name>${name}</name><content></content>`
});
return new AIMessage({
...message.lc_kwargs,
content: updatedContent,
name: void 0
});
}
/**
* Remove explicit name and content XML tags from the AI message content.
*
* Examples:
*
* @example
* ```typescript
* removeInlineAgentName(new AIMessage({ content: "<name>assistant</name><content>Hello</content>", name: "assistant" }))
* // AIMessage with content: "Hello"
*
* removeInlineAgentName(new AIMessage({ content: [{type: "text", text: "<name>assistant</name><content>Hello</content>"}], name: "assistant" }))
* // AIMessage with content: [{type: "text", text: "Hello"}]
* ```
*
* @internal
*/
function _removeInlineAgentName(message) {
if (!AIMessage.isInstance(message) || !message.content) return message;
let updatedContent = [];
let updatedName;
if (Array.isArray(message.content)) updatedContent = message.content.filter((block) => {
if (block.type === "text" && typeof block.text === "string") {
const nameMatch = block.text.match(NAME_PATTERN);
const contentMatch = block.text.match(CONTENT_PATTERN);
if (nameMatch && (!contentMatch || contentMatch[1] === "")) {
updatedName = nameMatch[1];
return false;
}
return true;
}
return true;
}).map((block) => {
if (block.type === "text" && typeof block.text === "string") {
const nameMatch = block.text.match(NAME_PATTERN);
const contentMatch = block.text.match(CONTENT_PATTERN);
if (!nameMatch || !contentMatch) return block;
updatedName = nameMatch[1];
return {
...block,
text: contentMatch[1]
};
}
return block;
});
else {
const content = message.content;
const nameMatch = content.match(NAME_PATTERN);
const contentMatch = content.match(CONTENT_PATTERN);
if (!nameMatch || !contentMatch) return message;
updatedName = nameMatch[1];
updatedContent = contentMatch[1];
}
return new AIMessage({
...Object.keys(message.lc_kwargs ?? {}).length > 0 ? message.lc_kwargs : message,
content: updatedContent,
name: updatedName
});
}
function isClientTool(tool) {
return Runnable.isRunnable(tool);
}
/**
* Helper function to check if a language model has a bindTools method.
* @param llm - The language model to check if it has a bindTools method.
* @returns True if the language model has a bindTools method, false otherwise.
*/
function _isChatModelWithBindTools(llm) {
if (!isBaseChatModel(llm)) return false;
return "bindTools" in llm && typeof llm.bindTools === "function";
}
/**
* Helper function to bind tools to a language model.
* @param llm - The language model to bind tools to.
* @param toolClasses - The tools to bind to the language model.
* @param options - The options to pass to the language model.
* @returns The language model with the tools bound to it.
*/
const _simpleBindTools = (llm, toolClasses, options = {}) => {
if (_isChatModelWithBindTools(llm)) return llm.bindTools(toolClasses, options);
if (RunnableBinding.isRunnableBinding(llm) && _isChatModelWithBindTools(llm.bound)) {
const newBound = llm.bound.bindTools(toolClasses, options);
if (RunnableBinding.isRunnableBinding(newBound)) return new RunnableBinding({
bound: newBound.bound,
config: {
...llm.config,
...newBound.config
},
kwargs: {
...llm.kwargs,
...newBound.kwargs
},
configFactories: newBound.configFactories ?? llm.configFactories
});
return new RunnableBinding({
bound: newBound,
config: llm.config,
kwargs: llm.kwargs,
configFactories: llm.configFactories
});
}
return null;
};
/**
* Check if the LLM already has bound tools and throw if it does.
*
* @param llm - The LLM to check.
* @returns void
*/
function validateLLMHasNoBoundTools(llm) {
/**
* If llm is a function, we can't validate until runtime, so skip
*/
if (typeof llm === "function") return;
let model = llm;
/**
* If model is a RunnableSequence, find a RunnableBinding in its steps
*/
if (RunnableSequence.isRunnableSequence(model)) model = model.steps.find((step) => RunnableBinding.isRunnableBinding(step)) || model;
/**
* If model is configurable, get the underlying model
*/
if (isConfigurableModel(model))
/**
* Can't validate async model retrieval in constructor
*/
return;
/**
* Check if model is a RunnableBinding with bound tools
*/
if (RunnableBinding.isRunnableBinding(model)) {
const hasToolsInKwargs = model.kwargs != null && typeof model.kwargs === "object" && "tools" in model.kwargs && Array.isArray(model.kwargs.tools) && model.kwargs.tools.length > 0;
const hasToolsInConfig = model.config != null && typeof model.config === "object" && "tools" in model.config && Array.isArray(model.config.tools) && model.config.tools.length > 0;
if (hasToolsInKwargs || hasToolsInConfig) throw new MultipleToolsBoundError();
}
/**
* Also check if model has tools property directly (e.g., FakeToolCallingModel)
*/
if ("tools" in model && model.tools !== void 0 && Array.isArray(model.tools) && model.tools.length > 0) throw new MultipleToolsBoundError();
}
/**
* Check if the last message in the messages array has tool calls.
*
* @param messages - The messages to check.
* @returns True if the last message has tool calls, false otherwise.
*/
function hasToolCalls(message) {
return Boolean(AIMessage.isInstance(message) && message.tool_calls && message.tool_calls.length > 0);
}
function getPromptRunnable(prompt) {
let promptRunnable;
if (prompt == null) promptRunnable = RunnableLambda.from((state) => state.messages).withConfig({ runName: PROMPT_RUNNABLE_NAME });
else if (typeof prompt === "string") {
const systemMessage = new SystemMessage(prompt);
promptRunnable = RunnableLambda.from((state) => {
return [systemMessage, ...state.messages ?? []];
}).withConfig({ runName: PROMPT_RUNNABLE_NAME });
} else throw new Error(`Got unexpected type for 'prompt': ${typeof prompt}`);
return promptRunnable;
}
/**
* Helper function to bind tools to a language model.
* @param llm - The language model to bind tools to.
* @param toolClasses - The tools to bind to the language model.
* @param options - The options to pass to the language model.
* @returns The language model with the tools bound to it.
*/
async function bindTools(llm, toolClasses, options = {}) {
const model = _simpleBindTools(llm, toolClasses, options);
if (model) return model;
if (isConfigurableModel(llm)) {
const model$1 = _simpleBindTools(await llm._model(), toolClasses, options);
if (model$1) return model$1;
}
if (RunnableSequence.isRunnableSequence(llm)) {
const modelStep = llm.steps.findIndex((step) => RunnableBinding.isRunnableBinding(step) || isBaseChatModel(step) || isConfigurableModel(step));
if (modelStep >= 0) {
const model$1 = _simpleBindTools(llm.steps[modelStep], toolClasses, options);
if (model$1) {
const nextSteps = llm.steps.slice();
nextSteps.splice(modelStep, 1, model$1);
return RunnableSequence.from(nextSteps);
}
}
}
throw new Error(`llm ${llm} must define bindTools method.`);
}
/**
* Compose multiple wrapToolCall handlers into a single middleware stack.
*
* Composes handlers so the first in the list becomes the outermost layer.
* Each handler receives a handler callback to execute inner layers.
*
* @param handlers - List of handlers. First handler wraps all others.
* @returns Composed handler, or undefined if handlers array is empty.
*
* @example
* ```typescript
* // handlers=[auth, retry] means: auth wraps retry
* // Flow: auth calls retry, retry calls base handler
* const auth: ToolCallWrapper = async (request, handler) => {
* try {
* return await handler(request);
* } catch (error) {
* if (error.message === "Unauthorized") {
* await refreshToken();
* return await handler(request);
* }
* throw error;
* }
* };
*
* const retry: ToolCallWrapper = async (request, handler) => {
* for (let attempt = 0; attempt < 3; attempt++) {
* try {
* return await handler(request);
* } catch (error) {
* if (attempt === 2) throw error;
* }
* }
* throw new Error("Unreachable");
* };
*
* const composedHandler = chainToolCallHandlers([auth, retry]);
* ```
*/
function chainToolCallHandlers(handlers) {
if (handlers.length === 0) return void 0;
if (handlers.length === 1) return handlers[0];
function composeTwo(outer, inner) {
return async (request, handler) => {
const innerHandler = async (req) => inner(req, async (innerReq) => handler(innerReq));
return outer(request, innerHandler);
};
}
let result = handlers[handlers.length - 1];
for (let i = handlers.length - 2; i >= 0; i--) result = composeTwo(handlers[i], result);
return result;
}
/**
* Wrapping `wrapToolCall` invocation so we can inject middleware name into
* the error message.
*
* @param middleware list of middleware passed to the agent
* @returns single wrap function
*/
function wrapToolCall(middleware) {
const middlewareWithWrapToolCall = middleware.filter((m) => m.wrapToolCall);
if (middlewareWithWrapToolCall.length === 0) return;
return chainToolCallHandlers(middlewareWithWrapToolCall.map((m) => {
const originalHandler = m.wrapToolCall;
/**
* Wrap with error handling and validation
*/
const wrappedHandler = async (request, handler) => {
try {
const result = await originalHandler(request, handler);
/**
* Validate return type
*/
if (!ToolMessage.isInstance(result) && !isCommand(result)) throw new Error(`Invalid response from "wrapToolCall" in middleware "${m.name}": expected ToolMessage or Command, got ${typeof result}`);
return result;
} catch (error) {
/**
* Add middleware context to error if not already added
*/
if (error instanceof Error && !error.message.includes(`middleware "${m.name}"`)) error.message = `Error in middleware "${m.name}": ${error.message}`;
throw error;
}
};
return wrappedHandler;
}));
}
//#endregion
export { _addInlineAgentName, _removeInlineAgentName, bindTools, getPromptRunnable, hasToolCalls, isClientTool, validateLLMHasNoBoundTools, wrapToolCall };
//# sourceMappingURL=utils.js.map