diff --git a/Dockerfile b/Dockerfile index 2095ad194..d29a3288c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim +FROM python:3.9 # Set the working directory to /app WORKDIR /app @@ -7,6 +7,6 @@ WORKDIR /app COPY . . # Install any needed packages specified in requirements.txt -RUN pip install poetry && poetry config virtualenvs.create false && poetry install --with test,lint,typing +RUN pip install poetry && poetry config virtualenvs.create false && poetry install --with test,lint,typing,dev RUN poetry run pytest diff --git a/README.md b/README.md index 37be4a254..87aab4f13 100644 --- a/README.md +++ b/README.md @@ -23,13 +23,13 @@ pip install langgraph ## Quick Start -Here we will go over an example of recreating the [`AgentExecutor`](https://python.langchain.com/docs/modules/agents/concepts#agentexecutor) class from LangChain. -The benefits of creating it with LangGraph is that it is more modifiable. +Here we will go over an example of creating a simple agent that uses chat models and function calling. +This agent will represent all state as a list of messages. -We will also want to install some LangChain packages, as well as [Tavily](https://app.tavily.com/sign-in) to use as an example tool. +We will need to install some LangChain packages, as well as [Tavily](https://app.tavily.com/sign-in) to use as an example tool. ```shell -pip install -U langchain langchain_openai langchainhub tavily-python +pip install -U langchain langchain_openai tavily-python ``` We also need to export some environment variables needed for our agent. @@ -47,28 +47,77 @@ export LANGCHAIN_API_KEY=ls__... export LANGCHAIN_ENDPOINT=https://api.langchain.plus ``` -### Define the LangChain Agent +### Set up the tools -This is the LangChain agent. -Crucially, this agent is just responsible for deciding what actions to take. -For more information on what is happening here, please see [this documentation](https://python.langchain.com/docs/modules/agents/quick_start). +We will first define the tools we want to use. +For this simple example, we will use a built-in search tool via Tavily. +However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that. ```python -from langchain import hub -from langchain.agents import create_openai_functions_agent -from langchain_openai.chat_models import ChatOpenAI from langchain_community.tools.tavily_search import TavilySearchResults tools = [TavilySearchResults(max_results=1)] +``` -# Get the prompt to use - you can modify this! -prompt = hub.pull("hwchase17/openai-functions-agent") +We can now wrap these tools in a simple ToolExecutor. +This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output. +A ToolInvocation is any class with `tool` and `tool_input` attribute. -# Choose the LLM that will drive the agent -llm = ChatOpenAI(model="gpt-3.5-turbo-1106") +```python +from langgraph.prebuilt import ToolExecutor -# Construct the OpenAI Functions agent -agent_runnable = create_openai_functions_agent(llm, tools, prompt) +tool_executor = ToolExecutor(tools) +``` + +### Set up the model + +Now we need to load the chat model we want to use. +Importantly, this should satisfy two criteria: + +1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them. +2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface. + +Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example. + +```python +from langchain_openai import ChatOpenAI + +# We will set streaming=True so that we can stream tokens +# See the streaming section for more information on this. +model = ChatOpenAI(temperature=0, streaming=True) +``` + +After we've done this, we should make sure the model knows that it has these tools available to call. +We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class. + +```python +from langchain.tools.render import format_tool_to_openai_function + +functions = [format_tool_to_openai_function(t) for t in tools] +model = model.bind_functions(functions) +``` + + +### Define the agent state + +The main type of graph in `langgraph` is the `StatefulGraph`. +This graph is parameterized by a state object that it passes around to each node. +Each node then returns operations to update that state. +These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute. +Whether to set or add is denoted by annotating the state object you construct the graph with. + +For this example, the state we will track will just be a list of messages. +We want each node to just add messages to that list. +Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to. + +```python +from typing import TypedDict, Annotated, Sequence +import operator +from langchain_core.messages import BaseMessage + + +class AgentState(TypedDict): + messages: Annotated[Sequence[BaseMessage], operator.add] ``` ### Define the nodes @@ -93,58 +142,59 @@ The path that is taken is not known until that node is run (the LLM decides). Let's define the nodes, as well as a function to decide how what conditional edge to take. ```python -from langchain_core.runnables import RunnablePassthrough -from langchain_core.agents import AgentFinish +from langgraph.prebuilt import ToolInvocation +import json +from langchain_core.messages import FunctionMessage - -# Define the agent -# Note that here, we are using `.assign` to add the output of the agent to the dictionary -# This dictionary will be returned from the node -# The reason we don't want to return just the result of `agent_runnable` from this node is -# that we want to continue passing around all the other inputs -agent = RunnablePassthrough.assign( - agent_outcome = agent_runnable -) - -# Define the function to execute tools -def execute_tools(data): - # Get the most recent agent_outcome - this is the key added in the `agent` above - agent_action = data.pop('agent_outcome') - # Get the tool to use - tool_to_use = {t.name: t for t in tools}[agent_action.tool] - # Call that tool on the input - observation = tool_to_use.invoke(agent_action.tool_input) - # We now add in the action and the observation to the `intermediate_steps` list - # This is the list of all previous actions taken and their output - data['intermediate_steps'].append((agent_action, observation)) - return data - -# Define logic that will be used to determine which conditional edge to go down -def should_continue(data): - # If the agent outcome is an AgentFinish, then we return `exit` string - # This will be used when setting up the graph to define the flow - if isinstance(data['agent_outcome'], AgentFinish): - return "exit" - # Otherwise, an AgentAction is returned - # Here we return `continue` string - # This will be used when setting up the graph to define the flow +# Define the function that determines whether to continue or not +def should_continue(state): + messages = state['messages'] + last_message = messages[-1] + # If there is no function call, then we finish + if "function_call" 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): + messages = state['messages'] + response = model.invoke(messages) + # We return a list, because this will get added to the existing list + return {"messages": [response]} + +# Define the function to execute tools +def call_tool(state): + messages = state['messages'] + # Based on the continue condition + # we know the last message involves a function call + last_message = messages[-1] + # We construct an ToolInvocation from the function_call + action = ToolInvocation( + tool=last_message.additional_kwargs["function_call"]["name"], + tool_input=json.loads(last_message.additional_kwargs["function_call"]["arguments"]), + ) + # We call the tool_executor and get back a response + response = tool_executor.invoke(action) + # We use the response to create a FunctionMessage + function_message = FunctionMessage(content=str(response), name=action.tool) + # We return a list, because this will get added to the existing list + return {"messages": [function_message]} ``` ### Define the graph -We can now put it alltogether and define the graph! +We can now put it all together and define the graph! ```python -from langgraph.graph import END, Graph +from langgraph.graph import StateGraph, END +# Define a new graph +workflow = StateGraph(AgentState) -workflow = Graph() - -# Add the agent node, we give it name `agent` which we will use later -workflow.add_node("agent", agent) -# Add the tools node, we give it name `tools` which we will use later -workflow.add_node("tools", execute_tools) +# Define the two nodes we will cycle between +workflow.add_node("agent", call_model) +workflow.add_node("action", call_tool) # Set the entrypoint as `agent` # This means that this node is the first one called @@ -165,31 +215,38 @@ workflow.add_conditional_edges( # Based on which one it matches, that node will then be called. { # If `tools`, then we call the tool node. - "continue": "tools", + "continue": "action", # Otherwise we finish. - "exit": END + "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('tools', 'agent') +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 -chain = workflow.compile() +app = workflow.compile() ``` ### Use it! We can now use it! -This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables +This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables. +This runnable accepts a list of messages. ```python -chain.invoke({"input": "what is the weather in sf", "intermediate_steps": []}) +from langchain_core.messages import HumanMessage + +inputs = {"messages": [HumanMessage(content="what is the weather in sf")]} +app.invoke(inputs) ``` +This may take a little bit - it's making a few calls behind the scenes. +In order to start seeing some intermediate results as they happen, we can use streaming - see below for more information on that. + ## Streaming LangGraph has support for several different types of streaming. @@ -199,9 +256,8 @@ LangGraph has support for several different types of streaming. One of the benefits of using LangGraph is that it is easy to stream output as it's produced by each node. ```python -for output in chain.stream( - {"input": "what is the weather in sf", "intermediate_steps": []} -): +inputs = {"messages": [HumanMessage(content="what is the weather in sf")]} +for output in app.stream(inputs): # stream() yields dictionaries with output keyed by node name for key, value in output.items(): print(f"Output from node '{key}':") @@ -213,108 +269,38 @@ for output in chain.stream( ``` Output from node 'agent': --- -{'agent_outcome': AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log="\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\n\n\n", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{"query":"weather in San Francisco"}', 'name': 'tavily_search_results_json'}})]), - 'input': 'what is the weather in sf', - 'intermediate_steps': []} +{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\n "query": "weather in San Francisco"\n}', 'name': 'tavily_search_results_json'}})]} --- -Output from node 'tools': +Output from node 'action': --- -{'input': 'what is the weather in sf', - 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log="\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\n\n\n", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{"query":"weather in San Francisco"}', 'name': 'tavily_search_results_json'}})]), - [{'content': 'Best time to go to San Francisco? ' - 'Weather in San Francisco in january ' - '2024 How was the weather last january? ' - 'Here is the day by day recorded weather ' - 'in San Francisco in january 2023: ' - 'Seasonal average climate and ' - 'temperature of San Francisco in ' - 'january 8% 46% 29% 12% 8% Evolution of ' - 'daily average temperature and ' - 'precipitation in San Francisco in ' - 'januaryWeather in San Francisco in ' - 'january 2024. The weather in San ' - 'Francisco in january comes from ' - 'statistical datas on the past years. ' - 'You can view the weather statistics the ' - 'entire month, but also by using the ' - 'tabs for the beginning, the middle and ' - 'the end of the month. ... 08-01-2023 ' - '52°F to 58°F. 09-01-2023 54°F to 61°F. ' - '10-01-2023 52°F to ...', - 'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/'}])]} +{'messages': [FunctionMessage(content="[{'url': 'https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States', 'content': 'January 2024 Weather History in San Francisco California, United States Daily Precipitation in January 2024 in San Francisco Observed Weather in January 2024 in San Francisco San Francisco Temperature History January 2024 Hourly Temperature in January 2024 in San Francisco Hours of Daylight and Twilight in January 2024 in San FranciscoThis report shows the past weather for San Francisco, providing a weather history for January 2024. It features all historical weather data series we have available, including the San Francisco temperature history for January 2024. You can drill down from year to month and even day level reports by clicking on the graphs.'}]", name='tavily_search_results_json')]} --- Output from node 'agent': --- -{'agent_outcome': AgentFinish(return_values={'output': 'The weather in San Francisco in January ranges from 52°F to 61°F. For more detailed and current weather information, you may want to check a reliable weather website or app.'}, log='The weather in San Francisco in January ranges from 52°F to 61°F. For more detailed and current weather information, you may want to check a reliable weather website or app.'), - 'input': 'what is the weather in sf', - 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log="\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\n\n\n", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{"query":"weather in San Francisco"}', 'name': 'tavily_search_results_json'}})]), - [{'content': 'Best time to go to San Francisco? ' - 'Weather in San Francisco in january ' - '2024 How was the weather last january? ' - 'Here is the day by day recorded weather ' - 'in San Francisco in january 2023: ' - 'Seasonal average climate and ' - 'temperature of San Francisco in ' - 'january 8% 46% 29% 12% 8% Evolution of ' - 'daily average temperature and ' - 'precipitation in San Francisco in ' - 'januaryWeather in San Francisco in ' - 'january 2024. The weather in San ' - 'Francisco in january comes from ' - 'statistical datas on the past years. ' - 'You can view the weather statistics the ' - 'entire month, but also by using the ' - 'tabs for the beginning, the middle and ' - 'the end of the month. ... 08-01-2023 ' - '52°F to 58°F. 09-01-2023 54°F to 61°F. ' - '10-01-2023 52°F to ...', - 'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/'}])]} +{'messages': [AIMessage(content="I couldn't find the current weather in San Francisco. However, you can visit [WeatherSpark](https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States) to check the historical weather data for January 2024 in San Francisco.")]} --- Output from node '__end__': --- -{'agent_outcome': AgentFinish(return_values={'output': 'The weather in San Francisco in January ranges from 52°F to 61°F. For more detailed and current weather information, you may want to check a reliable weather website or app.'}, log='The weather in San Francisco in January ranges from 52°F to 61°F. For more detailed and current weather information, you may want to check a reliable weather website or app.'), - 'input': 'what is the weather in sf', - 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log="\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\n\n\n", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{"query":"weather in San Francisco"}', 'name': 'tavily_search_results_json'}})]), - [{'content': 'Best time to go to San Francisco? ' - 'Weather in San Francisco in january ' - '2024 How was the weather last january? ' - 'Here is the day by day recorded weather ' - 'in San Francisco in january 2023: ' - 'Seasonal average climate and ' - 'temperature of San Francisco in ' - 'january 8% 46% 29% 12% 8% Evolution of ' - 'daily average temperature and ' - 'precipitation in San Francisco in ' - 'januaryWeather in San Francisco in ' - 'january 2024. The weather in San ' - 'Francisco in january comes from ' - 'statistical datas on the past years. ' - 'You can view the weather statistics the ' - 'entire month, but also by using the ' - 'tabs for the beginning, the middle and ' - 'the end of the month. ... 08-01-2023 ' - '52°F to 58°F. 09-01-2023 54°F to 61°F. ' - '10-01-2023 52°F to ...', - 'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/'}])]} +{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\n "query": "weather in San Francisco"\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content="[{'url': 'https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States', 'content': 'January 2024 Weather History in San Francisco California, United States Daily Precipitation in January 2024 in San Francisco Observed Weather in January 2024 in San Francisco San Francisco Temperature History January 2024 Hourly Temperature in January 2024 in San Francisco Hours of Daylight and Twilight in January 2024 in San FranciscoThis report shows the past weather for San Francisco, providing a weather history for January 2024. It features all historical weather data series we have available, including the San Francisco temperature history for January 2024. You can drill down from year to month and even day level reports by clicking on the graphs.'}]", name='tavily_search_results_json'), AIMessage(content="I couldn't find the current weather in San Francisco. However, you can visit [WeatherSpark](https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States) to check the historical weather data for January 2024 in San Francisco.")]} --- ``` ### Streaming LLM Tokens -You can also access the LLM tokens as they are produced by each node. In this case only the "agent" node produces LLM tokens. +You can also access the LLM tokens as they are produced by each node. +In this case only the "agent" node produces LLM tokens. +In order for this to work properly, you must be using an LLM that supports streaming as well as have set it when constructing the LLM (e.g. `ChatOpenAI(model="gpt-3.5-turbo-1106", streaming=True)`) ```python -async for output in chain.astream_log( - {"input": "what is the weather in sf", "intermediate_steps": []}, - include_types=["llm"], -): +inputs = {"messages": [HumanMessage(content="what is the weather in sf")]} +async for output in app.astream_log(inputs, include_types=["llm"]): # astream_log() yields the requested logs (here LLMs) in JSONPatch format for op in output.ops: if op["path"] == "/streamed_output/-": @@ -329,86 +315,232 @@ async for output in chain.astream_log( ``` content='' additional_kwargs={'function_call': {'arguments': '', 'name': 'tavily_search_results_json'}} -content='' additional_kwargs={'function_call': {'arguments': '{"', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': '{\n', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': ' ', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': ' "', 'name': ''}} content='' additional_kwargs={'function_call': {'arguments': 'query', 'name': ''}} -content='' additional_kwargs={'function_call': {'arguments': '":"', 'name': ''}} -content='' additional_kwargs={'function_call': {'arguments': 'current', 'name': ''}} -content='' additional_kwargs={'function_call': {'arguments': ' weather', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': '":', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': ' "', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': 'weather', 'name': ''}} content='' additional_kwargs={'function_call': {'arguments': ' in', 'name': ''}} content='' additional_kwargs={'function_call': {'arguments': ' San', 'name': ''}} content='' additional_kwargs={'function_call': {'arguments': ' Francisco', 'name': ''}} -content='' additional_kwargs={'function_call': {'arguments': '"}', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': '"\n', 'name': ''}} +content='' additional_kwargs={'function_call': {'arguments': '}', 'name': ''}} content='' content='' content='I' -content=' found' -content=' a' -content=' website' -content=' that' -content=' provides' -content=' detailed' -content=' weather' -content=' information' -content=' for' -content=' San' -content=' Francisco' -content='.' -content=' You' -content=' can' -content=' visit' -content=' the' -content=' following' -content=' link' -content=' for' +content="'m" +content=' sorry' +content=',' +content=' but' +content=' I' +content=' couldn' +content="'t" +content=' find' content=' the' content=' current' content=' weather' -content=' report' -content=':' -content=' [' -content='San' +content=' in' +content=' San' content=' Francisco' -content=' Weather' -content=' Report' +content='.' +content=' However' +content=',' +content=' you' +content=' can' +content=' check' +content=' the' +content=' historical' +content=' weather' +content=' data' +content=' for' +content=' January' +content=' ' +content='202' +content='4' +content=' in' +content=' San' +content=' Francisco' +content=' [' +content='here' content='](' content='https' content='://' -content='www' -content='.weather' -content='25' +content='we' +content='athers' +content='park' content='.com' -content='/n' -content='orth' -content='-' -content='amer' -content='ica' +content='/h' +content='/m' content='/' -content='usa' -content='/cal' -content='ifornia' -content='/s' +content='557' +content='/' +content='202' +content='4' +content='/' +content='1' +content='/H' +content='istorical' +content='-' +content='Weather' +content='-in' +content='-Jan' +content='uary' +content='-' +content='202' +content='4' +content='-in' +content='-S' content='an' -content='-fr' +content='-F' +content='r' content='anc' content='isco' -content=')' +content='-Cal' +content='ifornia' +content='-' +content='United' +content='-' +content='States' +content=').' content='' ``` +## When to Use + +When should you use this versus [LangChain Expression Language](https://python.langchain.com/docs/expression_language/)? + +If you need cycles. + +Langchain Expression Language allows you to easily define chains (DAGs) but does not have a good mechanism for adding in cycles. +`langgraph` adds that syntax. + +## Examples + + +### ChatAgentExecutor: with function calling + +This agent executor takes a list of messages as input and outputs a list of messages. +All agent state is represented as a list of messages. +This specifically uses OpenAI function calling. +This is recommended agent executor for newer chat based models that support function calling. + +- [Getting Started Notebook](examples/chat_agent_executor_with_function_calling/base.ipynb): Walks through creating this type of executor from scratch +- [High Level Entrypoint](examples/chat_agent_executor_with_function_calling/high-level.ipynb): Walks through how to use the high level entrypoint for the chat agent executor. + +**Modifications** + +We also have a lot of examples highlighting how to slightly modify the base chat agent executor. These all build off the [getting started notebook](examples/chat_agent_executor_with_function_calling/base.ipynb) so it is recommended you start with that first. +- [Human-in-the-loop](examples/chat_agent_executor_with_function_calling/human-in-the-loop.ipynb): How to add a human-in-the-loop component +- [Force calling a tool first](examples/chat_agent_executor_with_function_calling/force-calling-a-tool-first.ipynb): How to always call a specific tool first +- [Respond in a specific format](examples/chat_agent_executor_with_function_calling/respond-in-format.ipynb): How to force the agent to respond in a specific format +- [Dynamically returning tool output directly](examples/chat_agent_executor_with_function_calling/dynamically-returning-directly.ipynb): How to dynamically let the agent choose whether to return the result of a tool directly to the user +- [Managing agent steps](examples/chat_agent_executor_with_function_calling/managing-agent-steps.ipynb): How to more explicitly manage intermediate steps that an agent takes + +### AgentExecutor + +This agent executor uses existing LangChain agents. + +- [Getting Started Notebook](examples/agent_executor/base.ipynb): Walks through creating this type of executor from scratch +- [High Level Entrypoint](examples/agent_executor/high-level.ipynb): Walks through how to use the high level entrypoint for the chat agent executor. + +**Modifications** + +We also have a lot of examples highlighting how to slightly modify the base chat agent executor. These all build off the [getting started notebook](examples/agent_executor/base.ipynb) so it is recommended you start with that first. +- [Human-in-the-loop](examples/agent_executor/human-in-the-loop.ipynb): How to add a human-in-the-loop component +- [Force calling a tool first](examples/agent_executor/force-calling-a-tool-first.ipynb): How to always call a specific tool first +- [Managing agent steps](examples/agent_executor/managing-agent-steps.ipynb): How to more explicitly manage intermediate steps that an agent takes + +### Async + +If you are running LangGraph in async workflows, you may want to create the nodes to be async by default. +In order for a walkthrough on how to do that, see [this documentation](examples/async.ipynb) + +### Streaming Tokens + +Sometimes language models take a while to respond and you may want to stream tokens to end users. +For a guide on how to do this, see [this documentation](examples/streaming-tokens.ipynb) + ## Documentation There are only a few new APIs to use. -The main new class is `Graph`. +### StateGraph + +The main entrypoint is `StateGraph`. ```python -from langgraph.graph import Graph +from langgraph.graph import StateGraph ``` This class is responsible for constructing the graph. It exposes an interface inspired by [NetworkX](https://networkx.org/documentation/latest/). +This graph is parameterized by a state object that it passes around to each node. -### `.add_node` + +#### `__init__` + +```python + def __init__(self, schema: Type[Any]) -> None: +``` + +When constructing the graph, you need to pass in a schema for a state. +Each node then returns operations to update that state. +These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute. +Whether to set or add is denoted by annotating the state object you construct the graph with. + +The recommended way to specify the schema is with a typed dictionary: `from typing import TypedDict` + +You can then annotate the different attributes using `from typing imoport Annotated`. +Currently, the only supported annotation is `import operator; operator.add`. +This annotation will make it so that any node that returns this attribute ADDS that new result to the existing value. + +Let's take a look at an example: + +```python +from typing import TypedDict, Annotated, Union +from langchain_core.agents import AgentAction, AgentFinish +import operator + + +class AgentState(TypedDict): + # The input string + input: str + # The outcome of a given call to the agent + # Needs `None` as a valid type, since this is what this will start as + agent_outcome: Union[AgentAction, AgentFinish, None] + # List of actions and corresponding observations + # Here we annotate this with `operator.add` to indicate that operations to + # this state should be ADDED to the existing values (not overwrite it) + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + +``` + +We can then use this like: + +```python +# Initialize the StateGraph with this state +graph = StateGraph(AgentState) +# Create nodes and edges +... +# Compile the graph +app = graph.compile() + +# The inputs should be a dictionary, because the state is a TypedDict +inputs = { + # Let's assume this the input + "input": "hi" + # Let's assume agent_outcome is set by the graph as some point + # It doesn't need to be provided, and it will be None by default + # Let's assume `intermediate_steps` is built up over time by the graph + # It doesn't need to provided, and it will be empty list by default + # The reason `intermediate_steps` is an empty list and not `None` is because + # it's annotated with `operator.add` +} +``` + +#### `.add_node` ```python def add_node(self, key: str, action: RunnableLike) -> None: @@ -420,7 +552,7 @@ It takes two arguments: - `key`: A string representing the name of the node. This must be unique. - `action`: The action to take when this node is called. This should either be a function or a runnable. -### `.add_edge` +#### `.add_edge` ```python def add_edge(self, start_key: str, end_key: str) -> None: @@ -433,7 +565,7 @@ It takes two arguments. - `start_key`: A string representing the name of the start node. This key must have already been registered in the graph. - `end_key`: A string representing the name of the end node. This key must have already been registered in the graph. -### `.add_conditional_edges` +#### `.add_conditional_edges` ```python def add_conditional_edges( @@ -452,7 +584,7 @@ This takes three arguments: - `condition`: A function to call to decide what to do next. The input will be the output of the start node. It should return a string that is present in `conditional_edge_mapping` and represents the edge to take. - `conditional_edge_mapping`: A mapping of string to string. The keys should be strings that may be returned by `condition`. The values should be the downstream node to call if that condition is returned. -### `.set_entry_point` +#### `.set_entry_point` ```python def set_entry_point(self, key: str) -> None: @@ -464,7 +596,7 @@ It only takes one argument: - `key`: The name of the node that should be called first. -### `.set_finish_point` +#### `.set_finish_point` ```python def set_finish_point(self, key: str) -> None: @@ -478,6 +610,17 @@ It only has one argument: Note: This does not need to be called if at any point you previously created an edge (conditional or normal) to `END` +### Graph + +```python +from langgraph.graph import Graph + +graph = Graph() +``` + +This has the same interface as `StateGraph` with the exception that it doesn't update a state object over time, and rather relies on passing around the full state from each step. +This means that whatever is returned from one node is the input to the next as is. + ### `END` ```python @@ -491,89 +634,86 @@ It can be used in two places: - As the `end_key` in `add_edge` - As a value in `conditional_edge_mapping` as passed to `add_conditional_edges` -## When to Use -When should you use this versus [LangChain Expression Language](https://python.langchain.com/docs/expression_language/)? +## Prebuilt Examples -If you need cycles. +There are also a few methods we've added to make it easy to use common, prebuilt graphs and components. -Langchain Expression Language allows you to easily define chains (DAGs) but does not have a good mechanism for adding in cycles. -`langgraph` adds that syntax. - -## Examples - -### AgentExecutor - -See the above Quick Start for an example of re-creating the LangChain [`AgentExecutor`](https://python.langchain.com/docs/modules/agents/concepts#agentexecutor) class. - -### Forced Function Calling - -One simple modification of the above Graph is to modify it such that a certain tool is always called first. -This can be useful if you want to enforce a certain tool is called, but still want to enable agentic behavior after the fact. - -Assuming you have done the above Quick Start, you can build off it like: - -#### Define the first tool call - -Here, we manually define the first tool call that we will make. -Notice that it does that same thing as `agent` would have done (adds the `agent_outcome` key). -This is so that we can easily plug it in. +### ToolExecutor ```python -from langchain_core.agents import AgentActionMessageLog - -def first_agent(inputs): - action = AgentActionMessageLog( - # We force call this tool - tool="tavily_search_results_json", - # We just pass in the `input` key to this tool - tool_input=inputs["input"], - log="", - message_log=[] - ) - inputs["agent_outcome"] = action - return inputs +from langgraph.prebuilt import ToolExecutor ``` -#### Create the graph - -We can now create a new graph with this new node +This is a simple helper class to help with calling tools. +It is parameterized by a list of tools: ```python -workflow = Graph() - -# Add the same nodes as before, plus this "first agent" -workflow.add_node("first_agent", first_agent) -workflow.add_node("agent", agent) -workflow.add_node("tools", execute_tools) - -# We now set the entry point to be this first agent -workflow.set_entry_point("first_agent") - -# We define the same edges as before -workflow.add_conditional_edges( - "agent", - should_continue, - { - "continue": "tools", - "exit": END - } -) -workflow.add_edge('tools', 'agent') - -# We also define a new edge, from the "first agent" to the tools node -# This is so that we can call the tool -workflow.add_edge('first_agent', 'tools') - -# We now compile the graph as before -chain = workflow.compile() +tools = [...] +tool_executor = ToolExecutor(tools) ``` -#### Use it! +It then exposes a [runnable interface](https://python.langchain.com/docs/expression_language/interface). +It can be used to call tools: you can pass in an [AgentAction](https://python.langchain.com/docs/modules/agents/concepts#agentaction) and it will look up the relevant tool and call it with the appropriate input. -We can now use it as before! -Depending on whether or not the first tool call is actually useful, this may save you an LLM call or two. +### chat_agent_executor.create_function_calling_executor ```python -chain.invoke({"input": "what is the weather in sf", "intermediate_steps": []}) +from langgraph.prebuilt import chat_agent_executor +``` + +This is a helper function for creating a graph that works with a chat model that utilizes function calling. +Can be created by passing in a model and a list of tools. +The model must be one that supports OpenAI function calling. + +```python +from langchain_openai import ChatOpenAI +from langchain_community.tools.tavily_search import TavilySearchResults +from langgraph.prebuilt import chat_agent_executor +from langchain_core.messages import HumanMessage + +tools = [TavilySearchResults(max_results=1)] +model = ChatOpenAI() + +app = chat_agent_executor.create_function_calling_executor(model, tools) + +inputs = {"messages": [HumanMessage(content="what is the weather in sf")]} +for s in app.stream(inputs): + print(list(s.values())[0]) + print("----") +``` + +### create_agent_executor + +```python +from langgraph.prebuilt import create_agent_executor +``` + +This is a helper function for creating a graph that works with [LangChain Agents](https://python.langchain.com/docs/modules/agents/). +Can be created by passing in an agent and a list of tools. + +```python +from langgraph.prebuilt import create_agent_executor +from langchain_openai import ChatOpenAI +from langchain import hub +from langchain.agents import create_openai_functions_agent +from langchain_community.tools.tavily_search import TavilySearchResults + +tools = [TavilySearchResults(max_results=1)] + +# Get the prompt to use - you can modify this! +prompt = hub.pull("hwchase17/openai-functions-agent") + +# Choose the LLM that will drive the agent +llm = ChatOpenAI(model="gpt-3.5-turbo-1106") + +# Construct the OpenAI Functions agent +agent_runnable = create_openai_functions_agent(llm, tools, prompt) + +app = create_agent_executor(agent_runnable, tools) + +inputs = {"input": "what is the weather in sf", "chat_history": []} +for s in app.stream(inputs): + print(list(s.values())[0]) + print("----") ``` diff --git a/examples/agent_executor/base.ipynb b/examples/agent_executor/base.ipynb new file mode 100644 index 000000000..1e2077740 --- /dev/null +++ b/examples/agent_executor/base.ipynb @@ -0,0 +1,334 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f725852e-71ef-4615-8cac-011a516fbe72", + "metadata": {}, + "source": [ + "# Agent Executor From Scratch\n", + "\n", + "In this notebook we will go over how to build a basic agent executor from scratch." + ] + }, + { + "cell_type": "markdown", + "id": "c0860511-03c2-49bb-937b-035f84142b7e", + "metadata": {}, + "source": [ + "## Setup¶\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fdd4ce41-4152-423b-b3f7-be3b4d568cf4", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "5f4179ce-48fa-4aaf-a5a1-027b5229be1a", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6398c4c1-da78-4595-8a5a-051ed2d1de72", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "37943b1c-2b0a-4c09-bfbd-5dc24b839e3c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dcbf79ad-4de5-43b0-a3a1-25b33711e46c", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "5dace4a9-7c9e-4da2-bf7b-e58d0d05581e", + "metadata": {}, + "source": [ + "## Create the LangChain agent\n", + "\n", + "First, we will create the LangChain agent. For more information on LangChain agents, see [this documentation](https://python.langchain.com/docs/modules/agents/)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4499eb16-bca8-4a60-9a3a-2f34ae3f7078", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain import hub\n", + "from langchain.agents import create_openai_functions_agent\n", + "from langchain_openai.chat_models import ChatOpenAI\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]\n", + "\n", + "# Get the prompt to use - you can modify this!\n", + "prompt = hub.pull(\"hwchase17/openai-functions-agent\")\n", + "\n", + "# Choose the LLM that will drive the agent\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-1106\", streaming=True)\n", + "\n", + "# Construct the OpenAI Functions agent\n", + "agent_runnable = create_openai_functions_agent(llm, tools, prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "972e58b3-fe3c-449d-b3c4-8fa2217afd07", + "metadata": {}, + "source": [ + "## Define the graph state\n", + "\n", + "We now define the graph state. The state for the traditional LangChain agent has a few attributes:\n", + "\n", + "1. `input`: This is the input string representing the main ask from the user, passed in as input.\n", + "2. `chat_history`: This is any previous conversation messages, also passed in as input.\n", + "3. `intermediate_steps`: This is list of actions and corresponding observations that the agent takes over time. This is updated each iteration of the agent.\n", + "4. `agent_outcome`: This is the response from the agent, either an AgentAction or AgentFinish. The AgentExecutor should finish when this is an AgentFinish, otherwise it should call the requested tools.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "c941fb10-dbe5-4d6a-ab7d-133d01c33cc4", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, List, Union\n", + "from langchain_core.agents import AgentAction, AgentFinish\n", + "from langchain_core.messages import BaseMessage\n", + "import operator\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " # The input string\n", + " input: str\n", + " # The list of previous messages in the conversation\n", + " chat_history: list[BaseMessage]\n", + " # The outcome of a given call to the agent\n", + " # Needs `None` as a valid type, since this is what this will start as\n", + " agent_outcome: Union[AgentAction, AgentFinish, None]\n", + " # List of actions and corresponding observations\n", + " # Here we annotate this with `operator.add` to indicate that operations to\n", + " # this state should be ADDED to the existing values (not overwrite it)\n", + " intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n" + ] + }, + { + "cell_type": "markdown", + "id": "cd27b281-cc9a-49c9-be78-8b98a7d905c4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "d61a970d-edf4-4eef-9678-28bab7c72331", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.agents import AgentFinish\n", + "from langgraph.prebuilt.tool_executor import ToolExecutor\n", + "\n", + "# This a helper class we have that is useful for running tools\n", + "# It takes in an agent action and calls that tool and returns the result\n", + "tool_executor = ToolExecutor(tools)\n", + "\n", + "# Define the agent\n", + "def run_agent(data):\n", + " agent_outcome = agent_runnable.invoke(data)\n", + " return {\"agent_outcome\": agent_outcome}\n", + "\n", + "# Define the function to execute tools\n", + "def execute_tools(data):\n", + " # Get the most recent agent_outcome - this is the key added in the `agent` above\n", + " agent_action = data['agent_outcome']\n", + " output = tool_executor.invoke(agent_action)\n", + " return {\"intermediate_steps\": [(agent_action, str(output))]}\n", + "\n", + "# Define logic that will be used to determine which conditional edge to go down\n", + "def should_continue(data):\n", + " # If the agent outcome is an AgentFinish, then we return `exit` string\n", + " # This will be used when setting up the graph to define the flow\n", + " if isinstance(data['agent_outcome'], AgentFinish):\n", + " return \"end\"\n", + " # Otherwise, an AgentAction is returned\n", + " # Here we return `continue` string\n", + " # This will be used when setting up the graph to define the flow\n", + " else:\n", + " return \"continue\"" + ] + }, + { + "cell_type": "markdown", + "id": "c0b211f4-0c5c-4792-b18d-cd70907c71e7", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "c4054dde-4618-49b7-998a-daa0c1d6d6c0", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", run_agent)\n", + "workflow.add_node(\"action\", execute_tools)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "214ae46e-c297-465d-86db-2b0312ed3530", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent_outcome': AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})])}\n", + "----\n", + "{'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\")]}\n", + "----\n", + "{'agent_outcome': AgentFinish(return_values={'output': 'I found some information about the weather in San Francisco in January 2024, but it seems that the search results are not specific to the current weather. Would you like me to try a different search method to get the current weather in San Francisco?'}, log='I found some information about the weather in San Francisco in January 2024, but it seems that the search results are not specific to the current weather. Would you like me to try a different search method to get the current weather in San Francisco?')}\n", + "----\n", + "{'input': 'what is the weather in sf', 'chat_history': [], 'agent_outcome': AgentFinish(return_values={'output': 'I found some information about the weather in San Francisco in January 2024, but it seems that the search results are not specific to the current weather. Would you like me to try a different search method to get the current weather in San Francisco?'}, log='I found some information about the weather in San Francisco in January 2024, but it seems that the search results are not specific to the current weather. Would you like me to try a different search method to get the current weather in San Francisco?'), 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\")]}\n", + "----\n" + ] + } + ], + "source": [ + "inputs = {\"input\": \"what is the weather in sf\", \"chat_history\": []}\n", + "for s in app.stream(inputs):\n", + " print(list(s.values())[0])\n", + " print(\"----\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2eb662bc-de7d-4a57-a3e8-2f00dcf4ff8b", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/agent_executor/force-calling-a-tool-first.ipynb b/examples/agent_executor/force-calling-a-tool-first.ipynb new file mode 100644 index 000000000..49dccc319 --- /dev/null +++ b/examples/agent_executor/force-calling-a-tool-first.ipynb @@ -0,0 +1,399 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f725852e-71ef-4615-8cac-011a516fbe72", + "metadata": {}, + "source": [ + "# Agent Executor From Scratch\n", + "\n", + "In this notebook we will create an agent with a search tool. However, at the start we will force the agent to call the search tool (and then let it do whatever it wants after). This is useful when you want to force agents to call particular tools, but still want flexibility of what happens after that.\n", + "\n", + "This examples builds off the base agent executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "6821de30-6eeb-4f70-b0a7-e05d3187b14b", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "694cfc4c-22a7-495d-930d-56b21d850ff9", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "dc039752-6d34-4ad4-aa31-9a10f4d4d597", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "30c06a84-291a-4f58-9d31-53d3b56a3def", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "5e7f4767-54fb-4b6e-bd9a-3d433df924fb", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a8fb285a-7e6e-46fc-a273-43ab1a676189", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "5dace4a9-7c9e-4da2-bf7b-e58d0d05581e", + "metadata": {}, + "source": [ + "## Create the LangChain agent\n", + "\n", + "First, we will create the LangChain agent. For more information on LangChain agents, see [this documentation](https://python.langchain.com/docs/modules/agents/)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4499eb16-bca8-4a60-9a3a-2f34ae3f7078", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain import hub\n", + "from langchain.agents import create_openai_functions_agent\n", + "from langchain_openai.chat_models import ChatOpenAI\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]\n", + "\n", + "# Get the prompt to use - you can modify this!\n", + "prompt = hub.pull(\"hwchase17/openai-functions-agent\")\n", + "\n", + "# Choose the LLM that will drive the agent\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-1106\", streaming=True)\n", + "\n", + "# Construct the OpenAI Functions agent\n", + "agent_runnable = create_openai_functions_agent(llm, tools, prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "972e58b3-fe3c-449d-b3c4-8fa2217afd07", + "metadata": {}, + "source": [ + "## Define the graph state\n", + "\n", + "We now define the graph state. The state for the traditional LangChain agent has a few attributes:\n", + "\n", + "1. `input`: This is the input string representing the main ask from the user, passed in as input.\n", + "2. `chat_history`: This is any previous conversation messages, also passed in as input.\n", + "3. `intermediate_steps`: This is list of actions and corresponding observations that the agent takes over time. This is updated each iteration of the agent.\n", + "4. `agent_outcome`: This is the response from the agent, either an AgentAction or AgentFinish. The AgentExecutor should finish when this is an AgentFinish, otherwise it should call the requested tools.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c941fb10-dbe5-4d6a-ab7d-133d01c33cc4", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, List, Union\n", + "from langchain_core.agents import AgentAction, AgentFinish\n", + "from langchain_core.messages import BaseMessage\n", + "import operator\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " # The input string\n", + " input: str\n", + " # The list of previous messages in the conversation\n", + " chat_history: list[BaseMessage]\n", + " # The outcome of a given call to the agent\n", + " # Needs `None` as a valid type, since this is what this will start as\n", + " agent_outcome: Union[AgentAction, AgentFinish, None]\n", + " # List of actions and corresponding observations\n", + " # Here we annotate this with `operator.add` to indicate that operations to\n", + " # this state should be ADDED to the existing values (not overwrite it)\n", + " intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n" + ] + }, + { + "cell_type": "markdown", + "id": "cd27b281-cc9a-49c9-be78-8b98a7d905c4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "d61a970d-edf4-4eef-9678-28bab7c72331", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.agents import AgentFinish\n", + "from langgraph.prebuilt.tool_executor import ToolExecutor\n", + "\n", + "# This a helper class we have that is useful for running tools\n", + "# It takes in an agent action and calls that tool and returns the result\n", + "tool_executor = ToolExecutor(tools)\n", + "\n", + "# Define the agent\n", + "def run_agent(data):\n", + " agent_outcome = agent_runnable.invoke(data)\n", + " return {\"agent_outcome\": agent_outcome}\n", + "\n", + "# Define the function to execute tools\n", + "def execute_tools(data):\n", + " # Get the most recent agent_outcome - this is the key added in the `agent` above\n", + " agent_action = data['agent_outcome']\n", + " output = tool_executor.invoke(agent_action)\n", + " return {\"intermediate_steps\": [(agent_action, str(output))]}\n", + "\n", + "# Define logic that will be used to determine which conditional edge to go down\n", + "def should_continue(data):\n", + " # If the agent outcome is an AgentFinish, then we return `exit` string\n", + " # This will be used when setting up the graph to define the flow\n", + " if isinstance(data['agent_outcome'], AgentFinish):\n", + " return \"end\"\n", + " # Otherwise, an AgentAction is returned\n", + " # Here we return `continue` string\n", + " # This will be used when setting up the graph to define the flow\n", + " else:\n", + " return \"continue\"" + ] + }, + { + "cell_type": "markdown", + "id": "02437e83-5485-4827-87e6-7ad1d02cf9be", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "Here we create a node that returns an AgentAction that just calls the Tavily search with the input" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "2ed8463e-73e5-417d-9fab-be6bcee87835", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'tavily_search_results_json'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "tools[0].name" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "df25d899-2338-4f31-a8bf-0582a2eec325", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.agents import AgentActionMessageLog\n", + "\n", + "def first_agent(inputs):\n", + " action = AgentActionMessageLog(\n", + " # We force call this tool\n", + " tool=\"tavily_search_results_json\",\n", + " # We just pass in the `input` key to this tool\n", + " tool_input=inputs[\"input\"],\n", + " log=\"\",\n", + " message_log=[]\n", + " )\n", + " return {\"agent_outcome\": action}" + ] + }, + { + "cell_type": "markdown", + "id": "c0b211f4-0c5c-4792-b18d-cd70907c71e7", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!\n", + "\n", + "**MODIFICATION**\n", + "\n", + "We now add a new `first_agent` node which we set as the entrypoint." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c4054dde-4618-49b7-998a-daa0c1d6d6c0", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", run_agent)\n", + "workflow.add_node(\"action\", execute_tools)\n", + "workflow.add_node(\"first_agent\", first_agent)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"first_agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# After the first agent, we want to take an action\n", + "workflow.add_edge('first_agent', 'action')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "214ae46e-c297-465d-86db-2b0312ed3530", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent_outcome': AgentActionMessageLog(tool='tavily_search_results_json', tool_input='what is the weather in sf', log='', message_log=[])}\n", + "----\n", + "{'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input='what is the weather in sf', log='', message_log=[]), \"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january The climate of San Francisco in january is tolerableWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 15-01-2023 50°F to 52°F. 16-01-2023 45°F to 52°F. 17-01-2023 45°F to ...'}]\")]}\n", + "----\n", + "{'agent_outcome': AgentFinish(return_values={'output': 'The weather in San Francisco in January is typically tolerable, with temperatures ranging from 45°F to 52°F. If you need more specific and up-to-date information about the current weather in San Francisco, I can look it up for you.'}, log='The weather in San Francisco in January is typically tolerable, with temperatures ranging from 45°F to 52°F. If you need more specific and up-to-date information about the current weather in San Francisco, I can look it up for you.')}\n", + "----\n", + "{'input': 'what is the weather in sf', 'chat_history': [], 'agent_outcome': AgentFinish(return_values={'output': 'The weather in San Francisco in January is typically tolerable, with temperatures ranging from 45°F to 52°F. If you need more specific and up-to-date information about the current weather in San Francisco, I can look it up for you.'}, log='The weather in San Francisco in January is typically tolerable, with temperatures ranging from 45°F to 52°F. If you need more specific and up-to-date information about the current weather in San Francisco, I can look it up for you.'), 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input='what is the weather in sf', log='', message_log=[]), \"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january The climate of San Francisco in january is tolerableWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 15-01-2023 50°F to 52°F. 16-01-2023 45°F to 52°F. 17-01-2023 45°F to ...'}]\")]}\n", + "----\n" + ] + } + ], + "source": [ + "inputs = {\"input\": \"what is the weather in sf\", \"chat_history\": []}\n", + "for s in app.stream(inputs):\n", + " print(list(s.values())[0])\n", + " print(\"----\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2eb662bc-de7d-4a57-a3e8-2f00dcf4ff8b", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/agent_executor/high-level.ipynb b/examples/agent_executor/high-level.ipynb new file mode 100644 index 000000000..1922e5ab7 --- /dev/null +++ b/examples/agent_executor/high-level.ipynb @@ -0,0 +1,363 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f961801a-6025-4b73-be3b-c3a8a75d4167", + "metadata": {}, + "source": [ + "# Agent Executor\n", + "\n", + "This notebook walks through an example creating an agent executor to work with an existing LangChain agent.\n", + "This is useful for getting started quickly.\n", + "However, it is highly likely you will want to customize the logic - for information on that, check out the other examples in this folder." + ] + }, + { + "cell_type": "markdown", + "id": "e6dd032b-bfe9-458c-a8ef-a14c78e0ad3f", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1759bc06-8af3-4b73-abbf-0be3fa4c31fb", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "fa08bd1a-efaa-46f5-adf8-47a84f738381", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eb8e51dc-028b-4ea5-9847-f22fcbed6dac", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "9242c0d7-b1da-41a0-9a3e-ed3afab3528e", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5db4438c-7802-4050-9dd9-14a6cac21a91", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "6ae180d9-abd3-4a44-8fb1-a2c89434fbeb", + "metadata": {}, + "source": [ + "## Set up LangChain Agent\n", + "\n", + "First, will set up our LangChain Agent. \n", + "See documentation [here](https://python.langchain.com/docs/modules/agents/) for more information on what these agents are and how to think about them" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "e2fdcac4-d134-402b-b423-b0cf4b939f5d", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain import hub\n", + "from langchain.agents import create_openai_functions_agent\n", + "from langchain_community.tools.tavily_search import TavilySearchResults" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "afb59979-c7a3-435f-b147-f8d501f6ff13", + "metadata": {}, + "outputs": [], + "source": [ + "tools = [TavilySearchResults(max_results=1)]\n", + "\n", + "# Get the prompt to use - you can modify this!\n", + "prompt = hub.pull(\"hwchase17/openai-functions-agent\")\n", + "\n", + "# Choose the LLM that will drive the agent\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-1106\")\n", + "\n", + "# Construct the OpenAI Functions agent\n", + "agent_runnable = create_openai_functions_agent(llm, tools, prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "0bcb5ff8-b2d1-4fb2-bed4-3726f96db772", + "metadata": {}, + "source": [ + "## Create agent executor\n", + "\n", + "Now we will use the high level method to create the agent executor" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7a138eb4-a469-4b30-a059-99d6ea944648", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import create_agent_executor" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9be722f0-c9ab-4bd2-af27-66adf51134d2", + "metadata": {}, + "outputs": [], + "source": [ + "app = create_agent_executor(agent_runnable, tools)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "019b591b-fd71-4ee8-ae94-06d0e2dc6a4d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent_outcome': AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'current weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'current weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"current weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})])}\n", + "----\n", + "{'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'current weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'current weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"current weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://en.climate-data.org/north-america/united-states-of-america/california/san-francisco-385/t/january-1/', 'content': 'San Francisco Weather in January you can find all information about the weather in San Francisco in January: San Francisco weather in January San Francisco weather by month // weather averages 9.6 (49.2) 6.2 (43.2) 14 (57.3) 113 San Francisco weather in January // weather averages Airport close to San FranciscoWeather ☀ ⛅ San Francisco ☀ ⛅ January ☀ ⛅ Information on temperature, sunshine hours, water temperature & rainfall in January for San Francisco. ... Are you planning a holiday with hopefully nice weather in San Francisco in January 2024? Here you can find all information about the weather in San Francisco in January: ... 15. January ...'}]\")]}\n", + "----\n", + "{'agent_outcome': AgentFinish(return_values={'output': \"I couldn't find the current weather in San Francisco. However, you can visit a reliable weather website or check a weather app for the most up-to-date information.\"}, log=\"I couldn't find the current weather in San Francisco. However, you can visit a reliable weather website or check a weather app for the most up-to-date information.\")}\n", + "----\n", + "{'input': 'what is the weather in sf', 'chat_history': [], 'agent_outcome': AgentFinish(return_values={'output': \"I couldn't find the current weather in San Francisco. However, you can visit a reliable weather website or check a weather app for the most up-to-date information.\"}, log=\"I couldn't find the current weather in San Francisco. However, you can visit a reliable weather website or check a weather app for the most up-to-date information.\"), 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'current weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'current weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"current weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://en.climate-data.org/north-america/united-states-of-america/california/san-francisco-385/t/january-1/', 'content': 'San Francisco Weather in January you can find all information about the weather in San Francisco in January: San Francisco weather in January San Francisco weather by month // weather averages 9.6 (49.2) 6.2 (43.2) 14 (57.3) 113 San Francisco weather in January // weather averages Airport close to San FranciscoWeather ☀ ⛅ San Francisco ☀ ⛅ January ☀ ⛅ Information on temperature, sunshine hours, water temperature & rainfall in January for San Francisco. ... Are you planning a holiday with hopefully nice weather in San Francisco in January 2024? Here you can find all information about the weather in San Francisco in January: ... 15. January ...'}]\")]}\n", + "----\n" + ] + } + ], + "source": [ + "inputs = {\"input\": \"what is the weather in sf\", \"chat_history\": []}\n", + "for s in app.stream(inputs):\n", + " print(list(s.values())[0])\n", + " print(\"----\")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "c6a664cd-083e-4d85-aeaf-501463881f05", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "AgentFinish(return_values={'output': \"I couldn't find the current weather in San Francisco. However, you can check the weather on a reliable weather website or using a weather app for the most up-to-date information.\"}, log=\"I couldn't find the current weather in San Francisco. However, you can check the weather on a reliable weather website or using a weather app for the most up-to-date information.\")" + ] + }, + "execution_count": 18, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s['__end__']['agent_outcome']" + ] + }, + { + "cell_type": "markdown", + "id": "a7bd3e55-ee7e-4276-81bd-39e6131fcf77", + "metadata": {}, + "source": [ + "## Custom Input Schema\n", + "\n", + "By default, the `create_agent_executor` assumes that the input will be a dictionary with two keys: `input` and `chat_history`. \n", + "If this is not the case, you can easily customize the input schema.\n", + "You should do this, by defining a schema as a TypedDict.\n", + "\n", + "For this example, we will create a new agent that expects `question` and `language` as inputs." + ] + }, + { + "cell_type": "markdown", + "id": "a98c5ec5-f836-4b3c-b37b-00102b496366", + "metadata": {}, + "source": [ + "### Create New Agent" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "676841ec-b5a6-495e-a88a-7eb0ab3cbae6", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n", + "\n", + "prompt = ChatPromptTemplate.from_messages([\n", + " (\"human\", \"Respond to the user question: {question}. Answer in this language: {language}\"),\n", + " MessagesPlaceholder(variable_name=\"agent_scratchpad\")\n", + "])\n", + "agent_runnable = create_openai_functions_agent(llm, tools, prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "5889d980-d209-447b-8489-1d4873acfdc2", + "metadata": {}, + "source": [ + "### Define Input Schema" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3d1df06d-1564-46a1-a72f-58dfc65927bc", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2fdbb687-9c72-42c7-afcb-3f8940f3e5f4", + "metadata": {}, + "outputs": [], + "source": [ + "class InputSchema(TypedDict):\n", + " question: str\n", + " language: str" + ] + }, + { + "cell_type": "markdown", + "id": "329bb518-02a8-477c-8898-d04cb64fc460", + "metadata": {}, + "source": [ + "### Create new agent executor" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "1ad88990-896d-48d5-bd34-01c9f6a37734", + "metadata": {}, + "outputs": [], + "source": [ + "app = create_agent_executor(agent_runnable, tools, input_schema=InputSchema)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "13ffa18c-9a9f-4e0e-8298-32aeff94ce5d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent_outcome': AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'che tempo fa a sf'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'che tempo fa a sf'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"che tempo fa a sf\"}', 'name': 'tavily_search_results_json'}})])}\n", + "----\n", + "{'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'che tempo fa a sf'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'che tempo fa a sf'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"che tempo fa a sf\"}', 'name': 'tavily_search_results_json'}})]), '[{\\'url\\': \\'https://www.mxbars.net/2024/01/14/san-francisco-supercross-2024-results-and-points-video/\\', \\'content\\': \"Scritto domenica 14 Gennaio 2024 alle 04:38. SAN FRANCISCO Oracle Park, CA January 13, 2024 sera gli orari sono anticipati di due ore causa mal tempo per non compromettere lo spettacolo! secondo weekend con la seconda tappa e pista molto tecnica per San Francisco dove sta piovendo e lo stadio aperto non . Commenta la gara\\\\xa0CLICCANDO\\\\xa0il link! http://forum.mxbars.net/viewtopic.php?f=18&t=50182SAN FRANCISCO. Oracle Park, CA. January 13, 2024. Ecco che la NUOVA stagione del Monster Energy Supercross 2024 continua, dopo Anaheim 1 si passa al secondo weekend con la seconda tappa e pista molto tecnica per San Francisco dove sta piovendo e lo stadio aperto non vede possibilità di chiudersi, per la lotta nella 450 dove sono tutti agguerriti e quest\\'anno il livello è ancora più alto ...\"}]')]}\n", + "----\n", + "{'agent_outcome': AgentFinish(return_values={'output': 'Al momento sta piovendo a San Francisco.'}, log='Al momento sta piovendo a San Francisco.')}\n", + "----\n", + "{'question': 'what is the weather in sf', 'language': 'italian', 'agent_outcome': AgentFinish(return_values={'output': 'Al momento sta piovendo a San Francisco.'}, log='Al momento sta piovendo a San Francisco.'), 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'che tempo fa a sf'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'che tempo fa a sf'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"che tempo fa a sf\"}', 'name': 'tavily_search_results_json'}})]), '[{\\'url\\': \\'https://www.mxbars.net/2024/01/14/san-francisco-supercross-2024-results-and-points-video/\\', \\'content\\': \"Scritto domenica 14 Gennaio 2024 alle 04:38. SAN FRANCISCO Oracle Park, CA January 13, 2024 sera gli orari sono anticipati di due ore causa mal tempo per non compromettere lo spettacolo! secondo weekend con la seconda tappa e pista molto tecnica per San Francisco dove sta piovendo e lo stadio aperto non . Commenta la gara\\\\xa0CLICCANDO\\\\xa0il link! http://forum.mxbars.net/viewtopic.php?f=18&t=50182SAN FRANCISCO. Oracle Park, CA. January 13, 2024. Ecco che la NUOVA stagione del Monster Energy Supercross 2024 continua, dopo Anaheim 1 si passa al secondo weekend con la seconda tappa e pista molto tecnica per San Francisco dove sta piovendo e lo stadio aperto non vede possibilità di chiudersi, per la lotta nella 450 dove sono tutti agguerriti e quest\\'anno il livello è ancora più alto ...\"}]')]}\n", + "----\n" + ] + } + ], + "source": [ + "inputs = {\"question\": \"what is the weather in sf\", \"language\": \"italian\"}\n", + "for s in app.stream(inputs):\n", + " print(list(s.values())[0])\n", + " print(\"----\")" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "fd60f5d6-bd4b-4995-80dd-63f268c17cff", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "AgentFinish(return_values={'output': 'Il clima a San Francisco durante il mese di gennaio è generalmente fresco con temperature medie di circa 9.6°C (49.2°F) e massime di 14°C (57.3°F). Si consiglia di prepararsi a temperature fresche se si pianifica una visita a San Francisco in gennaio.'}, log='Il clima a San Francisco durante il mese di gennaio è generalmente fresco con temperature medie di circa 9.6°C (49.2°F) e massime di 14°C (57.3°F). Si consiglia di prepararsi a temperature fresche se si pianifica una visita a San Francisco in gennaio.')" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "s['__end__']['agent_outcome']" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "20cac1a0-0c51-4cbd-ae27-929d71db2b56", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/agent_executor/human-in-the-loop.ipynb b/examples/agent_executor/human-in-the-loop.ipynb new file mode 100644 index 000000000..d52ad06e2 --- /dev/null +++ b/examples/agent_executor/human-in-the-loop.ipynb @@ -0,0 +1,373 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f725852e-71ef-4615-8cac-011a516fbe72", + "metadata": {}, + "source": [ + "# Human in the Loop\n", + "\n", + "In this notebook we will go over how to add a human-in-the-loop workflow to the base agent executor. We will use the human to approve\n", + "\n", + "This examples builds off the base agent executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "f7714f98-eb0e-43dd-8ae7-4a32ef2e72de", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3fa9e224-2f00-49e2-bca3-e9cb8d9f3d41", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "2dd8be50-2f92-478b-a918-6d9e4ad66dd6", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d180f0d0-385f-4ce3-994c-11e1d64595b5", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "31d59506-f33f-42ad-b072-9a344c4af2e6", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72ad0539-ecd8-4eb1-b2c1-2242e5fc556f", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "5dace4a9-7c9e-4da2-bf7b-e58d0d05581e", + "metadata": {}, + "source": [ + "## Create the LangChain agent\n", + "\n", + "First, we will create the LangChain agent. For more information on LangChain agents, see [this documentation](https://python.langchain.com/docs/modules/agents/)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4499eb16-bca8-4a60-9a3a-2f34ae3f7078", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain import hub\n", + "from langchain.agents import create_openai_functions_agent\n", + "from langchain_openai.chat_models import ChatOpenAI\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]\n", + "\n", + "# Get the prompt to use - you can modify this!\n", + "prompt = hub.pull(\"hwchase17/openai-functions-agent\")\n", + "\n", + "# Choose the LLM that will drive the agent\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-1106\", streaming=True)\n", + "\n", + "# Construct the OpenAI Functions agent\n", + "agent_runnable = create_openai_functions_agent(llm, tools, prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "972e58b3-fe3c-449d-b3c4-8fa2217afd07", + "metadata": {}, + "source": [ + "## Define the graph state\n", + "\n", + "We now define the graph state. The state for the traditional LangChain agent has a few attributes:\n", + "\n", + "1. `input`: This is the input string representing the main ask from the user, passed in as input.\n", + "2. `chat_history`: This is any previous conversation messages, also passed in as input.\n", + "3. `intermediate_steps`: This is list of actions and corresponding observations that the agent takes over time. This is updated each iteration of the agent.\n", + "4. `agent_outcome`: This is the response from the agent, either an AgentAction or AgentFinish. The AgentExecutor should finish when this is an AgentFinish, otherwise it should call the requested tools.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c941fb10-dbe5-4d6a-ab7d-133d01c33cc4", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, List, Union\n", + "from langchain_core.agents import AgentAction, AgentFinish\n", + "from langchain_core.messages import BaseMessage\n", + "import operator\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " # The input string\n", + " input: str\n", + " # The list of previous messages in the conversation\n", + " chat_history: list[BaseMessage]\n", + " # The outcome of a given call to the agent\n", + " # Needs `None` as a valid type, since this is what this will start as\n", + " agent_outcome: Union[AgentAction, AgentFinish, None]\n", + " # List of actions and corresponding observations\n", + " # Here we annotate this with `operator.add` to indicate that operations to\n", + " # this state should be ADDED to the existing values (not overwrite it)\n", + " intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n" + ] + }, + { + "cell_type": "markdown", + "id": "cd27b281-cc9a-49c9-be78-8b98a7d905c4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2b757f84-1175-445e-8f8c-e5aeb765a03d", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.agents import AgentFinish\n", + "from langgraph.prebuilt.tool_executor import ToolExecutor\n", + "\n", + "# This a helper class we have that is useful for running tools\n", + "# It takes in an agent action and calls that tool and returns the result\n", + "tool_executor = ToolExecutor(tools)\n", + "\n", + "# Define the agent\n", + "def run_agent(data):\n", + " agent_outcome = agent_runnable.invoke(data)\n", + " return {\"agent_outcome\": agent_outcome}" + ] + }, + { + "cell_type": "markdown", + "id": "35ace508-d5fe-4139-a0f8-887e38047401", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "We modify the function that is calling the tool to first ask for user approval to continue. Note that this is a simple example and we could modify it to change the tool input, use some other channel besides input, etc." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "2fecf5e0-9604-4992-9c82-b9627466cd32", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function to execute tools\n", + "def execute_tools(data):\n", + " # Get the most recent agent_outcome - this is the key added in the `agent` above\n", + " agent_action = data['agent_outcome']\n", + " response = input(prompt=f\"[y/n] continue with: {agent_action}?\")\n", + " if response == \"n\":\n", + " raise ValueError\n", + " output = tool_executor.invoke(agent_action)\n", + " return {\"intermediate_steps\": [(agent_action, str(output))]}\n", + "\n", + "# Define logic that will be used to determine which conditional edge to go down\n", + "def should_continue(data):\n", + " # If the agent outcome is an AgentFinish, then we return `exit` string\n", + " # This will be used when setting up the graph to define the flow\n", + " if isinstance(data['agent_outcome'], AgentFinish):\n", + " return \"end\"\n", + " # Otherwise, an AgentAction is returned\n", + " # Here we return `continue` string\n", + " # This will be used when setting up the graph to define the flow\n", + " else:\n", + " return \"continue\"" + ] + }, + { + "cell_type": "markdown", + "id": "c0b211f4-0c5c-4792-b18d-cd70907c71e7", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c4054dde-4618-49b7-998a-daa0c1d6d6c0", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", run_agent)\n", + "workflow.add_node(\"action\", execute_tools)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "214ae46e-c297-465d-86db-2b0312ed3530", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent_outcome': AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})])}\n", + "----\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "[y/n] continue with: tool='tavily_search_results_json' tool_input={'query': 'weather in San Francisco'} log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\" message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]? y\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States', 'content': 'January 2024 Weather History in San Francisco California, United States Daily Precipitation in January 2024 in San Francisco Observed Weather in January 2024 in San Francisco San Francisco Temperature History January 2024 Hourly Temperature in January 2024 in San Francisco Hours of Daylight and Twilight in January 2024 in San FranciscoThis report shows the past weather for San Francisco, providing a weather history for January 2024. It features all historical weather data series we have available, including the San Francisco temperature history for January 2024. You can drill down from year to month and even day level reports by clicking on the graphs.'}]\")]}\n", + "----\n", + "{'agent_outcome': AgentFinish(return_values={'output': \"It seems that I didn't find the current weather information for San Francisco. I recommend checking a reliable weather website or using a weather app to get the most up-to-date information.\"}, log=\"It seems that I didn't find the current weather information for San Francisco. I recommend checking a reliable weather website or using a weather app to get the most up-to-date information.\")}\n", + "----\n", + "{'input': 'what is the weather in sf', 'chat_history': [], 'agent_outcome': AgentFinish(return_values={'output': \"It seems that I didn't find the current weather information for San Francisco. I recommend checking a reliable weather website or using a weather app to get the most up-to-date information.\"}, log=\"It seems that I didn't find the current weather information for San Francisco. I recommend checking a reliable weather website or using a weather app to get the most up-to-date information.\"), 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States', 'content': 'January 2024 Weather History in San Francisco California, United States Daily Precipitation in January 2024 in San Francisco Observed Weather in January 2024 in San Francisco San Francisco Temperature History January 2024 Hourly Temperature in January 2024 in San Francisco Hours of Daylight and Twilight in January 2024 in San FranciscoThis report shows the past weather for San Francisco, providing a weather history for January 2024. It features all historical weather data series we have available, including the San Francisco temperature history for January 2024. You can drill down from year to month and even day level reports by clicking on the graphs.'}]\")]}\n", + "----\n" + ] + } + ], + "source": [ + "inputs = {\"input\": \"what is the weather in sf\", \"chat_history\": []}\n", + "for s in app.stream(inputs):\n", + " print(list(s.values())[0])\n", + " print(\"----\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2eb662bc-de7d-4a57-a3e8-2f00dcf4ff8b", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/agent_executor/managing-agent-steps.ipynb b/examples/agent_executor/managing-agent-steps.ipynb new file mode 100644 index 000000000..d509d8f8d --- /dev/null +++ b/examples/agent_executor/managing-agent-steps.ipynb @@ -0,0 +1,360 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f725852e-71ef-4615-8cac-011a516fbe72", + "metadata": {}, + "source": [ + "# Managing Agent Steps\n", + "\n", + "In this notebook we will go over how to build a basic agent executor where we custom handle how to manage the intermediate steps. Normally, all previous steps are passed to the agent at future iterations, but in long-running cases that could lead to an overly large amount of steps that you may want to trim\n", + "\n", + "This examples builds off the base agent executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "bd763d4e-fd5e-4ce4-aa3a-54ab895d10a6", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aa752131-27e3-4bd8-9f21-d6749a7e74f4", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "dbbfe916-5c23-4bf4-a5fa-5048e676dae3", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5732e68f-4ae2-4db9-bf9c-454b4cc9ec01", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "4141f30e-4e5a-4b98-9fd8-b95e859d203a", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "652d4600-8f95-493f-b9b9-d4095aed9218", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "5dace4a9-7c9e-4da2-bf7b-e58d0d05581e", + "metadata": {}, + "source": [ + "## Create the LangChain agent\n", + "\n", + "First, we will create the LangChain agent. For more information on LangChain agents, see [this documentation](https://python.langchain.com/docs/modules/agents/)" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4499eb16-bca8-4a60-9a3a-2f34ae3f7078", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain import hub\n", + "from langchain.agents import create_openai_functions_agent\n", + "from langchain_openai.chat_models import ChatOpenAI\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]\n", + "\n", + "# Get the prompt to use - you can modify this!\n", + "prompt = hub.pull(\"hwchase17/openai-functions-agent\")\n", + "\n", + "# Choose the LLM that will drive the agent\n", + "llm = ChatOpenAI(model=\"gpt-3.5-turbo-1106\", streaming=True)\n", + "\n", + "# Construct the OpenAI Functions agent\n", + "agent_runnable = create_openai_functions_agent(llm, tools, prompt)" + ] + }, + { + "cell_type": "markdown", + "id": "972e58b3-fe3c-449d-b3c4-8fa2217afd07", + "metadata": {}, + "source": [ + "## Define the graph state\n", + "\n", + "We now define the graph state. The state for the traditional LangChain agent has a few attributes:\n", + "\n", + "1. `input`: This is the input string representing the main ask from the user, passed in as input.\n", + "2. `chat_history`: This is any previous conversation messages, also passed in as input.\n", + "3. `intermediate_steps`: This is list of actions and corresponding observations that the agent takes over time. This is updated each iteration of the agent.\n", + "4. `agent_outcome`: This is the response from the agent, either an AgentAction or AgentFinish. The AgentExecutor should finish when this is an AgentFinish, otherwise it should call the requested tools.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c941fb10-dbe5-4d6a-ab7d-133d01c33cc4", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, List, Union\n", + "from langchain_core.agents import AgentAction, AgentFinish\n", + "from langchain_core.messages import BaseMessage\n", + "import operator\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " # The input string\n", + " input: str\n", + " # The list of previous messages in the conversation\n", + " chat_history: list[BaseMessage]\n", + " # The outcome of a given call to the agent\n", + " # Needs `None` as a valid type, since this is what this will start as\n", + " agent_outcome: Union[AgentAction, AgentFinish, None]\n", + " # List of actions and corresponding observations\n", + " # Here we annotate this with `operator.add` to indicate that operations to\n", + " # this state should be ADDED to the existing values (not overwrite it)\n", + " intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n" + ] + }, + { + "cell_type": "markdown", + "id": "cd27b281-cc9a-49c9-be78-8b98a7d905c4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "77e3c059-e31f-4c8f-81bf-edb58688e12b", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.agents import AgentFinish\n", + "from langgraph.prebuilt.tool_executor import ToolExecutor\n", + "\n", + "# This a helper class we have that is useful for running tools\n", + "# It takes in an agent action and calls that tool and returns the result\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "4c804a34-d384-4ca9-b9fc-dc86d678ab39", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "Here, we modify the agent to only look at the last five intermediate steps. This is a relatively simple example of shortening the intermediate step history." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "a9f66a3e-aba1-4893-95b1-a433c7091d5e", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the agent\n", + "def run_agent(data):\n", + " inputs = data.copy()\n", + " if len(inputs['intermediate_steps']) > 5:\n", + " inputs['intermediate_steps'] = inputs['intermediate_steps'][-5:]\n", + " agent_outcome = agent_runnable.invoke(inputs)\n", + " return {\"agent_outcome\": agent_outcome}\n", + "\n", + "# Define the function to execute tools\n", + "def execute_tools(data):\n", + " # Get the most recent agent_outcome - this is the key added in the `agent` above\n", + " agent_action = data['agent_outcome']\n", + " output = tool_executor.invoke(agent_action)\n", + " return {\"intermediate_steps\": [(agent_action, str(output))]}\n", + "\n", + "# Define logic that will be used to determine which conditional edge to go down\n", + "def should_continue(data):\n", + " # If the agent outcome is an AgentFinish, then we return `exit` string\n", + " # This will be used when setting up the graph to define the flow\n", + " if isinstance(data['agent_outcome'], AgentFinish):\n", + " return \"end\"\n", + " # Otherwise, an AgentAction is returned\n", + " # Here we return `continue` string\n", + " # This will be used when setting up the graph to define the flow\n", + " else:\n", + " return \"continue\"" + ] + }, + { + "cell_type": "markdown", + "id": "c0b211f4-0c5c-4792-b18d-cd70907c71e7", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c4054dde-4618-49b7-998a-daa0c1d6d6c0", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import END, StateGraph\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", run_agent)\n", + "workflow.add_node(\"action\", execute_tools)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "214ae46e-c297-465d-86db-2b0312ed3530", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent_outcome': AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})])}\n", + "----\n", + "{'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://en.climate-data.org/north-america/united-states-of-america/california/san-francisco-385/t/january-1/', 'content': 'San Francisco Weather in January San Francisco weather in January San Francisco weather by month // weather averages 9.6 (49.2) 6.2 (43.2) 14 (57.3) 113 San Francisco weather in January // weather averages Airport close to San Francisco you can find all information about the weather in San Francisco in January:Data: 1991 - 2021 Min. Temperature °C (°F), Max. Temperature °C (°F), Precipitation / Rainfall mm (in), Humidity, Rainy days. Data: 1999 - 2019: avg. Sun hours San Francisco weather and climate for further months San Francisco in February San Francisco in March San Francisco in April San Francisco in May San Francisco in June San Francisco in July'}]\")]}\n", + "----\n", + "{'agent_outcome': AgentFinish(return_values={'output': \"The weather in San Francisco varies by month. In January, the average minimum temperature is 9.6°C (49.2°F), and the average maximum temperature is 14°C (57.3°F). The city experiences an average of 113mm of precipitation and has an average of 6 rainy days in January. If you'd like to know more about the weather in other months, feel free to ask!\"}, log=\"The weather in San Francisco varies by month. In January, the average minimum temperature is 9.6°C (49.2°F), and the average maximum temperature is 14°C (57.3°F). The city experiences an average of 113mm of precipitation and has an average of 6 rainy days in January. If you'd like to know more about the weather in other months, feel free to ask!\")}\n", + "----\n", + "{'input': 'what is the weather in sf', 'chat_history': [], 'agent_outcome': AgentFinish(return_values={'output': \"The weather in San Francisco varies by month. In January, the average minimum temperature is 9.6°C (49.2°F), and the average maximum temperature is 14°C (57.3°F). The city experiences an average of 113mm of precipitation and has an average of 6 rainy days in January. If you'd like to know more about the weather in other months, feel free to ask!\"}, log=\"The weather in San Francisco varies by month. In January, the average minimum temperature is 9.6°C (49.2°F), and the average maximum temperature is 14°C (57.3°F). The city experiences an average of 113mm of precipitation and has an average of 6 rainy days in January. If you'd like to know more about the weather in other months, feel free to ask!\"), 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}})]), \"[{'url': 'https://en.climate-data.org/north-america/united-states-of-america/california/san-francisco-385/t/january-1/', 'content': 'San Francisco Weather in January San Francisco weather in January San Francisco weather by month // weather averages 9.6 (49.2) 6.2 (43.2) 14 (57.3) 113 San Francisco weather in January // weather averages Airport close to San Francisco you can find all information about the weather in San Francisco in January:Data: 1991 - 2021 Min. Temperature °C (°F), Max. Temperature °C (°F), Precipitation / Rainfall mm (in), Humidity, Rainy days. Data: 1999 - 2019: avg. Sun hours San Francisco weather and climate for further months San Francisco in February San Francisco in March San Francisco in April San Francisco in May San Francisco in June San Francisco in July'}]\")]}\n", + "----\n" + ] + } + ], + "source": [ + "inputs = {\"input\": \"what is the weather in sf\", \"chat_history\": []}\n", + "for s in app.stream(inputs):\n", + " print(list(s.values())[0])\n", + " print(\"----\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2eb662bc-de7d-4a57-a3e8-2f00dcf4ff8b", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/async.ipynb b/examples/async.ipynb new file mode 100644 index 000000000..7d4a8081c --- /dev/null +++ b/examples/async.ipynb @@ -0,0 +1,616 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Async\n", + "\n", + "In this example we will build a chat executor with native async implementations of the core logic. This enables taking advantage of Chat Models which have async clients, removing the need for calling the model in a separate thread." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.3.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m23.3.2\u001b[0m\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n" + ] + } + ], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "OpenAI API Key: ········\n", + "Tavily API Key: ········\n" + ] + } + ], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n", + "\n", + "**MODIFICATION**\n", + "\n", + "We define each node as an async function." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3b541bb9-900c-40d0-964d-7b5dfee30667", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage\n", + "\n", + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we continue\n", + " else:\n", + " return \"continue\"\n", + "\n", + "# Define the function that calls the model\n", + "async def call_model(state):\n", + " messages = state['messages']\n", + " response = await model.ainvoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "# Define the function to execute tools\n", + "async def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " action = ToolInvocation(\n", + " tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n", + " tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n", + " )\n", + " # We call the tool_executor and get back a response\n", + " response = await tool_executor.ainvoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "547c3931-3dae-4281-ad4e-4b51305594d4", + "metadata": {}, + "source": [ + "## Use it!\n", + "\n", + "We can now use it!\n", + "This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "8edb04b9-40b6-46f1-a7a8-4b2d8aba7752", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'messages': [HumanMessage(content='what is the weather in sf'),\n", + " AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}),\n", + " FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json'),\n", + " AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "await app.ainvoke(inputs)" + ] + }, + { + "cell_type": "markdown", + "id": "5a9e8155-70c5-4973-912c-dc55104b2acf", + "metadata": {}, + "source": [ + "This may take a little bit - it's making a few calls behind the scenes.\n", + "In order to start seeing some intermediate results as they happen, we can use streaming - see below for more information on that.\n", + "\n", + "## Streaming\n", + "\n", + "LangGraph has support for several different types of streaming.\n", + "\n", + "### Streaming Node Output\n", + "\n", + "One of the benefits of using LangGraph is that it is easy to stream output as it's produced by each node.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "f544977e-31f7-41f0-88c4-ec9c27b8cecb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node 'action':\n", + "---\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n", + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}\n", + "\n", + "---\n", + "\n", + "Output from node '__end__':\n", + "---\n", + "{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json'), AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}\n", + "\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "async for output in app.astream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "markdown", + "id": "2a1b56c5-bd61-4192-8bdb-458a1e9f0159", + "metadata": {}, + "source": [ + "### Streaming LLM Tokens\n", + "\n", + "You can also access the LLM tokens as they are produced by each node. \n", + "In this case only the \"agent\" node produces LLM tokens.\n", + "In order for this to work properly, you must be using an LLM that supports streaming as well as have set it when constructing the LLM (e.g. `ChatOpenAI(model=\"gpt-3.5-turbo-1106\", streaming=True)`)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "cfd140f0-a5a6-4697-8115-322242f197b5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "content='' additional_kwargs={'function_call': {'arguments': '', 'name': 'tavily_search_results_json'}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '{\\n', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' ', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': 'query', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '\":', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': 'weather', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' in', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' San', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' Francisco', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '\"\\n', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '}', 'name': ''}}\n", + "content=''\n", + "content=''\n", + "content='The'\n", + "content=' current'\n", + "content=' weather'\n", + "content=' in'\n", + "content=' San'\n", + "content=' Francisco'\n", + "content=' is'\n", + "content=' '\n", + "content='52'\n", + "content='.'\n", + "content='0'\n", + "content=' °'\n", + "content='F'\n", + "content=' with'\n", + "content=' a'\n", + "content=' light'\n", + "content=' breeze'\n", + "content=' of'\n", + "content=' '\n", + "content='6'\n", + "content='.'\n", + "content='9'\n", + "content=' mph'\n", + "content=' coming'\n", + "content=' from'\n", + "content=' the'\n", + "content=' east'\n", + "content='.'\n", + "content=' The'\n", + "content=' sky'\n", + "content=' is'\n", + "content=' mostly'\n", + "content=' cloudy'\n", + "content=' with'\n", + "content=' cloud'\n", + "content=' cover'\n", + "content=' at'\n", + "content=' '\n", + "content='18'\n", + "content=','\n", + "content='000'\n", + "content=' ft'\n", + "content=','\n", + "content=' mostly'\n", + "content=' clear'\n", + "content=' at'\n", + "content=' '\n", + "content='4'\n", + "content=','\n", + "content='000'\n", + "content=' ft'\n", + "content=','\n", + "content=' and'\n", + "content=' partly'\n", + "content=' cloudy'\n", + "content=' at'\n", + "content=' '\n", + "content='15'\n", + "content=','\n", + "content='000'\n", + "content=' ft'\n", + "content='.'\n", + "content=''\n" + ] + } + ], + "source": [ + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "async for output in app.astream_log(inputs, include_types=[\"llm\"]):\n", + " # astream_log() yields the requested logs (here LLMs) in JSONPatch format\n", + " for op in output.ops:\n", + " if op[\"path\"] == \"/streamed_output/-\":\n", + " # this is the output from .stream()\n", + " ...\n", + " elif op[\"path\"].startswith(\"/logs/\") and op[\"path\"].endswith(\n", + " \"/streamed_output/-\"\n", + " ):\n", + " # because we chose to only include LLMs, these are LLM tokens\n", + " print(op[\"value\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08ae8246-11d5-40e1-8567-361e5bef8917", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chat_agent_executor_with_function_calling/base.ipynb b/examples/chat_agent_executor_with_function_calling/base.ipynb new file mode 100644 index 000000000..d6dd885e1 --- /dev/null +++ b/examples/chat_agent_executor_with_function_calling/base.ipynb @@ -0,0 +1,569 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Chat Agent Executor\n", + "\n", + "In this example we will build a chat executor that uses function calling from scratch." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "3b541bb9-900c-40d0-964d-7b5dfee30667", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage\n", + "\n", + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we continue\n", + " else:\n", + " return \"continue\"\n", + "\n", + "# Define the function that calls the model\n", + "def call_model(state):\n", + " messages = state['messages']\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "# Define the function to execute tools\n", + "def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " action = ToolInvocation(\n", + " tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n", + " tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n", + " )\n", + " # We call the tool_executor and get back a response\n", + " response = tool_executor.invoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "547c3931-3dae-4281-ad4e-4b51305594d4", + "metadata": {}, + "source": [ + "## Use it!\n", + "\n", + "We can now use it!\n", + "This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "8edb04b9-40b6-46f1-a7a8-4b2d8aba7752", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'messages': [HumanMessage(content='what is the weather in sf'),\n", + " AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}),\n", + " FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json'),\n", + " AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "app.invoke(inputs)" + ] + }, + { + "cell_type": "markdown", + "id": "5a9e8155-70c5-4973-912c-dc55104b2acf", + "metadata": {}, + "source": [ + "This may take a little bit - it's making a few calls behind the scenes.\n", + "In order to start seeing some intermediate results as they happen, we can use streaming - see below for more information on that.\n", + "\n", + "## Streaming\n", + "\n", + "LangGraph has support for several different types of streaming.\n", + "\n", + "### Streaming Node Output\n", + "\n", + "One of the benefits of using LangGraph is that it is easy to stream output as it's produced by each node.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "f544977e-31f7-41f0-88c4-ec9c27b8cecb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node 'action':\n", + "---\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n", + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}\n", + "\n", + "---\n", + "\n", + "Output from node '__end__':\n", + "---\n", + "{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json'), AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}\n", + "\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "for output in app.stream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "markdown", + "id": "2a1b56c5-bd61-4192-8bdb-458a1e9f0159", + "metadata": {}, + "source": [ + "### Streaming LLM Tokens\n", + "\n", + "You can also access the LLM tokens as they are produced by each node. \n", + "In this case only the \"agent\" node produces LLM tokens.\n", + "In order for this to work properly, you must be using an LLM that supports streaming as well as have set it when constructing the LLM (e.g. `ChatOpenAI(model=\"gpt-3.5-turbo-1106\", streaming=True)`)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "cfd140f0-a5a6-4697-8115-322242f197b5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "content='' additional_kwargs={'function_call': {'arguments': '', 'name': 'tavily_search_results_json'}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '{\\n', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' ', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': 'query', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '\":', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': 'weather', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' in', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' San', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' Francisco', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '\"\\n', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '}', 'name': ''}}\n", + "content=''\n", + "content=''\n", + "content='I'\n", + "content=\"'m\"\n", + "content=' sorry'\n", + "content=','\n", + "content=' but'\n", + "content=' I'\n", + "content=' couldn'\n", + "content=\"'t\"\n", + "content=' find'\n", + "content=' the'\n", + "content=' current'\n", + "content=' weather'\n", + "content=' in'\n", + "content=' San'\n", + "content=' Francisco'\n", + "content='.'\n", + "content=' However'\n", + "content=','\n", + "content=' you'\n", + "content=' can'\n", + "content=' check'\n", + "content=' the'\n", + "content=' weather'\n", + "content=' forecast'\n", + "content=' for'\n", + "content=' San'\n", + "content=' Francisco'\n", + "content=' on'\n", + "content=' websites'\n", + "content=' like'\n", + "content=' Weather'\n", + "content='.com'\n", + "content=' or'\n", + "content=' Acc'\n", + "content='u'\n", + "content='Weather'\n", + "content='.'\n", + "content=''\n" + ] + } + ], + "source": [ + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf?\")]}\n", + "\n", + "async for output in app.astream_log(inputs, include_types=[\"llm\"]):\n", + " # astream_log() yields the requested logs (here LLMs) in JSONPatch format\n", + " for op in output.ops:\n", + " if op[\"path\"] == \"/streamed_output/-\":\n", + " # this is the output from .stream()\n", + " ...\n", + " elif op[\"path\"].startswith(\"/logs/\") and op[\"path\"].endswith(\n", + " \"/streamed_output/-\"\n", + " ):\n", + " # because we chose to only include LLMs, these are LLM tokens\n", + " print(op[\"value\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08ae8246-11d5-40e1-8567-361e5bef8917", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chat_agent_executor_with_function_calling/dynamically-returning-directly.ipynb b/examples/chat_agent_executor_with_function_calling/dynamically-returning-directly.ipynb new file mode 100644 index 000000000..0b42ce534 --- /dev/null +++ b/examples/chat_agent_executor_with_function_calling/dynamically-returning-directly.ipynb @@ -0,0 +1,565 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Dynamically Returning Directly\n", + "\n", + "In this example we will build a chat executor where the LLM can optionally decide to return the result of a tool call as the final answer. This is useful in cases where you have tools that can sometimes generate responses that are acceptable as final answers, and you want to use the LLM to determine when that is the case\n", + "\n", + "This examples builds off the base chat executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n", + "\n", + "**MODIFICATION**\n", + "\n", + "We overwrite the default schema of the input tool to have an additional parameter for returning directly." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "4a1b9990-3b11-4a51-bd51-76117afd38b9", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "\n", + "class SearchTool(BaseModel):\n", + " \"\"\"Look up things online, optionally returning directly\"\"\"\n", + " query: str = Field(description=\"query to look up online\")\n", + " return_direct: bool = Field(\n", + " description=\"Whether or the result of this should be returned directly to the user without you seeing what it is\", \n", + " default = False\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "search_tool = TavilySearchResults(max_results=1, args_schema=SearchTool)\n", + "tools = [search_tool]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "03308b6b-de72-4cdc-b6c6-47e654df340e", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage" + ] + }, + { + "cell_type": "markdown", + "id": "50bf356c-2dbd-4f66-8fa3-133e9c2e371e", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "We change the `should_continue` function to check whether return_direct was set to True" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "55e088b1-f3c8-4798-9ca8-5b0be961b49a", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we check if it's suppose to return direct\n", + " else:\n", + " arguments = json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"])\n", + " if arguments.get(\"return_direct\", False):\n", + " return \"final\"\n", + " else:\n", + " return \"continue\"" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2b45da72-1afa-4cd7-9b7f-49a7c99cdb8a", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function that calls the model\n", + "def call_model(state):\n", + " messages = state['messages']\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}" + ] + }, + { + "cell_type": "markdown", + "id": "8535a36c-3ced-401e-98b5-ec1d1b434bbc", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "We change the tool calling to get rid of the `return_direct` parameter (not used in the actual tool call)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "dd876f5d-88d6-4f93-b1d0-f2f0b6f4d991", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function to execute tools\n", + "def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " tool_name = last_message.additional_kwargs[\"function_call\"][\"name\"]\n", + " arguments = json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"])\n", + " if tool_name == \"tavily_search_results_json\":\n", + " if \"return_direct\" in arguments:\n", + " del arguments[\"return_direct\"]\n", + " action = ToolInvocation(\n", + " tool=tool_name,\n", + " tool_input=arguments,\n", + " )\n", + " # We call the tool_executor and get back a response\n", + " response = tool_executor.invoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!\n", + "\n", + "**MODIFICATION**\n", + "\n", + "We add a separate node for any tool call where `return_direct=True`. The reason this is needed is that after this node we want to end, while after other tool calls we want to go back to the LLM. " + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "workflow.add_node(\"final\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Final call\n", + " \"final\": \"final\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "workflow.add_edge('final', END)\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "547c3931-3dae-4281-ad4e-4b51305594d4", + "metadata": {}, + "source": [ + "## Use it!\n", + "\n", + "We can now use it!\n", + "This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "f544977e-31f7-41f0-88c4-ec9c27b8cecb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node 'action':\n", + "---\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in january59°F Clear/Sunny 47% of time 26% 14% 9% Weather at 6pm 54°F Clear/Sunny 50% of time 23% 14%'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n", + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='The weather in San Francisco is currently not available. However, you can check the weather in San Francisco in January 2024 on this website: [San Francisco Weather in January 2024](https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/).')]}\n", + "\n", + "---\n", + "\n", + "Output from node '__end__':\n", + "---\n", + "{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in january59°F Clear/Sunny 47% of time 26% 14% 9% Weather at 6pm 54°F Clear/Sunny 50% of time 23% 14%'}]\", name='tavily_search_results_json'), AIMessage(content='The weather in San Francisco is currently not available. However, you can check the weather in San Francisco in January 2024 on this website: [San Francisco Weather in January 2024](https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/).')]}\n", + "\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "for output in app.stream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "08ae8246-11d5-40e1-8567-361e5bef8917", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\",\\n \"return_direct\": true\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node 'final':\n", + "---\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://www.weather2travel.com/california/san-francisco/january/', 'content': 'San Francisco weather in January 2024 Expect 13°C daytime maximum temperatures long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. San Francisco January sunrise & sunset times How sunny is it in San Francisco in January?San Francisco weather in January 2024 Expect 13°C daytime maximum temperatures in the shade with on average 6 hours of sunshine per day in San Francisco in January. Check more long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. 13 13°C max day temperature 6'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n", + "Output from node '__end__':\n", + "---\n", + "{'messages': [HumanMessage(content='what is the weather in sf? return this result directly by setting return_direct = True'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\",\\n \"return_direct\": true\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://www.weather2travel.com/california/san-francisco/january/', 'content': 'San Francisco weather in January 2024 Expect 13°C daytime maximum temperatures long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. San Francisco January sunrise & sunset times How sunny is it in San Francisco in January?San Francisco weather in January 2024 Expect 13°C daytime maximum temperatures in the shade with on average 6 hours of sunshine per day in San Francisco in January. Check more long-term weather averages for San Francisco in January before you book your next holiday to California in 2024/2025. 13 13°C max day temperature 6'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf? return this result directly by setting return_direct = True\")]}\n", + "for output in app.stream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "49ccc134-4abe-4982-8ecd-d70fc56a4d2d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chat_agent_executor_with_function_calling/force-calling-a-tool-first.ipynb b/examples/chat_agent_executor_with_function_calling/force-calling-a-tool-first.ipynb new file mode 100644 index 000000000..05096c966 --- /dev/null +++ b/examples/chat_agent_executor_with_function_calling/force-calling-a-tool-first.ipynb @@ -0,0 +1,485 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Force Calling a Tool First\n", + "\n", + "In this example we will build a chat executor that always calls a certain tool first. In this example, we will create an agent with a search tool. However, at the start we will force the agent to call the search tool (and then let it do whatever it wants after). This is useful when you want to force agents to call particular tools, but still want flexibility of what happens after that.\n", + "\n", + "This examples builds off the base chat executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "3b541bb9-900c-40d0-964d-7b5dfee30667", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage\n", + "\n", + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we continue\n", + " else:\n", + " return \"continue\"\n", + "\n", + "# Define the function that calls the model\n", + "def call_model(state):\n", + " messages = state['messages']\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "# Define the function to execute tools\n", + "def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " action = ToolInvocation(\n", + " tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n", + " tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n", + " )\n", + " # We call the tool_executor and get back a response\n", + " response = tool_executor.invoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "7c3e0ac2-0c89-4751-bc2c-f644654841d1", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "Here we create a node that returns an AIMessage with a tool call - we will use this at the start to force it call a tool" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "1bfd2b22-292a-4f4d-91a0-46bb704f5e38", + "metadata": {}, + "outputs": [], + "source": [ + "# This is the new first - the first call of the model we want to explicitly hard-code some action\n", + "from langchain_core.messages import AIMessage\n", + "import json\n", + "\n", + "def first_model(state):\n", + " human_input = state['messages'][-1].content\n", + " return {\n", + " \"messages\": [\n", + " AIMessage(\n", + " content=\"\", \n", + " additional_kwargs={\n", + " \"function_call\": {\n", + " \"name\": \"tavily_search_results_json\", \n", + " \"arguments\": json.dumps({\"query\": human_input})\n", + " }\n", + " }\n", + " )\n", + " ]\n", + " }" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!\n", + "\n", + "**MODIFICATION**\n", + "\n", + "We will define a `first_agent` node which we will set as the entrypoint." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the new entrypoint\n", + "workflow.add_node(\"first_agent\", first_model)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"first_agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# After we call the first agent, we know we want to go to action\n", + "workflow.add_edge('first_agent', 'action')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "547c3931-3dae-4281-ad4e-4b51305594d4", + "metadata": {}, + "source": [ + "## Use it!\n", + "\n", + "We can now use it!\n", + "This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "8edb04b9-40b6-46f1-a7a8-4b2d8aba7752", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'first_agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'name': 'tavily_search_results_json', 'arguments': '{\"query\": \"what is the weather in sf\"}'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node 'action':\n", + "---\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january The climate of San Francisco in january is tolerable49°F Clear/Sunny 47% of time 24% 13% 8% Weather at 12pm 59°F Clear/Sunny 47% of time 26% 14% 9% Weather at 6pm 54°F Clear/Sunny 50% of time 23% 14%'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n", + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content=\"I couldn't find the current weather in San Francisco. However, the average weather in January is around 49°F (9.4°C) during the day and 54°F (12.2°C) in the evening. It is mostly clear and sunny, with a 47% chance of clear/sunny weather during the day and 50% chance in the evening.\")]}\n", + "\n", + "---\n", + "\n", + "Output from node '__end__':\n", + "---\n", + "{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'name': 'tavily_search_results_json', 'arguments': '{\"query\": \"what is the weather in sf\"}'}}), FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january The climate of San Francisco in january is tolerable49°F Clear/Sunny 47% of time 24% 13% 8% Weather at 12pm 59°F Clear/Sunny 47% of time 26% 14% 9% Weather at 6pm 54°F Clear/Sunny 50% of time 23% 14%'}]\", name='tavily_search_results_json'), AIMessage(content=\"I couldn't find the current weather in San Francisco. However, the average weather in January is around 49°F (9.4°C) during the day and 54°F (12.2°C) in the evening. It is mostly clear and sunny, with a 47% chance of clear/sunny weather during the day and 50% chance in the evening.\")]}\n", + "\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "for output in app.stream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08ae8246-11d5-40e1-8567-361e5bef8917", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chat_agent_executor_with_function_calling/high-level.ipynb b/examples/chat_agent_executor_with_function_calling/high-level.ipynb new file mode 100644 index 000000000..f4fc724fc --- /dev/null +++ b/examples/chat_agent_executor_with_function_calling/high-level.ipynb @@ -0,0 +1,136 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8bcd1a3d-7c50-4f58-be4e-1ed654aa33be", + "metadata": {}, + "source": [ + "# Chat Executor: with function calling\n", + "\n", + "This notebook walks through an example creating a chat executor that uses function calling.\n", + "This is useful for getting started quickly.\n", + "However, it is highly likely you will want to customize the logic - for information on that, check out the other examples in this folder." + ] + }, + { + "cell_type": "markdown", + "id": "e130cf70-a30e-47d7-8fd5-464f1a92e374", + "metadata": {}, + "source": [ + "## Set up the chat model and tools\n", + "\n", + "Here we will define the chat model and tools that we want to use.\n", + "Importantly, this model MUST support OpenAI function calling." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "efb7e3c0-c63f-40f6-93ce-19681d650fc2", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "from langgraph.prebuilt import chat_agent_executor\n", + "from langchain_core.messages import HumanMessage" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a7025f33-3160-41cf-868b-17ebc916fb1d", + "metadata": {}, + "outputs": [], + "source": [ + "tools = [TavilySearchResults(max_results=1)]\n", + "model = ChatOpenAI()" + ] + }, + { + "cell_type": "markdown", + "id": "43064805-2ac9-4b5a-850c-a68dd7282350", + "metadata": {}, + "source": [ + "## Create executor\n", + "\n", + "We can now use the high level interface to create the executor" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "32b4ae66-f667-4a8b-a602-503fd0effcd9", + "metadata": {}, + "outputs": [], + "source": [ + "app = chat_agent_executor.create_function_calling_executor(model, tools)" + ] + }, + { + "cell_type": "markdown", + "id": "d63dbfc7-a5c1-4a03-991c-f0789ba52c52", + "metadata": {}, + "source": [ + "We can now invoke this executor. The input to this must be a dictionary with a single `messsages` key that contains a list of messages." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0abc5655-d772-450c-832f-1fee1111a5f6", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "----\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States', 'content': 'January 2024 Weather History in San Francisco California, United States Daily Precipitation in January 2024 in San Francisco Observed Weather in January 2024 in San Francisco San Francisco Temperature History January 2024 Hourly Temperature in January 2024 in San Francisco Hours of Daylight and Twilight in January 2024 in San FranciscoThis report shows the past weather for San Francisco, providing a weather history for January 2024. It features all historical weather data series we have available, including the San Francisco temperature history for January 2024. You can drill down from year to month and even day level reports by clicking on the graphs.'}]\", name='tavily_search_results_json')]}\n", + "----\n", + "{'messages': [AIMessage(content=\"I couldn't find the current weather in San Francisco. However, you can check historical weather data for January 2024 [here](https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States).\")]}\n", + "----\n", + "{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States', 'content': 'January 2024 Weather History in San Francisco California, United States Daily Precipitation in January 2024 in San Francisco Observed Weather in January 2024 in San Francisco San Francisco Temperature History January 2024 Hourly Temperature in January 2024 in San Francisco Hours of Daylight and Twilight in January 2024 in San FranciscoThis report shows the past weather for San Francisco, providing a weather history for January 2024. It features all historical weather data series we have available, including the San Francisco temperature history for January 2024. You can drill down from year to month and even day level reports by clicking on the graphs.'}]\", name='tavily_search_results_json'), AIMessage(content=\"I couldn't find the current weather in San Francisco. However, you can check historical weather data for January 2024 [here](https://weatherspark.com/h/m/557/2024/1/Historical-Weather-in-January-2024-in-San-Francisco-California-United-States).\")]}\n", + "----\n" + ] + } + ], + "source": [ + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "for s in app.stream(inputs):\n", + " print(list(s.values())[0])\n", + " print(\"----\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "87f147e3-f96f-4b96-a3cc-ec7affd7a57f", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chat_agent_executor_with_function_calling/human-in-the-loop.ipynb b/examples/chat_agent_executor_with_function_calling/human-in-the-loop.ipynb new file mode 100644 index 000000000..c2e919376 --- /dev/null +++ b/examples/chat_agent_executor_with_function_calling/human-in-the-loop.ipynb @@ -0,0 +1,471 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Human-in-the-loop\n", + "\n", + "In this example we will build a chat executor that has a human in the loop. We will use the human to approve specific actions.\n", + "\n", + "This examples builds off the base chat executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "b547109f-f9e8-4e77-a7e7-ed2bae7a72ab", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage\n", + "\n", + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we continue\n", + " else:\n", + " return \"continue\"\n", + "\n", + "# Define the function that calls the model\n", + "def call_model(state):\n", + " messages = state['messages']\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}" + ] + }, + { + "cell_type": "markdown", + "id": "ac402f66-4442-4a1f-9f9b-4a5d97532ceb", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "We modify the function that is calling the tool to first ask for user approval to continue. Note that this is a simple example and we could modify it to change the tool input, use some other channel besides input, etc." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "73fd6432-42e8-472a-89ca-bb5ddbbcc35a", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function to execute tools\n", + "def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " action = ToolInvocation(\n", + " tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n", + " tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n", + " )\n", + " response = input(prompt=f\"[y/n] continue with: {action}?\")\n", + " if response == \"n\":\n", + " raise ValueError\n", + " # We call the tool_executor and get back a response\n", + " response = tool_executor.invoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "547c3931-3dae-4281-ad4e-4b51305594d4", + "metadata": {}, + "source": [ + "## Use it!\n", + "\n", + "We can now use it!\n", + "This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "f544977e-31f7-41f0-88c4-ec9c27b8cecb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "\n", + "---\n", + "\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "[y/n] continue with: tool='tavily_search_results_json' tool_input={'query': 'weather in San Francisco'}? n\n" + ] + }, + { + "ename": "ValueError", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[10], line 4\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[38;5;28;01mfrom\u001b[39;00m \u001b[38;5;21;01mlangchain_core\u001b[39;00m\u001b[38;5;21;01m.\u001b[39;00m\u001b[38;5;21;01mmessages\u001b[39;00m \u001b[38;5;28;01mimport\u001b[39;00m HumanMessage\n\u001b[1;32m 3\u001b[0m inputs \u001b[38;5;241m=\u001b[39m {\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mmessages\u001b[39m\u001b[38;5;124m\"\u001b[39m: [HumanMessage(content\u001b[38;5;241m=\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mwhat is the weather in sf\u001b[39m\u001b[38;5;124m\"\u001b[39m)]}\n\u001b[0;32m----> 4\u001b[0m \u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43moutput\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mapp\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mstream\u001b[49m\u001b[43m(\u001b[49m\u001b[43minputs\u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[1;32m 5\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;66;43;03m# stream() yields dictionaries with output keyed by node name\u001b[39;49;00m\n\u001b[1;32m 6\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mkey\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mvalue\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43moutput\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mitems\u001b[49m\u001b[43m(\u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[1;32m 7\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mprint\u001b[39;49m\u001b[43m(\u001b[49m\u001b[38;5;124;43mf\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mOutput from node \u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;132;43;01m{\u001b[39;49;00m\u001b[43mkey\u001b[49m\u001b[38;5;132;43;01m}\u001b[39;49;00m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43m:\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/workplace/permchain/langgraph/pregel/__init__.py:528\u001b[0m, in \u001b[0;36mPregel.transform\u001b[0;34m(self, input, config, output_keys, input_keys, **kwargs)\u001b[0m\n\u001b[1;32m 519\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21mtransform\u001b[39m(\n\u001b[1;32m 520\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[1;32m 521\u001b[0m \u001b[38;5;28minput\u001b[39m: Iterator[Union[\u001b[38;5;28mdict\u001b[39m[\u001b[38;5;28mstr\u001b[39m, Any], Any]],\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 526\u001b[0m \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs: Any,\n\u001b[1;32m 527\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m Iterator[Union[\u001b[38;5;28mdict\u001b[39m[\u001b[38;5;28mstr\u001b[39m, Any], Any]]:\n\u001b[0;32m--> 528\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43;01mfor\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mchunk\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;129;43;01min\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_transform_stream_with_config\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 529\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 530\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_transform\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 531\u001b[0m \u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 532\u001b[0m \u001b[43m \u001b[49m\u001b[43moutput_keys\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43moutput_keys\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 533\u001b[0m \u001b[43m \u001b[49m\u001b[43minput_keys\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43minput_keys\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 534\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 535\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\u001b[43m:\u001b[49m\n\u001b[1;32m 536\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43;01myield\u001b[39;49;00m\u001b[43m \u001b[49m\u001b[43mchunk\u001b[49m\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:1226\u001b[0m, in \u001b[0;36mRunnable._transform_stream_with_config\u001b[0;34m(self, input, transformer, config, run_type, **kwargs)\u001b[0m\n\u001b[1;32m 1224\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m 1225\u001b[0m \u001b[38;5;28;01mwhile\u001b[39;00m \u001b[38;5;28;01mTrue\u001b[39;00m:\n\u001b[0;32m-> 1226\u001b[0m chunk: Output \u001b[38;5;241m=\u001b[39m context\u001b[38;5;241m.\u001b[39mrun(\u001b[38;5;28mnext\u001b[39m, iterator) \u001b[38;5;66;03m# type: ignore\u001b[39;00m\n\u001b[1;32m 1227\u001b[0m \u001b[38;5;28;01myield\u001b[39;00m chunk\n\u001b[1;32m 1228\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m final_output_supported:\n", + "File \u001b[0;32m~/workplace/permchain/langgraph/pregel/__init__.py:313\u001b[0m, in \u001b[0;36mPregel._transform\u001b[0;34m(self, input, run_manager, config, input_keys, output_keys)\u001b[0m\n\u001b[1;32m 303\u001b[0m done, inflight \u001b[38;5;241m=\u001b[39m concurrent\u001b[38;5;241m.\u001b[39mfutures\u001b[38;5;241m.\u001b[39mwait(\n\u001b[1;32m 304\u001b[0m [\n\u001b[1;32m 305\u001b[0m executor\u001b[38;5;241m.\u001b[39msubmit(proc\u001b[38;5;241m.\u001b[39minvoke, \u001b[38;5;28minput\u001b[39m, config)\n\u001b[0;32m (...)\u001b[0m\n\u001b[1;32m 309\u001b[0m timeout\u001b[38;5;241m=\u001b[39m\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mstep_timeout,\n\u001b[1;32m 310\u001b[0m )\n\u001b[1;32m 312\u001b[0m \u001b[38;5;66;03m# interrupt on failure or timeout\u001b[39;00m\n\u001b[0;32m--> 313\u001b[0m \u001b[43m_interrupt_or_proceed\u001b[49m\u001b[43m(\u001b[49m\u001b[43mdone\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43minflight\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mstep\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 315\u001b[0m \u001b[38;5;66;03m# apply writes to channels\u001b[39;00m\n\u001b[1;32m 316\u001b[0m _apply_writes(checkpoint, channels, pending_writes, config, step \u001b[38;5;241m+\u001b[39m \u001b[38;5;241m1\u001b[39m)\n", + "File \u001b[0;32m~/workplace/permchain/langgraph/pregel/__init__.py:611\u001b[0m, in \u001b[0;36m_interrupt_or_proceed\u001b[0;34m(done, inflight, step)\u001b[0m\n\u001b[1;32m 609\u001b[0m inflight\u001b[38;5;241m.\u001b[39mpop()\u001b[38;5;241m.\u001b[39mcancel()\n\u001b[1;32m 610\u001b[0m \u001b[38;5;66;03m# raise the exception\u001b[39;00m\n\u001b[0;32m--> 611\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m exc\n\u001b[1;32m 612\u001b[0m \u001b[38;5;66;03m# TODO this is where retry of an entire step would happen\u001b[39;00m\n\u001b[1;32m 614\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m inflight:\n\u001b[1;32m 615\u001b[0m \u001b[38;5;66;03m# if we got here means we timed out\u001b[39;00m\n", + "File \u001b[0;32m~/.pyenv/versions/3.11.1/lib/python3.11/concurrent/futures/thread.py:58\u001b[0m, in \u001b[0;36m_WorkItem.run\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 55\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m\n\u001b[1;32m 57\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[0;32m---> 58\u001b[0m result \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfn\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 59\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m exc:\n\u001b[1;32m 60\u001b[0m \u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39mfuture\u001b[38;5;241m.\u001b[39mset_exception(exc)\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:3596\u001b[0m, in \u001b[0;36mRunnableBindingBase.invoke\u001b[0;34m(self, input, config, **kwargs)\u001b[0m\n\u001b[1;32m 3590\u001b[0m \u001b[38;5;28;01mdef\u001b[39;00m \u001b[38;5;21minvoke\u001b[39m(\n\u001b[1;32m 3591\u001b[0m \u001b[38;5;28mself\u001b[39m,\n\u001b[1;32m 3592\u001b[0m \u001b[38;5;28minput\u001b[39m: Input,\n\u001b[1;32m 3593\u001b[0m config: Optional[RunnableConfig] \u001b[38;5;241m=\u001b[39m \u001b[38;5;28;01mNone\u001b[39;00m,\n\u001b[1;32m 3594\u001b[0m \u001b[38;5;241m*\u001b[39m\u001b[38;5;241m*\u001b[39mkwargs: Optional[Any],\n\u001b[1;32m 3595\u001b[0m ) \u001b[38;5;241m-\u001b[39m\u001b[38;5;241m>\u001b[39m Output:\n\u001b[0;32m-> 3596\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mbound\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43minvoke\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 3597\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3598\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_merge_configs\u001b[49m\u001b[43m(\u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3599\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43m{\u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m}\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3600\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:1774\u001b[0m, in \u001b[0;36mRunnableSequence.invoke\u001b[0;34m(self, input, config)\u001b[0m\n\u001b[1;32m 1772\u001b[0m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[1;32m 1773\u001b[0m \u001b[38;5;28;01mfor\u001b[39;00m i, step \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28menumerate\u001b[39m(\u001b[38;5;28mself\u001b[39m\u001b[38;5;241m.\u001b[39msteps):\n\u001b[0;32m-> 1774\u001b[0m \u001b[38;5;28minput\u001b[39m \u001b[38;5;241m=\u001b[39m \u001b[43mstep\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43minvoke\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 1775\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 1776\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;66;43;03m# mark each step as a child run\u001b[39;49;00m\n\u001b[1;32m 1777\u001b[0m \u001b[43m \u001b[49m\u001b[43mpatch_config\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 1778\u001b[0m \u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mcallbacks\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mrun_manager\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mget_child\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43mf\u001b[39;49m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[38;5;124;43mseq:step:\u001b[39;49m\u001b[38;5;132;43;01m{\u001b[39;49;00m\u001b[43mi\u001b[49m\u001b[38;5;241;43m+\u001b[39;49m\u001b[38;5;241;43m1\u001b[39;49m\u001b[38;5;132;43;01m}\u001b[39;49;00m\u001b[38;5;124;43m\"\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[1;32m 1779\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 1780\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 1781\u001b[0m \u001b[38;5;66;03m# finish the root run\u001b[39;00m\n\u001b[1;32m 1782\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:3074\u001b[0m, in \u001b[0;36mRunnableLambda.invoke\u001b[0;34m(self, input, config, **kwargs)\u001b[0m\n\u001b[1;32m 3072\u001b[0m \u001b[38;5;250m\u001b[39m\u001b[38;5;124;03m\"\"\"Invoke this runnable synchronously.\"\"\"\u001b[39;00m\n\u001b[1;32m 3073\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28mhasattr\u001b[39m(\u001b[38;5;28mself\u001b[39m, \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mfunc\u001b[39m\u001b[38;5;124m\"\u001b[39m):\n\u001b[0;32m-> 3074\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_call_with_config\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 3075\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_invoke\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3076\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3077\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43m_config\u001b[49m\u001b[43m(\u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfunc\u001b[49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3078\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 3079\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 3080\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[1;32m 3081\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mTypeError\u001b[39;00m(\n\u001b[1;32m 3082\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mCannot invoke a coroutine function synchronously.\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 3083\u001b[0m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mUse `ainvoke` instead.\u001b[39m\u001b[38;5;124m\"\u001b[39m\n\u001b[1;32m 3084\u001b[0m )\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:975\u001b[0m, in \u001b[0;36mRunnable._call_with_config\u001b[0;34m(self, func, input, config, run_type, **kwargs)\u001b[0m\n\u001b[1;32m 971\u001b[0m context \u001b[38;5;241m=\u001b[39m copy_context()\n\u001b[1;32m 972\u001b[0m context\u001b[38;5;241m.\u001b[39mrun(var_child_runnable_config\u001b[38;5;241m.\u001b[39mset, child_config)\n\u001b[1;32m 973\u001b[0m output \u001b[38;5;241m=\u001b[39m cast(\n\u001b[1;32m 974\u001b[0m Output,\n\u001b[0;32m--> 975\u001b[0m \u001b[43mcontext\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mrun\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 976\u001b[0m \u001b[43m \u001b[49m\u001b[43mcall_func_with_variable_args\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 977\u001b[0m \u001b[43m \u001b[49m\u001b[43mfunc\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;66;43;03m# type: ignore[arg-type]\u001b[39;49;00m\n\u001b[1;32m 978\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;66;43;03m# type: ignore[arg-type]\u001b[39;49;00m\n\u001b[1;32m 979\u001b[0m \u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 980\u001b[0m \u001b[43m \u001b[49m\u001b[43mrun_manager\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 981\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 982\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m,\n\u001b[1;32m 983\u001b[0m )\n\u001b[1;32m 984\u001b[0m \u001b[38;5;28;01mexcept\u001b[39;00m \u001b[38;5;167;01mBaseException\u001b[39;00m \u001b[38;5;28;01mas\u001b[39;00m e:\n\u001b[1;32m 985\u001b[0m run_manager\u001b[38;5;241m.\u001b[39mon_chain_error(e)\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/config.py:326\u001b[0m, in \u001b[0;36mcall_func_with_variable_args\u001b[0;34m(func, input, config, run_manager, **kwargs)\u001b[0m\n\u001b[1;32m 324\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m run_manager \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01mand\u001b[39;00m accepts_run_manager(func):\n\u001b[1;32m 325\u001b[0m kwargs[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mrun_manager\u001b[39m\u001b[38;5;124m\"\u001b[39m] \u001b[38;5;241m=\u001b[39m run_manager\n\u001b[0;32m--> 326\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/base.py:2950\u001b[0m, in \u001b[0;36mRunnableLambda._invoke\u001b[0;34m(self, input, run_manager, config, **kwargs)\u001b[0m\n\u001b[1;32m 2948\u001b[0m output \u001b[38;5;241m=\u001b[39m chunk\n\u001b[1;32m 2949\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[0;32m-> 2950\u001b[0m output \u001b[38;5;241m=\u001b[39m \u001b[43mcall_func_with_variable_args\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 2951\u001b[0m \u001b[43m \u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mfunc\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mconfig\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mrun_manager\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\n\u001b[1;32m 2952\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 2953\u001b[0m \u001b[38;5;66;03m# If the output is a runnable, invoke it\u001b[39;00m\n\u001b[1;32m 2954\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;28misinstance\u001b[39m(output, Runnable):\n", + "File \u001b[0;32m~/workplace/langchain/libs/core/langchain_core/runnables/config.py:326\u001b[0m, in \u001b[0;36mcall_func_with_variable_args\u001b[0;34m(func, input, config, run_manager, **kwargs)\u001b[0m\n\u001b[1;32m 324\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m run_manager \u001b[38;5;129;01mis\u001b[39;00m \u001b[38;5;129;01mnot\u001b[39;00m \u001b[38;5;28;01mNone\u001b[39;00m \u001b[38;5;129;01mand\u001b[39;00m accepts_run_manager(func):\n\u001b[1;32m 325\u001b[0m kwargs[\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mrun_manager\u001b[39m\u001b[38;5;124m\"\u001b[39m] \u001b[38;5;241m=\u001b[39m run_manager\n\u001b[0;32m--> 326\u001b[0m \u001b[38;5;28;01mreturn\u001b[39;00m \u001b[43mfunc\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43minput\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[38;5;241;43m*\u001b[39;49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n", + "Cell \u001b[0;32mIn[7], line 14\u001b[0m, in \u001b[0;36mcall_tool\u001b[0;34m(state)\u001b[0m\n\u001b[1;32m 12\u001b[0m response \u001b[38;5;241m=\u001b[39m \u001b[38;5;28minput\u001b[39m(prompt\u001b[38;5;241m=\u001b[39m\u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;124m[y/n] continue with: \u001b[39m\u001b[38;5;132;01m{\u001b[39;00maction\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m?\u001b[39m\u001b[38;5;124m\"\u001b[39m)\n\u001b[1;32m 13\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m response \u001b[38;5;241m==\u001b[39m \u001b[38;5;124m\"\u001b[39m\u001b[38;5;124mn\u001b[39m\u001b[38;5;124m\"\u001b[39m:\n\u001b[0;32m---> 14\u001b[0m \u001b[38;5;28;01mraise\u001b[39;00m \u001b[38;5;167;01mValueError\u001b[39;00m\n\u001b[1;32m 15\u001b[0m \u001b[38;5;66;03m# We call the tool_executor and get back a response\u001b[39;00m\n\u001b[1;32m 16\u001b[0m response \u001b[38;5;241m=\u001b[39m tool_executor\u001b[38;5;241m.\u001b[39minvoke(action)\n", + "\u001b[0;31mValueError\u001b[0m: " + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "for output in app.stream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08ae8246-11d5-40e1-8567-361e5bef8917", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chat_agent_executor_with_function_calling/managing-agent-steps.ipynb b/examples/chat_agent_executor_with_function_calling/managing-agent-steps.ipynb new file mode 100644 index 000000000..c0b7089a3 --- /dev/null +++ b/examples/chat_agent_executor_with_function_calling/managing-agent-steps.ipynb @@ -0,0 +1,463 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Managing Agent Steps\n", + "\n", + "In this example we will build a chat executor that better manages intermediate steps. The base chat executor will just put all messages into the model, but if the intermediate steps an agent is taking start to get long, you may want to modify that. In this example we will only include the ten most recent messages.\n", + "\n", + "This examples builds off the base chat executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e718a9c5-6596-457f-ac25-a25d8cb8c259", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage\n", + "\n", + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we continue\n", + " else:\n", + " return \"continue\"" + ] + }, + { + "cell_type": "markdown", + "id": "a763aa63-701c-40fa-a9d3-9d992ebe7e4d", + "metadata": {}, + "source": [ + "**MODIFICATION**\n", + "\n", + "Here we don't pass all messages to the model but rather only pass the five most recent. Note that this is a pretty simplistic way to handle messages, and there may be other methods you may want to look into depending on your use case" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "714e4135-7cb5-4f17-b2ae-46f7e98bde61", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function that calls the model\n", + "def call_model(state):\n", + " messages = state['messages'][-5:]\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "b3ca9564-63cc-4309-b158-5e8d3e907164", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function to execute tools\n", + "def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " action = ToolInvocation(\n", + " tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n", + " tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n", + " )\n", + " # We call the tool_executor and get back a response\n", + " response = tool_executor.invoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "547c3931-3dae-4281-ad4e-4b51305594d4", + "metadata": {}, + "source": [ + "## Use it!\n", + "\n", + "We can now use it!\n", + "This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "f544977e-31f7-41f0-88c4-ec9c27b8cecb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node 'action':\n", + "---\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n", + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}\n", + "\n", + "---\n", + "\n", + "Output from node '__end__':\n", + "---\n", + "{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json'), AIMessage(content=\"I'm sorry, but I couldn't find the current weather in San Francisco. However, you can check the weather forecast for San Francisco on websites like Weather.com or AccuWeather.\")]}\n", + "\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "for output in app.stream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08ae8246-11d5-40e1-8567-361e5bef8917", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/chat_agent_executor_with_function_calling/respond-in-format.ipynb b/examples/chat_agent_executor_with_function_calling/respond-in-format.ipynb new file mode 100644 index 000000000..ea61b18a4 --- /dev/null +++ b/examples/chat_agent_executor_with_function_calling/respond-in-format.ipynb @@ -0,0 +1,458 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Respond in a format\n", + "\n", + "In this example we will build a chat executor that responds in a specific format. We will do this by using OpenAI function calling. This is useful when you want to enforce that an agent's response is in a specific format. In this example, we will ask it respond as if a weatherman, so to return the temperature and then any other additional info.\n", + "\n", + "This examples builds off the base chat executor. It is highly recommended you learn about that executor before going through this notebook. You can find documentation for that example [here](./base.ipynb).\n", + "\n", + "Any modifications of that example are called below with **MODIFICATION**, so if you are looking for the differences you can just search for that." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n", + "\n", + "\n", + "**MODIFICATION**\n", + "\n", + "We also want to define a response schema for the language model and bind it to the model as a function as well" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_core.utils.function_calling import convert_pydantic_to_openai_function\n", + "\n", + "class Response(BaseModel):\n", + " \"\"\"Final response to the user\"\"\"\n", + " temperature: float = Field(description=\"the temperature\")\n", + " other_notes: str = Field(description=\"any other notes about the weather\")\n", + "\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "functions.append(convert_pydantic_to_openai_function(Response))\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n", + "\n", + "**MODIFICATION**\n", + "\n", + "We will change the `should_continue` function to check what function was called. If the function `Response` was called - that is the function that is NOT a tool, but rather the formatted response, so we should NOT continue in that case." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "3b541bb9-900c-40d0-964d-7b5dfee30667", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage\n", + "\n", + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we need to check what type of function call it is\n", + " elif last_message.additional_kwargs[\"function_call\"][\"name\"] == \"Response\":\n", + " return \"end\"\n", + " # Otherwise we continue\n", + " else:\n", + " return \"continue\"\n", + "\n", + "# Define the function that calls the model\n", + "def call_model(state):\n", + " messages = state['messages']\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "# Define the function to execute tools\n", + "def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " action = ToolInvocation(\n", + " tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n", + " tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n", + " )\n", + " # We call the tool_executor and get back a response\n", + " response = tool_executor.invoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "547c3931-3dae-4281-ad4e-4b51305594d4", + "metadata": {}, + "source": [ + "## Use it!\n", + "\n", + "We can now use it!\n", + "This now exposes the [same interface](https://python.langchain.com/docs/expression_language/) as all other LangChain runnables." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "f544977e-31f7-41f0-88c4-ec9c27b8cecb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node 'action':\n", + "---\n", + "{'messages': [FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json')]}\n", + "\n", + "---\n", + "\n", + "Output from node 'agent':\n", + "---\n", + "{'messages': [AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"temperature\": 45,\\n \"other_notes\": \"Partly cloudy\"\\n}', 'name': 'Response'}})]}\n", + "\n", + "---\n", + "\n", + "Output from node '__end__':\n", + "---\n", + "{'messages': [HumanMessage(content='what is the weather in sf'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco\"\\n}', 'name': 'tavily_search_results_json'}}), FunctionMessage(content=\"[{'url': 'https://www.whereandwhen.net/when/north-america/california/san-francisco-ca/january/', 'content': 'Best time to go to San Francisco? Weather in San Francisco in january 2024 How was the weather last january? Here is the day by day recorded weather in San Francisco in january 2023: Seasonal average climate and temperature of San Francisco in january 8% 46% 29% 12% 8% Evolution of daily average temperature and precipitation in San Francisco in januaryWeather in San Francisco in january 2024. The weather in San Francisco in january comes from statistical datas on the past years. You can view the weather statistics the entire month, but also by using the tabs for the beginning, the middle and the end of the month. ... 16-01-2023 45°F to 52°F. 17-01-2023 45°F to 54°F. 18-01-2023 47°F to ...'}]\", name='tavily_search_results_json'), AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\\n \"temperature\": 45,\\n \"other_notes\": \"Partly cloudy\"\\n}', 'name': 'Response'}})]}\n", + "\n", + "---\n", + "\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "for output in app.stream(inputs):\n", + " # stream() yields dictionaries with output keyed by node name\n", + " for key, value in output.items():\n", + " print(f\"Output from node '{key}':\")\n", + " print(\"---\")\n", + " print(value)\n", + " print(\"\\n---\\n\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eed4360d-2cdf-497b-b03f-8bc51062f780", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/combine_docs.ipynb b/examples/combine_docs.ipynb deleted file mode 100644 index d981c2444..000000000 --- a/examples/combine_docs.ipynb +++ /dev/null @@ -1,371 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "780c1001-557c-4b03-8ebd-a2a381d5f85d", - "metadata": {}, - "source": [ - "# Combine Docs\n", - "\n", - "LangGraph is a great choice for implementating workflows that involve operating over longer documents because of its recursive nature" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "624c452c-ddd5-4390-9065-7ec55dc64b96", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain.chat_models.openai import ChatOpenAI\n", - "from langchain.prompts import ChatPromptTemplate, PromptTemplate\n", - "from langchain.schema.output_parser import StrOutputParser\n", - "from langchain.schema.runnable import Runnable\n", - "from langchain.schema.output_parser import StrOutputParser\n", - "from langchain.schema.document import Document\n", - "from langchain.schema import format_document\n", - "\n", - "from langgraph.pregel import Channel, Pregel\n", - "from langgraph.channels import Topic" - ] - }, - { - "cell_type": "markdown", - "id": "271728d7-b3c8-4ec6-a728-19835e282ec3", - "metadata": {}, - "source": [ - "## Stuff Documents\n", - "\n", - "Stuff documents is simple - just a chain" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "0462aff0-1b88-49cc-bfe2-3c169d5e1d63", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain.schema.runnable import RunnableLambda" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "59d6430b-c113-4498-9ffc-f4623f7a0b5c", - "metadata": {}, - "outputs": [], - "source": [ - "DEFAULT_DOCUMENT_PROMPT = PromptTemplate.from_template(template=\"{page_content}\")\n", - "\n", - "_combine_documents = RunnableLambda(\n", - " lambda x: format_document(x, DEFAULT_DOCUMENT_PROMPT)\n", - ").map() | (lambda x: \"\\n\\n\".join(x))" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "29b2668d-e4a6-4876-9b04-bdc841774c62", - "metadata": {}, - "outputs": [], - "source": [ - "docs = [\n", - " Document(page_content=\"Harrison used to work at Kensho\"),\n", - " Document(page_content=\"Ankush worked at Facebook\"),\n", - "]" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "17da58b7-8685-4d0a-9a47-c398c085d477", - "metadata": {}, - "outputs": [], - "source": [ - "stuff_chain = (\n", - " {\n", - " \"question\": lambda x: x[\"question\"],\n", - " \"context\": (lambda x: x[\"docs\"]) | _combine_documents,\n", - " }\n", - " | ChatPromptTemplate.from_messages(\n", - " [\n", - " (\n", - " \"system\",\n", - " \"Answer user questions based on the following documents:\\n\\n{context}\",\n", - " ),\n", - " (\"human\", \"{question}\"),\n", - " ]\n", - " )\n", - " | ChatOpenAI()\n", - " | StrOutputParser()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "87295b71-0afc-4901-b57c-a7b945aa4bd9", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Harrison used to work at Kensho.'" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "stuff_chain.invoke({\"question\": \"where did harrison work\", \"docs\": docs})" - ] - }, - { - "cell_type": "markdown", - "id": "fff324c1-7fbf-41e5-861f-a10ba0112dbd", - "metadata": {}, - "source": [ - "## Reduce Documents\n", - "\n", - "Reduce documents tries to merge documents recursively." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "b15f5abb-1cfe-4965-a021-c891506c5dd2", - "metadata": {}, - "outputs": [], - "source": [ - "many_docs = docs * 5" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "ccad04a3-fd3f-4e73-b895-29e53535f000", - "metadata": {}, - "outputs": [], - "source": [ - "def _split_list_of_docs(docs, max_length=70):\n", - " new_result_doc_list = []\n", - " _sub_result_docs = []\n", - " for doc in docs:\n", - " _sub_result_docs.append(doc)\n", - " _num_tokens = sum([len(d.page_content) for d in _sub_result_docs])\n", - " if _num_tokens > max_length:\n", - " if len(_sub_result_docs) == 1:\n", - " raise ValueError(\n", - " \"A single document was longer than the context length,\"\n", - " \" we cannot handle this.\"\n", - " )\n", - " new_result_doc_list.append(_sub_result_docs[:-1])\n", - " _sub_result_docs = _sub_result_docs[-1:]\n", - " new_result_doc_list.append(_sub_result_docs)\n", - " return new_result_doc_list" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "11cfd337-9f3b-4b26-ba30-251e17b18994", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[[Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook')],\n", - " [Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook')],\n", - " [Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook')],\n", - " [Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook')],\n", - " [Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook')]]" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Just to show what its like split\n", - "split_docs = _split_list_of_docs(many_docs)\n", - "split_docs" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "8d524ba6-0939-4a5d-8db0-4fa1ef06eaeb", - "metadata": {}, - "outputs": [], - "source": [ - "channels = {\n", - " # input\n", - " \"docs\": Topic(Document),\n", - " # intermediate\n", - " \"docs_to_finalize\": Topic(Document),\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "id": "67370694-86f4-4b64-9d4f-38b2e306abeb", - "metadata": {}, - "outputs": [], - "source": [ - "def decide(docs: list[Document]) -> Runnable:\n", - " if len(_split_list_of_docs(docs)) > 1:\n", - " # send back to the beginning if we still need to collapse more\n", - " return Channel.write_to(\"docs\")\n", - " else:\n", - " # send to the finalizer if we're ready to produce final answer\n", - " return Channel.write_to(\"docs_to_finalize\")\n", - "\n", - "\n", - "def split_docs_with_question(input: dict[str, str | list[Document]]) -> list[dict[str, str | list[Document]]]:\n", - " return [\n", - " {\"docs\": docs, \"question\": input[\"question\"]}\n", - " for docs in _split_list_of_docs(input[\"docs\"])\n", - " ]\n", - "\n", - "\n", - "collapse = (\n", - " Channel.subscribe_to([\"docs\", \"question\"])\n", - " | split_docs_with_question\n", - " | stuff_chain.map() # Collapse each list of docs to a single string\n", - " | (lambda x: [Document(page_content=s) for s in x]) # A new (smaller) list of docs\n", - " | decide\n", - ")\n", - "\n", - "# Convert final set of docs to an answer\n", - "finalize = (\n", - " Channel.subscribe_to(\"docs_to_finalize\", key=\"docs\").join([\"question\"])\n", - " | stuff_chain\n", - " | Channel.write_to(\"answer\")\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "id": "3019e7d2-ab7f-4868-b43c-ad898d824a26", - "metadata": {}, - "outputs": [], - "source": [ - "reduce_chain = Pregel(\n", - " chains={\n", - " \"collapse\": collapse,\n", - " \"finalize\": finalize,\n", - " },\n", - " channels=channels,\n", - " input=[\"question\", \"docs\"],\n", - " output=\"answer\",\n", - " debug=True,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "id": "69fcb829-3dae-432a-8db3-11bbb179a7d2", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001b[36;1m\u001b[1;3m[pregel/step]\u001b[0m \u001b[1mStarting step 0 with 1 task. Next tasks:\n", - "\u001b[0m- collapse({'docs': [Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook'),\n", - " Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook'),\n", - " Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook'),\n", - " Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook'),\n", - " Document(page_content='Harrison used to work at Kensho'),\n", - " Document(page_content='Ankush worked at Facebook')],\n", - " 'question': 'where did harrison work'})\n", - "\u001b[36;1m\u001b[1;3m[pregel/checkpoint]\u001b[0m \u001b[1mFinishing step 0. Channel values:\n", - "\u001b[0m{'docs': [...], 'docs_to_finalize': [], 'question': 'where did harrison work'}\n", - "\u001b[36;1m\u001b[1;3m[pregel/step]\u001b[0m \u001b[1mStarting step 1 with 1 task. Next tasks:\n", - "\u001b[0m- collapse({'docs': [Document(page_content='Harrison used to work at Kensho.'),\n", - " Document(page_content='Harrison used to work at Kensho.'),\n", - " Document(page_content='Harrison used to work at Kensho.'),\n", - " Document(page_content='Harrison used to work at Kensho.'),\n", - " Document(page_content='Harrison used to work at Kensho.')],\n", - " 'question': 'where did harrison work'})\n", - "\u001b[36;1m\u001b[1;3m[pregel/checkpoint]\u001b[0m \u001b[1mFinishing step 1. Channel values:\n", - "\u001b[0m{'docs': [...], 'docs_to_finalize': [], 'question': 'where did harrison work'}\n", - "\u001b[36;1m\u001b[1;3m[pregel/step]\u001b[0m \u001b[1mStarting step 2 with 1 task. Next tasks:\n", - "\u001b[0m- collapse({'docs': [Document(page_content='Harrison used to work at Kensho.'),\n", - " Document(page_content='Harrison used to work at Kensho.'),\n", - " Document(page_content='Harrison used to work at Kensho.')],\n", - " 'question': 'where did harrison work'})\n", - "\u001b[36;1m\u001b[1;3m[pregel/checkpoint]\u001b[0m \u001b[1mFinishing step 2. Channel values:\n", - "\u001b[0m{'docs': [], 'docs_to_finalize': [...], 'question': 'where did harrison work'}\n", - "\u001b[36;1m\u001b[1;3m[pregel/step]\u001b[0m \u001b[1mStarting step 3 with 1 task. Next tasks:\n", - "\u001b[0m- finalize({'docs': [Document(page_content='Harrison used to work at Kensho.'),\n", - " Document(page_content='Harrison used to work at Kensho.')]})\n", - "\u001b[36;1m\u001b[1;3m[pregel/checkpoint]\u001b[0m \u001b[1mFinishing step 3. Channel values:\n", - "\u001b[0m{'answer': 'Harrison used to work at Kensho.',\n", - " 'docs': [],\n", - " 'docs_to_finalize': [],\n", - " 'question': 'where did harrison work'}\n" - ] - }, - { - "data": { - "text/plain": [ - "'Harrison used to work at Kensho.'" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "reduce_chain.invoke({\"question\": \"where did harrison work\", \"docs\": many_docs})" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "265b29cd-d4f4-4e48-8d4e-b759e909ac2e", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "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.11.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/draft-revise-loop.py b/examples/draft-revise-loop.py deleted file mode 100644 index fae95a14e..000000000 --- a/examples/draft-revise-loop.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -from langchain.chat_models.openai import ChatOpenAI -from langchain.output_parsers.openai_functions import JsonOutputFunctionsParser -from langchain_core.output_parsers import StrOutputParser -from langchain_core.prompts import SystemMessagePromptTemplate - -from langgraph.pregel import Channel, Pregel - -# prompts - -drafter_prompt = ( - SystemMessagePromptTemplate.from_template( - "You are an expert on turtles, who likes to write in pirate-speak. You have been tasked by your editor with drafting a 100-word article answering the following question." - ) - + "Question:\n\n{question}" -) - -reviser_prompt = ( - SystemMessagePromptTemplate.from_template( - "You are an expert on turtles. You have been tasked by your editor with revising the following draft, which was written by a non-expert. You may follow the editor's notes or not, as you see fit." - ) - + "Draft:\n\n{draft}" - + "Editor's notes:\n\n{notes}" -) - -editor_prompt = ( - SystemMessagePromptTemplate.from_template( - "You are an editor. You have been tasked with editing the following draft, which was written by a non-expert. Please accept the draft if it is good enough to publish, or send it for revision, along with your notes to guide the revision." - ) - + "Draft:\n\n{draft}" -) - -editor_functions = [ - { - "name": "revise", - "description": "Sends the draft for revision", - "parameters": { - "type": "object", - "properties": { - "notes": { - "type": "string", - "description": "The editor's notes to guide the revision.", - }, - }, - }, - }, - { - "name": "accept", - "description": "Accepts the draft", - "parameters": { - "type": "object", - "properties": {"ready": {"const": True}}, - }, - }, -] - -# llms - -gpt3 = ChatOpenAI(model="gpt-3.5-turbo") -gpt4 = ChatOpenAI(model="gpt-4") - -# chains - -drafter_chain = drafter_prompt | gpt3 | StrOutputParser() - -editor_chain = ( - editor_prompt - | gpt4.bind(functions=editor_functions) - | JsonOutputFunctionsParser(args_only=False) -) - -reviser_chain = reviser_prompt | gpt3 | StrOutputParser() - -# application - -drafter = ( - # subscribe to question channel as a dict with a single key, "question" - Channel.subscribe_to(["question"]) | drafter_chain | Channel.write_to("draft") -) - -editor = ( - # subscribe to draft channel as a dict with a single key, "draft" - Channel.subscribe_to(["draft"]) - | editor_chain - | Channel.write_to( - # send to "notes" channel if the editor does not accept the draft - notes=lambda x: x["arguments"]["notes"] if x["name"] == "revise" else None - ) -) - -reviser = ( - # subscribe to new values of "notes" channel, - # and join them with the input value (question) and "draft" - Channel.subscribe_to(["notes"]).join(["question", "draft"]) - | reviser_chain - | Channel.write_to("draft") -) - -draft_revise_loop = Pregel( - chains={ - "drafter": drafter, - "editor": editor, - "reviser": reviser, - }, - # input will be a dict with a single key, "question" - input=["question"], - # output will be the value of "draft" - output="draft", - # debug logging - debug=True, -) - -# run - -print(draft_revise_loop.invoke({"question": "What food do turtles eat?"})) diff --git a/examples/langgraph.ipynb b/examples/langgraph.ipynb deleted file mode 100644 index 34fd6c3f4..000000000 --- a/examples/langgraph.ipynb +++ /dev/null @@ -1,400 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "396e20d9-8684-40ea-a46a-e3dfa36ed5a6", - "metadata": {}, - "source": [ - "## Existing Agent Executor" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "d642e6af-217a-4414-a78c-509b44155eca", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain import hub\n", - "from langchain.agents import create_openai_functions_agent\n", - "from langchain.chat_models import ChatOpenAI\n", - "from langchain_community.chat_models import ChatOpenAI\n", - "from langchain_community.tools.tavily_search import TavilySearchResults\n", - "from langchain_core.runnables import RunnablePassthrough\n", - "\n", - "from langgraph.graph import END, Graph\n", - "\n", - "tools = [TavilySearchResults(max_results=1)]\n", - "\n", - "# Get the prompt to use - you can modify this!\n", - "prompt = hub.pull(\"hwchase17/openai-functions-agent\")\n", - "\n", - "# Choose the LLM that will drive the agent\n", - "llm = ChatOpenAI(model=\"gpt-3.5-turbo-1106\")\n", - "\n", - "# Construct the OpenAI Functions agent\n", - "agent_runnable = create_openai_functions_agent(llm, tools, prompt)\n", - "\n", - "from langchain_core.agents import AgentFinish\n", - "# Define decision-making logic\n", - "def should_continue(data):\n", - " # Logic to decide whether to continue in the loop or exit\n", - " if isinstance(data['agent_outcome'], AgentFinish):\n", - " return \"exit\"\n", - " else:\n", - " return \"continue\"\n", - " \n", - "def execute_tools(data):\n", - " agent_action = data.pop('agent_outcome')\n", - " observation = {t.name: t for t in tools}[agent_action.tool].invoke(agent_action.tool_input)\n", - " data['intermediate_steps'].append((agent_action, observation))\n", - " return data\n", - " \n", - " \n", - "\n", - "# Define agents\n", - "agent = RunnablePassthrough.assign(\n", - " agent_outcome = agent_runnable\n", - ")\n", - "\n", - "\n", - "# Define a new graph\n", - "workflow = Graph()\n", - "\n", - "workflow.add_node(\"agent\", agent)\n", - "workflow.add_node(\"tools\", execute_tools)\n", - "\n", - "workflow.set_entry_point(\"agent\")\n", - "\n", - "workflow.add_conditional_edges(\n", - " \"agent\",\n", - " should_continue,\n", - " {\n", - " \"continue\": \"tools\",\n", - " \"exit\": END\n", - " }\n", - ")\n", - "\n", - "workflow.add_edge('tools', 'agent')\n", - "\n", - "chain = workflow.compile()" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "c46bd262-9605-4449-9391-f6b6e0fe440e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'input': 'what is the weather in sf',\n", - " 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'weather in San Francisco'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'weather in San Francisco'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'name': 'tavily_search_results_json', 'arguments': '{\"query\":\"weather in San Francisco\"}'}})]),\n", - " [{'url': 'https://www.weather25.com/north-america/usa/california/san-francisco',\n", - " 'content': 'will give you an idea of weather trends in San Francisco. For example, the weather in San Francisco in January 2024. San Francisco 14 day weather The weather today in San Francisco San Francisco weather report The weather in San Francisco, United States San Francisco weather by months San Francisco weather the weather in San Francisco including humidity, wind, chance of rain and more on the San Francisco current weather01 January 02 February 03 March 04 April 05 May 06 June 07 July 08 August 09 September 10 October 11 November 12 December. ... For example, the weather in San Francisco in January 2024. These trends can be helpful when planning trips to San Francisco or preparing for the weather in advance. There are many factors to consider when looking at the ...'}])],\n", - " 'agent_outcome': AgentFinish(return_values={'output': 'For the current weather in San Francisco, you can visit the following website: [San Francisco Weather](https://www.weather25.com/north-america/usa/california/san-francisco). This will provide you with the latest weather updates including humidity, wind, chance of rain, and more.'}, log='For the current weather in San Francisco, you can visit the following website: [San Francisco Weather](https://www.weather25.com/north-america/usa/california/san-francisco). This will provide you with the latest weather updates including humidity, wind, chance of rain, and more.')}" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "chain.invoke({\"input\": \"what is the weather in sf\", \"intermediate_steps\": []})" - ] - }, - { - "cell_type": "markdown", - "id": "592c3886-71d1-4539-80dd-111e55cc3a85", - "metadata": {}, - "source": [ - "## Reflexion Agent" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "f6f96e81-4a20-4599-a625-8d18df6fa76d", - "metadata": {}, - "outputs": [], - "source": [ - "from langchain.agents import AgentExecutor, BaseMultiActionAgent, Tool\n", - "from langchain.schema import AgentAction, AgentFinish\n", - "from langchain_core.language_models.chat_models import BaseChatModel\n", - "from langchain.chains import LLMChain\n", - "\n", - "from langchain.globals import set_llm_cache\n", - "\n", - "from dotenv import load_dotenv\n", - "\n", - "from pydantic import BaseModel\n", - "\n", - "from langchain.chat_models import ChatOpenAI\n", - "from langchain.cache import SQLiteCache\n", - "\n", - "from langchain_core.output_parsers import BaseOutputParser\n", - "\n", - "from langchain.prompts.chat import ChatPromptTemplate\n", - "from langchain.callbacks import get_openai_callback\n", - "from langchain.tools.tavily_search import TavilySearchResults\n", - "from langchain.utilities.tavily_search import TavilySearchAPIWrapper\n", - "from langchain.pydantic_v1 import BaseModel\n", - "import os\n", - "\n", - "from langchain.agents import AgentType, initialize_agent, load_tools\n", - "\n", - "set_llm_cache(SQLiteCache(database_path=\".langchain.db\"))\n", - "\n", - "\n", - "llm = ChatOpenAI(\n", - " temperature=0.0,\n", - " max_tokens=2000,\n", - " max_retries=100,\n", - " model=\"gpt-4-1106-preview\",\n", - ")\n", - "\n", - "search = TavilySearchAPIWrapper()\n", - "tavily_tool = TavilySearchResults(api_wrapper=search, max_results=5)\n", - "\n", - "NEXT_STEP_TEMPLATE = \"\"\"You are expert researcher trying answer a question ~250 words. You are asked to answer the following question: {question}\n", - "\n", - "The way you are going to answer the question is as follows:\n", - "\n", - "1. Revise your previous answer using the new information.\n", - " - You should use the previous critique to add important information to your answer.\n", - " _ You MUST include numerical citations in your revised answer to ensure it can be verified.\n", - " - Add a \"References\" section to the bottom of your answer (which does not count towards the word limit). In form of:\n", - " - [1] https://example.com\n", - " - [2] https://example.com\n", - " - You should use the previous critique to remove superfluous information from your answer and make SURE it is not more than 250 words.\n", - "2. Reflect and critique your answer. Specifically, you should:\n", - " - Think about what is missing from your answer.\n", - " - Think about what is superfluous in your answer.\n", - " - Think about what search query you should use next to improve your answer.\n", - " Give your answer in exactly 2 parts. The first should address what is missing from your answer. The second should address what could be removed from your answer. Your should be VERY harsh as we really want to improve the answer.\n", - "3. Give the search query you came up with to improve your answer.\n", - "\n", - "Previous steps: \n", - "\n", - "{previous_steps}\n", - "\n", - "===\n", - "\n", - "Format your answer as follows:\n", - "\n", - "Revised answer: [give your revised answer based on the previous critique and new information from the search engine then the \"References\" section]\n", - "Critique: [give your harsh critique of your revised answer in 2 parts: what is missing and what is superfluous]\n", - "Search query: [give the new search query you came up with to enter into the search engine to improve your answer. If you have more than one, make sure they are comma separated and in quotes]\n", - "\n", - "SAY NOTHING else please.\"\"\"\n", - "\n", - "INITIAL_ANSWER_TEMPLATE = \"\"\"You are expert researcher trying answer a question ~250 words. You are asked to answer the following question: {question}\n", - "\n", - "The way you are going to answer the question is as follows:\n", - "\n", - "1. Give a detailed in ~250 words.\n", - "2. Reflect and critique your answer. Specifically, you should:\n", - " - Think about what is missing from your answer.\n", - " - Think about what is superfluous in your answer.\n", - " - Think about what search query you should use next to improve your answer.\n", - " Give your answer in exactly 2 parts. The first should address what is missing from your answer. The second should address what could be removed from your answer. Your should be VERY harsh as we really want to improve the answer.\n", - "3. Give the search query you came up with to improve your answer.\n", - "\n", - "===\n", - "\n", - "Format your answer as follows:\n", - "\n", - "Answer: [give your initial answer]\n", - "Critique: [give your harsh critique of your answer in 2 parts: what is missing and what is superfluous]\n", - "Search query: [give the search query you came up with to improve your answer. If you have more than one, make sure they are comma separated and in quotes]\n", - "\n", - "SAY NOTHING else please.\"\"\"\n", - "\n", - "\n", - "class ReflexionStep(BaseModel):\n", - " \"\"\"A single step in the reflexion process.\"\"\"\n", - "\n", - " answer: str\n", - " critique: str\n", - " search_query: str\n", - "\n", - " def __str__(self):\n", - " return f\"Answer: {self.answer}\\nCritique: {self.critique}\\nSearch query: {self.search_query}\"\n", - "\n", - "def _parse_reflexion_step(output: str) -> tuple[str, str, str]:\n", - " # find answer using .split()\n", - " if (\"Answer:\" not in output and \"Revised answer:\" not in output) or not \"Critique:\" in output or not \"Search query:\" in output:\n", - " raise ValueError(f\"The output is not formatted correctly. Output: {output}\")\n", - " if \"Answer:\" in output:\n", - " answer = output.split(\"Answer:\")[1].split(\"Critique:\")[0].strip()\n", - " else:\n", - " answer = output.split(\"Revised answer:\")[1].split(\"Critique:\")[0].strip()\n", - " critique = output.split(\"Critique:\")[1].split(\"Search query:\")[0].strip()\n", - " search_query = output.split(\"Search query:\")[1].strip()\n", - " return answer, critique, search_query\n", - "\n", - "class ReflexionStepParser(BaseOutputParser[ReflexionStep]):\n", - " \"\"\"Parser for the reflexion step.\"\"\"\n", - "\n", - " def parse(self, output: str) -> ReflexionStep:\n", - " \"\"\"Parse the output.\"\"\"\n", - " # try to find answer or initial answer\n", - " answer, critique, search_query = _parse_reflexion_step(output)\n", - " return ReflexionStep(\n", - " answer=answer, critique=critique, search_query=search_query\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "7708fa95-547b-4bea-b126-3656de7d5873", - "metadata": {}, - "outputs": [], - "source": [ - "initial_chain = RunnablePassthrough.assign(\n", - " agent_outcome = ChatPromptTemplate.from_template(INITIAL_ANSWER_TEMPLATE) | llm | ReflexionStepParser() | (lambda x: AgentAction(\n", - " tool=\"tavily_search_results_json\",\n", - " tool_input=x.search_query,\n", - " log=str(x),\n", - " ))\n", - ")\n", - "\n", - "def prep_next(inputs):\n", - " intermediate_steps = inputs[\"intermediate_steps\"]\n", - " previous_steps = list[str]()\n", - "\n", - " for i, (action, observation) in enumerate(intermediate_steps, start=1):\n", - " last_step_str = f\"\"\"Step {i}:\n", - "\n", - "{action.log}\n", - "\n", - "Search output for \"{action.tool_input}\":\n", - "\n", - "{observation}\"\"\"\n", - " previous_steps.append(last_step_str)\n", - "\n", - " previous_steps_str = \"\\n\\n\".join(previous_steps)\n", - " inputs[\"previous_steps\"] = previous_steps_str\n", - " return inputs\n", - " \n", - "next_chain = RunnablePassthrough.assign(\n", - " agent_outcome = prep_next | ChatPromptTemplate.from_template(NEXT_STEP_TEMPLATE) | llm | ReflexionStepParser() | (lambda x: AgentAction(\n", - " tool=\"tavily_search_results_json\",\n", - " tool_input=x.search_query,\n", - " log=str(x),\n", - " ))\n", - ")\n", - "\n", - "def finish(inputs):\n", - " intermediate_steps = inputs[\"intermediate_steps\"]\n", - " last_action, _ = intermediate_steps[-1]\n", - " last_step_str = last_action.log\n", - " # extract answer\n", - " answer, _, _ = _parse_reflexion_step(last_step_str)\n", - "\n", - " first_action, _ = intermediate_steps[0]\n", - " first_step_str = first_action.log\n", - " # extract answer\n", - " initial_answer, _, _ = _parse_reflexion_step(first_step_str)\n", - "\n", - " return AgentFinish(\n", - " log=\"Reached max steps.\",\n", - " return_values={\"output\": answer, \"initial_answer\": initial_answer},\n", - " )\n", - "\n", - "\n", - "def execute_tools(data):\n", - " agent_action = data.pop('agent_outcome')\n", - " observation = {t.name: t for t in tools}[agent_action.tool].invoke(agent_action.tool_input)\n", - " data['intermediate_steps'].append((agent_action, observation))\n", - " return data\n" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "d6cdd1cd-e480-4dd7-99b4-9018eb243b4d", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "AgentFinish(return_values={'output': \"The current weather in San Francisco can be accessed through various weather reporting services, which provide real-time temperature, humidity, wind, and chances of precipitation [1]. Historically, San Francisco experiences a mild, Mediterranean climate with average temperatures ranging from the low 50s to the mid-60s Fahrenheit. The city's unique topography creates microclimates, leading to significant weather variations across different neighborhoods. San Francisco's summers are notably cooler compared to other Californian cities, largely due to the cold California Current and persistent fog, especially in June and July. Winters are mild and the wettest months span from November to March, with an annual rainfall average of approximately 23 inches. Wind is a prominent feature, with spring being particularly windy. For historical weather extremes and average wind speeds, additional specific data can be sought from climatological records.\\n\\nReferences:\\n[1] https://www.weather25.com/north-america/usa/california/san-francisco\", 'initial_answer': \"The weather in San Francisco (SF) is characterized by a mild, Mediterranean-like climate with wet winters and dry summers. The city's unique topography and coastal location result in microclimates, where weather conditions can vary significantly from one neighborhood to another. Average temperatures typically range from the low 50s to the mid-60s Fahrenheit throughout the year. Summers in San Francisco are often cooler than in other parts of California due to the cold California Current offshore and the presence of fog, particularly in June and July. The fog usually burns off by the afternoon, leading to clearer skies and slightly warmer temperatures. Winters are mild and moist, with the majority of the city's rainfall occurring between November and March. Rainfall averages around 23 inches annually. Wind is also a notable feature of San Francisco's weather, with spring being the windiest season. Despite the general patterns, it's always advisable to dress in layers due to the potential for rapid weather changes.\"}, log='Reached max steps.')" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "workflow = Graph()\n", - "\n", - "# add actors\n", - "workflow.add_node(\"initial\", initial_chain)\n", - "workflow.add_node(\"next\", next_chain)\n", - "workflow.add_node(\"finish\", finish)\n", - "workflow.add_node(\"tools\", execute_tools)\n", - "\n", - "# Enter with initial actor, then loop through tools -> next steps until finished\n", - "workflow.set_entry_point('initial')\n", - "\n", - "workflow.add_edge('initial', 'tools')\n", - "workflow.add_conditional_edges(\n", - " 'tools',\n", - " lambda x: \"exit\" if len(x['intermediate_steps']) >= 2 else \"continue\",\n", - " {\n", - " \"continue\": 'next',\n", - " \"exit\": 'finish'\n", - " }\n", - ")\n", - "workflow.add_edge('next', 'tools')\n", - "workflow.set_finish_point('finish')\n", - "\n", - "chain = workflow.compile()\n", - "\n", - "chain.invoke({\"question\": \"what is the weather in sf\", \"intermediate_steps\": []})" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9babf196-b1fd-492d-9197-96a674f5e81d", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "58ce0d58-fb00-4dc1-a12b-8fc015474611", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "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.11.5" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/examples/rag.py b/examples/rag.py deleted file mode 100644 index 414d4ab10..000000000 --- a/examples/rag.py +++ /dev/null @@ -1,58 +0,0 @@ -from langchain.chat_models import ChatOpenAI -from langchain.embeddings import OpenAIEmbeddings -from langchain.vectorstores import FAISS -from langchain_core.messages import AIMessage, AnyMessage, FunctionMessage -from langchain_core.prompts import PromptTemplate - -from langgraph.channels import Topic -from langgraph.pregel import Channel, Pregel - -texts = ["harrison went to kensho"] -embeddings = OpenAIEmbeddings() -db = FAISS.from_texts(texts, embeddings) - -retriever = db.as_retriever() - - -prompt = PromptTemplate.from_template( - """Answer the question "{question}" based on the following context: {context}""" -) - -model = ChatOpenAI() - -chain = ( - Channel.subscribe_to(["question"]) - | { - "context": (lambda x: x["question"]) - | Channel.write_to( - messages=lambda _input: AIMessage( - content="", - additional_kwargs={ - "function_call": "retrieval", - "arguments": {"question": _input}, - }, - ) - ) - | retriever - | Channel.write_to( - messages=lambda documents: FunctionMessage.construct( - content=documents, # function message requires content to be str - name="retrieval", - ) - ), - "question": lambda x: x["question"], - } - | prompt - | model - | Channel.write_to(messages=lambda message: [message]) -) - -app = Pregel( - chains={"chain": chain}, - channels={"messages": Topic(AnyMessage)}, - input=["question"], - output=["messages"], -) - -for s in app.stream({"question": "where did harrison go"}): - print(s) diff --git a/examples/readme.py b/examples/readme.py deleted file mode 100644 index b7077a321..000000000 --- a/examples/readme.py +++ /dev/null @@ -1,132 +0,0 @@ -import asyncio -from pprint import pprint - -from langchain import hub -from langchain.agents import create_openai_functions_agent -from langchain_community.tools.tavily_search import TavilySearchResults -from langchain_core.agents import AgentFinish -from langchain_core.runnables import RunnablePassthrough -from langchain_openai.chat_models import ChatOpenAI - -from langgraph.graph import END, Graph - -tools = [TavilySearchResults(max_results=1)] - -# Get the prompt to use - you can modify this! -prompt = hub.pull("hwchase17/openai-functions-agent") - -# Choose the LLM that will drive the agent -llm = ChatOpenAI(model="gpt-3.5-turbo-1106") - -# Construct the OpenAI Functions agent -agent_runnable = create_openai_functions_agent(llm, tools, prompt) - - -# Define the agent -# Note that here, we are using `.assign` to add the output of the agent to the dictionary -# This dictionary will be returned from the node -# The reason we don't want to return just the result of `agent_runnable` from this node is -# that we want to continue passing around all the other inputs -agent = RunnablePassthrough.assign(agent_outcome=agent_runnable) - - -# Define the function to execute tools -def execute_tools(data): - # Get the most recent agent_outcome - this is the key added in the `agent` above - agent_action = data.pop("agent_outcome") - # Get the tool to use - tool_to_use = {t.name: t for t in tools}[agent_action.tool] - # Call that tool on the input - observation = tool_to_use.invoke(agent_action.tool_input) - # We now add in the action and the observation to the `intermediate_steps` list - # This is the list of all previous actions taken and their output - data["intermediate_steps"].append((agent_action, observation)) - return data - - -# Define logic that will be used to determine which conditional edge to go down -def should_continue(data): - # If the agent outcome is an AgentFinish, then we return `exit` string - # This will be used when setting up the graph to define the flow - if isinstance(data["agent_outcome"], AgentFinish): - return "exit" - # Otherwise, an AgentAction is returned - # Here we return `continue` string - # This will be used when setting up the graph to define the flow - else: - return "continue" - - -# Define the graph - - -workflow = Graph() - -# Add the agent node, we give it name `agent` which we will use later -workflow.add_node("agent", agent) -# Add the tools node, we give it name `tools` which we will use later -workflow.add_node("tools", execute_tools) - -# 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": "tools", - # Otherwise we finish. - "exit": 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("tools", "agent") - -# Finally, we compile it! -# This compiles it into a LangChain Runnable, -# meaning you can use it as you would any other runnable -chain = workflow.compile() - - -def main(): - for output in chain.stream( - {"input": "what is the weather in sf", "intermediate_steps": []} - ): - for key, value in output.items(): - print(f"Output from node '{key}':") - print("---") - pprint(value) - print("\n---\n") - - -async def amain(): - async for output in chain.astream_log( - {"input": "what is the weather in sf", "intermediate_steps": []}, - include_types=["llm"], - ): - for op in output.ops: - if op["path"] == "/streamed_output/-": - # this is the output from .stream() - ... - elif op["path"].startswith("/logs/") and op["path"].endswith( - "/streamed_output/-" - ): - # these are tokens from the LLM - print(op["value"]) - - -asyncio.run(amain()) diff --git a/examples/recursive-web-loader.py b/examples/recursive-web-loader.py deleted file mode 100644 index 1d6f186ba..000000000 --- a/examples/recursive-web-loader.py +++ /dev/null @@ -1,134 +0,0 @@ -from contextlib import asynccontextmanager, contextmanager -from typing import AsyncGenerator, Callable, FrozenSet, Generator, Optional, TypedDict - -import httpx -from langchain_core.documents import Document -from langchain_core.runnables import RunnableLambda, RunnablePassthrough -from langchain_core.utils.html import extract_sub_links - -from langgraph.channels.context import Context -from langgraph.channels.topic import Topic -from langgraph.pregel import Channel, Pregel - -# Load url with sync httpx client - - -@contextmanager -def httpx_client() -> Generator[httpx.Client, None, None]: - with httpx.HTTPTransport(retries=3) as transport, httpx.Client( - transport=transport - ) as client: - yield client - - -class LoadUrlInput(TypedDict): - url: str - visited: FrozenSet[str] - client: httpx.Client - - -def load_url(input: LoadUrlInput) -> str: - response = input["client"].get(input["url"]) - return response.text - - -# Same as above but with async httpx client - - -@asynccontextmanager -async def httpx_aclient() -> AsyncGenerator[httpx.AsyncClient, None]: - async with httpx.AsyncHTTPTransport(retries=3) as transport, httpx.AsyncClient( - transport=transport - ) as client: - yield client - - -class LoadUrlInputAsync(TypedDict): - url: str - visited: FrozenSet[str] - client: httpx.AsyncClient - - -async def load_url_async(input: LoadUrlInputAsync) -> str: - response = await input["client"].get(input["url"]) - return response.text - - -# default metadata extractor copied from langchain.document_loaders - - -def _metadata_extractor(raw_html: str, url: str) -> dict: - """Extract metadata from raw html using BeautifulSoup.""" - metadata = {"source": url} - - try: - from bs4 import BeautifulSoup - except ImportError: - return metadata - soup = BeautifulSoup(raw_html, "html.parser") - if title := soup.find("title"): - metadata["title"] = title.get_text() - if description := soup.find("meta", attrs={"name": "description"}): - metadata["description"] = description.get("content", None) - if html := soup.find("html"): - metadata["language"] = html.get("lang", None) - return metadata - - -def recursive_web_loader( - *, - max_depth: int = 2, - extractor: Optional[Callable[[str], str]] = None, - metadata_extractor: Optional[Callable[[str, str], dict]] = None, -) -> Pregel: - # assign default extractors - extractor = extractor or (lambda x: x) - metadata_extractor = metadata_extractor or _metadata_extractor - # define the channels - channels = { - "next_urls": Topic(str, unique=True), - "documents": Topic(Document, accumulate=True), - "client": Context(httpx_client, httpx_aclient), - } - # the main chain that gets executed recursively - # while there are urls in next_urls - visitor = ( - # run the chain below for each url in next_urls - # adding the current values of base_url and httpx client - Channel.subscribe_to_each("next_urls", key="url").join(["client", "base_url"]) - # load the url (with sync and async implementations) - | RunnablePassthrough.assign(body=RunnableLambda(load_url, load_url_async)) - | Channel.write_to( - # send a new document to the documents stream - documents=lambda x: Document( - page_content=extractor(x["body"]), - metadata=metadata_extractor(x["body"], x["url"]), - ), - # send the next urls to the next_urls topic - next_urls=lambda x: extract_sub_links( - x["body"], x["url"], base_url=x["base_url"] - ), - ) - ) - return Pregel( - channels=channels, - chains={ - # use the base_url as the first url to visit - "input": Channel.subscribe_to("base_url") | Channel.write_to("next_urls"), - # add the main chain - "visitor": visitor, - }, - # this will accept a string as input - input="base_url", - # and return a dict with documents and visited set - output=["documents", "visited"], - # debug logging - debug=True, - ).with_config({"recursion_limit": max_depth + 1}) - - -loader = recursive_web_loader(max_depth=3) - -documents = loader.invoke("https://docs.python.org/3.9/") - -print(len(documents["documents"])) diff --git a/examples/streaming-tokens.ipynb b/examples/streaming-tokens.ipynb new file mode 100644 index 000000000..d1dfed602 --- /dev/null +++ b/examples/streaming-tokens.ipynb @@ -0,0 +1,498 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Streaming Tokens\n", + "\n", + "In this example we will focus on explaining how to stream tokens from a language model that is powering an agent. We will use a chat agent executor as an example. There a few specific things we need to do in order to properly stream tokens. They are: \n", + "\n", + "1. Set `streaming=True` when creating the LLM\n", + "2. Create nodes with [async methods](./async.ipynb) - this is best practice because in order to stream tokens we will use the `async_log` method.\n", + "\n", + "we will call them out with the **STREAMING** tag below (if you just want to search for those)." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.3.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m23.3.2\u001b[0m\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n" + ] + } + ], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "OpenAI API Key: ········\n", + "Tavily API Key: ········\n" + ] + } + ], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolExecutor.\n", + "This is a real simple class that takes in a ToolInvocation and calls that tool, returning the output.\n", + "A ToolInvocation is any class with `tool` and `tool_input` attribute.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolExecutor\n", + "\n", + "tool_executor = ToolExecutor(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example.\n", + "\n", + "**STREAMING**\n", + "\n", + "Here, we set `streaming=True` when creating the model." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "# We will set streaming=True so that we can stream tokens\n", + "# See the streaming section for more information on this.\n", + "model = ChatOpenAI(temperature=0, streaming=True)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this by converting the LangChain tools into the format for OpenAI function calling, and then bind them to the model class.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain.tools.render import format_tool_to_openai_function\n", + "\n", + "functions = [format_tool_to_openai_function(t) for t in tools]\n", + "model = model.bind_functions(functions)" + ] + }, + { + "cell_type": "markdown", + "id": "8e8b9211-93d0-4ad5-aa7a-9c09099c53ff", + "metadata": {}, + "source": [ + "## Define the agent state\n", + "\n", + "The main type of graph in `langgraph` is the `StatefulGraph`.\n", + "This graph is parameterized by a state object that it passes around to each node.\n", + "Each node then returns operations to update that state.\n", + "These operations can either SET specific attributes on the state (e.g. overwrite the existing values) or ADD to the existing attribute.\n", + "Whether to set or add is denoted by annotating the state object you construct the graph with.\n", + "\n", + "For this example, the state we will track will just be a list of messages.\n", + "We want each node to just add messages to that list.\n", + "Therefore, we will use a `TypedDict` with one key (`messages`) and annotate it so that the `messages` attribute is always added to.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "ea793afa-2eab-4901-910d-6eed90cd6564", + "metadata": {}, + "outputs": [], + "source": [ + "from typing import TypedDict, Annotated, Sequence\n", + "import operator\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " messages: Annotated[Sequence[BaseMessage], operator.add]" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take.\n", + "\n", + "**STREAMING**\n", + "\n", + "We define each node as an async function." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "3b541bb9-900c-40d0-964d-7b5dfee30667", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolInvocation\n", + "import json\n", + "from langchain_core.messages import FunctionMessage\n", + "\n", + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " messages = state['messages']\n", + " last_message = messages[-1]\n", + " # If there is no function call, then we finish\n", + " if \"function_call\" not in last_message.additional_kwargs:\n", + " return \"end\"\n", + " # Otherwise if there is, we continue\n", + " else:\n", + " return \"continue\"\n", + "\n", + "# Define the function that calls the model\n", + "async def call_model(state):\n", + " messages = state['messages']\n", + " response = await model.ainvoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "# Define the function to execute tools\n", + "async def call_tool(state):\n", + " messages = state['messages']\n", + " # Based on the continue condition\n", + " # we know the last message involves a function call\n", + " last_message = messages[-1]\n", + " # We construct an ToolInvocation from the function_call\n", + " action = ToolInvocation(\n", + " tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n", + " tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n", + " )\n", + " # We call the tool_executor and get back a response\n", + " response = await tool_executor.ainvoke(action)\n", + " # We use the response to create a FunctionMessage\n", + " function_message = FunctionMessage(content=str(response), name=action.tool)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [function_message]}" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "813ae66c-3b58-4283-a02a-36da72a2ab90", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", call_model)\n", + "workflow.add_node(\"action\", call_tool)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END\n", + " }\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge('action', 'agent')\n", + "\n", + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile()" + ] + }, + { + "cell_type": "markdown", + "id": "2a1b56c5-bd61-4192-8bdb-458a1e9f0159", + "metadata": {}, + "source": [ + "## Streaming LLM Tokens\n", + "\n", + "You can access the LLM tokens as they are produced by each node. \n", + "In this case only the \"agent\" node produces LLM tokens.\n", + "In order for this to work properly, you must be using an LLM that supports streaming as well as have set it when constructing the LLM (e.g. `ChatOpenAI(model=\"gpt-3.5-turbo-1106\", streaming=True)`)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "cfd140f0-a5a6-4697-8115-322242f197b5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "content='' additional_kwargs={'function_call': {'arguments': '', 'name': 'tavily_search_results_json'}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '{\\n', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' ', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': 'query', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '\":', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' \"', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': 'weather', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' in', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' San', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': ' Francisco', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '\"\\n', 'name': ''}}\n", + "content='' additional_kwargs={'function_call': {'arguments': '}', 'name': ''}}\n", + "content=''\n", + "content=''\n", + "content='I'\n", + "content=\"'m\"\n", + "content=' sorry'\n", + "content=','\n", + "content=' but'\n", + "content=' I'\n", + "content=' couldn'\n", + "content=\"'t\"\n", + "content=' find'\n", + "content=' the'\n", + "content=' current'\n", + "content=' weather'\n", + "content=' in'\n", + "content=' San'\n", + "content=' Francisco'\n", + "content='.'\n", + "content=' However'\n", + "content=','\n", + "content=' you'\n", + "content=' can'\n", + "content=' check'\n", + "content=' the'\n", + "content=' weather'\n", + "content=' forecast'\n", + "content=' for'\n", + "content=' San'\n", + "content=' Francisco'\n", + "content=' on'\n", + "content=' websites'\n", + "content=' like'\n", + "content=' Weather'\n", + "content='.com'\n", + "content=' or'\n", + "content=' Acc'\n", + "content='u'\n", + "content='Weather'\n", + "content='.'\n", + "content=''\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf\")]}\n", + "async for output in app.astream_log(inputs, include_types=[\"llm\"]):\n", + " # astream_log() yields the requested logs (here LLMs) in JSONPatch format\n", + " for op in output.ops:\n", + " if op[\"path\"] == \"/streamed_output/-\":\n", + " # this is the output from .stream()\n", + " ...\n", + " elif op[\"path\"].startswith(\"/logs/\") and op[\"path\"].endswith(\n", + " \"/streamed_output/-\"\n", + " ):\n", + " # because we chose to only include LLMs, these are LLM tokens\n", + " print(op[\"value\"])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "08ae8246-11d5-40e1-8567-361e5bef8917", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "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.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/langgraph/graph/__init__.py b/langgraph/graph/__init__.py index 4d5393c5a..1173035b5 100644 --- a/langgraph/graph/__init__.py +++ b/langgraph/graph/__init__.py @@ -1,133 +1,4 @@ -from asyncio import iscoroutinefunction -from collections import defaultdict -from typing import Any, Callable, Dict, NamedTuple +from langgraph.graph.graph import END, Graph +from langgraph.graph.state import StateGraph -from langchain_core.runnables import Runnable -from langchain_core.runnables.base import ( - RunnableLambda, - RunnableLike, - coerce_to_runnable, -) - -from langgraph.pregel import Channel, Pregel - -END = "__end__" - - -class Branch(NamedTuple): - condition: Callable[..., str] - ends: dict[str, str] - - def runnable(self, input: Any) -> Runnable: - result = self.condition(input) - destination = self.ends[result] - return Channel.write_to(f"{destination}:inbox" if destination != END else END) - - -class Graph: - def __init__(self) -> None: - self.nodes: dict[str, Runnable] = {} - self.edges = set[tuple[str, str]]() - self.branches: defaultdict[str, list[Branch]] = defaultdict(list) - - def add_node(self, key: str, action: RunnableLike) -> None: - if key in self.nodes: - raise ValueError(f"Node `{key}` already present.") - if key == END: - raise ValueError(f"Node `{key}` is reserved.") - - self.nodes[key] = coerce_to_runnable(action) - - def add_edge(self, start_key: str, end_key: str) -> None: - if start_key == END: - raise ValueError("END cannot be a start node") - if start_key not in self.nodes: - raise ValueError(f"Need to add_node `{start_key}` first") - if end_key not in self.nodes and end_key != END: - raise ValueError(f"Need to add_node `{end_key}` first") - - # TODO: support multiple message passing - if start_key in set(start for start, _ in self.edges): - raise ValueError(f"Already found path for {start_key}") - - self.edges.add((start_key, end_key)) - - def add_conditional_edges( - self, - start_key: str, - condition: Callable[..., str], - conditional_edge_mapping: Dict[str, str], - ) -> None: - if start_key not in self.nodes: - raise ValueError(f"Need to add_node `{start_key}` first") - if iscoroutinefunction(condition): - raise ValueError("Condition cannot be a coroutine function") - for destination in conditional_edge_mapping.values(): - if destination not in self.nodes and destination != END: - raise ValueError(f"Need to add_node `{destination}` first") - - self.branches[start_key].append(Branch(condition, conditional_edge_mapping)) - - def set_entry_point(self, key: str) -> None: - if key not in self.nodes: - raise ValueError(f"Need to add_node `{key}` first") - self.entry_point = key - - def set_finish_point(self, key: str) -> None: - return self.add_edge(key, END) - - def compile(self) -> Pregel: - ################################################ - # STEP 1: VALIDATE GRAPH STRUCTURE # - ################################################ - - all_starts = {src for src, _ in self.edges} | {src for src in self.branches} - all_ends = ( - {end for _, end in self.edges} - | { - end - for branch_list in self.branches.values() - for branch in branch_list - for end in branch.ends.values() - } - | {self.entry_point} - ) - - for node in self.nodes: - if node not in all_ends: - raise ValueError(f"Node `{node}` is not reachable") - if node not in all_starts: - raise ValueError(f"Node `{node}` is a dead-end") - - ################################################ - # STEP 2: CREATE GRAPH # - ################################################ - - outgoing_edges = defaultdict(list) - for start, end in self.edges: - outgoing_edges[start].append(f"{end}:inbox" if end != END else END) - - nodes = { - key: (Channel.subscribe_to(f"{key}:inbox") | node | Channel.write_to(key)) - for key, node in self.nodes.items() - } - - for key in self.nodes: - outgoing = outgoing_edges[key] - edges_key = f"{key}:edges" - if outgoing or key in self.branches: - nodes[edges_key] = Channel.subscribe_to(key) - if outgoing: - nodes[edges_key] |= Channel.write_to(*[dest for dest in outgoing]) - if key in self.branches: - for branch in self.branches[key]: - nodes[edges_key] |= RunnableLambda( - branch.runnable, name=f"{key}_condition" - ) - - return Pregel( - nodes=nodes, - input=f"{self.entry_point}:inbox", - output=END, - hidden=[f"{node}:inbox" for node in self.nodes], - ) +__all__ = ["END", "Graph", "StateGraph"] diff --git a/langgraph/graph/graph.py b/langgraph/graph/graph.py new file mode 100644 index 000000000..4ebd8144e --- /dev/null +++ b/langgraph/graph/graph.py @@ -0,0 +1,132 @@ +from asyncio import iscoroutinefunction +from collections import defaultdict +from typing import Any, Callable, Dict, NamedTuple, Optional + +from langchain_core.runnables import Runnable +from langchain_core.runnables.base import ( + RunnableLambda, + RunnableLike, + coerce_to_runnable, +) + +from langgraph.checkpoint import BaseCheckpointSaver +from langgraph.pregel import Channel, Pregel + +END = "__end__" + + +class Branch(NamedTuple): + condition: Callable[..., str] + ends: dict[str, str] + + def runnable(self, input: Any) -> Runnable: + result = self.condition(input) + destination = self.ends[result] + return Channel.write_to(f"{destination}:inbox" if destination != END else END) + + +class Graph: + def __init__(self) -> None: + self.nodes: dict[str, Runnable] = {} + self.edges = set[tuple[str, str]]() + self.branches: defaultdict[str, list[Branch]] = defaultdict(list) + self.support_multiple_edges = False + + def add_node(self, key: str, action: RunnableLike) -> None: + if key in self.nodes: + raise ValueError(f"Node `{key}` already present.") + if key == END: + raise ValueError(f"Node `{key}` is reserved.") + + self.nodes[key] = coerce_to_runnable(action) + + def add_edge(self, start_key: str, end_key: str) -> None: + if start_key == END: + raise ValueError("END cannot be a start node") + if start_key not in self.nodes: + raise ValueError(f"Need to add_node `{start_key}` first") + if end_key not in self.nodes and end_key != END: + raise ValueError(f"Need to add_node `{end_key}` first") + + if not self.support_multiple_edges and start_key in set( + start for start, _ in self.edges + ): + raise ValueError(f"Already found path for {start_key}") + + self.edges.add((start_key, end_key)) + + def add_conditional_edges( + self, + start_key: str, + condition: Callable[..., str], + conditional_edge_mapping: Dict[str, str], + ) -> None: + if start_key not in self.nodes: + raise ValueError(f"Need to add_node `{start_key}` first") + if iscoroutinefunction(condition): + raise ValueError("Condition cannot be a coroutine function") + for destination in conditional_edge_mapping.values(): + if destination not in self.nodes and destination != END: + raise ValueError(f"Need to add_node `{destination}` first") + + self.branches[start_key].append(Branch(condition, conditional_edge_mapping)) + + def set_entry_point(self, key: str) -> None: + if key not in self.nodes: + raise ValueError(f"Need to add_node `{key}` first") + self.entry_point = key + + def set_finish_point(self, key: str) -> None: + return self.add_edge(key, END) + + def validate(self) -> None: + all_starts = {src for src, _ in self.edges} | {src for src in self.branches} + all_ends = ( + {end for _, end in self.edges} + | { + end + for branch_list in self.branches.values() + for branch in branch_list + for end in branch.ends.values() + } + | {self.entry_point} + ) + + for node in self.nodes: + if node not in all_ends: + raise ValueError(f"Node `{node}` is not reachable") + if node not in all_starts: + raise ValueError(f"Node `{node}` is a dead-end") + + def compile(self, checkpointer: Optional[BaseCheckpointSaver] = None) -> Pregel: + self.validate() + + outgoing_edges = defaultdict(list) + for start, end in self.edges: + outgoing_edges[start].append(f"{end}:inbox" if end != END else END) + + nodes = { + key: (Channel.subscribe_to(f"{key}:inbox") | node | Channel.write_to(key)) + for key, node in self.nodes.items() + } + + for key in self.nodes: + outgoing = outgoing_edges[key] + edges_key = f"{key}:edges" + if outgoing or key in self.branches: + nodes[edges_key] = Channel.subscribe_to(key, tags=["langsmith:hidden"]) + if outgoing: + nodes[edges_key] |= Channel.write_to(*[dest for dest in outgoing]) + if key in self.branches: + for branch in self.branches[key]: + nodes[edges_key] |= RunnableLambda( + branch.runnable, name=f"{key}_condition" + ) + + return Pregel( + nodes=nodes, + input=f"{self.entry_point}:inbox", + output=END, + hidden=[f"{node}:inbox" for node in self.nodes], + checkpointer=checkpointer, + ) diff --git a/langgraph/graph/state.py b/langgraph/graph/state.py new file mode 100644 index 000000000..b0cb163d0 --- /dev/null +++ b/langgraph/graph/state.py @@ -0,0 +1,124 @@ +from collections import defaultdict +from functools import partial +from inspect import signature +from typing import Any, Optional, Type + +from langchain_core.runnables import RunnableConfig, RunnableLambda + +from langgraph.channels.base import BaseChannel +from langgraph.channels.binop import BinaryOperatorAggregate +from langgraph.channels.last_value import LastValue +from langgraph.checkpoint import BaseCheckpointSaver +from langgraph.graph.graph import END, Graph +from langgraph.pregel import Channel, Pregel +from langgraph.pregel.read import ChannelRead +from langgraph.pregel.write import ChannelWrite + +START = "__start__" + + +class StateGraph(Graph): + def __init__(self, schema: Type[Any]) -> None: + super().__init__() + self.schema = schema + self.channels = _get_channels(schema) + if any(isinstance(c, BinaryOperatorAggregate) for c in self.channels.values()): + self.support_multiple_edges = True + + def compile(self, checkpointer: Optional[BaseCheckpointSaver] = None) -> Pregel: + self.validate() + + if any(key in self.nodes for key in self.channels): + raise ValueError("Cannot use channel names as node names") + + state_keys = list(self.channels) + + outgoing_edges = defaultdict(list) + for start, end in self.edges: + outgoing_edges[start].append(f"{end}:inbox" if end != END else END) + + nodes = { + key: ( + Channel.subscribe_to(f"{key}:inbox") + | partial(_coerce_state, self.schema) # coerce/validate using schema + | node + | _update_state + | Channel.write_to(key) + ) + for key, node in self.nodes.items() + } + + for key in self.nodes: + outgoing = outgoing_edges[key] + edges_key = f"{key}:edges" + if outgoing or key in self.branches: + nodes[edges_key] = Channel.subscribe_to( + key, tags=["langsmith:hidden"] + ) | ChannelRead(state_keys) + if outgoing: + nodes[edges_key] |= Channel.write_to(*[dest for dest in outgoing]) + if key in self.branches: + for branch in self.branches[key]: + nodes[edges_key] |= RunnableLambda( + branch.runnable, name=f"{key}_condition" + ) + + nodes[START] = ( + Channel.subscribe_to(f"{START}:inbox", tags=["langsmith:hidden"]) + | _update_state + | Channel.write_to(START) + ) + nodes[f"{START}:edges"] = ( + Channel.subscribe_to(START, tags=["langsmith:hidden"]) + | ChannelRead(state_keys) + | Channel.write_to(f"{self.entry_point}:inbox") + ) + + return Pregel( + nodes=nodes, + channels=self.channels, + input=f"{START}:inbox", + output=END, + hidden=[f"{node}:inbox" for node in self.nodes] + [START] + state_keys, + checkpointer=checkpointer, + ) + + +def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]: + return schema(**input) + + +def _update_state(input: dict[str, Any], config: RunnableConfig): + if input is not None: + ChannelWrite.do_write(config, **input) + return input + + +def _get_channels(schema: Type[dict]) -> dict[str, BaseChannel]: + if not hasattr(schema, "__annotations__"): + raise ValueError("Schema must be a class with type annotations") + + channels: dict[str, BaseChannel] = {} + for name, typ in schema.__annotations__.items(): + if channel := _is_field_binop(typ): + channels[name] = channel + else: + channels[name] = LastValue(typ) + + return channels + + +def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]: + if hasattr(typ, "__metadata__"): + meta = typ.__metadata__ + if len(meta) == 1 and callable(meta[0]): + sig = signature(meta[0]) + params = list(sig.parameters.values()) + if len(params) == 2 and len( + [ + p + for p in params + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD) + ] + ): + return BinaryOperatorAggregate(typ, meta[0]) diff --git a/langgraph/prebuilt/__init__.py b/langgraph/prebuilt/__init__.py new file mode 100644 index 000000000..fb2340d42 --- /dev/null +++ b/langgraph/prebuilt/__init__.py @@ -0,0 +1,5 @@ +from langgraph.prebuilt.agent_executor import create_agent_executor +from langgraph.prebuilt import chat_agent_executor +from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation + +__all__ = ["create_agent_executor", "chat_agent_executor", "ToolExecutor", ToolInvocation] diff --git a/langgraph/prebuilt/agent_executor.py b/langgraph/prebuilt/agent_executor.py new file mode 100644 index 000000000..aabda460d --- /dev/null +++ b/langgraph/prebuilt/agent_executor.py @@ -0,0 +1,123 @@ +import operator +from typing import Annotated, Sequence, TypedDict, Union + +from langchain_core.agents import AgentAction, AgentFinish +from langchain_core.messages import BaseMessage +from langchain_core.runnables import RunnableLambda + +from langgraph.graph import END, StateGraph +from langgraph.prebuilt.tool_executor import ToolExecutor + + +def _get_agent_state(input_schema=None): + if input_schema is None: + + class AgentState(TypedDict): + # The input string + input: str + # The list of previous messages in the conversation + chat_history: Sequence[BaseMessage] + # The outcome of a given call to the agent + # Needs `None` as a valid type, since this is what this will start as + agent_outcome: Union[AgentAction, AgentFinish, None] + # List of actions and corresponding observations + # Here we annotate this with `operator.add` to indicate that operations to + # this state should be ADDED to the existing values (not overwrite it) + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + + else: + + class AgentState(input_schema): + # The outcome of a given call to the agent + # Needs `None` as a valid type, since this is what this will start as + agent_outcome: Union[AgentAction, AgentFinish, None] + # List of actions and corresponding observations + # Here we annotate this with `operator.add` to indicate that operations to + # this state should be ADDED to the existing values (not overwrite it) + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + + return AgentState + + +def create_agent_executor(agent_runnable, tools, input_schema=None): + if isinstance(tools, ToolExecutor): + tool_executor = tools + else: + tool_executor = ToolExecutor(tools) + + state = _get_agent_state(input_schema) + + # Define logic that will be used to determine which conditional edge to go down + + def should_continue(data): + # If the agent outcome is an AgentFinish, then we return `exit` string + # This will be used when setting up the graph to define the flow + if isinstance(data["agent_outcome"], AgentFinish): + return "end" + # Otherwise, an AgentAction is returned + # Here we return `continue` string + # This will be used when setting up the graph to define the flow + else: + return "continue" + + def run_agent(data): + agent_outcome = agent_runnable.invoke(data) + return {"agent_outcome": agent_outcome} + + async def arun_agent(data): + agent_outcome = await agent_runnable.ainvoke(data) + return {"agent_outcome": agent_outcome} + + # Define the function to execute tools + def execute_tools(data): + # Get the most recent agent_outcome - this is the key added in the `agent` above + agent_action = data["agent_outcome"] + output = tool_executor.invoke(agent_action) + return {"intermediate_steps": [(agent_action, str(output))]} + + async def aexecute_tools(data): + # Get the most recent agent_outcome - this is the key added in the `agent` above + agent_action = data["agent_outcome"] + output = await tool_executor.ainvoke(agent_action) + return {"intermediate_steps": [(agent_action, str(output))]} + + # Define a new graph + workflow = StateGraph(state) + + # Define the two nodes we will cycle between + workflow.add_node("agent", RunnableLambda(run_agent, arun_agent)) + workflow.add_node("action", RunnableLambda(execute_tools, aexecute_tools)) + + # 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() diff --git a/langgraph/prebuilt/chat_agent_executor.py b/langgraph/prebuilt/chat_agent_executor.py new file mode 100644 index 000000000..253473416 --- /dev/null +++ b/langgraph/prebuilt/chat_agent_executor.py @@ -0,0 +1,128 @@ +import json +import operator +from typing import Annotated, Sequence, TypedDict + +from langchain.tools.render import format_tool_to_openai_function +from langchain_core.agents import AgentAction +from langchain_core.messages import BaseMessage, FunctionMessage +from langchain_core.runnables import RunnableLambda + +from langgraph.graph import END, StateGraph +from langgraph.prebuilt.tool_executor import ToolExecutor + + +def create_function_calling_executor(model, tools): + if isinstance(tools, ToolExecutor): + tool_executor = tools + tool_classes = tools.tools + else: + tool_executor = ToolExecutor(tools) + tool_classes = tools + model = model.bind_functions( + [format_tool_to_openai_function(t) for t in tool_classes] + ) + + # Define the function that determines whether to continue or not + def should_continue(state): + messages = state["messages"] + last_message = messages[-1] + # If there is no function call, then we finish + if "function_call" 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): + 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): + 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): + 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( + tool=last_message.additional_kwargs["function_call"]["name"], + tool_input=json.loads( + last_message.additional_kwargs["function_call"]["arguments"] + ), + log="", + ) + + def call_tool(state): + action = _get_action(state) + # We call the tool_executor and get back a response + response = tool_executor.invoke(action) + # We use the response to create a FunctionMessage + function_message = FunctionMessage(content=str(response), name=action.tool) + # We return a list, because this will get added to the existing list + return {"messages": [function_message]} + + async def acall_tool(state): + action = _get_action(state) + # We call the tool_executor and get back a response + response = await tool_executor.ainvoke(action) + # We use the response to create a FunctionMessage + function_message = FunctionMessage(content=str(response), name=action.tool) + # 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() diff --git a/langgraph/prebuilt/tool_executor.py b/langgraph/prebuilt/tool_executor.py new file mode 100644 index 000000000..3c1bb20d9 --- /dev/null +++ b/langgraph/prebuilt/tool_executor.py @@ -0,0 +1,70 @@ +from typing import Any, Sequence, Union + +from langchain_core.load.serializable import Serializable +from langchain_core.runnables import RunnableBinding, RunnableLambda +from langchain_core.tools import BaseTool + +INVALID_TOOL_MSG_TEMPLATE = ( + "{requested_tool_name} is not a valid tool, " + "try one of [{available_tool_names_str}]." +) + + +class ToolInvocationInterface: + """Interface for invoking a tool""" + + tool: str + tool_input: Union[str, dict] + + +class ToolInvocation(Serializable): + """Information about how to invoke a tool.""" + + tool: str + """The name of the Tool to execute.""" + tool_input: Union[str, dict] + """The input to pass in to the Tool.""" + + +class ToolExecutor(RunnableBinding): + tools: Sequence[BaseTool] + tool_map: dict + invalid_tool_msg_template: str + + def __init__( + self, + tools: Sequence[BaseTool], + *, + invalid_tool_msg_template: str = INVALID_TOOL_MSG_TEMPLATE, + **kwargs: Any, + ) -> None: + bound = RunnableLambda(self._execute, afunc=self._aexecute) + super().__init__( + bound=bound, + tools=tools, + tool_map={t.name: t for t in tools}, + invalid_tool_msg_template=invalid_tool_msg_template, + **kwargs, + ) + + def _execute(self, tool_invocation: ToolInvocationInterface) -> Any: + if tool_invocation.tool not in self.tool_map: + return self.invalid_tool_msg_template.format( + requested_tool_name=tool_invocation.tool, + available_tool_names_str=", ".join([t.name for t in self.tools]), + ) + else: + tool = self.tool_map[tool_invocation.tool] + output = tool.invoke(tool_invocation.tool_input) + return output + + async def _aexecute(self, tool_invocation: ToolInvocationInterface) -> Any: + if tool_invocation.tool not in self.tool_map: + return self.invalid_tool_msg_template.format( + requested_tool_name=tool_invocation.tool, + available_tool_names_str=", ".join([t.name for t in self.tools]), + ) + else: + tool = self.tool_map[tool_invocation.tool] + output = await tool.ainvoke(tool_invocation.tool_input) + return output diff --git a/langgraph/pregel/__init__.py b/langgraph/pregel/__init__.py index 2521708ac..4ec34ae32 100644 --- a/langgraph/pregel/__init__.py +++ b/langgraph/pregel/__init__.py @@ -61,7 +61,7 @@ from langgraph.pregel.io import map_input, map_output from langgraph.pregel.log import logger from langgraph.pregel.read import ChannelBatch, ChannelInvoke from langgraph.pregel.reserved import ReservedChannels -from langgraph.pregel.validate import validate_graph +from langgraph.pregel.validate import validate_graph, validate_keys from langgraph.pregel.write import ChannelWrite WriteValue = Union[ @@ -84,8 +84,10 @@ class Channel: def subscribe_to( cls, channels: str, + *, key: Optional[str] = None, when: Optional[Callable[[Any], bool]] = None, + tags: Optional[Sequence[str]] = None, ) -> ChannelInvoke: ... @@ -94,8 +96,10 @@ class Channel: def subscribe_to( cls, channels: Sequence[str], + *, key: None = None, when: Optional[Callable[[Any], bool]] = None, + tags: Optional[Sequence[str]] = None, ) -> ChannelInvoke: ... @@ -103,8 +107,10 @@ class Channel: def subscribe_to( cls, channels: Union[str, Sequence[str]], + *, key: Optional[str] = None, when: Optional[Callable[[Any], bool]] = None, + tags: Optional[Sequence[str]] = None, ) -> ChannelInvoke: """Runs process.invoke() each time channels are updated, with a dict of the channel values as input.""" @@ -121,6 +127,7 @@ class Channel: ), triggers=[channels] if isinstance(channels, str) else channels, when=when, + tags=tags, ) @classmethod @@ -154,13 +161,17 @@ class Pregel( hidden: Sequence[str] = Field(default_factory=list) + interrupt: Sequence[str] = Field(default_factory=list) + input: Union[str, Sequence[str]] = "input" step_timeout: Optional[float] = None debug: bool = Field(default_factory=get_debug) - saver: Optional[BaseCheckpointSaver] = None + checkpointer: Optional[BaseCheckpointSaver] = None + + name: str = "LangGraph" class Config: arbitrary_types_allowed = True @@ -168,7 +179,12 @@ class Pregel( @root_validator(skip_on_failure=True) def validate_pregel(cls, values: dict[str, Any]) -> dict[str, Any]: validate_graph( - values["nodes"], values["channels"], values["input"], values["output"] + values["nodes"], + values["channels"], + values["input"], + values["output"], + values["hidden"], + values["interrupt"], ) return values @@ -176,7 +192,7 @@ class Pregel( def config_specs(self) -> list[ConfigurableFieldSpec]: return get_unique_config_specs( [spec for node in self.nodes.values() for spec in node.config_specs] - + (self.saver.config_specs if self.saver is not None else []) + + (self.checkpointer.config_specs if self.checkpointer is not None else []) ) @property @@ -191,7 +207,7 @@ class Pregel( return super().get_input_schema(config) else: return create_model( # type: ignore[call-overload] - "PregelInput", + self.get_name("Input"), **{ k: (self.channels[k].UpdateType, None) for k in self.input or self.channels.keys() @@ -210,7 +226,7 @@ class Pregel( return super().get_output_schema(config) else: return create_model( # type: ignore[call-overload] - "PregelOutput", + self.get_name("Output"), **{k: (self.channels[k].ValueType, None) for k in self.output}, ) @@ -220,17 +236,24 @@ class Pregel( run_manager: CallbackManagerForChainRun, config: RunnableConfig, *, - output: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, ) -> Iterator[Union[dict[str, Any], Any]]: if config["recursion_limit"] < 1: raise ValueError("recursion_limit must be at least 1") # assign defaults - if output is None: - output = [chan for chan in self.channels if chan not in self.hidden] + if output_keys is None: + output_keys = [chan for chan in self.channels if chan not in self.hidden] + else: + validate_keys(output_keys, self.channels) + if input_keys is None: + input_keys = self.input + else: + validate_keys(input_keys, self.channels) # copy nodes to ignore mutations during execution processes = {**self.nodes} # get checkpoint from saver, or create an empty one - checkpoint = self.saver.get(config) if self.saver else None + checkpoint = self.checkpointer.get(config) if self.checkpointer else None checkpoint = checkpoint or empty_checkpoint() # create channels from checkpoint with ChannelsManager( @@ -240,7 +263,7 @@ class Pregel( _apply_writes( checkpoint, channels, - deque(w for c in input for w in map_input(self.input, c)), + deque(w for c in input for w in map_input(input_keys, c)), config, 0, ) @@ -305,22 +328,32 @@ class Pregel( print_checkpoint(step, channels) # yield current value and checkpoint view - if step_output := map_output(output, pending_writes, channels): + if step_output := map_output(output_keys, pending_writes, channels): yield step_output # we can detect updates when output is multiple channels (ie. dict) - if not isinstance(output, str): + if not isinstance(output_keys, str): # if view was updated, apply writes to channels _apply_writes_from_view(checkpoint, channels, step_output) # save end of step checkpoint - if self.saver is not None and self.saver.at == CheckpointAt.END_OF_STEP: + if ( + self.checkpointer is not None + and self.checkpointer.at == CheckpointAt.END_OF_STEP + ): checkpoint = create_checkpoint(checkpoint, channels) - self.saver.put(config, checkpoint) + self.checkpointer.put(config, checkpoint) + + # interrupt if any channel written to is in interrupt list + if any(chan for chan, _ in pending_writes if chan in self.interrupt): + break # save end of run checkpoint - if self.saver is not None and self.saver.at == CheckpointAt.END_OF_RUN: + if ( + self.checkpointer is not None + and self.checkpointer.at == CheckpointAt.END_OF_RUN + ): checkpoint = create_checkpoint(checkpoint, channels) - self.saver.put(config, checkpoint) + self.checkpointer.put(config, checkpoint) async def _atransform( self, @@ -328,7 +361,8 @@ class Pregel( run_manager: AsyncCallbackManagerForChainRun, config: RunnableConfig, *, - output: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, ) -> AsyncIterator[Union[dict[str, Any], Any]]: if config["recursion_limit"] < 1: raise ValueError("recursion_limit must be at least 1") @@ -342,12 +376,18 @@ class Pregel( None, ) # assign defaults - if output is None: - output = [chan for chan in self.channels if chan not in self.hidden] + if output_keys is None: + output_keys = [chan for chan in self.channels if chan not in self.hidden] + else: + validate_keys(output_keys, self.channels) + if input_keys is None: + input_keys = self.input + else: + validate_keys(input_keys, self.channels) # copy nodes to ignore mutations during execution processes = {**self.nodes} # get checkpoint from saver, or create an empty one - checkpoint = await self.saver.aget(config) if self.saver else None + checkpoint = await self.checkpointer.aget(config) if self.checkpointer else None checkpoint = checkpoint or empty_checkpoint() # create channels from checkpoint async with AsyncChannelsManager(self.channels, checkpoint) as channels: @@ -355,7 +395,7 @@ class Pregel( _apply_writes( checkpoint, channels, - deque([w async for c in input for w in map_input(self.input, c)]), + deque([w async for c in input for w in map_input(input_keys, c)]), config, 0, ) @@ -425,36 +465,48 @@ class Pregel( print_checkpoint(step, channels) # yield current value and checkpoint view - if step_output := map_output(output, pending_writes, channels): + if step_output := map_output(output_keys, pending_writes, channels): yield step_output # we can detect updates when output is multiple channels (ie. dict) - if not isinstance(output, str): + if not isinstance(output_keys, str): # if view was updated, apply writes to channels _apply_writes_from_view(checkpoint, channels, step_output) # save end of step checkpoint - if self.saver is not None and self.saver.at == CheckpointAt.END_OF_STEP: + if ( + self.checkpointer is not None + and self.checkpointer.at == CheckpointAt.END_OF_STEP + ): checkpoint = create_checkpoint(checkpoint, channels) - await self.saver.aput(config, checkpoint) + await self.checkpointer.aput(config, checkpoint) + + # interrupt if any channel written to is in interrupt list + if any(chan for chan, _ in pending_writes if chan in self.interrupt): + break # save end of run checkpoint - if self.saver is not None and self.saver.at == CheckpointAt.END_OF_RUN: + if ( + self.checkpointer is not None + and self.checkpointer.at == CheckpointAt.END_OF_RUN + ): checkpoint = create_checkpoint(checkpoint, channels) - await self.saver.aput(config, checkpoint) + await self.checkpointer.aput(config, checkpoint) def invoke( self, input: Union[dict[str, Any], Any], config: Optional[RunnableConfig] = None, *, - output: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, **kwargs: Any, ) -> Union[dict[str, Any], Any]: latest: Union[dict[str, Any], Any] = None for chunk in self.stream( input, config, - output=output if output is not None else self.output, + output_keys=output_keys if output_keys is not None else self.output, + input_keys=input_keys, **kwargs, ): latest = chunk @@ -465,21 +517,34 @@ class Pregel( input: Union[dict[str, Any], Any], config: Optional[RunnableConfig] = None, *, - output: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, **kwargs: Any, ) -> Iterator[Union[dict[str, Any], Any]]: - return self.transform(iter([input]), config, output=output, **kwargs) + return self.transform( + iter([input]), + config, + output_keys=output_keys, + input_keys=input_keys, + **kwargs, + ) def transform( self, input: Iterator[Union[dict[str, Any], Any]], config: Optional[RunnableConfig] = None, *, - output: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, **kwargs: Any, ) -> Iterator[Union[dict[str, Any], Any]]: for chunk in self._transform_stream_with_config( - input, self._transform, config, output=output, **kwargs + input, + self._transform, + config, + output_keys=output_keys, + input_keys=input_keys, + **kwargs, ): yield chunk @@ -488,14 +553,16 @@ class Pregel( input: Union[dict[str, Any], Any], config: Optional[RunnableConfig] = None, *, - output: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, **kwargs: Any, ) -> Union[dict[str, Any], Any]: latest: Union[dict[str, Any], Any] = None async for chunk in self.astream( input, config, - output=output if output is not None else self.output, + output_keys=output_keys if output_keys is not None else self.output, + input_keys=input_keys, **kwargs, ): latest = chunk @@ -506,14 +573,19 @@ class Pregel( input: Union[dict[str, Any], Any], config: Optional[RunnableConfig] = None, *, - output: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, **kwargs: Any, ) -> AsyncIterator[Union[dict[str, Any], Any]]: async def input_stream() -> AsyncIterator[Union[dict[str, Any], Any]]: yield input async for chunk in self.atransform( - input_stream(), config, output=output, **kwargs + input_stream(), + config, + output_keys=output_keys, + input_keys=input_keys, + **kwargs, ): yield chunk @@ -522,11 +594,17 @@ class Pregel( input: AsyncIterator[Union[dict[str, Any], Any]], config: Optional[RunnableConfig] = None, *, - output: Optional[Union[str, Sequence[str]]] = None, + output_keys: Optional[Union[str, Sequence[str]]] = None, + input_keys: Optional[Union[str, Sequence[str]]] = None, **kwargs: Any, ) -> AsyncIterator[Union[dict[str, Any], Any]]: async for chunk in self._atransform_stream_with_config( - input, self._atransform, config, output=output, **kwargs + input, + self._atransform, + config, + output_keys=output_keys, + input_keys=input_keys, + **kwargs, ): yield chunk @@ -561,7 +639,10 @@ def _read_channel( try: return channels[chan].get() except EmptyChannelError: - return None + if catch: + return None + else: + raise def _apply_writes( @@ -602,7 +683,7 @@ def _apply_writes_from_view( checkpoint: Checkpoint, channels: Mapping[str, BaseChannel], values: dict[str, Any] ) -> None: for chan, value in values.items(): - if value == channels[chan].get(): + if value == _read_channel(channels, chan): continue assert isinstance(channels[chan], LastValue), ( diff --git a/langgraph/pregel/read.py b/langgraph/pregel/read.py index 33fadf46c..ebba367b9 100644 --- a/langgraph/pregel/read.py +++ b/langgraph/pregel/read.py @@ -23,7 +23,7 @@ from langgraph.constants import CONFIG_KEY_READ class ChannelRead(RunnableLambda): - channel: str + channel: Union[str, list[str]] @property def config_specs(self) -> list[ConfigurableFieldSpec]: @@ -37,7 +37,7 @@ class ChannelRead(RunnableLambda): ), ] - def __init__(self, channel: str) -> None: + def __init__(self, channel: Union[str, list[str]]) -> None: super().__init__(func=self._read, afunc=self._aread) self.channel = channel self.name = f"ChannelRead<{channel}>" @@ -50,7 +50,11 @@ class ChannelRead(RunnableLambda): f"Runnable {self} is not configured with a read function" "Make sure to call in the context of a Pregel process" ) - return read(self.channel) + return ( + read(self.channel) + if isinstance(self.channel, str) + else {chan: read(chan) for chan in self.channel} + ) async def _aread(self, _: Any, config: RunnableConfig) -> Any: try: @@ -60,7 +64,11 @@ class ChannelRead(RunnableLambda): f"Runnable {self} is not configured with a read function" "Make sure to call in the context of a Pregel process" ) - return read(self.channel) + return ( + read(self.channel) + if isinstance(self.channel, str) + else {chan: read(chan) for chan in self.channel} + ) default_bound: RunnablePassthrough = RunnablePassthrough() @@ -82,6 +90,7 @@ class ChannelInvoke(RunnableBindingBase): channels: Mapping[None, str] | Mapping[str, str], triggers: Sequence[str], when: Optional[Callable[[Any], bool]] = None, + tags: Optional[Sequence[str]] = None, *, bound: Optional[Runnable[Any, Any]] = None, kwargs: Optional[Mapping[str, Any]] = None, @@ -94,7 +103,7 @@ class ChannelInvoke(RunnableBindingBase): when=when, bound=bound or default_bound, kwargs=kwargs or {}, - config=config, + config={**(config or {}), "tags": tags or []}, **other_kwargs, ) diff --git a/langgraph/pregel/validate.py b/langgraph/pregel/validate.py index 096061665..1186bb8be 100644 --- a/langgraph/pregel/validate.py +++ b/langgraph/pregel/validate.py @@ -11,6 +11,8 @@ def validate_graph( channels: dict[str, BaseChannel], input: Union[str, Sequence[str]], output: Union[str, Sequence[str]], + hidden: Sequence[str], + interrupt: Sequence[str], ) -> None: subscribed_channels = set[str]() for node in nodes.values(): @@ -52,3 +54,19 @@ def validate_graph( for chan in ReservedChannels: if chan not in channels: channels[chan] = LastValue(Any) # type: ignore[arg-type] + + validate_keys(hidden, channels) + validate_keys(interrupt, channels) + + +def validate_keys( + keys: Union[str, Sequence[str]], + channels: dict[str, BaseChannel], +) -> None: + if isinstance(keys, str): + if keys not in channels: + raise ValueError(f"Key {keys} not in channels") + else: + for chan in keys: + if chan not in channels: + raise ValueError(f"Key {chan} not in channels") diff --git a/langgraph/pregel/write.py b/langgraph/pregel/write.py index 2063f5018..e44b3e3db 100644 --- a/langgraph/pregel/write.py +++ b/langgraph/pregel/write.py @@ -51,6 +51,11 @@ class ChannelWrite(RunnablePassthrough): values = [ (chan, r.invoke(input, config) if r else input) for chan, r in self.channels ] + values = [ + write + for write, chan in zip(values, self.channels) + if chan[1] is None or write[1] is not None + ] self.do_write(config, **dict(values)) @@ -59,10 +64,15 @@ class ChannelWrite(RunnablePassthrough): (chan, await r.ainvoke(input, config) if r else input) for chan, r in self.channels ] + values = [ + write + for write, chan in zip(values, self.channels) + if chan[1] is None or write[1] is not None + ] self.do_write(config, **dict(values)) @staticmethod def do_write(config: RunnableConfig, **values: Any) -> None: write: TYPE_SEND = config["configurable"][CONFIG_KEY_SEND] - write([(chan, val) for chan, val in values.items() if val is not None]) + write([(chan, val) for chan, val in values.items()]) diff --git a/tests/test_pregel.py b/tests/test_pregel.py index cccb64fce..961cd370c 100644 --- a/tests/test_pregel.py +++ b/tests/test_pregel.py @@ -2,7 +2,7 @@ import operator import time from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager -from typing import Generator +from typing import Annotated, Generator, Optional, TypedDict, Union import pytest from langchain_core.runnables import RunnablePassthrough @@ -15,6 +15,7 @@ from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, Graph +from langgraph.graph.state import StateGraph from langgraph.pregel import Channel, Pregel from langgraph.pregel.reserved import ReservedChannels @@ -40,10 +41,10 @@ def test_invoke_single_process_in_out(mocker: MockerFixture) -> None: graph.set_finish_point("add_one") gapp = graph.compile() - assert app.input_schema.schema() == {"title": "PregelInput", "type": "integer"} - assert app.output_schema.schema() == {"title": "PregelOutput", "type": "integer"} + assert app.input_schema.schema() == {"title": "LangGraphInput", "type": "integer"} + assert app.output_schema.schema() == {"title": "LangGraphOutput", "type": "integer"} assert app.invoke(2) == 3 - assert app.invoke(2, output=["output"]) == {"output": 3} + assert app.invoke(2, output_keys=["output"]) == {"output": 3} assert repr(app), "does not raise recursion error" assert gapp.invoke(2) == 3 @@ -55,8 +56,8 @@ def test_invoke_single_process_in_out_implicit_channels(mocker: MockerFixture) - app = Pregel(nodes={"one": chain}) - assert app.input_schema.schema() == {"title": "PregelInput"} - assert app.output_schema.schema() == {"title": "PregelOutput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} + assert app.output_schema.schema() == {"title": "LangGraphOutput"} assert app.invoke(2) == 3 @@ -70,9 +71,9 @@ def test_invoke_single_process_in_write_kwargs(mocker: MockerFixture) -> None: app = Pregel(nodes={"one": chain}, output=["output", "fixed", "output_plus_one"]) - assert app.input_schema.schema() == {"title": "PregelInput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} assert app.output_schema.schema() == { - "title": "PregelOutput", + "title": "LangGraphOutput", "type": "object", "properties": { "output": {"title": "Output"}, @@ -94,8 +95,8 @@ def test_invoke_single_process_in_out_reserved_is_last(mocker: MockerFixture) -> app = Pregel(nodes={"one": chain}) - assert app.input_schema.schema() == {"title": "PregelInput"} - assert app.output_schema.schema() == {"title": "PregelOutput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} + assert app.output_schema.schema() == {"title": "LangGraphOutput"} assert app.invoke(2) == {"input": 3, "is_last_step": False} assert app.invoke(2, {"recursion_limit": 1}) == {"input": 3, "is_last_step": True} @@ -111,9 +112,9 @@ def test_invoke_single_process_in_out_dict(mocker: MockerFixture) -> None: output=["output"], ) - assert app.input_schema.schema() == {"title": "PregelInput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} assert app.output_schema.schema() == { - "title": "PregelOutput", + "title": "LangGraphOutput", "type": "object", "properties": {"output": {"title": "Output"}}, } @@ -133,12 +134,12 @@ def test_invoke_single_process_in_dict_out_dict(mocker: MockerFixture) -> None: ) assert app.input_schema.schema() == { - "title": "PregelInput", + "title": "LangGraphInput", "type": "object", "properties": {"input": {"title": "Input"}}, } assert app.output_schema.schema() == { - "title": "PregelOutput", + "title": "LangGraphOutput", "type": "object", "properties": {"output": {"title": "Output"}}, } @@ -156,6 +157,8 @@ def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None: assert app.invoke(2) == 4 + assert app.invoke(2, input_keys="inbox") == 3 + for step, values in enumerate(app.stream(2), start=1): if step == 1: assert values == { @@ -237,7 +240,7 @@ def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: input=["input", "inbox"], ) - assert [*app.stream({"input": 2, "inbox": 12}, output="output")] == [ + assert [*app.stream({"input": 2, "inbox": 12}, output_keys="output")] == [ 13, 4, ] # [12 + 1, 2 + 1 + 1] @@ -258,7 +261,7 @@ def test_batch_two_processes_in_out() -> None: app = Pregel(nodes={"one": one, "two": two}) assert app.batch([3, 2, 1, 3, 5]) == [5, 4, 3, 5, 7] - assert app.batch([3, 2, 1, 3, 5], output=["output"]) == [ + assert app.batch([3, 2, 1, 3, 5], output_keys=["output"]) == [ {"output": 5}, {"output": 4}, {"output": 3}, @@ -379,7 +382,7 @@ def test_invoke_checkpoint(mocker: MockerFixture) -> None: app = Pregel( nodes={"one": one}, channels={"total": BinaryOperatorAggregate(int, operator.add)}, - saver=memory, + checkpointer=memory, ) # total starts out as 0, so output is 0+2=2 @@ -581,7 +584,7 @@ def test_conditional_graph() -> None: ] ) - def agent_parser(input: str) -> AgentFinish | AgentAction: + def agent_parser(input: str) -> Union[AgentAction, AgentFinish]: if input.startswith("finish"): _, answer = input.split(":") return AgentFinish(return_values={"answer": answer}, log=input) @@ -771,3 +774,191 @@ def test_conditional_graph() -> None: } }, ] + + +def test_conditional_graph_state() -> None: + from copy import deepcopy + + from langchain.llms.fake import FakeStreamingListLLM + from langchain_community.tools import tool + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.prompts import PromptTemplate + + class AgentState(TypedDict): + input: str + agent_outcome: Optional[Union[AgentAction, AgentFinish]] + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + + # Assemble the tools + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + # Construct the agent + prompt = PromptTemplate.from_template("Hello!") + + llm = FakeStreamingListLLM( + responses=[ + "tool:search_api:query", + "tool:search_api:another", + "finish:answer", + ] + ) + + def agent_parser(input: str) -> Union[AgentAction, AgentFinish]: + if input.startswith("finish"): + _, answer = input.split(":") + return { + "agent_outcome": AgentFinish( + return_values={"answer": answer}, log=input + ) + } + else: + _, tool_name, tool_input = input.split(":") + return { + "agent_outcome": AgentAction( + tool=tool_name, tool_input=tool_input, log=input + ) + } + + agent = prompt | llm | agent_parser + + # Define tool execution logic + def execute_tools(data: AgentState) -> dict: + agent_action: AgentAction = data.pop("agent_outcome") + observation = {t.name: t for t in tools}[agent_action.tool].invoke( + agent_action.tool_input + ) + return {"intermediate_steps": [(agent_action, observation)]} + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # Logic to decide whether to continue in the loop or exit + if isinstance(data["agent_outcome"], AgentFinish): + return "exit" + else: + return "continue" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("agent", agent) + workflow.add_node("tools", execute_tools) + + workflow.set_entry_point("agent") + + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + + workflow.add_edge("tools", "agent") + + app = workflow.compile() + + assert app.invoke({"input": "what is weather in sf"}) == { + "input": "what is weather in sf", + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ), + ( + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ), + ], + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + + assert [deepcopy(c) for c in app.stream({"input": "what is weather in sf"})] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + } + }, + { + "tools": { + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ) + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + { + "tools": { + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ), + ], + } + }, + { + "agent": { + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + }, + { + "__end__": { + "input": "what is weather in sf", + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ), + ( + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ), + ], + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + }, + ] diff --git a/tests/test_pregel_async.py b/tests/test_pregel_async.py index 3ee58d257..012992ecb 100644 --- a/tests/test_pregel_async.py +++ b/tests/test_pregel_async.py @@ -1,7 +1,16 @@ import asyncio import operator from contextlib import asynccontextmanager, contextmanager -from typing import Any, AsyncGenerator, AsyncIterator, Generator +from typing import ( + Annotated, + Any, + AsyncGenerator, + AsyncIterator, + Generator, + Optional, + TypedDict, + Union, +) import pytest from langchain_core.runnables import RunnablePassthrough @@ -13,7 +22,7 @@ from langgraph.channels.context import Context from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic from langgraph.checkpoint.memory import MemorySaver -from langgraph.graph import END, Graph +from langgraph.graph import END, Graph, StateGraph from langgraph.pregel import Channel, Pregel from langgraph.pregel.reserved import ReservedChannels @@ -39,10 +48,10 @@ async def test_invoke_single_process_in_out(mocker: MockerFixture) -> None: graph.set_finish_point("add_one") gapp = graph.compile() - assert app.input_schema.schema() == {"title": "PregelInput", "type": "integer"} - assert app.output_schema.schema() == {"title": "PregelOutput", "type": "integer"} + assert app.input_schema.schema() == {"title": "LangGraphInput", "type": "integer"} + assert app.output_schema.schema() == {"title": "LangGraphOutput", "type": "integer"} assert await app.ainvoke(2) == 3 - assert await app.ainvoke(2, output=["output"]) == {"output": 3} + assert await app.ainvoke(2, output_keys=["output"]) == {"output": 3} assert await gapp.ainvoke(2) == 3 @@ -55,8 +64,8 @@ async def test_invoke_single_process_in_out_implicit_channels( app = Pregel(nodes={"one": chain}) - assert app.input_schema.schema() == {"title": "PregelInput"} - assert app.output_schema.schema() == {"title": "PregelOutput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} + assert app.output_schema.schema() == {"title": "LangGraphOutput"} assert await app.ainvoke(2) == 3 @@ -70,9 +79,9 @@ async def test_invoke_single_process_in_write_kwargs(mocker: MockerFixture) -> N app = Pregel(nodes={"one": chain}, output=["output", "fixed", "output_plus_one"]) - assert app.input_schema.schema() == {"title": "PregelInput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} assert app.output_schema.schema() == { - "title": "PregelOutput", + "title": "LangGraphOutput", "type": "object", "properties": { "output": {"title": "Output"}, @@ -96,8 +105,8 @@ async def test_invoke_single_process_in_out_reserved_is_last( app = Pregel(nodes={"one": chain}) - assert app.input_schema.schema() == {"title": "PregelInput"} - assert app.output_schema.schema() == {"title": "PregelOutput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} + assert app.output_schema.schema() == {"title": "LangGraphOutput"} assert await app.ainvoke(2) == {"input": 3, "is_last_step": False} assert await app.ainvoke(2, {"recursion_limit": 1}) == { "input": 3, @@ -114,9 +123,9 @@ async def test_invoke_single_process_in_out_dict(mocker: MockerFixture) -> None: output=["output"], ) - assert app.input_schema.schema() == {"title": "PregelInput"} + assert app.input_schema.schema() == {"title": "LangGraphInput"} assert app.output_schema.schema() == { - "title": "PregelOutput", + "title": "LangGraphOutput", "type": "object", "properties": {"output": {"title": "Output"}}, } @@ -136,12 +145,12 @@ async def test_invoke_single_process_in_dict_out_dict(mocker: MockerFixture) -> ) assert app.input_schema.schema() == { - "title": "PregelInput", + "title": "LangGraphInput", "type": "object", "properties": {"input": {"title": "Input"}}, } assert app.output_schema.schema() == { - "title": "PregelOutput", + "title": "LangGraphOutput", "type": "object", "properties": {"output": {"title": "Output"}}, } @@ -157,6 +166,8 @@ async def test_invoke_two_processes_in_out(mocker: MockerFixture) -> None: assert await app.ainvoke(2) == 4 + assert await app.ainvoke(2, input_keys="inbox") == 3 + step = 0 async for values in app.astream(2): step += 1 @@ -247,7 +258,7 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: # [12 + 1, 2 + 1 + 1] assert [ - c async for c in pubsub.astream({"input": 2, "inbox": 12}, output="output") + c async for c in pubsub.astream({"input": 2, "inbox": 12}, output_keys="output") ] == [13, 4] assert [c async for c in pubsub.astream({"input": 2, "inbox": 12})] == [ {"inbox": [3], "output": 13}, @@ -269,7 +280,7 @@ async def test_batch_two_processes_in_out() -> None: ) assert await app.abatch([3, 2, 1, 3, 5]) == [5, 4, 3, 5, 7] - assert await app.abatch([3, 2, 1, 3, 5], output=["output"]) == [ + assert await app.abatch([3, 2, 1, 3, 5], output_keys=["output"]) == [ {"output": 5}, {"output": 4}, {"output": 3}, @@ -394,7 +405,7 @@ async def test_invoke_checkpoint(mocker: MockerFixture) -> None: app = Pregel( nodes={"one": one}, channels={"total": BinaryOperatorAggregate(int, operator.add)}, - saver=memory, + checkpointer=memory, ) # total starts out as 0, so output is 0+2=2 @@ -619,7 +630,7 @@ async def test_conditional_graph() -> None: ] ) - async def agent_parser(input: str) -> AgentFinish | AgentAction: + async def agent_parser(input: str) -> Union[AgentAction, AgentFinish]: if input.startswith("finish"): _, answer = input.split(":") return AgentFinish(return_values={"answer": answer}, log=input) @@ -817,3 +828,193 @@ async def test_conditional_graph() -> None: # Check that agent (one of the nodes) has its output streamed to the logs assert "/logs/agent/streamed_output/-" in patch_paths + + +async def test_conditional_graph_state() -> None: + from copy import deepcopy + + from langchain.llms.fake import FakeStreamingListLLM + from langchain_community.tools import tool + from langchain_core.agents import AgentAction, AgentFinish + from langchain_core.prompts import PromptTemplate + + class AgentState(TypedDict): + input: str + agent_outcome: Optional[Union[AgentAction, AgentFinish]] + intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add] + + # Assemble the tools + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + # Construct the agent + prompt = PromptTemplate.from_template("Hello!") + + llm = FakeStreamingListLLM( + responses=[ + "tool:search_api:query", + "tool:search_api:another", + "finish:answer", + ] + ) + + def agent_parser(input: str) -> Union[AgentAction, AgentFinish]: + if input.startswith("finish"): + _, answer = input.split(":") + return { + "agent_outcome": AgentFinish( + return_values={"answer": answer}, log=input + ) + } + else: + _, tool_name, tool_input = input.split(":") + return { + "agent_outcome": AgentAction( + tool=tool_name, tool_input=tool_input, log=input + ) + } + + agent = prompt | llm | agent_parser + + # Define tool execution logic + def execute_tools(data: AgentState) -> dict: + agent_action: AgentAction = data.pop("agent_outcome") + observation = {t.name: t for t in tools}[agent_action.tool].invoke( + agent_action.tool_input + ) + return {"intermediate_steps": [(agent_action, observation)]} + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # Logic to decide whether to continue in the loop or exit + if isinstance(data["agent_outcome"], AgentFinish): + return "exit" + else: + return "continue" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("agent", agent) + workflow.add_node("tools", execute_tools) + + workflow.set_entry_point("agent") + + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + + workflow.add_edge("tools", "agent") + + app = workflow.compile() + + assert await app.ainvoke({"input": "what is weather in sf"}) == { + "input": "what is weather in sf", + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ), + ( + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ), + ], + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + + assert [ + deepcopy(c) async for c in app.astream({"input": "what is weather in sf"}) + ] == [ + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", tool_input="query", log="tool:search_api:query" + ), + } + }, + { + "tools": { + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ) + ], + } + }, + { + "agent": { + "agent_outcome": AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + } + }, + { + "tools": { + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ), + ], + } + }, + { + "agent": { + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + }, + { + "__end__": { + "input": "what is weather in sf", + "intermediate_steps": [ + ( + AgentAction( + tool="search_api", + tool_input="query", + log="tool:search_api:query", + ), + "result for query", + ), + ( + AgentAction( + tool="search_api", + tool_input="another", + log="tool:search_api:another", + ), + "result for another", + ), + ], + "agent_outcome": AgentFinish( + return_values={"answer": "answer"}, log="finish:answer" + ), + } + }, + ]