Merge branch 'main' into harrison/rewoo

This commit is contained in:
William Fu-Hinthorn
2024-02-12 14:31:27 -08:00
13 changed files with 2873 additions and 68 deletions
+8 -3
View File
@@ -135,7 +135,7 @@ The path that is taken is not known until that node is run (the LLM decides).
1. Conditional Edge: after the agent is called, we should either:
a. If the agent said to take an action, then the function to invoke tools should be called
b. If the agent said that it was finished, then it should finish
2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next
@@ -469,7 +469,7 @@ It can often be tough to evaluation chat bots in multi-turn situations. One way
### 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](https://github.com/langchain-ai/langgraph/blob/main/examples/async.ipynb)
For a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/async.ipynb)
### Streaming Tokens
@@ -479,7 +479,12 @@ For a guide on how to do this, see [this documentation](https://github.com/langc
### Persistence
LangGraph comes with built-in persistence, allowing you to save the state of the graph at point and resume from there.
In order for a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/persistence.ipynb)
For a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/persistence.ipynb)
### Human-in-the-loop
LangGraph comes with built-in support for human-in-the-loop workflows. This is useful when you want to have a human review the current state before proceeding to a particular node.
For a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/human-in-the-loop.ipynb)
## Documentation
+501
View File
@@ -0,0 +1,501 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53",
"metadata": {},
"source": [
"# Human-in-the-loop\n",
"\n",
"When creating LangGraph agents, it is often nice to add a human in the loop component.\n",
"This can be helpful when giving them access to tools.\n",
"Often in these situations you may want to manually approve an action before taking.\n",
"\n",
"This can be in several ways, but the primary supported way is to add an \"interupt\" before a node is executed.\n",
"This interupts execution at that node.\n",
"You can then resume from that spot to continue."
]
},
{
"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;49m24.0\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."
]
},
{
"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_core.utils.function_calling import convert_to_openai_function\n",
"\n",
"functions = [convert_to_openai_function(t) for t in tools]\n",
"model = model.bind_functions(functions)"
]
},
{
"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."
]
},
{
"cell_type": "code",
"execution_count": 5,
"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(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(messages):\n",
" response = model.invoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return response\n",
"\n",
"# Define the function to execute tools\n",
"def call_tool(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 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": 6,
"id": "812b4e70-4956-4415-8880-db48b3dcbad2",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import MessageGraph, END\n",
"# Define a new graph\n",
"workflow = MessageGraph()\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')"
]
},
{
"cell_type": "markdown",
"id": "bc9c8536-f90b-44fa-958d-5df016c66d8f",
"metadata": {},
"source": [
"**Persistence**\n",
"\n",
"To add in persistence, we pass in a checkpoint when compiling the graph"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "6845ed6a-d155-4105-9160-28849877248b",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.checkpoint.sqlite import SqliteSaver\n",
"\n",
"memory = SqliteSaver.from_conn_string(\":memory:\")"
]
},
{
"cell_type": "markdown",
"id": "cc7fa795-b3f8-4731-b37e-db7a802558ac",
"metadata": {},
"source": [
"**Interrupt**\n",
"\n",
"To always interrupt before a particular node, pass the name of the node to compile."
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "79d29875-8aa8-434c-9f20-1c58346a6249",
"metadata": {},
"outputs": [],
"source": [
"# 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(checkpointer=memory, interrupt_before=['action'])"
]
},
{
"cell_type": "markdown",
"id": "2a1b56c5-bd61-4192-8bdb-458a1e9f0159",
"metadata": {},
"source": [
"## Interacting with the Agent\n",
"\n",
"We can now interact with the agent and see that it stops before calling a tool.\n"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "cfd140f0-a5a6-4697-8115-322242f197b5",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"content='Hello Bob! How can I assist you today?'\n"
]
}
],
"source": [
"from langchain_core.messages import HumanMessage\n",
"inputs = [HumanMessage(content=\"hi! I'm bob\")]\n",
"for event in app.stream(inputs, {\"configurable\": {\"thread_id\": \"2\"}}):\n",
" for k, v in event.items():\n",
" if k != \"__end__\":\n",
" print(v)"
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "08ae8246-11d5-40e1-8567-361e5bef8917",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"content='Your name is Bob.'\n"
]
}
],
"source": [
"inputs = [HumanMessage(content=\"what is my name?\")]\n",
"for event in app.stream(inputs, {\"configurable\": {\"thread_id\": \"2\"}}):\n",
" for k, v in event.items():\n",
" if k != \"__end__\":\n",
" print(v)"
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "273d56a8-f40f-4a51-a27f-7c6bb2bda0ba",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"content='' additional_kwargs={'function_call': {'arguments': '{\\n \"query\": \"weather in San Francisco now\"\\n}', 'name': 'tavily_search_results_json'}}\n"
]
}
],
"source": [
"inputs = [HumanMessage(content=\"what's the weather in sf now?\")]\n",
"for event in app.stream(inputs, {\"configurable\": {\"thread_id\": \"2\"}}):\n",
" for k, v in event.items():\n",
" if k != \"__end__\":\n",
" print(v)"
]
},
{
"cell_type": "markdown",
"id": "1bca3814-db08-4b0b-8c0c-95b6c5440c81",
"metadata": {},
"source": [
"**Resume**\n",
"\n",
"We can now call the agent again with no inputs to continue, ie. run the tool as requested."
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "51923913-20f7-4ee1-b9ba-d01f5fb2869b",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"content='[{\\'url\\': \\'https://www.sfexaminer.com/news/climate/san-francisco-weather-forecast-calls-for-strongest-2024-rain/article_75347810-bfc3-11ee-abf6-e74c528e0583.html\\', \\'content\\': \"San Francisco is projected to receive 2.5 and 3 inches of rain, Clouser said, as well as gusts of wind up to 45 mph. Bay Area starting Wednesday at 4 a.m. and a 24-hour wind advisory in San Francisco starting at the same time. One of winter\\'s \\'stronger\\' storms to douse San Francisco On the heels of record-breaking heat to open the week, heavy rain is slated to pound the Bay Area on Wednesday.A series of historic storms last winter led to one of the wettest water years (Oct. 1 to Sept. 30) in The City\\'s history, highlighted by a 10-day stretch last January in which San Francisco...\"}]' name='tavily_search_results_json'\n",
"content=\"Currently, I couldn't retrieve the exact weather information for San Francisco. However, there is a forecast of heavy rain and gusts of wind up to 45 mph in San Francisco starting Wednesday at 4 a.m. You may want to check a reliable weather website or app for the most up-to-date weather conditions.\"\n"
]
}
],
"source": [
"for event in app.stream(None, {\"configurable\": {\"thread_id\": \"2\"}}):\n",
" for k, v in event.items():\n",
" if k != \"__end__\":\n",
" print(v)"
]
}
],
"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
}
+884
View File
@@ -0,0 +1,884 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "0c8b472b-f3fb-46c2-841f-930a4692697b",
"metadata": {},
"source": [
"# LLMCompiler\n",
"\n",
"This notebook shows how to implement [LLMCompiler, by Kim, et. al](https://arxiv.org/abs/2312.04511) in LangGraph.\n",
"\n",
"LLMCompiler is an agent architecture intented on speeding up the latency of agentic tasks via fast, parallel tool execution. It has 3 main components:\n",
"\n",
"1. Planner: generate a DAG of tasks.\n",
"2. Task Fetching Unit: schedules and executes the tasks\n",
"3. Joiner: Responds to the user or triggers a second plan\n",
"\n",
"![diagram](./img/diagram.png)\n",
"\n",
"This notebook walks through each component and shows how to wire them together using LangGraph. \n",
"\n",
"\n",
"**First,** install the dependencies, and set up LangSmith for tracing to more easily debug and observe the agent."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "16bd5497-35ad-44f2-94d9-19ff39a5ffed",
"metadata": {},
"outputs": [],
"source": [
"# %pip install -U --quiet langchain_openai langsmith langgraph langchain numexpr"
]
},
{
"cell_type": "code",
"execution_count": 41,
"id": "abbd6948-e9a3-47ca-89c7-7ac2fc5eca8b",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import getpass\n",
"\n",
"def _get_pass(var: str):\n",
" if var not in os.environ:\n",
" os.environ[var] = getpass.getpass(f\"{var}: \")\n",
"# Optional: Debug + trace calls using LangSmith\n",
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"True\"\n",
"os.environ[\"LANGCHAIN_PROJECT\"] = \"LLMCompiler\"\n",
"_get_pass(\"LANGCHAIN_API_KEY\")\n",
"_get_pass(\"OPENAI_API_KEY\")"
]
},
{
"cell_type": "markdown",
"id": "a61b48ee-8c6f-4863-913a-676f659287de",
"metadata": {},
"source": [
"## Part 1: Tools\n",
"\n",
"We'll first define the tools for the agent to use in our demo. We'll give it the class search engine + calculator combo.\n",
"\n",
"If you don't want to sign up for tavily, you can replace it with the free [DuckDuckGo](https://python.langchain.com/docs/integrations/tools/ddg)."
]
},
{
"cell_type": "code",
"execution_count": 42,
"id": "e7476bb2-1a51-42f6-b7ae-82a0300bbf84",
"metadata": {},
"outputs": [],
"source": [
"from langchain_openai import ChatOpenAI\n",
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"# Imported from the https://github.com/langchain-ai/langgraph/tree/main/examples/plan-and-execute repo\n",
"from math_tools import get_math_tool\n",
"\n",
"_get_pass(\"TAVILY_API_KEY\")\n",
"\n",
"calculate = get_math_tool(ChatOpenAI(model=\"gpt-4-turbo-preview\"))\n",
"search = TavilySearchResults(max_results=1, description='tavily_search_results_json(query=\"the search query\") - a search engine.')\n",
"\n",
"tools = [search, calculate]"
]
},
{
"cell_type": "code",
"execution_count": 43,
"id": "152eecf3-6bef-4718-af71-a0b3c5a3b009",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'37'"
]
},
"execution_count": 43,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"calculate.invoke({\"problem\": \"What's the temp of sf + 5?\", \"context\": [\"Thet empreature of sf is 32 degrees\"]})"
]
},
{
"cell_type": "markdown",
"id": "1abdedbd-d81b-4ee9-b46f-f29439ed1350",
"metadata": {},
"source": [
"# Part 2: Planner\n",
"\n",
"\n",
"Largely adapted from [the original source code](https://github.com/SqueezeAILab/LLMCompiler/blob/main/src/llm_compiler/output_parser.py), the planner accepts the input question and generates a task list to execute.\n",
"\n",
"If it is provided with a previous plan, it is instructed to re-plan, which is useful if, upon completion of the first batch of tasks, the agent must take more actions.\n",
"\n",
"The code below composes constructs the prompt template for the planner and composes it with LLM and output parser, defined in [output_parser.py](./output_parser.py). The output parser processes a task list in the following form:\n",
"\n",
"```plaintext\n",
"1. tool_1(arg1=\"arg1\", arg2=3.5, ...)\n",
"Thought: I then want to find out Y by using tool_2\n",
"2. tool_2(arg1=\"\", arg2=\"${1}\")'\n",
"3. join()<END_OF_PLAN>\"\n",
"```\n",
"\n",
"The \"Thought\" lines are optional. The `${#}` placeholders are variables. These are used to route tool (task) outputs to other tools."
]
},
{
"cell_type": "code",
"execution_count": 44,
"id": "15dd9639-691f-4906-9012-83fd6e9ac126",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"================================\u001b[1m System Message \u001b[0m================================\n",
"\n",
"Given a user query, create a plan to solve it with the utmost parallelizability. Each plan should comprise an action from the following \u001b[33;1m\u001b[1;3m{num_tools}\u001b[0m types:\n",
"\u001b[33;1m\u001b[1;3m{tool_descriptions}\u001b[0m\n",
"\u001b[33;1m\u001b[1;3m{num_tools}\u001b[0m. join(): Collects and combines results from prior actions.\n",
"\n",
" - An LLM agent is called upon invoking join() to either finalize the user query or wait until the plans are executed.\n",
" - join should always be the last action in the plan, and will be called in two scenarios:\n",
" (a) if the answer can be determined by gathering the outputs from tasks to generate the final response.\n",
" (b) if the answer cannot be determined in the planning phase before you execute the plans. Guidelines:\n",
" - Each action described above contains input/output types and description.\n",
" - You must strictly adhere to the input and output types for each action.\n",
" - The action descriptions contain the guidelines. You MUST strictly follow those guidelines when you use the actions.\n",
" - Each action in the plan should strictly be one of the above types. Follow the Python conventions for each action.\n",
" - Each action MUST have a unique ID, which is strictly increasing.\n",
" - Inputs for actions can either be constants or outputs from preceding actions. In the latter case, use the format $id to denote the ID of the previous action whose output will be the input.\n",
" - Always call join as the last action in the plan. Say '<END_OF_PLAN>' after you call join\n",
" - Ensure the plan maximizes parallelizability.\n",
" - Only use the provided action types. If a query cannot be addressed using these, invoke the join action for the next steps.\n",
" - Never introduce new actions other than the ones provided.\n",
"\n",
"=============================\u001b[1m Messages Placeholder \u001b[0m=============================\n",
"\n",
"\u001b[33;1m\u001b[1;3m{messages}\u001b[0m\n",
"\n",
"================================\u001b[1m System Message \u001b[0m================================\n",
"\n",
"Remember, ONLY respond with the task list in the correct format! E.g.:\n",
"idx. tool(arg_name=args)\n",
"None\n"
]
}
],
"source": [
"from typing import Sequence\n",
"\n",
"from langchain_core.language_models import BaseChatModel\n",
"from langchain_core.prompts import ChatPromptTemplate\n",
"from langchain_core.runnables import RunnableBranch\n",
"from langchain_core.tools import BaseTool\n",
"from langchain_core.messages import BaseMessage, FunctionMessage, HumanMessage, SystemMessage\n",
"\n",
"from output_parser import LLMCompilerPlanParser, Task\n",
"from langchain import hub\n",
"from langchain_openai import ChatOpenAI\n",
"\n",
"\n",
"prompt = hub.pull(\"wfh/llm-compiler\")\n",
"print(prompt.pretty_print())"
]
},
{
"cell_type": "code",
"execution_count": 45,
"id": "45689d40-d8df-4316-a121-6ea9c87d2efe",
"metadata": {},
"outputs": [],
"source": [
"def create_planner(llm: BaseChatModel, tools: Sequence[BaseTool], base_prompt: ChatPromptTemplate):\n",
" tool_descriptions = \"\\n\".join(\n",
" f\"{i}. {tool.description}\\n\" for i, tool in enumerate(tools)\n",
" )\n",
" planner_prompt = base_prompt.partial(\n",
" replan=\"\",\n",
" num_tools=len(tools),\n",
" tool_descriptions=tool_descriptions,\n",
" )\n",
" replanner_prompt = base_prompt.partial(\n",
" replan=' - You are given \"Previous Plan\" which is the plan that the previous agent created along with the execution results '\n",
" \"(given as Observation) of each plan and a general thought (given as Thought) about the executed results.\"\n",
" 'You MUST use these information to create the next plan under \"Current Plan\".\\n'\n",
" ' - When starting the Current Plan, you should start with \"Thought\" that outlines the strategy for the next plan.\\n'\n",
" \" - In the Current Plan, you should NEVER repeat the actions that are already executed in the Previous Plan.\\n\"\n",
" \" - You must continue the task index from the end of the previous one. Do not repeat task indices.\",\n",
" num_tools=len(tools),\n",
" tool_descriptions=tool_descriptions,\n",
" )\n",
" \n",
" def should_replan(state: list):\n",
" # Context is passed as a system message\n",
" return isinstance(state[-1], SystemMessage)\n",
"\n",
" def wrap_messages(state: list):\n",
" return {\"messages\": state}\n",
"\n",
" def wrap_and_get_last_index(state: list):\n",
" next_task = 0\n",
" for message in state[::-1]:\n",
" if isinstance(message, FunctionMessage):\n",
" next_task = message.additional_kwargs[\"idx\"] + 1\n",
" break\n",
" state[-1].content = state[-1].content + f\" - Begin counting at : {next_task}\"\n",
" return {\"messages\": state}\n",
" \n",
" return (\n",
" RunnableBranch(\n",
" (should_replan, wrap_and_get_last_index | replanner_prompt),\n",
" wrap_messages | planner_prompt,\n",
" )\n",
" | llm\n",
" | LLMCompilerPlanParser(tools=tools)\n",
" )"
]
},
{
"cell_type": "code",
"execution_count": 46,
"id": "bbdcb57b-5362-4b9e-88db-fb3fae443fb0",
"metadata": {},
"outputs": [],
"source": [
"llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n",
"# This is the primary \"agent\" in our application\n",
"planner = create_planner(llm, tools, prompt)"
]
},
{
"cell_type": "code",
"execution_count": 47,
"id": "730490c6-6e3a-4173-82a1-9eb9d5eeff20",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"description='tavily_search_results_json(query=\"the search query\") - a search engine.' max_results=1 {'query': 'current temperature in San Francisco'}\n",
"---\n",
"name='math' description='math(problem: str, context: Optional[List[str]] = None, config: Optional[langchain_core.runnables.config.RunnableConfig] = None) - math(problem: str, context: Optional[list[str]]) -> float:\\n - Solves the provided math problem.\\n - `problem` can be either a simple math problem (e.g. \"1 + 3\") or a word problem (e.g. \"how many apples are there if there are 3 apples and 2 apples\").\\n - You cannot calculate multiple expressions in one call. For instance, `math(\\'1 + 3, 2 + 4\\')` does not work. If you need to calculate multiple expressions, you need to call them separately like `math(\\'1 + 3\\')` and then `math(\\'2 + 4\\')`\\n - Minimize the number of `math` actions as much as possible. For instance, instead of calling 2. math(\"what is the 10% of $1\") and then call 3. math(\"$1 + $2\"), you MUST call 2. math(\"what is the 110% of $1\") instead, which will reduce the number of math actions.\\n - You can optionally provide a list of strings as `context` to help the agent solve the problem. If there are multiple contexts you need to answer the question, you can provide them as a list of strings.\\n - `math` action will not see the output of the previous actions unless you provide it as `context`. You MUST provide the output of the previous actions as `context` if you need to do math on it.\\n - You MUST NEVER provide `search` type action\\'s outputs as a variable in the `problem` argument. This is because `search` returns a text blob that contains the information about the entity, not a number or value. Therefore, when you need to provide an output of `search` action, you MUST provide it as a `context` argument to `math` action. For example, 1. search(\"Barack Obama\") and then 2. math(\"age of $1\") is NEVER allowed. Use 2. math(\"age of Barack Obama\", context=[\"$1\"]) instead.\\n - When you ask a question about `context`, specify the units. For instance, \"what is xx in height?\" or \"what is xx in millions?\" instead of \"what is xx?\"' args_schema=<class 'pydantic.v1.main.mathSchema'> func=<function get_math_tool.<locals>.calculate_expression at 0x119a318a0> {'problem': 'pow($0, 3)', 'context': ['$0']}\n",
"---\n",
"join ()\n",
"---\n"
]
}
],
"source": [
"example_question = \"What's the temperature in SF raised to the 3rd power?\"\n",
"\n",
"for task in planner.stream([HumanMessage(content=example_question)]):\n",
" print(task['tool'], task['args'])\n",
" print('---')"
]
},
{
"cell_type": "markdown",
"id": "5d0e795f-61ff-4553-9823-23e7624ca180",
"metadata": {},
"source": [
"## 3. Task Fetching Unit\n",
"\n",
"This component schedules the tasks. It receives a stream of tools of the following format:\n",
"\n",
"```typescript\n",
"{\n",
" tool: BaseTool,\n",
" dependencies: number[],\n",
"}\n",
"```\n",
"\n",
"The basic idea is to begin executing tools as soon as their dependencies are met. This is done through multi-threading."
]
},
{
"cell_type": "code",
"execution_count": 48,
"id": "c1fbafdd-42d4-4575-8466-e5951cee71f4",
"metadata": {
"jp-MarkdownHeadingCollapsed": true
},
"outputs": [],
"source": [
"from typing import Any, Union, Iterable, List, Tuple, Dict\n",
"from typing_extensions import TypedDict\n",
"\n",
"from langchain_core.runnables import (\n",
" chain as as_runnable,\n",
")\n",
"\n",
"from concurrent.futures import ThreadPoolExecutor, wait\n",
"import time\n",
"\n",
"\n",
"def _get_observations(messages: List[BaseMessage]) -> Dict[int, Any]:\n",
" # Get all previous tool responses\n",
" results = {}\n",
" for message in messages[::-1]:\n",
" if isinstance(message, FunctionMessage):\n",
" results[int(message.additional_kwargs[\"idx\"])] = message.content\n",
" return results\n",
"\n",
"class SchedulerInput(TypedDict):\n",
" messages: List[BaseMessage]\n",
" tasks: Iterable[Task]\n",
"\n",
"\n",
"def _execute_task(task, observations, config):\n",
" tool_to_use = task[\"tool\"]\n",
" if isinstance(tool_to_use, str):\n",
" return tool_to_use\n",
" args = task[\"args\"]\n",
" try:\n",
" if isinstance(args, str):\n",
" resolved_args = _resolve_arg(args, observations)\n",
" elif isinstance(args, dict):\n",
" resolved_args = {key: _resolve_arg(val, observations) for key, val in args.items()}\n",
" else:\n",
" # This will likely fail\n",
" resolved_args = args\n",
" except Exception as e:\n",
" return (\n",
" f\"ERROR(Failed to call {tool_to_use.name} with args {args}.)\"\n",
" f\" Args could not be resolved. Error: {repr(e)}\"\n",
" )\n",
" try:\n",
" return tool_to_use.invoke(resolved_args, config)\n",
" except Exception as e:\n",
" return (\n",
" f\"ERROR(Failed to call {tool_to_use.name} with args {args}.\"\n",
" + f\" Args resolved to {resolved_args}. Error: {repr(e)})\"\n",
" )\n",
"\n",
"\n",
"def _resolve_arg(arg: Union[str, Any], observations: Dict[int, Any]):\n",
" if isinstance(arg, str) and arg.startswith(\"$\"):\n",
" try:\n",
" stripped = arg[1:].replace(\".output\", \"\").strip(\"{}\")\n",
" idx = int(stripped)\n",
" except Exception:\n",
" return str(arg)\n",
" return str(observations[idx])\n",
" elif isinstance(arg, list):\n",
" return [_resolve_arg(a, observations) for a in arg]\n",
" else:\n",
" return str(arg)\n",
"\n",
"\n",
"@as_runnable\n",
"def schedule_task(task_inputs, config):\n",
" task: Task = task_inputs['task']\n",
" observations: Dict[int, Any] = task_inputs['observations']\n",
" try:\n",
" observation = _execute_task(task, observations, config)\n",
" except Exception:\n",
" import traceback\n",
" observation = traceback.format_exception() #repr(e) + \n",
" observations[task['idx']] = observation\n",
"\n",
"def schedule_pending_task(task: Task, observations: Dict[int, Any], retry_after: float = 0.2):\n",
" while True:\n",
" deps = task[\"dependencies\"]\n",
" if (\n",
" deps\n",
" and (\n",
" any([dep not in observations for dep in deps])\n",
" )\n",
" ):\n",
" # Dependencies not yet satisfied\n",
" time.sleep(retry_after)\n",
" continue\n",
" schedule_task.invoke({\"task\": task, \"observations\": observations})\n",
" break\n",
"\n",
"@as_runnable\n",
"def schedule_tasks(scheduler_input: SchedulerInput) -> List[FunctionMessage]:\n",
" \"\"\"Group the tasks into a DAG schedule.\"\"\"\n",
" # For streaming, we are making a few simplifying assumption:\n",
" # 1. The LLM does not create cyclic dependencies\n",
" # 2. That the LLM will not generate tasks with future deps\n",
" # If this ceases to be a good assumption, you can either\n",
" # adjust to do a proper topological sort (not-stream)\n",
" # or use a more complicated data structure\n",
" tasks = scheduler_input[\"tasks\"]\n",
" messages = scheduler_input[\"messages\"]\n",
" # If we are re-planning, we may have calls that depend on previous\n",
" # plans. Start with those.\n",
" observations = _get_observations(messages)\n",
" task_names = {}\n",
" originals = set(observations)\n",
" # ^^ We assume each task inserts a different key above to\n",
" # avoid race conditions...\n",
" futures = []\n",
" retry_after = 0.25 # Retry every quarter second\n",
" with ThreadPoolExecutor() as executor:\n",
" for task in tasks:\n",
" deps = task[\"dependencies\"]\n",
" task_names[task[\"idx\"]] = task[\"tool\"] if isinstance(task[\"tool\"], str) else task[\"tool\"].name\n",
" if (\n",
" # Depends on other tasks\n",
" deps\n",
" and (\n",
" any([dep not in observations for dep in deps])\n",
" )\n",
" ):\n",
" futures.append(executor.submit(schedule_pending_task, task, observations, retry_after))\n",
" else:\n",
" # No deps or all deps satisfied\n",
" # can schedule now\n",
" schedule_task.invoke(dict(task=task, observations=observations))\n",
" # futures.append(executor.submit(schedule_task.invoke dict(task=task, observations=observations)))\n",
"\n",
" # All tasks have been submitted or enqueued\n",
" # Wait for them to complete\n",
" wait(futures)\n",
" # Convert observations to new tool messages to add to the state\n",
" new_observations = {k: (task_names[k], observations[k]) for k in sorted(observations.keys() - originals)}\n",
" tool_messages = [\n",
" FunctionMessage(\n",
" name=name,\n",
" content=str(obs),\n",
" additional_kwargs={\"idx\": k}\n",
" ) for k, (name, obs) in new_observations.items()]\n",
" return tool_messages"
]
},
{
"cell_type": "code",
"execution_count": 49,
"id": "052f6b16-103a-40e9-94dd-8fcc37e77ba4",
"metadata": {},
"outputs": [],
"source": [
"import itertools\n",
"\n",
"@as_runnable\n",
"def plan_and_schedule(messages: List[BaseMessage], config):\n",
" tasks = planner.stream(messages, config)\n",
" # Begin executing the planner immediately\n",
" tasks = itertools.chain([next(tasks)], tasks)\n",
" scheduled_tasks = schedule_tasks.invoke({\n",
" \"messages\": messages,\n",
" \"tasks\": tasks,\n",
" }, config)\n",
" return scheduled_tasks"
]
},
{
"cell_type": "markdown",
"id": "9efa15ae-817a-48c6-86ed-16bc112fedc5",
"metadata": {},
"source": [
"#### Example Plan\n",
"\n",
"We still haven't introduced any cycles in our computation graph, so this is all easily expressed in LCEL."
]
},
{
"cell_type": "code",
"execution_count": 50,
"id": "55142257-2674-4a47-988e-0d2810917329",
"metadata": {},
"outputs": [],
"source": [
"tool_messages = plan_and_schedule.invoke([HumanMessage(content=example_question)])"
]
},
{
"cell_type": "code",
"execution_count": 51,
"id": "a98e0525-2fcf-4fa1-baf6-79858bb8a6bd",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[FunctionMessage(content=\"[{'url': 'https://en.climate-data.org/north-america/united-states-of-america/california/san-francisco-385/t/september-9/', 'content': 'San Francisco Weather in September San Francisco weather in September San Francisco weather by month // weather averages 9.6 (49.2) 6.2 (43.2) 14 (57.3) Data: 1999 - 2019: avg. Sun hours San Francisco weather and climate for further months San Francisco weather in September // weather averages Airport close to San FranciscoJanuary February March April May June July August September October November December; Avg. Temperature °C (°F) 9.6 °C (49.2) °F. 10.5 °C (50.8) °F. 11.6 °C'}]\", additional_kwargs={'idx': 1}, name='tavily_search_results_json'),\n",
" FunctionMessage(content='1', additional_kwargs={'idx': 2}, name='math'),\n",
" FunctionMessage(content='join', additional_kwargs={'idx': 3}, name='join')]"
]
},
"execution_count": 51,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"tool_messages"
]
},
{
"cell_type": "markdown",
"id": "563d5311-55f0-4ca1-afbd-01fd970cf3e3",
"metadata": {},
"source": [
"## 4. \"Joiner\" \n",
"\n",
"So now we have the planning and initial execution done. We need a component to process these outputs and either:\n",
"\n",
"1. Respond with the correct answer.\n",
"2. Loop with a new plan.\n",
"\n",
"The paper refers to this as the \"joiner\". It's another LLM call. We are using function calling to improve parsing reliability."
]
},
{
"cell_type": "code",
"execution_count": 52,
"id": "942dab42-ad42-4ba2-90d5-49edbe4fae68",
"metadata": {},
"outputs": [],
"source": [
"from langchain_core.pydantic_v1 import BaseModel, Field\n",
"from langchain.chains.openai_functions import create_structured_output_runnable\n",
"from langchain_core.messages import AIMessage\n",
"\n",
"class FinalResponse(BaseModel):\n",
" \"\"\"The final response/answer.\"\"\"\n",
" response: str\n",
"\n",
"class Replan(BaseModel):\n",
" feedback: str = Field(description=\"Analysis of the previous attempts and recommendations on what needs to be fixed.\")\n",
"\n",
"class JoinOutputs(BaseModel):\n",
" \"\"\"Decide whether to replan or whether you can return the final response.\"\"\"\n",
" thought: str = Field(description=\"The chain of thought reasoning for the selected action\")\n",
" action: Union[FinalResponse, Replan]\n",
"\n",
"\n",
"joiner_prompt = hub.pull(\"wfh/llm-compiler-joiner\").partial(examples=\"\") # You can optionally add examples\n",
"llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n",
"\n",
"runnable = create_structured_output_runnable(JoinOutputs, llm, joiner_prompt)"
]
},
{
"cell_type": "markdown",
"id": "fb50c4cd-947c-4a5d-a9f7-f0d92a10600f",
"metadata": {},
"source": [
"We will select only the most recent messages in the state, and format the output to be more useful for\n",
"the planner, should the agent need to loop."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "951a33cf-2a05-4a33-899a-0ab1d97122fa",
"metadata": {},
"outputs": [],
"source": [
"def _parse_joiner_output(decision: JoinOutputs) -> List[BaseMessage]:\n",
" response = [AIMessage(content=f\"Thought: {decision.thought}\")]\n",
" if isinstance(decision.action, Replan):\n",
" return response + [SystemMessage(content=f\"Context from last attempt: {decision.action.feedback}\")]\n",
" else:\n",
" return response + [AIMessage(content=decision.action.response)]\n",
"\n",
"\n",
"def select_recent_messages(messages: list) -> dict:\n",
" selected = []\n",
" for msg in messages[::-1]:\n",
" selected.append(msg)\n",
" if isinstance(msg, HumanMessage):\n",
" break\n",
" return {\"messages\": selected[::-1]}\n",
"\n",
"joiner = (\n",
" select_recent_messages\n",
" | runnable\n",
" | _parse_joiner_output\n",
")"
]
},
{
"cell_type": "code",
"execution_count": 53,
"id": "1e49d4b1-8266-4520-a566-1448b1c31c8f",
"metadata": {},
"outputs": [],
"source": [
"input_messages = [HumanMessage(content=example_question)] + tool_messages"
]
},
{
"cell_type": "code",
"execution_count": 54,
"id": "31854dfd-b82f-4c24-9b58-6bae66777909",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[AIMessage(content=\"Thought: The information provided gives an average temperature for San Francisco in different months, but it doesn't specify the current temperature or any specific temperature to be raised to the 3rd power. Without the current temperature or a specific temperature value, it's impossible to calculate its value raised to the 3rd power.\"),\n",
" SystemMessage(content='Context from last attempt: The information provided is not sufficient to answer the question as it lacks the current temperature of San Francisco or any specific temperature value to be raised to the 3rd power. Need to find the current or a specific temperature to perform the calculation.')]"
]
},
"execution_count": 54,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"joiner.invoke(input_messages)"
]
},
{
"cell_type": "markdown",
"id": "b099e5ee-2c23-47d9-9387-0f64e02627d3",
"metadata": {},
"source": [
"## 5. Compose using LangGraph\n",
"\n",
"We'll define the agent as a stateful graph, with the main nodes being:\n",
"\n",
"1. Plan and execute (the DAG from the first step above)\n",
"2. Join: determine if we should finish or replan\n",
"3. Recontextualize: update the graph state based on the output from the joiner"
]
},
{
"cell_type": "code",
"execution_count": 55,
"id": "768b5f11-e3d2-47be-8143-a7dcd8765243",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import MessageGraph, END\n",
"from typing import Dict\n",
"\n",
"graph_builder = MessageGraph()\n",
"\n",
"# 1. Define vertices\n",
"# We defined plan_and_schedule above already\n",
"# Assign each node to a state variable to update\n",
"graph_builder.add_node(\"plan_and_schedule\", plan_and_schedule)\n",
"graph_builder.add_node(\"join\", joiner)\n",
"\n",
"\n",
"## Define edges\n",
"graph_builder.add_edge(\"plan_and_schedule\", \"join\")\n",
"\n",
"### This condition determines looping logic\n",
"\n",
"\n",
"def should_continue(state: List[BaseMessage]):\n",
" if isinstance(state[-1], AIMessage):\n",
" return END\n",
" return \"plan_and_schedule\"\n",
"\n",
"graph_builder.add_conditional_edges(\n",
" start_key=\"join\",\n",
" # Next, we pass in the function that will determine which node is called next.\n",
" condition=should_continue,\n",
")\n",
"graph_builder.set_entry_point(\"plan_and_schedule\")\n",
"chain = graph_builder.compile()"
]
},
{
"cell_type": "markdown",
"id": "9f8c9849-8531-463d-a0ef-dcc3d9888b2d",
"metadata": {},
"source": [
"#### Simple question\n",
"\n",
"Let's ask a simple question of the agent."
]
},
{
"cell_type": "code",
"execution_count": 56,
"id": "5bc4584a-e31c-4065-805e-76a6db30676a",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan_and_schedule': [FunctionMessage(content='[{\\'url\\': \\'https://www.statista.com/statistics/188087/gdp-of-the-us-federal-state-of-new-york-since-1997/\\', \\'content\\': \"Strategy and business building for the data-driven economy: U.S. real GDP of New York 2000-2022 Real gross domestic product of New York in the United States from 2000 to 2022 (in billion U.S. dollars) Economy U.S. New York metro area GDP 2001-2022 You only have access to basic statistics. U.S. state and local government outstanding debt 2021, by state Demographics Resident population in New York 1960-2022In 2022, the real gross domestic product (GDP) of New York was about 1.56 trillion U.S. dollars. This is an increase from the previous year, when the state\\'s GDP stood at 1.51 trillion...\"}]', additional_kwargs={'idx': 0}, name='tavily_search_results_json')]}\n",
"---\n",
"{'join': [AIMessage(content='Thought: The search result provides the information that in 2022, the real gross domestic product (GDP) of New York was about 1.56 trillion U.S. dollars.'), AIMessage(content='The GDP of New York in 2022 was about 1.56 trillion U.S. dollars.')]}\n",
"---\n",
"{'__end__': [HumanMessage(content=\"What's the GDP of New York?\"), FunctionMessage(content='[{\\'url\\': \\'https://www.statista.com/statistics/188087/gdp-of-the-us-federal-state-of-new-york-since-1997/\\', \\'content\\': \"Strategy and business building for the data-driven economy: U.S. real GDP of New York 2000-2022 Real gross domestic product of New York in the United States from 2000 to 2022 (in billion U.S. dollars) Economy U.S. New York metro area GDP 2001-2022 You only have access to basic statistics. U.S. state and local government outstanding debt 2021, by state Demographics Resident population in New York 1960-2022In 2022, the real gross domestic product (GDP) of New York was about 1.56 trillion U.S. dollars. This is an increase from the previous year, when the state\\'s GDP stood at 1.51 trillion...\"}]', additional_kwargs={'idx': 0}, name='tavily_search_results_json'), AIMessage(content='Thought: The search result provides the information that in 2022, the real gross domestic product (GDP) of New York was about 1.56 trillion U.S. dollars.'), AIMessage(content='The GDP of New York in 2022 was about 1.56 trillion U.S. dollars.')]}\n",
"---\n"
]
}
],
"source": [
"for step in chain.stream([HumanMessage(content=\"What's the GDP of New York?\")]):\n",
" print(step)\n",
" print('---')\n"
]
},
{
"cell_type": "code",
"execution_count": 57,
"id": "b96efd08-5314-44f0-a694-3073b638adad",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The GDP of New York in 2022 was about 1.56 trillion U.S. dollars.\n"
]
}
],
"source": [
"# Final answer\n",
"print(step[END][-1].content)"
]
},
{
"cell_type": "markdown",
"id": "33c65ef5-b4b2-4ab2-8c78-a551da7819b9",
"metadata": {},
"source": [
"#### Multi-hop question\n",
"\n",
"This question requires that the agent perform multiple searches."
]
},
{
"cell_type": "code",
"execution_count": 58,
"id": "0b3a0916-d8ca-4092-b91c-d9e2b05259d8",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan_and_schedule': [FunctionMessage(content='[{\\'url\\': \\'https://savetheeaglesinternational.org/old-parrot/\\', \\'content\\': \"What Is The World\\'s Oldest Parrot? Living Long and Healthy Lives: A Look at the Worlds Oldest Parrots certain parrot species are considered older: your parrot enters its senior years?One remarkable parrot that defied the odds and lived a long life is Cookie, a cockatoo who reached the impressive age of 83. Cookie spent his entire life at the Brookfield Zoo, serving as a testament to the exceptional care and environment provided by the zookeepers.\"}]', additional_kwargs={'idx': 1}, name='tavily_search_results_json'), FunctionMessage(content=\"[{'url': 'https://www.animalwised.com/how-long-does-a-parrot-live-3974.html', 'content': 'How Long Does a Parrot Live? How long does a parrot live in captivity? How long does a parrot live in the wild? Why do parrots live so long?Below is the average life expectancy of parrots in captivity, based on their species. Lovebirds. Lovebirds are members of the genus Agapornis, a small group of parrots in the parrot family Psittaculidae. The average life expectancy of a lovebird is between 12 and 15 years. Depending on care and circumstances, the bird can live up to 20 years ...'}]\", additional_kwargs={'idx': 2}, name='tavily_search_results_json'), FunctionMessage(content='join', additional_kwargs={'idx': 3}, name='join')]}\n",
"---\n",
"{'join': [AIMessage(content=\"Thought: The oldest parrot ever recorded is Cookie, a cockatoo, who lived to be 83 years old. However, the average lifespan provided is specifically for lovebirds, which is between 12 and 15 years. This information doesn't accurately reflect the average lifespan of all parrot species, which would be necessary to compare with Cookie's age accurately. Since parrots encompass a wide variety of species with different lifespans, the information on lovebirds' lifespan alone is insufficient for a comprehensive comparison.\"), SystemMessage(content=\"Context from last attempt: We need information on the average lifespan of parrots in general, not just lovebirds, to accurately compare with Cookie's age.\")]}\n",
"---\n",
"{'plan_and_schedule': [FunctionMessage(content='[{\\'url\\': \\'https://www.petmd.com/bird/how-long-do-parrots-live\\', \\'content\\': \"Average Parrot Lifespan and Aging How Long Do Parrots Live? How to Improve Your Parrot\\'s Lifespan Mcleod DVM, Lianne. The Spruce Pets. How Long do Pet Parrots and Other Birds Live?. 2023.Some pets, such as tortoises and parrots, may live for over 50 years. Because they are a lifelong commitment, lawyers often urge pet parents to provide documented plans for their pet parrots in their wills. Average Parrot Lifespan and Aging. Parrots are an incredibly diverse group of birds known by their scientific name: psittacines.\"}]', additional_kwargs={'idx': 4}, name='tavily_search_results_json')]}\n",
"---\n",
"{'join': [AIMessage(content=\"Thought: The information provided does not give a specific average lifespan for parrots in general, which is necessary for accurately comparing Cookie's age to the average lifespan of parrots. The search result mentions that parrots can live over 50 years but does not provide a detailed average lifespan applicable to all or most parrot species.\"), SystemMessage(content=\"Context from last attempt: We need information on the average lifespan of parrots in general to accurately compare with Cookie's age of 83 years. The provided information doesn't specify an average lifespan for parrots as a whole.\")]}\n",
"---\n",
"{'plan_and_schedule': [FunctionMessage(content='join', additional_kwargs={'idx': 5}, name='join')]}\n",
"---\n",
"{'join': [AIMessage(content=\"Thought: Despite multiple attempts, the specific average lifespan of parrots as a whole has not been provided. The information obtained mentions that parrots can live over 50 years, but a more precise average is necessary for a detailed comparison with Cookie's age of 83 years. However, it's clear that Cookie lived significantly longer than the average lifespan of many parrot species, including lovebirds which have an average lifespan of 12 to 15 years.\"), AIMessage(content=\"The oldest parrot on record is Cookie, a cockatoo, who lived to be 83 years old. While specific average lifespan information for all parrot species has not been provided, it's mentioned that some parrots can live over 50 years. This suggests that Cookie lived significantly longer than the average lifespan for many parrot species. For instance, lovebirds, a type of parrot, have an average lifespan of 12 to 15 years, indicating that Cookie's lifespan was exceptional among parrots.\")]}\n",
"---\n",
"{'__end__': [HumanMessage(content=\"What's the oldest parrot alive, and how much longer is that than the average?\"), FunctionMessage(content='[{\\'url\\': \\'https://savetheeaglesinternational.org/old-parrot/\\', \\'content\\': \"What Is The World\\'s Oldest Parrot? Living Long and Healthy Lives: A Look at the Worlds Oldest Parrots certain parrot species are considered older: your parrot enters its senior years?One remarkable parrot that defied the odds and lived a long life is Cookie, a cockatoo who reached the impressive age of 83. Cookie spent his entire life at the Brookfield Zoo, serving as a testament to the exceptional care and environment provided by the zookeepers.\"}]', additional_kwargs={'idx': 1}, name='tavily_search_results_json'), FunctionMessage(content=\"[{'url': 'https://www.animalwised.com/how-long-does-a-parrot-live-3974.html', 'content': 'How Long Does a Parrot Live? How long does a parrot live in captivity? How long does a parrot live in the wild? Why do parrots live so long?Below is the average life expectancy of parrots in captivity, based on their species. Lovebirds. Lovebirds are members of the genus Agapornis, a small group of parrots in the parrot family Psittaculidae. The average life expectancy of a lovebird is between 12 and 15 years. Depending on care and circumstances, the bird can live up to 20 years ...'}]\", additional_kwargs={'idx': 2}, name='tavily_search_results_json'), FunctionMessage(content='join', additional_kwargs={'idx': 3}, name='join'), AIMessage(content=\"Thought: The oldest parrot ever recorded is Cookie, a cockatoo, who lived to be 83 years old. However, the average lifespan provided is specifically for lovebirds, which is between 12 and 15 years. This information doesn't accurately reflect the average lifespan of all parrot species, which would be necessary to compare with Cookie's age accurately. Since parrots encompass a wide variety of species with different lifespans, the information on lovebirds' lifespan alone is insufficient for a comprehensive comparison.\"), SystemMessage(content=\"Context from last attempt: We need information on the average lifespan of parrots in general, not just lovebirds, to accurately compare with Cookie's age. - Begin counting at : 4\"), FunctionMessage(content='[{\\'url\\': \\'https://www.petmd.com/bird/how-long-do-parrots-live\\', \\'content\\': \"Average Parrot Lifespan and Aging How Long Do Parrots Live? How to Improve Your Parrot\\'s Lifespan Mcleod DVM, Lianne. The Spruce Pets. How Long do Pet Parrots and Other Birds Live?. 2023.Some pets, such as tortoises and parrots, may live for over 50 years. Because they are a lifelong commitment, lawyers often urge pet parents to provide documented plans for their pet parrots in their wills. Average Parrot Lifespan and Aging. Parrots are an incredibly diverse group of birds known by their scientific name: psittacines.\"}]', additional_kwargs={'idx': 4}, name='tavily_search_results_json'), AIMessage(content=\"Thought: The information provided does not give a specific average lifespan for parrots in general, which is necessary for accurately comparing Cookie's age to the average lifespan of parrots. The search result mentions that parrots can live over 50 years but does not provide a detailed average lifespan applicable to all or most parrot species.\"), SystemMessage(content=\"Context from last attempt: We need information on the average lifespan of parrots in general to accurately compare with Cookie's age of 83 years. The provided information doesn't specify an average lifespan for parrots as a whole. - Begin counting at : 5\"), FunctionMessage(content='join', additional_kwargs={'idx': 5}, name='join'), AIMessage(content=\"Thought: Despite multiple attempts, the specific average lifespan of parrots as a whole has not been provided. The information obtained mentions that parrots can live over 50 years, but a more precise average is necessary for a detailed comparison with Cookie's age of 83 years. However, it's clear that Cookie lived significantly longer than the average lifespan of many parrot species, including lovebirds which have an average lifespan of 12 to 15 years.\"), AIMessage(content=\"The oldest parrot on record is Cookie, a cockatoo, who lived to be 83 years old. While specific average lifespan information for all parrot species has not been provided, it's mentioned that some parrots can live over 50 years. This suggests that Cookie lived significantly longer than the average lifespan for many parrot species. For instance, lovebirds, a type of parrot, have an average lifespan of 12 to 15 years, indicating that Cookie's lifespan was exceptional among parrots.\")]}\n",
"---\n"
]
}
],
"source": [
"steps = chain.stream(\n",
" [HumanMessage(content=\"What's the oldest parrot alive, and how much longer is that than the average?\")],\n",
" {\n",
" \"recursion_limit\": 100,\n",
" },\n",
")\n",
"for step in steps:\n",
" print(step)\n",
" print('---')"
]
},
{
"cell_type": "code",
"execution_count": 59,
"id": "6c65c414-7668-4fdf-ba97-f42f659b1317",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The oldest parrot on record is Cookie, a cockatoo, who lived to be 83 years old. While specific average lifespan information for all parrot species has not been provided, it's mentioned that some parrots can live over 50 years. This suggests that Cookie lived significantly longer than the average lifespan for many parrot species. For instance, lovebirds, a type of parrot, have an average lifespan of 12 to 15 years, indicating that Cookie's lifespan was exceptional among parrots.\n"
]
}
],
"source": [
"# Final answer\n",
"print(step[END][-1].content)"
]
},
{
"cell_type": "markdown",
"id": "1b859bc7-1a85-4d35-b57b-f67c87282403",
"metadata": {},
"source": [
"#### Multi-step math"
]
},
{
"cell_type": "code",
"execution_count": 60,
"id": "38d3ea91-59ba-4267-8060-ed75bbc840c6",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan_and_schedule': [FunctionMessage(content='3307.0', additional_kwargs={'idx': 0}, name='math'), FunctionMessage(content='7.565011820330969', additional_kwargs={'idx': 1}, name='math'), FunctionMessage(content='join', additional_kwargs={'idx': 2}, name='join')]}\n",
"{'join': [AIMessage(content='Thought: The first calculation resulted in 3307.0, and the second calculation gave 7.565011820330969. To find the sum of these two values, I will simply add them together.'), AIMessage(content='The sum of ((3*(4+5)/0.5)+3245) + 8 and 32/4.23 is approximately 3314.565.')]}\n",
"{'__end__': [HumanMessage(content=\"What's ((3*(4+5)/0.5)+3245) + 8? What's 32/4.23? What's the sum of those two values?\"), FunctionMessage(content='3307.0', additional_kwargs={'idx': 0}, name='math'), FunctionMessage(content='7.565011820330969', additional_kwargs={'idx': 1}, name='math'), FunctionMessage(content='join', additional_kwargs={'idx': 2}, name='join'), AIMessage(content='Thought: The first calculation resulted in 3307.0, and the second calculation gave 7.565011820330969. To find the sum of these two values, I will simply add them together.'), AIMessage(content='The sum of ((3*(4+5)/0.5)+3245) + 8 and 32/4.23 is approximately 3314.565.')]}\n"
]
}
],
"source": [
"for step in chain.stream([HumanMessage(content=\"What's ((3*(4+5)/0.5)+3245) + 8? What's 32/4.23? What's the sum of those two values?\")]):\n",
" print(step)"
]
},
{
"cell_type": "code",
"execution_count": 61,
"id": "a6cf5fe0-f178-4197-950f-257711bff8d2",
"metadata": {
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The sum of ((3*(4+5)/0.5)+3245) + 8 and 32/4.23 is approximately 3314.565.\n"
]
}
],
"source": [
"# Final answer\n",
"print(step[END][-1].content)"
]
}
],
"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.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

