diff --git a/README.md b/README.md index e210902d2..9aa22eaa3 100644 --- a/README.md +++ b/README.md @@ -474,7 +474,6 @@ For a walkthrough on how to do that, see [this documentation](https://github.com LangGraph comes with built-in support for human-in-the-loop workflows. This is useful when you want to have a human review the current state before proceeding to a particular node. For a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/human-in-the-loop.ipynb) - ### Planning Agent Examples The following notebooks implement agent architectures prototypical of the "plan-and-execute" style, where an LLM planner decomposes a user request into a program, an executor executes the program, and an LLM synthesizes a response (and/or dynamically replans) based on the program outputs. @@ -490,9 +489,6 @@ When output quality is a major concern, it's common to incorporate some combinat - [Reflexion](./examples/reflexion/reflexion.ipynb): critique missing and superflous aspects of the agent's response to guide subsequent steps. Based on [Reflexion](https://arxiv.org/abs/2303.11366), by Shinn, et. al. - - - ### Multi-agent Examples - [Multi-agent collaboration](https://github.com/langchain-ai/langgraph/blob/main/examples/multi_agent/multi-agent-collaboration.ipynb): how to create two agents that work together to accomplish a task diff --git a/examples/agent_executor/base.ipynb b/examples/agent_executor/base.ipynb index 87fcbee9a..6a40577ed 100644 --- a/examples/agent_executor/base.ipynb +++ b/examples/agent_executor/base.ipynb @@ -26,7 +26,7 @@ "metadata": {}, "outputs": [], "source": [ - "!pip install --quiet -U langchain langchain_openai tavily-python" + "!pip install --quiet -U langchain langchain_openai langchainhub tavily-python" ] }, { diff --git a/examples/rag/langgraph_agentic_rag.ipynb b/examples/rag/langgraph_agentic_rag.ipynb index 20b1e9132..07ca74be3 100644 --- a/examples/rag/langgraph_agentic_rag.ipynb +++ b/examples/rag/langgraph_agentic_rag.ipynb @@ -19,7 +19,7 @@ "\n", "[Retrieval Agents](https://python.langchain.com/docs/use_cases/question_answering/conversational_retrieval_agents) are useful when we want to make decisions about whether to retrieve from an index.\n", "\n", - "To implement a retrieval agent, we simple need to give an LLM access to a retrier tool.\n", + "To implement a retrieval agent, we simple need to give an LLM access to a retriever tool.\n", "\n", "We can incorperate this into [LangGraph](https://python.langchain.com/docs/langgraph).\n", "\n", diff --git a/langgraph/__init__.py b/langgraph/__init__.py index e69de29bb..959f4ab2e 100644 --- a/langgraph/__init__.py +++ b/langgraph/__init__.py @@ -0,0 +1,3 @@ +from langgraph.version import __version__ + +__all__ = ["__version__"] diff --git a/langgraph/prebuilt/chat_agent_executor.py b/langgraph/prebuilt/chat_agent_executor.py index bc7208107..fcd4d6806 100644 --- a/langgraph/prebuilt/chat_agent_executor.py +++ b/langgraph/prebuilt/chat_agent_executor.py @@ -1,17 +1,23 @@ import json import operator -from typing import Annotated, Sequence, TypedDict +from typing import Annotated, Sequence, TypedDict, Union -from langchain_core.agents import AgentAction -from langchain_core.messages import BaseMessage, FunctionMessage +from langchain_core.language_models import LanguageModelLike +from langchain_core.messages import BaseMessage, FunctionMessage, ToolMessage from langchain_core.runnables import RunnableLambda -from langchain_core.utils.function_calling import convert_to_openai_function +from langchain_core.tools import BaseTool +from langchain_core.utils.function_calling import ( + convert_to_openai_function, + convert_to_openai_tool, +) from langgraph.graph import END, StateGraph -from langgraph.prebuilt.tool_executor import ToolExecutor +from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation -def create_function_calling_executor(model, tools): +def create_function_calling_executor( + model: LanguageModelLike, tools: Union[ToolExecutor, Sequence[BaseTool]] +): if isinstance(tools, ToolExecutor): tool_executor = tools tool_classes = tools.tools @@ -20,8 +26,15 @@ def create_function_calling_executor(model, tools): tool_classes = tools model = model.bind(functions=[convert_to_openai_function(t) for t in tool_classes]) + # We create the AgentState that we will pass around + # This simply involves a list of messages + # We want steps to return messages to append to the list + # So we annotate the messages attribute with operator.add + class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], operator.add] + # Define the function that determines whether to continue or not - def should_continue(state): + def should_continue(state: AgentState): messages = state["messages"] last_message = messages[-1] # If there is no function call, then we finish @@ -32,34 +45,33 @@ def create_function_calling_executor(model, tools): return "continue" # Define the function that calls the model - def call_model(state): + def call_model(state: AgentState): messages = state["messages"] response = model.invoke(messages) # We return a list, because this will get added to the existing list return {"messages": [response]} - async def acall_model(state): + async def acall_model(state: AgentState): messages = state["messages"] response = await model.ainvoke(messages) # We return a list, because this will get added to the existing list return {"messages": [response]} # Define the function to execute tools - def _get_action(state): + def _get_action(state: AgentState): messages = state["messages"] # Based on the continue condition # we know the last message involves a function call last_message = messages[-1] # We construct an AgentAction from the function_call - return AgentAction( + return ToolInvocation( tool=last_message.additional_kwargs["function_call"]["name"], tool_input=json.loads( last_message.additional_kwargs["function_call"]["arguments"] ), - log="", ) - def call_tool(state): + def call_tool(state: AgentState): action = _get_action(state) # We call the tool_executor and get back a response response = tool_executor.invoke(action) @@ -68,7 +80,7 @@ def create_function_calling_executor(model, tools): # We return a list, because this will get added to the existing list return {"messages": [function_message]} - async def acall_tool(state): + async def acall_tool(state: AgentState): action = _get_action(state) # We call the tool_executor and get back a response response = await tool_executor.ainvoke(action) @@ -77,13 +89,135 @@ def create_function_calling_executor(model, tools): # We return a list, because this will get added to the existing list return {"messages": [function_message]} - # We create the AgentState that we will pass around - # This simply involves a list of messages - # We want steps to return messages to append to the list - # So we annotate the messages attribute with operator.add - class AgentState(TypedDict): - messages: Annotated[Sequence[BaseMessage], operator.add] - + # Define a new graph + workflow = StateGraph(AgentState) + + # Define the two nodes we will cycle between + workflow.add_node("agent", RunnableLambda(call_model, acall_model)) + workflow.add_node("action", RunnableLambda(call_tool, acall_tool)) + + # Set the entrypoint as `agent` + # This means that this node is the first one called + workflow.set_entry_point("agent") + + # We now add a conditional edge + workflow.add_conditional_edges( + # First, we define the start node. We use `agent`. + # This means these are the edges taken after the `agent` node is called. + "agent", + # Next, we pass in the function that will determine which node is called next. + should_continue, + # Finally we pass in a mapping. + # The keys are strings, and the values are other nodes. + # END is a special node marking that the graph should finish. + # What will happen is we will call `should_continue`, and then the output of that + # will be matched against the keys in this mapping. + # Based on which one it matches, that node will then be called. + { + # If `tools`, then we call the tool node. + "continue": "action", + # Otherwise we finish. + "end": END, + }, + ) + + # We now add a normal edge from `tools` to `agent`. + # This means that after `tools` is called, `agent` node is called next. + workflow.add_edge("action", "agent") + + # Finally, we compile it! + # This compiles it into a LangChain Runnable, + # meaning you can use it as you would any other runnable + return workflow.compile() + + +def create_tool_calling_executor( + model: LanguageModelLike, tools: Union[ToolExecutor, Sequence[BaseTool]] +): + if isinstance(tools, ToolExecutor): + tool_executor = tools + tool_classes = tools.tools + else: + tool_executor = ToolExecutor(tools) + tool_classes = tools + model = model.bind(tools=[convert_to_openai_tool(t) for t in tool_classes]) + + # We create the AgentState that we will pass around + # This simply involves a list of messages + # We want steps to return messages to append to the list + # So we annotate the messages attribute with operator.add + class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], operator.add] + + # Define the function that determines whether to continue or not + def should_continue(state: AgentState): + messages = state["messages"] + last_message = messages[-1] + # If there is no function call, then we finish + if "tool_calls" not in last_message.additional_kwargs: + return "end" + # Otherwise if there is, we continue + else: + return "continue" + + # Define the function that calls the model + def call_model(state: AgentState): + messages = state["messages"] + response = model.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + async def acall_model(state: AgentState): + messages = state["messages"] + response = await model.ainvoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + + # Define the function to execute tools + def _get_actions(state: AgentState): + messages = state["messages"] + # Based on the continue condition + # we know the last message involves a tool call + last_message = messages[-1] + # We construct an AgentAction from each of the tool_calls + return ( + [ + ToolInvocation( + tool=tool_call["function"]["name"], + tool_input=json.loads(tool_call["function"]["arguments"]), + ) + for tool_call in last_message.additional_kwargs["tool_calls"] + ], + [ + tool_call["id"] + for tool_call in last_message.additional_kwargs["tool_calls"] + ], + ) + + def call_tool(state: AgentState): + actions, ids = _get_actions(state) + # We call the tool_executor and get back a response + responses = tool_executor.batch(actions) + # We use the response to create a FunctionMessage + tool_messages = [ + ToolMessage(content=str(response), tool_call_id=id) + for response, id in zip(responses, ids) + ] + # We return a list, because this will get added to the existing list + return {"messages": tool_messages} + + async def acall_tool(state: AgentState): + actions, ids = _get_actions(state) + # We call the tool_executor and get back a response + responses = await tool_executor.abatch(actions) + # We use the response to create a FunctionMessage + tool_messages = [ + ToolMessage(content=str(response), tool_call_id=id) + for response, id in zip(responses, ids) + ] + # We return a list, because this will get added to the existing list + return {"messages": tool_messages} + # Define a new graph workflow = StateGraph(AgentState) diff --git a/langgraph/version.py b/langgraph/version.py new file mode 100644 index 000000000..ac7aeef6f --- /dev/null +++ b/langgraph/version.py @@ -0,0 +1,9 @@ +"""Main entrypoint into package.""" +from importlib import metadata + +try: + __version__ = metadata.version(__package__) +except metadata.PackageNotFoundError: + # Case where package metadata is not available. + __version__ = "" +del metadata # optional, avoids polluting the results of dir(__package__) diff --git a/tests/test_pregel.py b/tests/test_pregel.py index f9fef277b..13427b9c6 100644 --- a/tests/test_pregel.py +++ b/tests/test_pregel.py @@ -20,7 +20,10 @@ from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.graph import END, Graph from langgraph.graph.message import MessageGraph from langgraph.graph.state import StateGraph -from langgraph.prebuilt.chat_agent_executor import create_function_calling_executor +from langgraph.prebuilt.chat_agent_executor import ( + create_function_calling_executor, + create_tool_calling_executor, +) from langgraph.prebuilt.tool_executor import ToolExecutor from langgraph.pregel import Channel, GraphRecursionError, Pregel from langgraph.pregel.reserved import ReservedChannels @@ -1062,6 +1065,250 @@ def test_conditional_graph_state() -> None: ] +def test_prebuilt_tool_chat() -> None: + from langchain.chat_models.fake import FakeMessagesListChatModel + from langchain_community.tools import tool + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + + class FakeFuntionChatModel(FakeMessagesListChatModel): + def bind_functions(self, functions: list): + return self + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + app = create_tool_calling_executor( + FakeFuntionChatModel( + responses=[ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": json.dumps("query"), + }, + } + ] + }, + ), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": json.dumps("another"), + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ), + AIMessage(content="answer"), + ] + ), + tools, + ) + + assert app.invoke( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) == { + "messages": [ + HumanMessage(content="what is weather in sf"), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"query"', + }, + } + ] + }, + ), + ToolMessage(content="result for query", tool_call_id="tool_call123"), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"another"', + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ), + ToolMessage(content="result for another", tool_call_id="tool_call234"), + ToolMessage(content="result for a third one", tool_call_id="tool_call567"), + AIMessage(content="answer"), + ] + } + + assert [ + *app.stream({"messages": [HumanMessage(content="what is weather in sf")]}) + ] == [ + { + "agent": { + "messages": [ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"query"', + }, + } + ] + }, + ) + ] + } + }, + { + "action": { + "messages": [ + ToolMessage(content="result for query", tool_call_id="tool_call123") + ] + } + }, + { + "agent": { + "messages": [ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"another"', + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ) + ] + } + }, + { + "action": { + "messages": [ + ToolMessage( + content="result for another", tool_call_id="tool_call234" + ), + ToolMessage( + content="result for a third one", tool_call_id="tool_call567" + ), + ] + } + }, + {"agent": {"messages": [AIMessage(content="answer")]}}, + { + "__end__": { + "messages": [ + HumanMessage(content="what is weather in sf"), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"query"', + }, + } + ] + }, + ), + ToolMessage( + content="result for query", tool_call_id="tool_call123" + ), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"another"', + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ), + ToolMessage( + content="result for another", tool_call_id="tool_call234" + ), + ToolMessage( + content="result for a third one", tool_call_id="tool_call567" + ), + AIMessage(content="answer"), + ] + } + }, + ] + + def test_prebuilt_chat() -> None: from langchain.chat_models.fake import FakeMessagesListChatModel from langchain_community.tools import tool diff --git a/tests/test_pregel_async.py b/tests/test_pregel_async.py index db281e7c6..30a344a95 100644 --- a/tests/test_pregel_async.py +++ b/tests/test_pregel_async.py @@ -26,7 +26,10 @@ from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, Graph, StateGraph from langgraph.graph.message import MessageGraph -from langgraph.prebuilt.chat_agent_executor import create_function_calling_executor +from langgraph.prebuilt.chat_agent_executor import ( + create_function_calling_executor, + create_tool_calling_executor, +) from langgraph.prebuilt.tool_executor import ToolExecutor from langgraph.pregel import Channel, GraphRecursionError, Pregel from langgraph.pregel.reserved import ReservedChannels @@ -1111,6 +1114,253 @@ async def test_conditional_graph_state() -> None: ] +async def test_prebuilt_tool_chat() -> None: + from langchain.chat_models.fake import FakeMessagesListChatModel + from langchain_community.tools import tool + from langchain_core.messages import AIMessage, HumanMessage, ToolMessage + + class FakeFuntionChatModel(FakeMessagesListChatModel): + def bind_functions(self, functions: list): + return self + + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + app = create_tool_calling_executor( + FakeFuntionChatModel( + responses=[ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": json.dumps("query"), + }, + } + ] + }, + ), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": json.dumps("another"), + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ), + AIMessage(content="answer"), + ] + ), + tools, + ) + + assert await app.ainvoke( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) == { + "messages": [ + HumanMessage(content="what is weather in sf"), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"query"', + }, + } + ] + }, + ), + ToolMessage(content="result for query", tool_call_id="tool_call123"), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"another"', + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ), + ToolMessage(content="result for another", tool_call_id="tool_call234"), + ToolMessage(content="result for a third one", tool_call_id="tool_call567"), + AIMessage(content="answer"), + ] + } + + assert [ + c + async for c in app.astream( + {"messages": [HumanMessage(content="what is weather in sf")]} + ) + ] == [ + { + "agent": { + "messages": [ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"query"', + }, + } + ] + }, + ) + ] + } + }, + { + "action": { + "messages": [ + ToolMessage(content="result for query", tool_call_id="tool_call123") + ] + } + }, + { + "agent": { + "messages": [ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"another"', + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ) + ] + } + }, + { + "action": { + "messages": [ + ToolMessage( + content="result for another", tool_call_id="tool_call234" + ), + ToolMessage( + content="result for a third one", tool_call_id="tool_call567" + ), + ] + } + }, + {"agent": {"messages": [AIMessage(content="answer")]}}, + { + "__end__": { + "messages": [ + HumanMessage(content="what is weather in sf"), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call123", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"query"', + }, + } + ] + }, + ), + ToolMessage( + content="result for query", tool_call_id="tool_call123" + ), + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "id": "tool_call234", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"another"', + }, + }, + { + "id": "tool_call567", + "type": "function", + "function": { + "name": "search_api", + "arguments": '"a third one"', + }, + }, + ] + }, + ), + ToolMessage( + content="result for another", tool_call_id="tool_call234" + ), + ToolMessage( + content="result for a third one", tool_call_id="tool_call567" + ), + AIMessage(content="answer"), + ] + } + }, + ] + + async def test_prebuilt_chat() -> None: from langchain.chat_models.fake import FakeMessagesListChatModel from langchain_community.tools import tool