diff --git a/langchain-core/src/messages/tool.ts b/langchain-core/src/messages/tool.ts index d8683f6bfbc8..98fc17a87149 100644 --- a/langchain-core/src/messages/tool.ts +++ b/langchain-core/src/messages/tool.ts @@ -27,10 +27,23 @@ export interface ToolMessageFieldsWithToolCallId extends BaseMessageFields { status?: "success" | "error"; } +export interface DirectToolOutput { + readonly lc_tool_output: boolean; +} + +export function isDirectToolOutput(x: unknown): x is DirectToolOutput { + return ( + x != null && + typeof x === "object" && + "lc_tool_output" in x && + x.lc_tool_output === true + ); +} + /** * Represents a tool message in a conversation. */ -export class ToolMessage extends BaseMessage { +export class ToolMessage extends BaseMessage implements DirectToolOutput { static lc_name() { return "ToolMessage"; } @@ -40,6 +53,8 @@ export class ToolMessage extends BaseMessage { return { tool_call_id: "tool_call_id" }; } + lc_tool_output = true; + /** * Status of the tool invocation. * @version 0.2.19 diff --git a/langchain-core/src/tools/index.ts b/langchain-core/src/tools/index.ts index 8ce02d28c935..37046abca0ac 100644 --- a/langchain-core/src/tools/index.ts +++ b/langchain-core/src/tools/index.ts @@ -16,7 +16,7 @@ import { type RunnableConfig, } from "../runnables/config.js"; import type { RunnableFunc, RunnableInterface } from "../runnables/base.js"; -import { ToolCall, ToolMessage } from "../messages/tool.js"; +import { isDirectToolOutput, ToolCall, ToolMessage } from "../messages/tool.js"; import { MessageContent } from "../messages/base.js"; import { AsyncLocalStorageProviderSingleton } from "../singletons/index.js"; import { _isToolCall, ToolInputParsingException } from "./utils.js"; @@ -159,7 +159,7 @@ export abstract class StructuredTool< protected abstract _call( arg: z.output, runManager?: CallbackManagerForToolRun, - parentConfig?: RunnableConfig + parentConfig?: RunnableConfig & { toolCall?: ToolCall } ): Promise; /** @@ -182,21 +182,24 @@ export abstract class StructuredTool< | ToolCall | undefined; + let enrichedConfig: RunnableConfig & { toolCall?: ToolCall } = + ensureConfig(config); if (_isToolCall(input)) { tool_call_id = input.id; toolInput = input.args; + enrichedConfig = { + ...enrichedConfig, + toolCall: input, + configurable: { + ...enrichedConfig.configurable, + tool_call_id, + }, + }; } else { toolInput = input; } - const ensuredConfig = ensureConfig(config); - return this.call(toolInput, { - ...ensuredConfig, - configurable: { - ...ensuredConfig.configurable, - tool_call_id, - }, - }); + return this.call(toolInput, enrichedConfig); } /** @@ -211,8 +214,8 @@ export abstract class StructuredTool< * @returns A Promise that resolves with a string. */ async call( - arg: (z.output extends string ? string : never) | z.input | ToolCall, - configArg?: Callbacks | RunnableConfig, + arg: (z.output extends string ? string : never) | z.input, + configArg?: Callbacks | (RunnableConfig & { toolCall?: ToolCall }), /** @deprecated */ tags?: string[] ): Promise { @@ -229,7 +232,7 @@ export abstract class StructuredTool< } const config = parseCallbackConfigArg(configArg); - const callbackManager_ = await CallbackManager.configure( + const callbackManager_ = CallbackManager.configure( config.callbacks, this.callbacks, config.tags || tags, @@ -350,7 +353,7 @@ export interface DynamicToolInput extends BaseDynamicToolInput { func: ( input: string, runManager?: CallbackManagerForToolRun, - config?: RunnableConfig + config?: RunnableConfig & { toolCall?: ToolCall } ) => Promise; } @@ -400,7 +403,7 @@ export class DynamicTool extends Tool { */ async call( arg: string | undefined | z.input | ToolCall, - configArg?: RunnableConfig | Callbacks + configArg?: (RunnableConfig & { toolCall?: ToolCall }) | Callbacks ): Promise { const config = parseCallbackConfigArg(configArg); if (config.runName === undefined) { @@ -413,7 +416,7 @@ export class DynamicTool extends Tool { async _call( input: string, runManager?: CallbackManagerForToolRun, - parentConfig?: RunnableConfig + parentConfig?: RunnableConfig & { toolCall?: ToolCall } ): Promise { return this.func(input, runManager, parentConfig); } @@ -553,18 +556,30 @@ interface ToolWrapperParams< * @returns {DynamicStructuredTool} A new StructuredTool instance. */ export function tool( - func: RunnableFunc, ToolReturnType>, + func: RunnableFunc< + z.output, + ToolReturnType, + RunnableConfig & { toolCall?: ToolCall } + >, fields: ToolWrapperParams ): DynamicTool; export function tool( - func: RunnableFunc, ToolReturnType>, + func: RunnableFunc< + z.output, + ToolReturnType, + RunnableConfig & { toolCall?: ToolCall } + >, fields: ToolWrapperParams ): DynamicStructuredTool; // eslint-disable-next-line @typescript-eslint/no-explicit-any export function tool>( - func: RunnableFunc, + func: RunnableFunc< + T, + ToolReturnType, + RunnableConfig & { toolCall?: ToolCall } + >, fields: ToolWrapperParams ): DynamicStructuredTool; @@ -572,7 +587,11 @@ export function tool< // eslint-disable-next-line @typescript-eslint/no-explicit-any T extends ZodObjectAny | z.ZodString | Record = ZodObjectAny >( - func: RunnableFunc : T, ToolReturnType>, + func: RunnableFunc< + T extends ZodObjectAny ? z.output : T, + ToolReturnType, + RunnableConfig & { toolCall?: ToolCall } + >, fields: ToolWrapperParams ): | DynamicStructuredTool @@ -649,7 +668,7 @@ function _formatToolOutput(params: { toolCallId?: string; }): ToolReturnType { const { content, artifact, toolCallId } = params; - if (toolCallId) { + if (toolCallId && !isDirectToolOutput(content)) { if ( typeof content === "string" || (Array.isArray(content) && diff --git a/langchain-core/src/tools/tests/tools.test.ts b/langchain-core/src/tools/tests/tools.test.ts index d5587756ee2e..26f441ed6f90 100644 --- a/langchain-core/src/tools/tests/tools.test.ts +++ b/langchain-core/src/tools/tests/tools.test.ts @@ -51,9 +51,15 @@ test("Does not return tool message if responseFormat is content_and_artifact and const weatherSchema = z.object({ location: z.string(), }); + const toolCall = { + args: { location: "San Francisco" }, + name: "weather", + type: "tool_call", + } as const; const weatherTool = tool( - (input) => { + (input, config) => { + expect(config.toolCall).toEqual(toolCall); return ["msg_content", input]; }, { @@ -63,11 +69,7 @@ test("Does not return tool message if responseFormat is content_and_artifact and } ); - const toolResult = await weatherTool.invoke({ - args: { location: "San Francisco" }, - name: "weather", - type: "tool_call", - }); + const toolResult = await weatherTool.invoke(toolCall); expect(toolResult).toBe("msg_content"); }); @@ -77,8 +79,16 @@ test("Returns tool message if responseFormat is content_and_artifact and returns location: z.string(), }); + const toolCall = { + id: "testid", + args: { location: "San Francisco" }, + name: "weather", + type: "tool_call", + } as const; + const weatherTool = tool( - (input) => { + (input, config) => { + expect(config.toolCall).toEqual(toolCall); return ["msg_content", input]; }, { @@ -88,23 +98,63 @@ test("Returns tool message if responseFormat is content_and_artifact and returns } ); - const toolResult = await weatherTool.invoke({ + const toolResult = await weatherTool.invoke(toolCall); + + expect(toolResult).toBeInstanceOf(ToolMessage); + expect(toolResult.content).toBe("msg_content"); + expect(toolResult.artifact).toEqual({ location: "San Francisco" }); + expect(toolResult.name).toBe("weather"); +}); + +test("Does not double wrap a returned tool message even if a tool call with id is passed in", async () => { + const weatherSchema = z.object({ + location: z.string(), + }); + + const toolCall = { id: "testid", args: { location: "San Francisco" }, name: "weather", type: "tool_call", - }); + } as const; + + const weatherTool = tool( + (_, config) => { + expect(config.toolCall).toEqual(toolCall); + return new ToolMessage({ + tool_call_id: "not_original", + content: "bar", + name: "baz", + }); + }, + { + name: "weather", + schema: weatherSchema, + } + ); + + const toolResult = await weatherTool.invoke(toolCall); expect(toolResult).toBeInstanceOf(ToolMessage); - expect(toolResult.content).toBe("msg_content"); - expect(toolResult.artifact).toEqual({ location: "San Francisco" }); - expect(toolResult.name).toBe("weather"); + expect(toolResult.tool_call_id).toBe("not_original"); + expect(toolResult.content).toBe("bar"); + expect(toolResult.name).toBe("baz"); }); test("Tool can accept single string input", async () => { + const toolCall = { + id: "testid", + args: { input: "b" }, + name: "string_tool", + type: "tool_call", + } as const; + const stringTool = tool( (input: string, config): string => { expect(config).toMatchObject({ configurable: { foo: "bar" } }); + if (config.configurable.usesToolCall) { + expect(config.toolCall).toEqual(toolCall); + } return `${input}a`; }, { @@ -116,6 +166,13 @@ test("Tool can accept single string input", async () => { const result = await stringTool.invoke("b", { configurable: { foo: "bar" } }); expect(result).toBe("ba"); + + const result2 = await stringTool.invoke(toolCall, { + configurable: { foo: "bar", usesToolCall: true }, + }); + expect(result2).toBeInstanceOf(ToolMessage); + expect(result2.content).toBe("ba"); + expect(result2.name).toBe("string_tool"); }); test("Tool declared with JSON schema", async () => {