+142
View File
@@ -0,0 +1,142 @@
import math
import re
from typing import List, Optional
import numexpr
from langchain.chains.openai_functions import create_structured_output_runnable
from langchain_community.chat_models import ChatOpenAI
from langchain_core.messages import SystemMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import StructuredTool
_MATH_DESCRIPTION = (
"math(problem: str, context: Optional[list[str]]) -> float:\n"
" - Solves the provided math problem.\n"
' - `problem` can be either a simple math problem (e.g. "1 + 3") or a word problem (e.g. "how many apples are there if there are 3 apples and 2 apples").\n'
" - You cannot calculate multiple expressions in one call. For instance, `math('1 + 3, 2 + 4')` does not work. "
"If you need to calculate multiple expressions, you need to call them separately like `math('1 + 3')` and then `math('2 + 4')`\n"
" - Minimize the number of `math` actions as much as possible. For instance, instead of calling "
'2. math("what is the 10% of $1") and then call 3. math("$1 + $2"), '
'you MUST call 2. math("what is the 110% of $1") instead, which will reduce the number of math actions.\n'
# Context specific rules below
" - You can optionally provide a list of strings as `context` to help the agent solve the problem. "
"If there are multiple contexts you need to answer the question, you can provide them as a list of strings.\n"
" - `math` action will not see the output of the previous actions unless you provide it as `context`. "
"You MUST provide the output of the previous actions as `context` if you need to do math on it.\n"
" - You MUST NEVER provide `search` type action's outputs as a variable in the `problem` argument. "
"This is because `search` returns a text blob that contains the information about the entity, not a number or value. "
"Therefore, when you need to provide an output of `search` action, you MUST provide it as a `context` argument to `math` action. "
'For example, 1. search("Barack Obama") and then 2. math("age of $1") is NEVER allowed. '
'Use 2. math("age of Barack Obama", context=["$1"]) instead.\n'
" - When you ask a question about `context`, specify the units. "
'For instance, "what is xx in height?" or "what is xx in millions?" instead of "what is xx?"\n'
)
_SYSTEM_PROMPT = """Translate a math problem into a expression that can be executed using Python's numexpr library. Use the output of running this code to answer the question.
Question: ${{Question with math problem.}}
```text
${{single line mathematical expression that solves the problem}}
```
...numexpr.evaluate(text)...
```output
${{Output of running the code}}
```
Answer: ${{Answer}}
Begin.
Question: What is 37593 * 67?
ExecuteCode({{code: "37593 * 67"}})
...numexpr.evaluate("37593 * 67")...
```output
2518731
```
Answer: 2518731
Question: 37593^(1/5)
ExecuteCode({{code: "37593**(1/5)"}})
...numexpr.evaluate("37593**(1/5)")...
```output
8.222831614237718
```
Answer: 8.222831614237718
"""
_ADDITIONAL_CONTEXT_PROMPT = """The following additional context is provided from other functions.\
Use it to substitute into any ${{#}} variables or other words in the problem.\
\n\n${context}\n\nNote that context varibles are not defined in code yet.\
You must extract the relevant numbers and directly put them in code."""
class ExecuteCode(BaseModel):
"""The input to the numexpr.evaluate() function."""
reasoning: str = Field(
...,
description="The reasoning behind the code expression, including how context is included, if applicable.",
)
code: str = Field(
...,
description="The simple code expresssion to execute by numexpr.evaluate().",
)
def _evaluate_expression(expression: str) -> str:
try:
local_dict = {"pi": math.pi, "e": math.e}
output = str(
numexpr.evaluate(
expression.strip(),
global_dict={}, # restrict access to globals
local_dict=local_dict, # add common mathematical functions
)
)
except Exception as e:
raise ValueError(
f'Failed to evaluate "{expression}". Raised error: {repr(e)}.'
" Please try again with a valid numerical expression"
)
# Remove any leading and trailing brackets from the output
return re.sub(r"^\[|\]$", "", output)
def get_math_tool(llm: ChatOpenAI):
prompt = ChatPromptTemplate.from_messages(
[
("system", _SYSTEM_PROMPT),
("user", "{problem}"),
MessagesPlaceholder(variable_name="context", optional=True),
]
)
extractor = create_structured_output_runnable(ExecuteCode, llm, prompt)
def calculate_expression(
problem: str,
context: Optional[List[str]] = None,
config: Optional[RunnableConfig] = None,
):
chain_input = {"problem": problem}
if context:
context_str = "\n".join(context)
if context_str.strip():
context_str = _ADDITIONAL_CONTEXT_PROMPT.format(
context=context_str.strip()
)
chain_input["context"] = [SystemMessage(content=context_str)]
code_model = extractor.invoke(chain_input, config)
try:
return _evaluate_expression(code_model.code)
except Exception as e:
return repr(e)
return StructuredTool.from_function(
name="math",
func=calculate_expression,
description=_MATH_DESCRIPTION,
)
+177
View File
@@ -0,0 +1,177 @@
import ast
import re
from typing import (
Any,
Dict,
Iterator,
List,
Optional,
Sequence,
Tuple,
Union,
)
from langchain_core.exceptions import OutputParserException
from langchain_core.messages import BaseMessage
from langchain_core.output_parsers.transform import BaseTransformOutputParser
from langchain_core.runnables import RunnableConfig
from langchain_core.tools import BaseTool
from typing_extensions import TypedDict
THOUGHT_PATTERN = r"Thought: ([^\n]*)"
ACTION_PATTERN = r"\n*(\d+)\. (\w+)\((.*)\)(\s*#\w+\n)?"
# $1 or ${1} -> 1
ID_PATTERN = r"\$\{?(\d+)\}?"
END_OF_PLAN = "<END_OF_PLAN>"
### Helper functions
def _ast_parse(arg: str) -> Any:
try:
return ast.literal_eval(arg)
except: # noqa
return arg
def _parse_llm_compiler_action_args(args: str, tool: Union[str, BaseTool]) -> list[Any]:
"""Parse arguments from a string."""
if args == "":
return ()
if isinstance(tool, str):
return ()
extracted_args = {}
tool_key = None
prev_idx = None
for key in tool.args.keys():
# Split if present
if f"{key}=" in args:
idx = args.index(f"{key}=")
if prev_idx is not None:
extracted_args[tool_key] = _ast_parse(
args[prev_idx:idx].strip().rstrip(",")
)
args = args.split(f"{key}=", 1)[1]
tool_key = key
prev_idx = 0
if prev_idx is not None:
extracted_args[tool_key] = _ast_parse(
args[prev_idx:].strip().rstrip(",").rstrip(")")
)
return extracted_args
def default_dependency_rule(idx, args: str):
matches = re.findall(ID_PATTERN, args)
numbers = [int(match) for match in matches]
return idx in numbers
def _get_dependencies_from_graph(
idx: int, tool_name: str, args: Dict[str, Any]
) -> dict[str, list[str]]:
"""Get dependencies from a graph."""
if tool_name == "join":
return list(range(1, idx))
return [i for i in range(1, idx) if default_dependency_rule(i, str(args))]
class Task(TypedDict):
idx: int
tool: BaseTool
args: list
dependencies: Dict[str, list]
thought: Optional[str]
def instantiate_task(
tools: Sequence[BaseTool],
idx: int,
tool_name: str,
args: Union[str, Any],
thought: Optional[str] = None,
) -> Task:
if tool_name == "join":
tool = "join"
else:
try:
tool = tools[[tool.name for tool in tools].index(tool_name)]
except ValueError as e:
raise OutputParserException(f"Tool {tool_name} not found.") from e
tool_args = _parse_llm_compiler_action_args(args, tool)
dependencies = _get_dependencies_from_graph(idx, tool_name, tool_args)
return Task(
idx=idx,
tool=tool,
args=tool_args,
dependencies=dependencies,
thought=thought,
)
class LLMCompilerPlanParser(BaseTransformOutputParser[dict], extra="allow"):
"""Planning output parser."""
tools: List[BaseTool]
def _transform(self, input: Iterator[Union[str, BaseMessage]]) -> Iterator[Task]:
texts = []
# TODO: Cleanup tuple state tracking here.
thought = None
for chunk in input:
# Assume input is str. TODO: support vision/other formats
text = chunk if isinstance(chunk, str) else str(chunk.content)
for task, thought in self.ingest_token(text, texts, thought):
yield task
# Final possible task
if texts:
task, _ = self._parse_task("".join(texts), thought)
if task:
yield task
def parse(self, text: str) -> List[Task]:
return list(self._transform([text]))
def stream(
self,
input: str | BaseMessage,
config: RunnableConfig | None = None,
**kwargs: Any | None,
) -> Iterator[Task]:
yield from self.transform([input], config, **kwargs)
def ingest_token(
self, token: str, buffer: List[str], thought: Optional[str]
) -> Iterator[Tuple[Optional[Task], str]]:
buffer.append(token)
if "\n" in token:
buffer_ = "".join(buffer).split("\n")
suffix = buffer_[-1]
for line in buffer_[:-1]:
task, thought = self._parse_task(line, thought)
if task:
yield task, thought
buffer.clear()
buffer.append(suffix)
def _parse_task(self, line: str, thought: Optional[str] = None):
task = None
if match := re.match(THOUGHT_PATTERN, line):
# Optionally, action can be preceded by a thought
thought = match.group(1)
elif match := re.match(ACTION_PATTERN, line):
# if action is parsed, return the task, and clear the buffer
idx, tool_name, args, _ = match.groups()
idx = int(idx)
task = instantiate_task(
tools=self.tools,
idx=idx,
tool_name=tool_name,
args=args,
thought=thought,
)
thought = None
# Else it is just dropped
return task, thought
@@ -0,0 +1,494 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "79b5811c-1074-495f-9722-8325b5e717d3",
"metadata": {},
"source": [
"# Plan-and-Execute\n",
"\n",
"This notebook shows how to create a \"plan-and-execute\" style agent. This is heavily inspired by the [Plan-and-Solve](https://arxiv.org/abs/2305.04091) paper as well as the [Baby-AGI](https://github.com/yoheinakajima/babyagi) project.\n",
"\n",
"The core idea is to first come up with a multi-step plan, and then go through that plan one item at a time.\n",
"After accomplishing a particular task, you can then revisit the plan and modify as appropriate.\n",
"\n",
"This compares to a typical [ReAct](https://arxiv.org/abs/2210.03629) style agent where you think one step at a time.\n",
"The advantages of this \"plan-and-execute\" style agent are:\n",
"\n",
"1. Explicit long term planning (which even really strong LLMs can struggle with)\n",
"2. Ability to use smaller/weaker models for the execution step, only using larger/better models for the planning step"
]
},
{
"cell_type": "markdown",
"id": "a44a72d6-7e0c-4478-9d20-4c09000420a8",
"metadata": {},
"source": [
"## Setup\n",
"\n",
"First, we need to install the packages required."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "b451b58a-89bd-424f-8c06-0d9fe325e01b",
"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.2\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m24.0\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;49mpython3.11 -m pip install --upgrade pip\u001b[0m\n"
]
}
],
"source": [
"!pip install --quiet -U langchain langchain_openai tavily-python"
]
},
{
"cell_type": "markdown",
"id": "35f267b0-98db-4a59-8b2c-a23f795576ff",
"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": "ce438281-08d5-4804-afe7-e4089f7b016b",
"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": "be2d7981-3737-4134-8bef-d00d18d4e91d",
"metadata": {},
"source": [
"Optionally, we can set API key for LangSmith tracing, which will give us best-in-class observability."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "01f460d1-f26f-47d1-ae76-de74d5d851de",
"metadata": {},
"outputs": [],
"source": [
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
"os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")"
]
},
{
"cell_type": "code",
"execution_count": 37,
"id": "e475c7f9-4c46-4f21-8ba6-2e4d67b09cae",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"os.environ[\"LANGCHAIN_PROJECT\"] = \"brex\""
]
},
{
"cell_type": "markdown",
"id": "6c5fb09a-0311-44c2-b243-d0e80de78902",
"metadata": {},
"source": [
"## Define Tools\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": 38,
"id": "25b9ec62-0675-4715-811c-9b32c635b22f",
"metadata": {},
"outputs": [],
"source": [
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"\n",
"tools = [TavilySearchResults(max_results=3)]"
]
},
{
"cell_type": "markdown",
"id": "3dcda478-fa80-4e3e-bb35-0f622fe73a31",
"metadata": {},
"source": [
"## Define our Execution Agent\n",
"\n",
"Now we will create the execution agent we want to use to execute tasks. \n",
"Note that for this example, we will be using the same execution agent for each task, but this doesn't HAVE to be the case."
]
},
{
"cell_type": "code",
"execution_count": 39,
"id": "72d233ca-1dbf-4b43-b680-b3bf39e3691f",
"metadata": {},
"outputs": [],
"source": [
"from langchain import hub\n",
"from langchain.agents import create_openai_functions_agent\n",
"from langchain_openai import ChatOpenAI\n",
"# Get the prompt to use - you can modify this!\n",
"prompt = hub.pull(\"hwchase17/openai-functions-agent\")\n",
"# Choose the LLM that will drive the agent\n",
"llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n",
"# Construct the OpenAI Functions agent\n",
"agent_runnable = create_openai_functions_agent(llm, tools, prompt)"
]
},
{
"cell_type": "code",
"execution_count": 40,
"id": "a3ea9bd3-87d9-4a78-aec6-8ab4bf34479b",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.prebuilt import create_agent_executor"
]
},
{
"cell_type": "code",
"execution_count": 41,
"id": "998aebde-c204-494f-930c-14747ed34861",
"metadata": {},
"outputs": [],
"source": [
"agent_executor = create_agent_executor(agent_runnable, tools)"
]
},
{
"cell_type": "code",
"execution_count": 42,
"id": "746e697a-dec4-4342-a814-9b3456828169",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"{'input': 'who is the winnner of the us open',\n",
" 'chat_history': [],\n",
" 'agent_outcome': AgentFinish(return_values={'output': 'The winners of the US Open in 2023 are:\\n\\n- For tennis, Coco Gauff won her first Grand Slam title at the US Open 2023 with a comeback victory against Aryna Sabalenka. [Source](https://sports.yahoo.com/us-open-2023-coco-gauff-wins-1st-grand-slam-title-with-wild-comeback-vs-aryna-sabalenka-222431287.html)\\n\\n- In golf, Wyndham Clark won the 2023 US Open, marking his first major championship victory. The tournament took place at the Los Angeles Country Club. [Source](https://www.nbclosangeles.com/news/sports/golf/wyndham-clark-wins-2023-us-open-for-first-major-championship/3172672/)'}, log='The winners of the US Open in 2023 are:\\n\\n- For tennis, Coco Gauff won her first Grand Slam title at the US Open 2023 with a comeback victory against Aryna Sabalenka. [Source](https://sports.yahoo.com/us-open-2023-coco-gauff-wins-1st-grand-slam-title-with-wild-comeback-vs-aryna-sabalenka-222431287.html)\\n\\n- In golf, Wyndham Clark won the 2023 US Open, marking his first major championship victory. The tournament took place at the Los Angeles Country Club. [Source](https://www.nbclosangeles.com/news/sports/golf/wyndham-clark-wins-2023-us-open-for-first-major-championship/3172672/)'),\n",
" 'intermediate_steps': [(AgentActionMessageLog(tool='tavily_search_results_json', tool_input={'query': 'US Open winner 2023'}, log=\"\\nInvoking: `tavily_search_results_json` with `{'query': 'US Open winner 2023'}`\\n\\n\\n\", message_log=[AIMessage(content='', additional_kwargs={'function_call': {'arguments': '{\"query\":\"US Open winner 2023\"}', 'name': 'tavily_search_results_json'}})]),\n",
" '[{\\'url\\': \\'https://sports.yahoo.com/us-open-2023-coco-gauff-wins-1st-grand-slam-title-with-wild-comeback-vs-aryna-sabalenka-222431287.html\\', \\'content\\': \\'— US Open Tennis (@usopen) September 9, 2023 — US Open Tennis (@usopen) September 9, 2023 US Open 2023: Coco Gauff wins 1st Grand Slam title with wild comeback vs. Aryna Sabalenka What a backhand winner from Coco Gauff! pic.twitter.com/JhDcFpsJ4E — US Open Tennis (@usopen) September 9, 2023— US Open Tennis (@usopen) September 9, 2023 Gauff got the momentum change the crowd was looking for early in the second set, breaking Sabalenka to go up 3-1 and holding serve from there to take ...\\'}, {\\'url\\': \\'https://www.nbclosangeles.com/news/sports/golf/wyndham-clark-wins-2023-us-open-for-first-major-championship/3172672/\\', \\'content\\': \"Wyndham Clark wins 2023 US Open for first major championship 2023 US Open features a record purse. Here\\'s how much the winning golfer will make Clark on Sunday claimed the 2023 US Open title at the Los Angeles Country Club, making it his first major championship US Open champion in 2011 and a four-time total major winner -- who recorded a nine-under.Clark on Sunday claimed the 2023 US Open title at the Los Angeles Country Club, making it his first major championship triumph. The 29-year-old finished the tournament going 10-under, just edging ...\"}, {\\'url\\': \\'https://www.sportingnews.com/us/golf/news/us-open-2023-live-scores-results-leaderboard/jbmxrpro5jc37drgq8e2lehn\\', \\'content\\': \\'MORE: Watch the 2023 U.S. Open live with Fubo (free trial) U.S. Open leaderboard 2023 Edition Who won the U.S. Open in 2023? Complete scores, results, highlights from Los Angeles Country Club The golf world headed to the City of Angels — Los Angeles — for the 2023 U.S. Open. MORE:\\\\xa0How much prize money does the U.S. Open winner make?Nick Brinkerhoff 06-19-2023 • 23 min read (Getty Images) The golf world headed to the City of Angels — Los Angeles — for the 2023 U.S. Open. And the tournament got its Hollywood ending....\\'}]')]}"
]
},
"execution_count": 42,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"agent_executor.invoke({\"input\": \"who is the winnner of the us open\", \"chat_history\": []})"
]
},
{
"cell_type": "markdown",
"id": "5cf66804-44b2-4904-b1a7-17ad70b551f5",
"metadata": {},
"source": [
"## Define the State\n",
"\n",
"Let's now start by defining the state the track for this agent.\n",
"\n",
"First, we will need to track the current plan. Let's represent that as a list of strings.\n",
"\n",
"Next, we should track previously executed steps. Let's represent that as a list of tuples (these tuples will contain the step and then the result)\n",
"\n",
"Finally, we need to have some state to represent the final response as well as the original input."
]
},
{
"cell_type": "code",
"execution_count": 43,
"id": "8eeeaeea-8f10-4fbe-8e24-4e1a2381a009",
"metadata": {},
"outputs": [],
"source": [
"from langchain_core.pydantic_v1 import BaseModel, Field\n",
"from typing import List, Tuple, Annotated, TypedDict\n",
"import operator\n",
"\n",
"\n",
"class PlanExecute(TypedDict):\n",
"\n",
" input: str \n",
" plan: List[str]\n",
" past_steps: Annotated[List[Tuple], operator.add]\n",
" response: str"
]
},
{
"cell_type": "markdown",
"id": "1dbd770a-9941-40a9-977e-4d55359eee21",
"metadata": {},
"source": [
"## Planning Step\n",
"\n",
"Let's now think about creating the planning step. This will use function calling to create a plan."
]
},
{
"cell_type": "code",
"execution_count": 44,
"id": "4a88626d-6dfd-4488-87f0-a9a0dd6da44c",
"metadata": {},
"outputs": [],
"source": [
"from langchain_core.pydantic_v1 import BaseModel\n",
"\n",
"\n",
"class Plan(BaseModel):\n",
" \"\"\"Plan to follow in future\"\"\"\n",
" steps: List[str] = Field(description=\"different steps to follow, should be in sorted order\")\n"
]
},
{
"cell_type": "code",
"execution_count": 45,
"id": "ec7b1867-1ea3-4df3-9a98-992a1c32ec49",
"metadata": {},
"outputs": [],
"source": [
"from langchain.chains.openai_functions import create_structured_output_runnable\n",
"from langchain_core.prompts import ChatPromptTemplate\n",
"\n",
"planner_prompt = ChatPromptTemplate.from_template(\"\"\"For the given objective, come up with a simple step by step plan. \\\n",
"This plan should involve individual tasks, that if executed correctly will yield the correct answer. Do not add any superfluous steps. \\\n",
"The result of the final step should be the final answer. Make sure that each step has all the information needed - do not skip steps.\n",
"\n",
"{objective}\"\"\")\n",
"planner = create_structured_output_runnable(Plan, ChatOpenAI(model=\"gpt-4-turbo-preview\", temperature=0), planner_prompt)"
]
},
{
"cell_type": "code",
"execution_count": 46,
"id": "67ce37b7-e089-479b-bcb8-c3f5d9874613",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"Plan(steps=['Identify the current year.', 'Search for the Australia Open winner of the current year.', 'Find the hometown of the identified winner.'])"
]
},
"execution_count": 46,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"planner.invoke({'objective': 'what is the hometown of the current Australia open winner?'})"
]
},
{
"cell_type": "markdown",
"id": "6e09ad9d-6f90-4bdc-bb43-b1ce94517c29",
"metadata": {},
"source": [
"## Re-Plan Step\n",
"\n",
"Now, let's create a step that re-does the plan based on the result of the previous step."
]
},
{
"cell_type": "code",
"execution_count": 47,
"id": "ec2d12cc-016a-44d1-aa08-4c5ce1e8fe2a",
"metadata": {},
"outputs": [],
"source": [
"from langchain.chains.openai_functions import create_openai_fn_runnable\n",
"class Response(BaseModel):\n",
" \"\"\"Response to user.\"\"\"\n",
" response: str\n",
"\n",
"replanner_prompt = ChatPromptTemplate.from_template(\"\"\"For the given objective, come up with a simple step by step plan. \\\n",
"This plan should involve individual tasks, that if executed correctly will yield the correct answer. Do not add any superfluous steps. \\\n",
"The result of the final step should be the final answer. Make sure that each step has all the information needed - do not skip steps.\n",
"\n",
"Your objective was this:\n",
"{input}\n",
"\n",
"Your original plan was this:\n",
"{plan}\n",
"\n",
"You have currently done the follow steps:\n",
"{past_steps}\n",
"\n",
"Update your plan accordingly. If no more steps are needed and you can return to the user, then respond with that. Otherwise, fill out the plan. Only add steps to the plan that still NEED to be done. Do not return previously done steps as part of the plan.\"\"\")\n",
"\n",
"\n",
"replanner = create_openai_fn_runnable([Plan, Response], ChatOpenAI(model=\"gpt-4-turbo-preview\", temperature=0), replanner_prompt)\n"
]
},
{
"cell_type": "markdown",
"id": "859abd13-6ba0-45ad-b341-e652dd5f755b",
"metadata": {},
"source": [
"## Create the Graph\n",
"\n",
"We can now create the graph!"
]
},
{
"cell_type": "code",
"execution_count": 48,
"id": "6c8e0dad-bcea-4c9a-8922-0d820892e2d0",
"metadata": {},
"outputs": [],
"source": [
"async def execute_step(state: PlanExecute):\n",
" task = state['plan'][0]\n",
" agent_response = await agent_executor.ainvoke({\"input\": task, \"chat_history\": []})\n",
" return {\"past_steps\": (task, agent_response['agent_outcome'].return_values['output'])}\n",
"\n",
"async def plan_step(state: PlanExecute):\n",
" plan = await planner.ainvoke({\"objective\": state[\"input\"]})\n",
" return {\"plan\": plan.steps}\n",
"\n",
"async def replan_step(state: PlanExecute):\n",
" output = await replanner.ainvoke(state)\n",
" if isinstance(output, Response):\n",
" return {\"response\": output.response}\n",
" else:\n",
" return {\"plan\": output.steps}\n",
"\n",
"def should_end(state: PlanExecute):\n",
" if state['response']:\n",
" return True\n",
" else:\n",
" return False"
]
},
{
"cell_type": "code",
"execution_count": 49,
"id": "e954cea0-5ccc-46c2-a27b-f5b7185b597d",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"workflow = StateGraph(PlanExecute)\n",
"\n",
"# Add the plan node\n",
"workflow.add_node(\"planner\", plan_step)\n",
"\n",
"# Add the execution step\n",
"workflow.add_node(\"agent\", execute_step)\n",
"\n",
"# Add a replan node\n",
"workflow.add_node(\"replan\", replan_step)\n",
"\n",
"workflow.set_entry_point(\"planner\")\n",
"\n",
"# From plan we go to agent\n",
"workflow.add_edge('planner', 'agent')\n",
"\n",
"# From agent, we replan\n",
"workflow.add_edge(\"agent\", \"replan\")\n",
"\n",
"workflow.add_conditional_edges(\n",
" \"replan\",\n",
" # Next, we pass in the function that will determine which node is called next.\n",
" should_end,\n",
" {\n",
" # If `tools`, then we call the tool node.\n",
" True: END,\n",
" False: \"agent\",\n",
" }\n",
")\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": 50,
"id": "b8ac1f67-e87a-427c-b4f7-44351295b788",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan': ['Identify the winner of the 2024 Australia Open.', \"Research the winner's biography to find their place of birth or hometown.\", 'Confirm the hometown of the 2024 Australia Open winner.']}\n",
"{'past_steps': ('Identify the winner of the 2024 Australia Open.', \"The winners of the 2024 Australian Open were Jannik Sinner in the men's singles category and Aryna Sabalenka in the women's singles category.\")}\n",
"{'plan': [\"Research Jannik Sinner's biography to find his place of birth or hometown.\", \"Research Aryna Sabalenka's biography to find her place of birth or hometown.\", 'Confirm the hometown of Jannik Sinner.', 'Confirm the hometown of Aryna Sabalenka.']}\n",
"{'past_steps': (\"Research Jannik Sinner's biography to find his place of birth or hometown.\", 'Jannik Sinner was born in Innichen, Italy. This town is also known as San Candido, which is mentioned as his hometown.')}\n",
"{'plan': [\"Research Aryna Sabalenka's biography to find her place of birth or hometown.\", 'Confirm the hometown of Aryna Sabalenka.']}\n",
"{'past_steps': (\"Research Aryna Sabalenka's biography to find her place of birth or hometown.\", 'Aryna Sabalenka was born in Minsk, the capital of Belarus.')}\n",
"{'response': 'The hometown of the 2024 Australia Open winners are Innichen (San Candido), Italy for Jannik Sinner and Minsk, Belarus for Aryna Sabalenka. No further steps are needed.'}\n"
]
}
],
"source": [
"from langchain_core.messages import HumanMessage\n",
"config = {\"recursion_limit\": 50}\n",
"inputs = {\"input\": \"what is the hometown of the 2024 Australia open winner?\"}\n",
"async for event in app.astream(inputs, config=config):\n",
" for k, v in event.items():\n",
" if k != \"__end__\":\n",
" print(v)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8c20341e-267d-4ba0-9a0b-dad055a76b1d",
"metadata": {},
"outputs": [],
"source": []
},
{
"cell_type": "code",
"execution_count": null,
"id": "ad8f7955-2cc9-4ebb-8c41-13abb3351a24",
"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
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+46 -4
View File
@@ -1,6 +1,7 @@
import logging
from asyncio import iscoroutinefunction
from collections import defaultdict
from typing import Any, Callable, Dict, NamedTuple, Optional
from typing import Any, Callable, Dict, NamedTuple, Optional, Sequence
from langchain_core.runnables import Runnable
from langchain_core.runnables.base import (
@@ -12,6 +13,8 @@ from langchain_core.runnables.base import (
from langgraph.checkpoint import BaseCheckpointSaver
from langgraph.pregel import Channel, Pregel
logger = logging.getLogger(__name__)
END = "__end__"
@@ -34,8 +37,14 @@ class Graph:
self.edges = set[tuple[str, str]]()
self.branches: defaultdict[str, list[Branch]] = defaultdict(list)
self.support_multiple_edges = False
self.compiled = False
def add_node(self, key: str, action: RunnableLike) -> None:
if self.compiled:
logger.warning(
"Adding a node to a graph that has already been compiled. This will "
"not be reflected in the compiled graph."
)
if key in self.nodes:
raise ValueError(f"Node `{key}` already present.")
if key == END:
@@ -44,6 +53,11 @@ class Graph:
self.nodes[key] = coerce_to_runnable(action)
def add_edge(self, start_key: str, end_key: str) -> None:
if self.compiled:
logger.warning(
"Adding an edge to a graph that has already been compiled. This will "
"not be reflected in the compiled graph."
)
if start_key == END:
raise ValueError("END cannot be a start node")
if start_key not in self.nodes:
@@ -64,6 +78,11 @@ class Graph:
condition: Callable[..., str],
conditional_edge_mapping: Optional[Dict[str, str]] = None,
) -> None:
if self.compiled:
logger.warning(
"Adding an edge to a graph that has already been compiled. This will "
"not be reflected in the compiled graph."
)
if start_key not in self.nodes:
raise ValueError(f"Need to add_node `{start_key}` first")
if iscoroutinefunction(condition):
@@ -81,6 +100,11 @@ class Graph:
self.branches[start_key].append(Branch(condition, conditional_edge_mapping))
def set_entry_point(self, key: str) -> None:
if self.compiled:
logger.warning(
"Setting the entry point of a graph that has already been compiled. "
"This will not be reflected in the compiled graph."
)
if key not in self.nodes:
raise ValueError(f"Need to add_node `{key}` first")
self.entry_point = key
@@ -88,7 +112,7 @@ class Graph:
def set_finish_point(self, key: str) -> None:
return self.add_edge(key, END)
def validate(self) -> None:
def validate(self, interrupt: Optional[Sequence[str]] = None) -> None:
all_starts = {src for src, _ in self.edges} | {src for src in self.branches}
for node in self.nodes:
if node not in all_starts:
@@ -114,8 +138,22 @@ class Graph:
if node not in all_ends:
raise ValueError(f"Node `{node}` is not reachable")
def compile(self, checkpointer: Optional[BaseCheckpointSaver] = None) -> Pregel:
self.validate()
if interrupt:
for node in interrupt:
if node not in self.nodes:
raise ValueError(f"Node `{node}` is not present")
self.compiled = True
def compile(
self,
checkpointer: Optional[BaseCheckpointSaver] = None,
interrupt_before: Optional[Sequence[str]] = None,
interrupt_after: Optional[Sequence[str]] = None,
) -> Pregel:
interrupt_before = interrupt_before or []
interrupt_after = interrupt_after or []
self.validate(interrupt=interrupt_before + interrupt_after)
outgoing_edges = defaultdict(list)
for start, end in self.edges:
@@ -145,4 +183,8 @@ class Graph:
output=END,
hidden=[f"{node}:inbox" for node in self.nodes],
checkpointer=checkpointer,
interrupt=(
[f"{node}:inbox" for node in interrupt_before]
+ [node for node in interrupt_after]
),
)
+14 -3
View File
@@ -1,7 +1,7 @@
from collections import defaultdict
from functools import partial
from inspect import signature
from typing import Any, Optional, Type
from typing import Any, Optional, Sequence, Type
from langchain_core.runnables import RunnableLambda, RunnablePassthrough
from langchain_core.runnables.base import RunnableLike
@@ -34,8 +34,15 @@ class StateGraph(Graph):
)
return super().add_node(key, action)
def compile(self, checkpointer: Optional[BaseCheckpointSaver] = None) -> Pregel:
self.validate()
def compile(
self,
checkpointer: Optional[BaseCheckpointSaver] = None,
interrupt_before: Optional[Sequence[str]] = None,
interrupt_after: Optional[Sequence[str]] = None,
) -> Pregel:
interrupt_before = interrupt_before or []
interrupt_after = interrupt_after or []
self.validate(interrupt=interrupt_before + interrupt_after)
state_keys = list(self.channels)
state_keys_read = state_keys[0] if state_keys == ["__root__"] else state_keys
@@ -98,6 +105,10 @@ class StateGraph(Graph):
output=END,
hidden=[f"{node}:inbox" for node in self.nodes] + [START] + state_keys,
checkpointer=checkpointer,
interrupt=(
[f"{node}:inbox" for node in interrupt_before]
+ [node for node in interrupt_after]
),
)
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.0.23"
version = "0.0.24"
description = "langgraph"
authors = []
license = "LangGraph License"