From 975b6129f6123e0fa2f759743e8c15d1a5aee0ab Mon Sep 17 00:00:00 2001 From: ccurme Date: Wed, 10 Jul 2024 19:29:59 -0400 Subject: [PATCH] core[patch]: support conversion of runnables to tools (#23992) Open to other thoughts on UX. string input: ```python as_tool = retriever.as_tool() as_tool.invoke("cat") # [Document(...), ...] ``` typed dict input: ```python class Args(TypedDict): key: int def f(x: Args) -> str: return str(x["key"] * 2) as_tool = RunnableLambda(f).as_tool( name="my tool", description="description", # name, description are inferred if not supplied ) as_tool.invoke({"key": 3}) # "6" ``` for untyped dict input, allow specification of parameters + types ```python def g(x: Dict[str, Any]) -> str: return str(x["key"] * 2) as_tool = RunnableLambda(g).as_tool(arg_types={"key": int}) result = as_tool.invoke({"key": 3}) # "6" ``` Passing the `arg_types` is slightly awkward but necessary to ensure tool calls populate parameters correctly: ```python from typing import Any, Dict from langchain_core.runnables import RunnableLambda from langchain_openai import ChatOpenAI def f(x: Dict[str, Any]) -> str: return str(x["key"] * 2) runnable = RunnableLambda(f) as_tool = runnable.as_tool(arg_types={"key": int}) llm = ChatOpenAI().bind_tools([as_tool]) result = llm.invoke("Use the tool on 3.") tool_call = result.tool_calls[0] args = tool_call["args"] assert args == {"key": 3} as_tool.run(args) ``` Contrived (?) example with langgraph agent as a tool: ```python from typing import List, Literal from typing_extensions import TypedDict from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent llm = ChatOpenAI(temperature=0) def magic_function(input: int) -> int: """Applies a magic function to an input.""" return input + 2 agent_1 = create_react_agent(llm, [magic_function]) class Message(TypedDict): role: Literal["human"] content: str agent_tool = agent_1.as_tool( arg_types={"messages": List[Message]}, name="Jeeves", description="Ask Jeeves.", ) agent_2 = create_react_agent(llm, [agent_tool]) ``` --------- Co-authored-by: Erick Friis --- .../how_to/convert_runnable_to_tool.ipynb | 541 ++++++++++++++++++ docs/docs/how_to/index.mdx | 1 + .../tools/e2b_data_analysis/tool.py | 2 +- libs/core/langchain_core/runnables/base.py | 73 +++ libs/core/langchain_core/tools.py | 74 +++ libs/core/tests/unit_tests/test_tools.py | 86 ++- .../unit_tests/utils/test_function_calling.py | 33 +- 7 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 docs/docs/how_to/convert_runnable_to_tool.ipynb diff --git a/docs/docs/how_to/convert_runnable_to_tool.ipynb b/docs/docs/how_to/convert_runnable_to_tool.ipynb new file mode 100644 index 0000000000..ed4b51e097 --- /dev/null +++ b/docs/docs/how_to/convert_runnable_to_tool.ipynb @@ -0,0 +1,541 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9a8bceb3-95bd-4496-bb9e-57655136e070", + "metadata": {}, + "source": [ + "# How to use Runnables as Tools\n", + "\n", + ":::info Prerequisites\n", + "\n", + "This guide assumes familiarity with the following concepts:\n", + "\n", + "- [Runnables](/docs/concepts#runnable-interface)\n", + "- [Tools](/docs/concepts#tools)\n", + "- [Agents](/docs/tutorials/agents)\n", + "\n", + ":::\n", + "\n", + "Here we will demonstrate how to convert a LangChain `Runnable` into a tool that can be used by agents, chains, or chat models.\n", + "\n", + "## Dependencies\n", + "\n", + "**Note**: this guide requires `langchain-core` >= 0.2.13. We will also use [OpenAI](/docs/integrations/platforms/openai/) for embeddings, but any LangChain embeddings should suffice. We will use a simple [LangGraph](https://langchain-ai.github.io/langgraph/) agent for demonstration purposes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92341f48-2c29-4ce9-8ab8-0a7c7a7c98a1", + "metadata": {}, + "outputs": [], + "source": [ + "%%capture --no-stderr\n", + "%pip install -U langchain-core langchain-openai langgraph" + ] + }, + { + "cell_type": "markdown", + "id": "2b0dcc1a-48e8-4a81-b920-3563192ce076", + "metadata": {}, + "source": [ + "LangChain [tools](/docs/concepts#tools) are interfaces that an agent, chain, or chat model can use to interact with the world. See [here](/docs/how_to/#tools) for how-to guides covering tool-calling, built-in tools, custom tools, and more information.\n", + "\n", + "LangChain tools-- instances of [BaseTool](https://api.python.langchain.com/en/latest/tools/langchain_core.tools.BaseTool.html)-- are [Runnables](/docs/concepts/#runnable-interface) with additional constraints that enable them to be invoked effectively by language models:\n", + "\n", + "- Their inputs are constrained to be serializable, specifically strings and Python `dict` objects;\n", + "- They contain names and descriptions indicating how and when they should be used;\n", + "- They may contain a detailed [args_schema](https://python.langchain.com/v0.2/docs/how_to/custom_tools/) for their arguments. That is, while a tool (as a `Runnable`) might accept a single `dict` input, the specific keys and type information needed to populate a dict should be specified in the `args_schema`.\n", + "\n", + "Runnables that accept string or `dict` input can be converted to tools using the [as_tool](https://api.python.langchain.com/en/latest/runnables/langchain_core.runnables.base.Runnable.html#langchain_core.runnables.base.Runnable.as_tool) method, which allows for the specification of names, descriptions, and additional schema information for arguments." + ] + }, + { + "cell_type": "markdown", + "id": "b4d76680-1b6b-4862-8c4f-22766a1d41f2", + "metadata": {}, + "source": [ + "## Basic usage\n", + "\n", + "With typed `dict` input:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b2cc4231-64a3-4733-a284-932dcbf2fcc3", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import List\n", + "\n", + "from langchain_core.runnables import RunnableLambda\n", + "from typing_extensions import TypedDict\n", + "\n", + "\n", + "class Args(TypedDict):\n", + " a: int\n", + " b: List[int]\n", + "\n", + "\n", + "def f(x: Args) -> str:\n", + " return str(x[\"a\"] * max(x[\"b\"]))\n", + "\n", + "\n", + "runnable = RunnableLambda(f)\n", + "as_tool = runnable.as_tool(\n", + " name=\"My tool\",\n", + " description=\"Explanation of when to use tool.\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "57f2d435-624d-459a-903d-8509fbbde610", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Explanation of when to use tool.\n" + ] + }, + { + "data": { + "text/plain": [ + "{'title': 'My tool',\n", + " 'type': 'object',\n", + " 'properties': {'a': {'title': 'A', 'type': 'integer'},\n", + " 'b': {'title': 'B', 'type': 'array', 'items': {'type': 'integer'}}},\n", + " 'required': ['a', 'b']}" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "print(as_tool.description)\n", + "\n", + "as_tool.args_schema.schema()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "54ae7384-a03d-4fa4-8cdf-9604a4bc39ee", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'6'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "as_tool.invoke({\"a\": 3, \"b\": [1, 2]})" + ] + }, + { + "cell_type": "markdown", + "id": "9038f587-4613-4f50-b349-135f9e7e3b15", + "metadata": {}, + "source": [ + "Without typing information, arg types can be specified via `arg_types`:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "169f733c-4936-497f-8577-ee769dc16b88", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import Any, Dict\n", + "\n", + "\n", + "def g(x: Dict[str, Any]) -> str:\n", + " return str(x[\"a\"] * max(x[\"b\"]))\n", + "\n", + "\n", + "runnable = RunnableLambda(g)\n", + "as_tool = runnable.as_tool(\n", + " name=\"My tool\",\n", + " description=\"Explanation of when to use tool.\",\n", + " arg_types={\"a\": int, \"b\": List[int]},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "32b1a992-8997-4c98-8eb2-c9fe9431b799", + "metadata": {}, + "source": [ + "Alternatively, we can add typing information via [Runnable.with_types](https://api.python.langchain.com/en/latest/runnables/langchain_core.runnables.base.Runnable.html#langchain_core.runnables.base.Runnable.with_types):" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "eb102705-89b7-48dc-9158-d36d5f98ae8e", + "metadata": {}, + "outputs": [], + "source": [ + "as_tool = runnable.with_types(input_type=Args).as_tool(\n", + " name=\"My tool\",\n", + " description=\"Explanation of when to use tool.\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "7c474d85-4e01-4fae-9bba-0c6c8c26475c", + "metadata": {}, + "source": [ + "String input is also supported:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c475282a-58d6-4c2b-af7d-99b73b7d8a13", + "metadata": {}, + "outputs": [], + "source": [ + "def f(x: str) -> str:\n", + " return x + \"a\"\n", + "\n", + "\n", + "def g(x: str) -> str:\n", + " return x + \"z\"\n", + "\n", + "\n", + "runnable = RunnableLambda(f) | g\n", + "as_tool = runnable.as_tool()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ad6d8d96-3a87-40bd-a2ac-44a8acde0a8e", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'baz'" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "as_tool.invoke(\"b\")" + ] + }, + { + "cell_type": "markdown", + "id": "89fdb3a7-d228-48f0-8f73-262af4febb58", + "metadata": {}, + "source": [ + "## In agents\n", + "\n", + "Below we will incorporate LangChain Runnables as tools in an [agent](/docs/concepts/#agents) application. We will demonstrate with:\n", + "\n", + "- a document [retriever](/docs/concepts/#retrievers);\n", + "- a simple [RAG](/docs/tutorials/rag/) chain, allowing an agent to delegate relevant queries to it.\n", + "\n", + "We first instantiate a chat model that supports [tool calling](/docs/how_to/tool_calling/):\n", + "\n", + "```{=mdx}\n", + "\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "d06c9f2a-4475-450f-9106-54db1d99623b", + "metadata": {}, + "outputs": [], + "source": [ + "# | output: false\n", + "# | echo: false\n", + "\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-0125\", temperature=0)" + ] + }, + { + "cell_type": "markdown", + "id": "e8a2038a-d762-4196-b5e3-fdb89c11e71d", + "metadata": {}, + "source": [ + "Following the [RAG tutorial](/docs/tutorials/rag/), let's first construct a retriever:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "23d2a47e-6712-4294-81c8-2c1d76b4bb81", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.documents import Document\n", + "from langchain_core.vectorstores import InMemoryVectorStore\n", + "from langchain_openai import OpenAIEmbeddings\n", + "\n", + "documents = [\n", + " Document(\n", + " page_content=\"Dogs are great companions, known for their loyalty and friendliness.\",\n", + " ),\n", + " Document(\n", + " page_content=\"Cats are independent pets that often enjoy their own space.\",\n", + " ),\n", + "]\n", + "\n", + "vectorstore = InMemoryVectorStore.from_documents(\n", + " documents, embedding=OpenAIEmbeddings()\n", + ")\n", + "\n", + "retriever = vectorstore.as_retriever(\n", + " search_type=\"similarity\",\n", + " search_kwargs={\"k\": 1},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "9ba737ac-43a2-4a6f-b855-5bd0305017f1", + "metadata": {}, + "source": [ + "We next create use a simple pre-built [LangGraph agent](https://python.langchain.com/v0.2/docs/tutorials/agents/) and provide it the tool:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c939cf2a-60e9-4afd-8b47-84d76ccb13f5", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import create_react_agent\n", + "\n", + "tools = [\n", + " retriever.as_tool(\n", + " name=\"pet_info_retriever\",\n", + " description=\"Get information about pets.\",\n", + " )\n", + "]\n", + "agent = create_react_agent(llm, tools)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "be29437b-a187-4a0a-9a5d-419c56f2434e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_W8cnfOjwqEn4cFcg19LN9mYD', 'function': {'arguments': '{\"__arg1\":\"dogs\"}', 'name': 'pet_info_retriever'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 19, 'prompt_tokens': 60, 'total_tokens': 79}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-d7f81de9-1fb7-4caf-81ed-16dcdb0b2ab4-0', tool_calls=[{'name': 'pet_info_retriever', 'args': {'__arg1': 'dogs'}, 'id': 'call_W8cnfOjwqEn4cFcg19LN9mYD'}], usage_metadata={'input_tokens': 60, 'output_tokens': 19, 'total_tokens': 79})]}}\n", + "----\n", + "{'tools': {'messages': [ToolMessage(content=\"[Document(id='86f835fe-4bbe-4ec6-aeb4-489a8b541707', page_content='Dogs are great companions, known for their loyalty and friendliness.')]\", name='pet_info_retriever', tool_call_id='call_W8cnfOjwqEn4cFcg19LN9mYD')]}}\n", + "----\n", + "{'agent': {'messages': [AIMessage(content='Dogs are known for being great companions, known for their loyalty and friendliness.', response_metadata={'token_usage': {'completion_tokens': 18, 'prompt_tokens': 134, 'total_tokens': 152}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-9ca5847a-a5eb-44c0-a774-84cc2c5bbc5b-0', usage_metadata={'input_tokens': 134, 'output_tokens': 18, 'total_tokens': 152})]}}\n", + "----\n" + ] + } + ], + "source": [ + "for chunk in agent.stream({\"messages\": [(\"human\", \"What are dogs known for?\")]}):\n", + " print(chunk)\n", + " print(\"----\")" + ] + }, + { + "cell_type": "markdown", + "id": "96f2ac9c-36f4-4b7a-ae33-f517734c86aa", + "metadata": {}, + "source": [ + "See [LangSmith trace](https://smith.langchain.com/public/44e438e3-2faf-45bd-b397-5510fc145eb9/r) for the above run." + ] + }, + { + "cell_type": "markdown", + "id": "a722fd8a-b957-4ba7-b408-35596b76835f", + "metadata": {}, + "source": [ + "Going further, we can create a simple [RAG](/docs/tutorials/rag/) chain that takes an additional parameter-- here, the \"style\" of the answer." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "bea518c9-c711-47c2-b8cc-dbd102f71f09", + "metadata": {}, + "outputs": [], + "source": [ + "from operator import itemgetter\n", + "\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.prompts import ChatPromptTemplate\n", + "from langchain_core.runnables import RunnablePassthrough\n", + "\n", + "system_prompt = \"\"\"\n", + "You are an assistant for question-answering tasks.\n", + "Use the below context to answer the question. If\n", + "you don't know the answer, say you don't know.\n", + "Use three sentences maximum and keep the answer\n", + "concise.\n", + "\n", + "Answer in the style of {answer_style}.\n", + "\n", + "Question: {question}\n", + "\n", + "Context: {context}\n", + "\"\"\"\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([(\"system\", system_prompt)])\n", + "\n", + "rag_chain = (\n", + " {\n", + " \"context\": itemgetter(\"question\") | retriever,\n", + " \"question\": itemgetter(\"question\"),\n", + " \"answer_style\": itemgetter(\"answer_style\"),\n", + " }\n", + " | prompt\n", + " | llm\n", + " | StrOutputParser()\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "955a23db-5218-4c34-8486-450a2ddb3443", + "metadata": {}, + "source": [ + "Note that the input schema for our chain contains the required arguments, so it converts to a tool without further specification:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "2c9f6e61-80ed-4abb-8e77-84de3ccbc891", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'title': 'RunnableParallelInput',\n", + " 'type': 'object',\n", + " 'properties': {'question': {'title': 'Question'},\n", + " 'answer_style': {'title': 'Answer Style'}}}" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "rag_chain.input_schema.schema()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "a3f9cf5b-8c71-4b0f-902b-f92e028780c9", + "metadata": {}, + "outputs": [], + "source": [ + "rag_tool = rag_chain.as_tool(\n", + " name=\"pet_expert\",\n", + " description=\"Get information about pets.\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "4570615b-8f96-4d97-ae01-1c08b14be584", + "metadata": {}, + "source": [ + "Below we again invoke the agent. Note that the agent populates the required parameters in its `tool_calls`:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "06409913-a2ad-400f-a202-7b8dd2ef483a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent': {'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_17iLPWvOD23zqwd1QVQ00Y63', 'function': {'arguments': '{\"question\":\"What are dogs known for according to pirates?\",\"answer_style\":\"quote\"}', 'name': 'pet_expert'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 28, 'prompt_tokens': 59, 'total_tokens': 87}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-7fef44f3-7bba-4e63-8c51-2ad9c5e65e2e-0', tool_calls=[{'name': 'pet_expert', 'args': {'question': 'What are dogs known for according to pirates?', 'answer_style': 'quote'}, 'id': 'call_17iLPWvOD23zqwd1QVQ00Y63'}], usage_metadata={'input_tokens': 59, 'output_tokens': 28, 'total_tokens': 87})]}}\n", + "----\n", + "{'tools': {'messages': [ToolMessage(content='\"Dogs are known for their loyalty and friendliness, making them great companions for pirates on long sea voyages.\"', name='pet_expert', tool_call_id='call_17iLPWvOD23zqwd1QVQ00Y63')]}}\n", + "----\n", + "{'agent': {'messages': [AIMessage(content='According to pirates, dogs are known for their loyalty and friendliness, making them great companions for pirates on long sea voyages.', response_metadata={'token_usage': {'completion_tokens': 27, 'prompt_tokens': 119, 'total_tokens': 146}, 'model_name': 'gpt-3.5-turbo-0125', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-5a30edc3-7be0-4743-b980-ca2f8cad9b8d-0', usage_metadata={'input_tokens': 119, 'output_tokens': 27, 'total_tokens': 146})]}}\n", + "----\n" + ] + } + ], + "source": [ + "agent = create_react_agent(llm, [rag_tool])\n", + "\n", + "for chunk in agent.stream(\n", + " {\"messages\": [(\"human\", \"What would a pirate say dogs are known for?\")]}\n", + "):\n", + " print(chunk)\n", + " print(\"----\")" + ] + }, + { + "cell_type": "markdown", + "id": "96cc9bc3-e79e-49a8-9915-428ea225358b", + "metadata": {}, + "source": [ + "See [LangSmith trace](https://smith.langchain.com/public/147ae4e6-4dfb-4dd9-8ca0-5c5b954f08ac/r) for the above run." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.4" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/how_to/index.mdx b/docs/docs/how_to/index.mdx index 1fb57fab48..422b06e11e 100644 --- a/docs/docs/how_to/index.mdx +++ b/docs/docs/how_to/index.mdx @@ -187,6 +187,7 @@ LangChain [Tools](/docs/concepts/#tools) contain a description of the tool (to p - [How to: create custom tools](/docs/how_to/custom_tools) - [How to: use built-in tools and built-in toolkits](/docs/how_to/tools_builtin) +- [How to: convert Runnables to tools](/docs/how_to/convert_runnable_to_tool) - [How to: use chat model to call tools](/docs/how_to/tool_calling) - [How to: pass tool results back to model](/docs/how_to/tool_results_pass_to_model) - [How to: add ad-hoc tool calling capability to LLMs and chat models](/docs/how_to/tools_prompting) diff --git a/libs/community/langchain_community/tools/e2b_data_analysis/tool.py b/libs/community/langchain_community/tools/e2b_data_analysis/tool.py index 6de115e0b4..f0498aeb25 100644 --- a/libs/community/langchain_community/tools/e2b_data_analysis/tool.py +++ b/libs/community/langchain_community/tools/e2b_data_analysis/tool.py @@ -234,7 +234,7 @@ class E2BDataAnalysisTool(BaseTool): ] self.description = self.description + "\n" + self.uploaded_files_description - def as_tool(self) -> Tool: + def as_tool(self) -> Tool: # type: ignore[override] return Tool.from_function( func=self._run, name=self.name, diff --git a/libs/core/langchain_core/runnables/base.py b/libs/core/langchain_core/runnables/base.py index 75656ed599..4b24b8b1aa 100644 --- a/libs/core/langchain_core/runnables/base.py +++ b/libs/core/langchain_core/runnables/base.py @@ -92,6 +92,7 @@ if TYPE_CHECKING: from langchain_core.runnables.fallbacks import ( RunnableWithFallbacks as RunnableWithFallbacksT, ) + from langchain_core.tools import BaseTool from langchain_core.tracers.log_stream import ( RunLog, RunLogPatch, @@ -2006,6 +2007,78 @@ class Runnable(Generic[Input, Output], ABC): if hasattr(iterator_, "aclose"): await iterator_.aclose() + @beta_decorator.beta(message="This API is in beta and may change in the future.") + def as_tool( + self, + *, + name: Optional[str] = None, + description: Optional[str] = None, + arg_types: Optional[Dict[str, Type]] = None, + ) -> BaseTool: + """Create a BaseTool from a Runnable. + + ``as_tool`` will instantiate a BaseTool with a name, description, and + ``args_schema`` from a runnable. Where possible, schemas are inferred + from ``runnable.get_input_schema``. Alternatively (e.g., if the + runnable takes a dict as input and the specific dict keys are not typed), + pass ``arg_types`` to specify the required arguments. + + Typed dict input: + + .. code-block:: python + + from typing import List + from typing_extensions import TypedDict + from langchain_core.runnables import RunnableLambda + + class Args(TypedDict): + a: int + b: List[int] + + def f(x: Args) -> str: + return str(x["a"] * max(x["b"])) + + runnable = RunnableLambda(f) + as_tool = runnable.as_tool() + as_tool.invoke({"a": 3, "b": [1, 2]}) + + ``dict`` input, specifying schema: + + .. code-block:: python + + from typing import Any, Dict, List + from langchain_core.runnables import RunnableLambda + + def f(x: Dict[str, Any]) -> str: + return str(x["a"] * max(x["b"])) + + runnable = RunnableLambda(f) + as_tool = runnable.as_tool(arg_types={"a": int, "b": List[int]}) + as_tool.invoke({"a": 3, "b": [1, 2]}) + + String input: + + .. code-block:: python + + from langchain_core.runnables import RunnableLambda + + def f(x: str) -> str: + return x + "a" + + def g(x: str) -> str: + return x + "z" + + runnable = RunnableLambda(f) | g + as_tool = runnable.as_tool() + as_tool.invoke("b") + """ + # Avoid circular import + from langchain_core.tools import convert_runnable_to_tool + + return convert_runnable_to_tool( + self, name=name, description=description, arg_types=arg_types + ) + class RunnableSerializable(Serializable, Runnable[Input, Output]): """Runnable that can be serialized to JSON.""" diff --git a/libs/core/langchain_core/tools.py b/libs/core/langchain_core/tools.py index ae5e9853c3..49ba78f469 100644 --- a/libs/core/langchain_core/tools.py +++ b/libs/core/langchain_core/tools.py @@ -39,6 +39,7 @@ from typing import ( Tuple, Type, Union, + get_type_hints, ) from typing_extensions import Annotated, get_args, get_origin @@ -1218,3 +1219,76 @@ class BaseToolkit(BaseModel, ABC): @abstractmethod def get_tools(self) -> List[BaseTool]: """Get the tools in the toolkit.""" + + +def _get_description_from_runnable(runnable: Runnable) -> str: + """Generate a placeholder description of a runnable.""" + input_schema = runnable.input_schema.schema() + return f"Takes {input_schema}." + + +def _get_schema_from_runnable_and_arg_types( + runnable: Runnable, + name: str, + arg_types: Optional[Dict[str, Type]] = None, +) -> Type[BaseModel]: + """Infer args_schema for tool.""" + if arg_types is None: + try: + arg_types = get_type_hints(runnable.InputType) + except TypeError as e: + raise TypeError( + "Tool input must be str or dict. If dict, dict arguments must be " + "typed. Either annotate types (e.g., with TypedDict) or pass " + f"arg_types into `.as_tool` to specify. {str(e)}" + ) + fields = {key: (key_type, Field(...)) for key, key_type in arg_types.items()} + return create_model(name, **fields) # type: ignore + + +def convert_runnable_to_tool( + runnable: Runnable, + name: Optional[str] = None, + description: Optional[str] = None, + arg_types: Optional[Dict[str, Type]] = None, +) -> BaseTool: + """Convert a Runnable into a BaseTool.""" + description = description or _get_description_from_runnable(runnable) + name = name or runnable.get_name() + + schema = runnable.input_schema.schema() + if schema.get("type") == "string": + return Tool( + name=name, + func=runnable.invoke, + coroutine=runnable.ainvoke, + description=description, + ) + else: + + async def ainvoke_wrapper( + callbacks: Optional[Callbacks] = None, **kwargs: Any + ) -> Any: + return await runnable.ainvoke(kwargs, config={"callbacks": callbacks}) + + def invoke_wrapper(callbacks: Optional[Callbacks] = None, **kwargs: Any) -> Any: + return runnable.invoke(kwargs, config={"callbacks": callbacks}) + + if ( + arg_types is None + and schema.get("type") == "object" + and schema.get("properties") + ): + args_schema = runnable.input_schema + else: + args_schema = _get_schema_from_runnable_and_arg_types( + runnable, name, arg_types=arg_types + ) + + return StructuredTool.from_function( + name=name, + func=invoke_wrapper, + coroutine=ainvoke_wrapper, + description=description, + args_schema=args_schema, + ) diff --git a/libs/core/tests/unit_tests/test_tools.py b/libs/core/tests/unit_tests/test_tools.py index 968b2a03d4..7760e90f78 100644 --- a/libs/core/tests/unit_tests/test_tools.py +++ b/libs/core/tests/unit_tests/test_tools.py @@ -11,14 +11,14 @@ from functools import partial from typing import Any, Callable, Dict, List, Optional, Type, Union import pytest -from typing_extensions import Annotated +from typing_extensions import Annotated, TypedDict from langchain_core.callbacks import ( AsyncCallbackManagerForToolRun, CallbackManagerForToolRun, ) from langchain_core.pydantic_v1 import BaseModel, ValidationError -from langchain_core.runnables import ensure_config +from langchain_core.runnables import Runnable, RunnableLambda, ensure_config from langchain_core.tools import ( BaseTool, SchemaAnnotationError, @@ -987,3 +987,85 @@ def test_tool_annotated_descriptions() -> None: }, "required": ["bar", "baz"], } + + +def test_convert_from_runnable_dict() -> None: + # Test with typed dict input + class Args(TypedDict): + a: int + b: List[int] + + def f(x: Args) -> str: + return str(x["a"] * max(x["b"])) + + runnable: Runnable = RunnableLambda(f) + as_tool = runnable.as_tool() + args_schema = as_tool.args_schema + assert args_schema is not None + assert args_schema.schema() == { + "title": "f", + "type": "object", + "properties": { + "a": {"title": "A", "type": "integer"}, + "b": {"title": "B", "type": "array", "items": {"type": "integer"}}, + }, + "required": ["a", "b"], + } + assert as_tool.description + result = as_tool.invoke({"a": 3, "b": [1, 2]}) + assert result == "6" + + as_tool = runnable.as_tool(name="my tool", description="test description") + assert as_tool.name == "my tool" + assert as_tool.description == "test description" + + # Dict without typed input-- must supply arg types + def g(x: Dict[str, Any]) -> str: + return str(x["a"] * max(x["b"])) + + runnable = RunnableLambda(g) + as_tool = runnable.as_tool(arg_types={"a": int, "b": List[int]}) + result = as_tool.invoke({"a": 3, "b": [1, 2]}) + assert result == "6" + + # Test with config + def h(x: Dict[str, Any]) -> str: + config = ensure_config() + assert config["configurable"]["foo"] == "not-bar" + return str(x["a"] * max(x["b"])) + + runnable = RunnableLambda(h) + as_tool = runnable.as_tool(arg_types={"a": int, "b": List[int]}) + result = as_tool.invoke( + {"a": 3, "b": [1, 2]}, config={"configurable": {"foo": "not-bar"}} + ) + assert result == "6" + + +def test_convert_from_runnable_other() -> None: + # String input + def f(x: str) -> str: + return x + "a" + + def g(x: str) -> str: + return x + "z" + + runnable: Runnable = RunnableLambda(f) | g + as_tool = runnable.as_tool() + args_schema = as_tool.args_schema + assert args_schema is None + assert as_tool.description + + result = as_tool.invoke("b") + assert result == "baz" + + # Test with config + def h(x: str) -> str: + config = ensure_config() + assert config["configurable"]["foo"] == "not-bar" + return x + "a" + + runnable = RunnableLambda(h) + as_tool = runnable.as_tool() + result = as_tool.invoke("b", config={"configurable": {"foo": "not-bar"}}) + assert result == "ba" diff --git a/libs/core/tests/unit_tests/utils/test_function_calling.py b/libs/core/tests/unit_tests/utils/test_function_calling.py index 6702b5ad63..32c6349a5f 100644 --- a/libs/core/tests/unit_tests/utils/test_function_calling.py +++ b/libs/core/tests/unit_tests/utils/test_function_calling.py @@ -4,10 +4,11 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Type import pytest from pydantic import BaseModel as BaseModelV2Maybe # pydantic: ignore from pydantic import Field as FieldV2Maybe # pydantic: ignore -from typing_extensions import Annotated +from typing_extensions import Annotated, TypedDict from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.pydantic_v1 import BaseModel, Field +from langchain_core.runnables import Runnable, RunnableLambda from langchain_core.tools import BaseTool, tool from langchain_core.utils.function_calling import ( convert_to_openai_function, @@ -52,6 +53,18 @@ def function() -> Callable: return dummy_function +@pytest.fixture() +def runnable() -> Runnable: + class Args(TypedDict): + arg1: Annotated[int, "foo"] + arg2: Annotated[Literal["bar", "baz"], "one of 'bar', 'baz'"] + + def dummy_function(input_dict: Args) -> None: + pass + + return RunnableLambda(dummy_function) + + @pytest.fixture() def dummy_tool() -> BaseTool: class Schema(BaseModel): @@ -141,6 +154,7 @@ def test_convert_to_openai_function( json_schema: Dict, annotated_function: Callable, dummy_pydantic: Type[BaseModel], + runnable: Runnable, ) -> None: expected = { "name": "dummy_function", @@ -173,6 +187,23 @@ def test_convert_to_openai_function( actual = convert_to_openai_function(fn) # type: ignore assert actual == expected + # Test runnables + actual = convert_to_openai_function(runnable.as_tool(description="dummy function")) + parameters = { + "type": "object", + "properties": { + "arg1": {"type": "integer"}, + "arg2": { + "enum": ["bar", "baz"], + "type": "string", + }, + }, + "required": ["arg1", "arg2"], + } + runnable_expected = expected.copy() + runnable_expected["parameters"] = parameters + assert actual == runnable_expected + def test_convert_to_openai_function_nested() -> None: class Nested(BaseModel):