This commit is contained in:
Harrison Chase
2024-02-26 11:32:55 -08:00
74 changed files with 15472 additions and 491 deletions
+47
View File
@@ -0,0 +1,47 @@
name: Check Links
on:
pull_request:
branches:
- main
push:
branches:
- main
schedule:
- cron: "0 5 * * *"
workflow_dispatch:
env:
POETRY_VERSION: "1.7.1"
jobs:
markdown-link-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check links in Markdown files
uses: gaurav-nelson/github-action-markdown-link-check@v1
notebook-link-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.x + Poetry ${{ env.POETRY_VERSION }}
uses: "./.github/actions/poetry_setup"
with:
python-version: "3.x"
poetry-version: ${{ env.POETRY_VERSION }}
cache-key: core
- name: Install dependencies
shell: bash
run: |
python -m pip install --upgrade pip
poetry install --with test
poetry pip install -U pytest pytest-check-links langsmith langchain GitPython
- name: Check links in notebooks
env:
LANGCHAIN_API_KEY: test
shell: bash
run: poetry run pytest -o python_files=non_python_only --check-links --ignore="*.py" -k .ipynb --check-links-ignore "https://(api|web)\.smith\.langchain\.com/.*" .
+4
View File
@@ -171,3 +171,7 @@ docs/api_reference/*/
docs/docs_skeleton/build
docs/docs_skeleton/node_modules
docs/docs_skeleton/yarn.lock
# Any new jupyter notebooks
# not intended for the repo
Untitled*.ipynb
+1 -1
View File
@@ -18,7 +18,7 @@ test:
poetry run pytest
test_watch:
poetry run ptw --snapshot-update --now . -- -vv -x tests
poetry run ptw .
######################
# LINTING AND FORMATTING
+41 -15
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
@@ -454,6 +454,43 @@ We also have a lot of examples highlighting how to slightly modify the base chat
- [Force calling a tool first](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/force-calling-a-tool-first.ipynb): How to always call a specific tool first
- [Managing agent steps](https://github.com/langchain-ai/langgraph/blob/main/examples/agent_executor/managing-agent-steps.ipynb): How to more explicitly manage intermediate steps that an agent takes
### Async
If you are running LangGraph in async workflows, you may want to create the nodes to be async by default.
For a walkthrough on how to do that, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/async.ipynb)
### Streaming Tokens
Sometimes language models take a while to respond and you may want to stream tokens to end users.
For a guide on how to do this, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/streaming-tokens.ipynb)
### Persistence
LangGraph comes with built-in persistence, allowing you to save the state of the graph at point and resume from there.
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)
### Planning Agent Examples
The following notebooks implement agent architectures prototypical of the "plan-and-execute" style, where an LLM planner decomposes a user request into a program, an executor executes the program, and an LLM synthesizes a response (and/or dynamically replans) based on the program outputs.
- [Plan-and-execute](https://github.com/langchain-ai/langgraph/blob/main/examples/plan-and-execute/plan-and-execute.ipynb): a simple agent with a **planner** that generates a multi-step task list, an **executor** that invokes the tools in the plan, and a **replanner** that responds or generates an updated plan. Based on the [Plan-and-solve](https://arxiv.org/abs/2305.04091) paper by Wang, et. al.
- [Reasoning without Observation](https://github.com/langchain-ai/langgraph/blob/main/examples/rewoo/rewoo.ipynb): planner generates a task list whose observations are saved as **variables**. Variables can be used in subsequent tasks to reduce the need for further re-planning. Based on the [ReWOO](https://arxiv.org/abs/2305.18323) paper by Xu, et. al.
- [LLMCompiler](https://github.com/langchain-ai/langgraph/blob/main/examples/llm-compiler/LLMCompiler.ipynb): planner generates a **DAG** of tasks with variable responses. Tasks are **streamed** and executed eagerly to minimize tool execution runtime. Based on the [paper](https://arxiv.org/abs/2312.04511) by Kim, et. al.
### Reflection / Self-Critique
When output quality is a major concern, it's common to incorporate some combination of self-critique or reflection and external validation to refine your system's outputs. The following examples demonstrate research that implement this type of design.
- [Basic Reflection](./examples/reflection/reflection.ipynb): add a simple "reflect" step in your graph to prompt your system to revise its outputs.
- [Reflexion](./examples/reflexion/reflexion.ipynb): critique missing and superflous aspects of the agent's response to guide subsequent steps. Based on [Reflexion](https://arxiv.org/abs/2303.11366), by Shinn, et. al.
- [Language Agent Tree Search](./examples/lats/lats.ipynb): execute multiple agents in parallel, using reflection and environmental rewards to drive a Monte Carlo Tree Search. Based on [LATS](https://arxiv.org/abs/2310.04406/LanguageAgentTreeSearch/), by Zhou, et. al.
### Multi-agent Examples
- [Multi-agent collaboration](https://github.com/langchain-ai/langgraph/blob/main/examples/multi_agent/multi-agent-collaboration.ipynb): how to create two agents that work together to accomplish a task
@@ -464,22 +501,11 @@ We also have a lot of examples highlighting how to slightly modify the base chat
It can often be tough to evaluation chat bots in multi-turn situations. One way to do this is with simulations.
- [Chat bot evaluation as multi-agent simulation](https://github.com/langchain-ai/langgraph/blob/main/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb): How to simulate a dialogue between a "virtual user" and your chat bot
- [Chat bot evaluation as multi-agent simulation](https://github.com/langchain-ai/langgraph/blob/main/examples/chatbot-simulation-evaluation/agent-simulation-evaluation.ipynb): how to simulate a dialogue between a "virtual user" and your chat bot
### Async
### Multimodal Examples
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)
### Streaming Tokens
Sometimes language models take a while to respond and you may want to stream tokens to end users.
For a guide on how to do this, see [this documentation](https://github.com/langchain-ai/langgraph/blob/main/examples/streaming-tokens.ipynb)
### 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)
- [WebVoyager](https://github.com/langchain-ai/langgraph/blob/main/examples/web-navigation/web_voyager.ipynb): vision-enabled web browsing agent that uses [Set-of-marks](https://som-gpt4v.github.io/) prompting to navigate a web browser and execute tasks
## Documentation
+20 -17
View File
@@ -26,7 +26,7 @@
"metadata": {},
"outputs": [],
"source": [
"!pip install --quiet -U langchain langchain_openai tavily-python"
"!pip install --quiet -U langchain langchain_openai langchainhub tavily-python"
]
},
{
@@ -133,17 +133,17 @@
"\n",
"\n",
"class AgentState(TypedDict):\n",
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n"
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]"
]
},
{
@@ -187,23 +187,26 @@
"# It takes in an agent action and calls that tool and returns the result\n",
"tool_executor = ToolExecutor(tools)\n",
"\n",
"\n",
"# Define the agent\n",
"def run_agent(data):\n",
" agent_outcome = agent_runnable.invoke(data)\n",
" return {\"agent_outcome\": agent_outcome}\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"def execute_tools(data):\n",
" # Get the most recent agent_outcome - this is the key added in the `agent` above\n",
" agent_action = data['agent_outcome']\n",
" agent_action = data[\"agent_outcome\"]\n",
" output = tool_executor.invoke(agent_action)\n",
" return {\"intermediate_steps\": [(agent_action, str(output))]}\n",
"\n",
"\n",
"# Define logic that will be used to determine which conditional edge to go down\n",
"def should_continue(data):\n",
" # If the agent outcome is an AgentFinish, then we return `exit` string\n",
" # This will be used when setting up the graph to define the flow\n",
" if isinstance(data['agent_outcome'], AgentFinish):\n",
" if isinstance(data[\"agent_outcome\"], AgentFinish):\n",
" return \"end\"\n",
" # Otherwise, an AgentAction is returned\n",
" # Here we return `continue` string\n",
@@ -259,13 +262,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -138,17 +138,17 @@
"\n",
"\n",
"class AgentState(TypedDict):\n",
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n"
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]"
]
},
{
@@ -192,23 +192,26 @@
"# It takes in an agent action and calls that tool and returns the result\n",
"tool_executor = ToolExecutor(tools)\n",
"\n",
"\n",
"# Define the agent\n",
"def run_agent(data):\n",
" agent_outcome = agent_runnable.invoke(data)\n",
" return {\"agent_outcome\": agent_outcome}\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"def execute_tools(data):\n",
" # Get the most recent agent_outcome - this is the key added in the `agent` above\n",
" agent_action = data['agent_outcome']\n",
" agent_action = data[\"agent_outcome\"]\n",
" output = tool_executor.invoke(agent_action)\n",
" return {\"intermediate_steps\": [(agent_action, str(output))]}\n",
"\n",
"\n",
"# Define logic that will be used to determine which conditional edge to go down\n",
"def should_continue(data):\n",
" # If the agent outcome is an AgentFinish, then we return `exit` string\n",
" # This will be used when setting up the graph to define the flow\n",
" if isinstance(data['agent_outcome'], AgentFinish):\n",
" if isinstance(data[\"agent_outcome\"], AgentFinish):\n",
" return \"end\"\n",
" # Otherwise, an AgentAction is returned\n",
" # Here we return `continue` string\n",
@@ -257,14 +260,15 @@
"source": [
"from langchain_core.agents import AgentActionMessageLog\n",
"\n",
"\n",
"def first_agent(inputs):\n",
" action = AgentActionMessageLog(\n",
" # We force call this tool\n",
" tool=\"tavily_search_results_json\",\n",
" # We just pass in the `input` key to this tool\n",
" tool_input=inputs[\"input\"],\n",
" log=\"\",\n",
" message_log=[]\n",
" # We force call this tool\n",
" tool=\"tavily_search_results_json\",\n",
" # We just pass in the `input` key to this tool\n",
" tool_input=inputs[\"input\"],\n",
" log=\"\",\n",
" message_log=[],\n",
" )\n",
" return {\"agent_outcome\": action}"
]
@@ -321,16 +325,16 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# After the first agent, we want to take an action\n",
"workflow.add_edge('first_agent', 'action')\n",
"workflow.add_edge(\"first_agent\", \"action\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
+11 -6
View File
@@ -192,7 +192,7 @@
}
],
"source": [
"s['__end__']['agent_outcome']"
"s[\"__end__\"][\"agent_outcome\"]"
]
},
{
@@ -226,10 +226,15 @@
"source": [
"from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n",
"\n",
"prompt = ChatPromptTemplate.from_messages([\n",
" (\"human\", \"Respond to the user question: {question}. Answer in this language: {language}\"),\n",
" MessagesPlaceholder(variable_name=\"agent_scratchpad\")\n",
"])\n",
"prompt = ChatPromptTemplate.from_messages(\n",
" [\n",
" (\n",
" \"human\",\n",
" \"Respond to the user question: {question}. Answer in this language: {language}\",\n",
" ),\n",
" MessagesPlaceholder(variable_name=\"agent_scratchpad\"),\n",
" ]\n",
")\n",
"agent_runnable = create_openai_functions_agent(llm, tools, prompt)"
]
},
@@ -327,7 +332,7 @@
}
],
"source": [
"s['__end__']['agent_outcome']"
"s[\"__end__\"][\"agent_outcome\"]"
]
},
{
+18 -16
View File
@@ -138,17 +138,17 @@
"\n",
"\n",
"class AgentState(TypedDict):\n",
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n"
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]"
]
},
{
@@ -192,6 +192,7 @@
"# It takes in an agent action and calls that tool and returns the result\n",
"tool_executor = ToolExecutor(tools)\n",
"\n",
"\n",
"# Define the agent\n",
"def run_agent(data):\n",
" agent_outcome = agent_runnable.invoke(data)\n",
@@ -218,18 +219,19 @@
"# Define the function to execute tools\n",
"def execute_tools(data):\n",
" # Get the most recent agent_outcome - this is the key added in the `agent` above\n",
" agent_action = data['agent_outcome']\n",
" agent_action = data[\"agent_outcome\"]\n",
" response = input(prompt=f\"[y/n] continue with: {agent_action}?\")\n",
" if response == \"n\":\n",
" raise ValueError\n",
" output = tool_executor.invoke(agent_action)\n",
" return {\"intermediate_steps\": [(agent_action, str(output))]}\n",
"\n",
"\n",
"# Define logic that will be used to determine which conditional edge to go down\n",
"def should_continue(data):\n",
" # If the agent outcome is an AgentFinish, then we return `exit` string\n",
" # This will be used when setting up the graph to define the flow\n",
" if isinstance(data['agent_outcome'], AgentFinish):\n",
" if isinstance(data[\"agent_outcome\"], AgentFinish):\n",
" return \"end\"\n",
" # Otherwise, an AgentAction is returned\n",
" # Here we return `continue` string\n",
@@ -285,13 +287,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -138,17 +138,17 @@
"\n",
"\n",
"class AgentState(TypedDict):\n",
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]\n"
" # The input string\n",
" input: str\n",
" # The list of previous messages in the conversation\n",
" chat_history: list[BaseMessage]\n",
" # The outcome of a given call to the agent\n",
" # Needs `None` as a valid type, since this is what this will start as\n",
" agent_outcome: Union[AgentAction, AgentFinish, None]\n",
" # List of actions and corresponding observations\n",
" # Here we annotate this with `operator.add` to indicate that operations to\n",
" # this state should be ADDED to the existing values (not overwrite it)\n",
" intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]"
]
},
{
@@ -213,23 +213,25 @@
"# Define the agent\n",
"def run_agent(data):\n",
" inputs = data.copy()\n",
" if len(inputs['intermediate_steps']) > 5:\n",
" inputs['intermediate_steps'] = inputs['intermediate_steps'][-5:]\n",
" if len(inputs[\"intermediate_steps\"]) > 5:\n",
" inputs[\"intermediate_steps\"] = inputs[\"intermediate_steps\"][-5:]\n",
" agent_outcome = agent_runnable.invoke(inputs)\n",
" return {\"agent_outcome\": agent_outcome}\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"def execute_tools(data):\n",
" # Get the most recent agent_outcome - this is the key added in the `agent` above\n",
" agent_action = data['agent_outcome']\n",
" agent_action = data[\"agent_outcome\"]\n",
" output = tool_executor.invoke(agent_action)\n",
" return {\"intermediate_steps\": [(agent_action, str(output))]}\n",
"\n",
"\n",
"# Define logic that will be used to determine which conditional edge to go down\n",
"def should_continue(data):\n",
" # If the agent outcome is an AgentFinish, then we return `exit` string\n",
" # This will be used when setting up the graph to define the flow\n",
" if isinstance(data['agent_outcome'], AgentFinish):\n",
" if isinstance(data[\"agent_outcome\"], AgentFinish):\n",
" return \"end\"\n",
" # Otherwise, an AgentAction is returned\n",
" # Here we return `continue` string\n",
@@ -285,13 +287,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
+13 -7
View File
@@ -265,9 +265,10 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
" if \"function_call\" not in last_message.additional_kwargs:\n",
@@ -276,23 +277,27 @@
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"async def call_model(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" response = await model.ainvoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"async def call_tool(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" # Based on the continue condition\n",
" # we know the last message involves a function call\n",
" last_message = messages[-1]\n",
" # We construct an ToolInvocation from the function_call\n",
" action = ToolInvocation(\n",
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" # We call the tool_executor and get back a response\n",
" response = await tool_executor.ainvoke(action)\n",
@@ -320,6 +325,7 @@
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -348,13 +354,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -242,9 +242,10 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
" if \"function_call\" not in last_message.additional_kwargs:\n",
@@ -253,23 +254,27 @@
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"def call_model(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" response = model.invoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"def call_tool(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" # Based on the continue condition\n",
" # we know the last message involves a function call\n",
" last_message = messages[-1]\n",
" # We construct an ToolInvocation from the function_call\n",
" action = ToolInvocation(\n",
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" # We call the tool_executor and get back a response\n",
" response = tool_executor.invoke(action)\n",
@@ -297,6 +302,7 @@
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -325,13 +331,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -100,12 +100,14 @@
"source": [
"from langchain_core.pydantic_v1 import BaseModel, Field\n",
"\n",
"\n",
"class SearchTool(BaseModel):\n",
" \"\"\"Look up things online, optionally returning directly\"\"\"\n",
"\n",
" query: str = Field(description=\"query to look up online\")\n",
" return_direct: bool = Field(\n",
" description=\"Whether or the result of this should be returned directly to the user without you seeing what it is\", \n",
" default = False\n",
" return_direct: bool = Field(\n",
" description=\"Whether or the result of this should be returned directly to the user without you seeing what it is\",\n",
" default=False,\n",
" )"
]
},
@@ -289,14 +291,16 @@
"source": [
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
" if \"function_call\" not in last_message.additional_kwargs:\n",
" return \"end\"\n",
" # Otherwise if there is, we check if it's suppose to return direct\n",
" else:\n",
" arguments = json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"])\n",
" arguments = json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" )\n",
" if arguments.get(\"return_direct\", False):\n",
" return \"final\"\n",
" else:\n",
@@ -312,7 +316,7 @@
"source": [
"# Define the function that calls the model\n",
"def call_model(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" response = model.invoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}"
@@ -337,7 +341,7 @@
"source": [
"# Define the function to execute tools\n",
"def call_tool(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" # Based on the continue condition\n",
" # we know the last message involves a function call\n",
" last_message = messages[-1]\n",
@@ -381,6 +385,7 @@
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -412,14 +417,14 @@
" # Final call\n",
" \"final\": \"final\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge('final', END)\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"workflow.add_edge(\"final\", END)\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -522,7 +527,13 @@
"source": [
"from langchain_core.messages import HumanMessage\n",
"\n",
"inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf? return this result directly by setting return_direct = True\")]}\n",
"inputs = {\n",
" \"messages\": [\n",
" HumanMessage(\n",
" content=\"what is the weather in sf? return this result directly by setting return_direct = True\"\n",
" )\n",
" ]\n",
"}\n",
"for output in app.stream(inputs):\n",
" # stream() yields dictionaries with output keyed by node name\n",
" for key, value in output.items():\n",
@@ -246,9 +246,10 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
" if \"function_call\" not in last_message.additional_kwargs:\n",
@@ -257,23 +258,27 @@
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"def call_model(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" response = model.invoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"def call_tool(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" # Based on the continue condition\n",
" # we know the last message involves a function call\n",
" last_message = messages[-1]\n",
" # We construct an ToolInvocation from the function_call\n",
" action = ToolInvocation(\n",
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" # We call the tool_executor and get back a response\n",
" response = tool_executor.invoke(action)\n",
@@ -304,20 +309,21 @@
"from langchain_core.messages import AIMessage\n",
"import json\n",
"\n",
"\n",
"def first_model(state):\n",
" human_input = state['messages'][-1].content\n",
" human_input = state[\"messages\"][-1].content\n",
" return {\n",
" \"messages\": [\n",
" AIMessage(\n",
" content=\"\", \n",
" content=\"\",\n",
" additional_kwargs={\n",
" \"function_call\": {\n",
" \"name\": \"tavily_search_results_json\", \n",
" \"arguments\": json.dumps({\"query\": human_input})\n",
" }\n",
" \"name\": \"tavily_search_results_json\",\n",
" \"arguments\": json.dumps({\"query\": human_input}),\n",
" }\n",
" )\n",
" ]\n",
" },\n",
" )\n",
" ]\n",
" }"
]
},
@@ -343,6 +349,7 @@
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -374,16 +381,16 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# After we call the first agent, we know we want to go to action\n",
"workflow.add_edge('first_agent', 'action')\n",
"workflow.add_edge(\"first_agent\", \"action\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -0,0 +1,136 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "8bcd1a3d-7c50-4f58-be4e-1ed654aa33be",
"metadata": {},
"source": [
"# Chat Executor: with tool calling\n",
"\n",
"This notebook walks through an example creating a chat executor that uses tool calling.\n",
"This is useful for getting started quickly.\n",
"However, it is highly likely you will want to customize the logic - for information on that, check out the other examples in this folder."
]
},
{
"cell_type": "markdown",
"id": "e130cf70-a30e-47d7-8fd5-464f1a92e374",
"metadata": {},
"source": [
"## Set up the chat model and tools\n",
"\n",
"Here we will define the chat model and tools that we want to use.\n",
"Importantly, this model MUST support OpenAI function calling."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "efb7e3c0-c63f-40f6-93ce-19681d650fc2",
"metadata": {},
"outputs": [],
"source": [
"from langchain_openai import ChatOpenAI\n",
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"from langgraph.prebuilt import chat_agent_executor\n",
"from langchain_core.messages import HumanMessage"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "a7025f33-3160-41cf-868b-17ebc916fb1d",
"metadata": {},
"outputs": [],
"source": [
"tools = [TavilySearchResults(max_results=1)]\n",
"model = ChatOpenAI()"
]
},
{
"cell_type": "markdown",
"id": "43064805-2ac9-4b5a-850c-a68dd7282350",
"metadata": {},
"source": [
"## Create executor\n",
"\n",
"We can now use the high level interface to create the executor"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "32b4ae66-f667-4a8b-a602-503fd0effcd9",
"metadata": {},
"outputs": [],
"source": [
"app = chat_agent_executor.create_tool_calling_executor(model, tools)"
]
},
{
"cell_type": "markdown",
"id": "d63dbfc7-a5c1-4a03-991c-f0789ba52c52",
"metadata": {},
"source": [
"We can now invoke this executor. The input to this must be a dictionary with a single `messsages` key that contains a list of messages."
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "0abc5655-d772-450c-832f-1fee1111a5f6",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_eI2B853W8Jrm8IvmwEafikFv', 'function': {'arguments': '{\"query\": \"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}, {'id': 'call_Aky1m2Z5dvUcHKyha7r5s3Wj', 'function': {'arguments': '{\"query\": \"weather in Los Angeles\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]})]}\n",
"----\n",
"{'messages': [ToolMessage(content=\"[{'url': 'https://www.wunderground.com/forecast/us/ca/san-francisco', 'content': 'Get the latest weather information for San Francisco, CA, including temperature, precipitation, wind speed, and humidity. See the hourly and 10-day forecast for the South of Market station and other nearby weather stations.'}]\", tool_call_id='call_eI2B853W8Jrm8IvmwEafikFv'), ToolMessage(content=\"[{'url': 'https://www.accuweather.com/en/us/los-angeles/90012/hourly-weather-forecast/347625', 'content': 'Get the latest hourly weather updates for Los Angeles, CA, including rain alerts, air quality, wind speed and direction, humidity, and cloud cover. See the forecast for the next eight hours and plan your activities accordingly.'}]\", tool_call_id='call_Aky1m2Z5dvUcHKyha7r5s3Wj')]}\n",
"----\n",
"{'messages': [AIMessage(content='The weather in San Francisco can be found [here](https://www.wunderground.com/forecast/us/ca/san-francisco), which includes information on temperature, precipitation, wind speed, and humidity.\\n\\nFor Los Angeles, you can check the hourly weather updates [here](https://www.accuweather.com/en/us/los-angeles/90012/hourly-weather-forecast/347625), which includes details on rain alerts, air quality, wind speed and direction, humidity, and cloud cover.')]}\n",
"----\n",
"{'messages': [HumanMessage(content='what is the weather in sf and la'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_eI2B853W8Jrm8IvmwEafikFv', 'function': {'arguments': '{\"query\": \"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}, {'id': 'call_Aky1m2Z5dvUcHKyha7r5s3Wj', 'function': {'arguments': '{\"query\": \"weather in Los Angeles\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}), ToolMessage(content=\"[{'url': 'https://www.wunderground.com/forecast/us/ca/san-francisco', 'content': 'Get the latest weather information for San Francisco, CA, including temperature, precipitation, wind speed, and humidity. See the hourly and 10-day forecast for the South of Market station and other nearby weather stations.'}]\", tool_call_id='call_eI2B853W8Jrm8IvmwEafikFv'), ToolMessage(content=\"[{'url': 'https://www.accuweather.com/en/us/los-angeles/90012/hourly-weather-forecast/347625', 'content': 'Get the latest hourly weather updates for Los Angeles, CA, including rain alerts, air quality, wind speed and direction, humidity, and cloud cover. See the forecast for the next eight hours and plan your activities accordingly.'}]\", tool_call_id='call_Aky1m2Z5dvUcHKyha7r5s3Wj'), AIMessage(content='The weather in San Francisco can be found [here](https://www.wunderground.com/forecast/us/ca/san-francisco), which includes information on temperature, precipitation, wind speed, and humidity.\\n\\nFor Los Angeles, you can check the hourly weather updates [here](https://www.accuweather.com/en/us/los-angeles/90012/hourly-weather-forecast/347625), which includes details on rain alerts, air quality, wind speed and direction, humidity, and cloud cover.')]}\n",
"----\n"
]
}
],
"source": [
"inputs = {\"messages\": [HumanMessage(content=\"what is the weather in sf and la\")]}\n",
"for s in app.stream(inputs):\n",
" print(list(s.values())[0])\n",
" print(\"----\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "87f147e3-f96f-4b96-a3cc-ec7affd7a57f",
"metadata": {},
"outputs": [],
"source": []
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.6"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
@@ -263,9 +263,10 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
" if \"function_call\" not in last_message.additional_kwargs:\n",
@@ -274,9 +275,10 @@
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"def call_model(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" response = model.invoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}"
@@ -301,14 +303,16 @@
"source": [
"# Define the function to execute tools\n",
"def call_tool(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" # Based on the continue condition\n",
" # we know the last message involves a function call\n",
" last_message = messages[-1]\n",
" # We construct an ToolInvocation from the function_call\n",
" action = ToolInvocation(\n",
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" response = input(f\"[y/n] continue with: {action}?\")\n",
" if response == \"n\":\n",
@@ -339,6 +343,7 @@
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -367,13 +372,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -246,9 +246,10 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
" if \"function_call\" not in last_message.additional_kwargs:\n",
@@ -277,7 +278,7 @@
"source": [
"# Define the function that calls the model\n",
"def call_model(state):\n",
" messages = state['messages'][-5:]\n",
" messages = state[\"messages\"][-5:]\n",
" response = model.invoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}"
@@ -292,14 +293,16 @@
"source": [
"# Define the function to execute tools\n",
"def call_tool(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" # Based on the continue condition\n",
" # we know the last message involves a function call\n",
" last_message = messages[-1]\n",
" # We construct an ToolInvocation from the function_call\n",
" action = ToolInvocation(\n",
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" # We call the tool_executor and get back a response\n",
" response = tool_executor.invoke(action)\n",
@@ -327,6 +330,7 @@
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -355,13 +359,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -177,8 +177,10 @@
"from langchain_core.pydantic_v1 import BaseModel, Field\n",
"from langchain_core.utils.function_calling import convert_pydantic_to_openai_function\n",
"\n",
"\n",
"class Response(BaseModel):\n",
" \"\"\"Final response to the user\"\"\"\n",
"\n",
" temperature: float = Field(description=\"the temperature\")\n",
" other_notes: str = Field(description=\"any other notes about the weather\")\n",
"\n",
@@ -264,9 +266,10 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" last_message = messages[-1]\n",
" # If there is no function call, then we finish\n",
" if \"function_call\" not in last_message.additional_kwargs:\n",
@@ -278,23 +281,27 @@
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"def call_model(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" response = model.invoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return {\"messages\": [response]}\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"def call_tool(state):\n",
" messages = state['messages']\n",
" messages = state[\"messages\"]\n",
" # Based on the continue condition\n",
" # we know the last message involves a function call\n",
" last_message = messages[-1]\n",
" # We construct an ToolInvocation from the function_call\n",
" action = ToolInvocation(\n",
" tool=last_message.additional_kwargs[\"function_call\"][\"name\"],\n",
" tool_input=json.loads(last_message.additional_kwargs[\"function_call\"][\"arguments\"]),\n",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" # We call the tool_executor and get back a response\n",
" response = tool_executor.invoke(action)\n",
@@ -322,6 +329,7 @@
"outputs": [],
"source": [
"from langgraph.graph import StateGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = StateGraph(AgentState)\n",
"\n",
@@ -350,13 +358,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -84,7 +84,10 @@
"\n",
"# This is flexible, but you can define your agent here, or call your agent API here.\n",
"def my_chat_bot(messages: List[dict]) -> dict:\n",
" system_message = {\"role\": \"system\", \"content\": \"You are a customer support agent for an airline.\"}\n",
" system_message = {\n",
" \"role\": \"system\",\n",
" \"content\": \"You are a customer support agent for an airline.\",\n",
" }\n",
" messages = [system_message] + messages\n",
" completion = openai.chat.completions.create(\n",
" messages=messages, model=\"gpt-3.5-turbo\"\n",
@@ -234,8 +237,7 @@
" # Call the chat bot\n",
" chat_bot_response = my_chat_bot(messages)\n",
" # Respond with an AI Message\n",
" return AIMessage(content=chat_bot_response[\"content\"])\n",
" "
" return AIMessage(content=chat_bot_response[\"content\"])"
]
},
{
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+508
View File
@@ -0,0 +1,508 @@
{
"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",
"\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",
"\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",
"\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(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\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",
"\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",
"\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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+837
View File
@@ -0,0 +1,837 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "9e0c1743-8775-4de2-a599-78b050551489",
"metadata": {},
"source": [
"# Language Agent Tree Search\n",
"\n",
"[Language Agent Tree Search](https://andyz245.github.io/LanguageAgentTreeSearch/) (LATS), by Zhou, et. al, is a general LLM agent search algorithm that combines reflection/evaluation and search (specifically monte-carlo trees search) to get achieve better overall task performance compared to similar techniques like ReACT, Reflexion, or Tree of Thoughts.\n",
"\n",
"![LATS diagram](./img/lats.png)\n",
"\n",
"It has four main steps:\n",
"\n",
"1. Select: pick the best next actions based on the aggreate rewards from step (2). Either respond (if a solution is found or the max search depth is reached) or continue searching.\n",
"2. Expand and simulate: select the \"best\" 5 potential actions to take and execute them in parallel.\n",
"3. Reflect + Evaluate: observe the outcomes of these actions and score the decisions based on reflection (and possibly external feedback)\n",
"4. Backpropagate: update the scores of the root trajectories based on the outcomes."
]
},
{
"cell_type": "markdown",
"id": "db28668b-5491-4c93-a961-bd339f09202c",
"metadata": {},
"source": [
"## 0. Prerequisites\n",
"\n",
"Install `langgraph` (for the framework), `langchain_openai` (for the LLM), and `langchain` + `tavily-python` (for the search engine).\n",
"\n",
"We will use tavily search as a tool. You can get an API key [here](https://app.tavily.com/sign-in) or replace with a different tool of your choosing."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "dcc9159b-cc8c-426d-9670-3e8ada06723f",
"metadata": {},
"outputs": [],
"source": [
"# %pip install -U --quiet langchain langgraph langchain_openai\n",
"# %pip install -U --quiet tavily-python"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "a177ecc9-0c96-460f-9b39-9c1ce54754f1",
"metadata": {},
"outputs": [],
"source": [
"import getpass\n",
"import os\n",
"\n",
"\n",
"def _set_if_undefined(var: str) -> None:\n",
" if os.environ.get(var):\n",
" return\n",
" os.environ[var] = getpass.getpass(var)\n",
"\n",
"\n",
"# Optional: Configure tracing to visualize and debug the agent\n",
"_set_if_undefined(\"LANGCHAIN_API_KEY\")\n",
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
"os.environ[\"LANGCHAIN_PROJECT\"] = \"LATS\"\n",
"\n",
"_set_if_undefined(\"OPENAI_API_KEY\")\n",
"_set_if_undefined(\"TAVILY_API_KEY\")"
]
},
{
"cell_type": "markdown",
"id": "f857eacb-af4a-47d1-b45f-da74941125c2",
"metadata": {},
"source": [
"## Graph State\n",
"\n",
"LATS is based on a (greedy) Monte-Carlo tree search. For each search steps, it picks the node with the highest \"upper confidence bound\", which is a metric that balances exploitation (highest average reward) and exploration (lowest visits). Starting from that node, it generates N (5 in this case) new candidate actions to take, and adds them to the tree. It stops searching either when it has generated a valid solution OR when it has reached the maximum number of rollouts (search tree depth).\n",
"\n",
"![Tree Diagram](./img/tree.png)\n",
"\n",
"Our LangGraph state will be composed of two items:\n",
"1. The root of the search tree\n",
"2. The user input"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "54c6f319-3966-4f66-aa7b-50e249189111",
"metadata": {},
"outputs": [],
"source": [
"from __future__ import annotations\n",
"\n",
"import math\n",
"from typing import List, Optional\n",
"\n",
"from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage\n",
"\n",
"\n",
"class Node:\n",
" def __init__(\n",
" self,\n",
" messages: List[BaseMessage],\n",
" reflection: Reflection,\n",
" parent: Optional[Node] = None,\n",
" ):\n",
" self.messages = messages\n",
" self.parent = parent\n",
" self.children = []\n",
" self.value = 0\n",
" self.visits = 0\n",
" self.reflection = reflection\n",
" self.depth = parent.depth + 1 if parent is not None else 1\n",
" self._is_solved = reflection.found_solution if reflection else False\n",
" if self._is_solved:\n",
" self._mark_tree_as_solved()\n",
" self.backpropagate(reflection.normalized_score)\n",
"\n",
" def __repr__(self) -> str:\n",
" return (\n",
" f\"<Node value={self.value}, visits={self.visits},\"\n",
" f\" solution={self.messages} reflection={self.reflection}/>\"\n",
" )\n",
"\n",
" @property\n",
" def is_solved(self):\n",
" \"\"\"If any solutions exist, we can end the search.\"\"\"\n",
" return self._is_solved\n",
"\n",
" @property\n",
" def is_terminal(self):\n",
" return not self.children\n",
"\n",
" @property\n",
" def best_child(self):\n",
" \"\"\"Select the child with the highest UCT to search next.\"\"\"\n",
" if not self.children:\n",
" return None\n",
" return max(self.children, key=lambda child: child.upper_confidence_bound())\n",
"\n",
" @property\n",
" def best_child_score(self):\n",
" \"\"\"Return the child with the highest value.\"\"\"\n",
" if not self.children:\n",
" return None\n",
" return max(self.children, key=lambda child: int(child.is_solved) * child.value)\n",
"\n",
" @property\n",
" def height(self) -> int:\n",
" \"\"\"Check for how far we've rolled out the tree.\"\"\"\n",
" if self.children:\n",
" return 1 + max([child.height for child in self.children])\n",
" return 1\n",
"\n",
" def upper_confidence_bound(self, exploration_weight=1.0):\n",
" \"\"\"Return the UCT score. This helps balance exploration vs. exploitation of a branch.\"\"\"\n",
" if self.parent is None:\n",
" raise ValueError(\"Cannot obtain UCT from root node\")\n",
" if self.visits == 0:\n",
" return self.value\n",
" # Encourages exploitation of high-value trajectories\n",
" average_reward = self.value / self.visits\n",
" # Encourages exploration of less-visited trajectories\n",
" exploration_term = math.sqrt(math.log(self.parent.visits) / self.visits)\n",
" return average_reward + exploration_weight * exploration_term\n",
"\n",
" def backpropagate(self, reward: float):\n",
" \"\"\"Update the score of this node and its parents.\"\"\"\n",
" node = self\n",
" while node:\n",
" node.visits += 1\n",
" node.value = (node.value * (node.visits - 1) + reward) / node.visits\n",
" node = node.parent\n",
"\n",
" def get_messages(self, include_reflections: bool = True):\n",
" if include_reflections:\n",
" return self.messages + [self.reflection.as_message()]\n",
" return self.messages\n",
"\n",
" def get_trajectory(self, include_reflections: bool = True) -> List[BaseMessage]:\n",
" \"\"\"Get messages representing this search branch.\"\"\"\n",
" messages = []\n",
" node = self\n",
" while node:\n",
" messages.extend(\n",
" node.get_messages(include_reflections=include_reflections)[::-1]\n",
" )\n",
" node = node.parent\n",
" # Reverse the final back-tracked trajectory to return in the correct order\n",
" return messages[::-1] # root solution, reflection, child 1, ...\n",
"\n",
" def get_best_solution(self):\n",
" \"\"\"Return the best solution from within the current sub-tree.\"\"\"\n",
" all_nodes = [self]\n",
" nodes = deque()\n",
" nodes.append(self)\n",
" while nodes:\n",
" node = nodes.popleft()\n",
" all_nodes.extend(node.children)\n",
" for n in node.children:\n",
" nodes.append(n)\n",
" best_node = max(\n",
" all_nodes,\n",
" # We filter out all non-terminal, non-solution trajectories\n",
" key=lambda node: int(node.is_terminal and node.is_solved) * node.value,\n",
" )\n",
" return best_node\n",
"\n",
" def _mark_tree_as_solved(self):\n",
" parent = self.parent\n",
" while parent:\n",
" parent._is_solved = True\n",
" parent = parent.parent"
]
},
{
"cell_type": "markdown",
"id": "cdd3111f-b860-471f-8784-1d5e3783910d",
"metadata": {},
"source": [
"#### The graph state itself\n",
"\n",
"The main component is the tree, represented by the root node."
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "e10c94ba-9daa-4899-97ce-4f28428c2c38",
"metadata": {},
"outputs": [],
"source": [
"from typing_extensions import TypedDict\n",
"\n",
"\n",
"class TreeState(TypedDict):\n",
" # The full tree\n",
" root: Node\n",
" # The original input\n",
" input: str"
]
},
{
"cell_type": "markdown",
"id": "2e8ddf25-d040-4e1f-87bd-5837ff105845",
"metadata": {},
"source": [
"## Define Language Agent\n",
"\n",
"Our agent will have three primary LLM-powered processes:\n",
"1. Reflect: score the action based on the tool response.\n",
"2. Initial response: to create the root node and start the search.\n",
"3. Expand: generate 5 candidate \"next steps\" from the best spot in the current tree\n",
"\n",
"For more \"Grounded\" tool applications (such as code synthesis), you could integrate code execution into the reflection/reward step. This type of external feedback is very useful (though adds complexity to an already complicated example notebook)."
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "48738896-42ac-47eb-b482-0d4d4dd86c87",
"metadata": {},
"outputs": [],
"source": [
"from langchain_openai import ChatOpenAI\n",
"\n",
"llm = ChatOpenAI(model=\"gpt-3.5-turbo\")"
]
},
{
"cell_type": "markdown",
"id": "5d460856-e26d-4430-910e-0aac58563612",
"metadata": {},
"source": [
"#### Tools\n",
"\n",
"For our example, we will give the language agent a search engine."
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "55c2aff3-f454-43da-8f45-1a3d46523cd5",
"metadata": {},
"outputs": [],
"source": [
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"from langchain_community.utilities.tavily_search import TavilySearchAPIWrapper\n",
"\n",
"from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation\n",
"\n",
"search = TavilySearchAPIWrapper()\n",
"tavily_tool = TavilySearchResults(api_wrapper=search, max_results=5)\n",
"tools = [tavily_tool]\n",
"tool_executor = ToolExecutor(tools=tools)"
]
},
{
"cell_type": "markdown",
"id": "1c611f1e-74b4-4157-997c-face8ad409a4",
"metadata": {},
"source": [
"### Reflection\n",
"\n",
"The reflection chain will score agent outputs based on the decision and the tool responses.\n",
"We will call this within the other two nodes."
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "ddfd1750-c265-4b29-b505-83b1c5e2d30e",
"metadata": {},
"outputs": [],
"source": [
"from langchain.chains import create_structured_output_runnable\n",
"from langchain.output_parsers.openai_tools import (\n",
" JsonOutputToolsParser,\n",
" PydanticToolsParser,\n",
")\n",
"from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n",
"from langchain_core.pydantic_v1 import BaseModel, Field\n",
"from langchain_core.runnables import chain as as_runnable\n",
"\n",
"\n",
"class Reflection(BaseModel):\n",
" reflections: str = Field(\n",
" description=\"The critique and reflections on the sufficiency, superfluency,\"\n",
" \" and general quality of the response\"\n",
" )\n",
" score: int = Field(\n",
" description=\"Score from 0-10 on the quality of the candidate response.\",\n",
" gte=0,\n",
" lte=10,\n",
" )\n",
" found_solution: bool = Field(\n",
" description=\"Whether the response has fully solved the question or task.\"\n",
" )\n",
"\n",
" def as_message(self):\n",
" return HumanMessage(\n",
" content=f\"Reasoning: {self.reflections}\\nScore: {self.score}\"\n",
" )\n",
"\n",
" @property\n",
" def normalized_score(self) -> float:\n",
" return self.score / 10.0\n",
"\n",
"\n",
"prompt = ChatPromptTemplate.from_messages(\n",
" [\n",
" (\n",
" \"system\",\n",
" \"Reflect and grade the assistant response to the user question below.\",\n",
" ),\n",
" (\"user\", \"{input}\"),\n",
" MessagesPlaceholder(variable_name=\"candidate\"),\n",
" ]\n",
")\n",
"\n",
"reflection_llm_chain = (\n",
" prompt\n",
" | llm.bind_tools(tools=[Reflection], tool_choice=\"Reflection\").with_config(\n",
" run_name=\"Reflection\"\n",
" )\n",
" | PydanticToolsParser(tools=[Reflection])\n",
")\n",
"\n",
"\n",
"@as_runnable\n",
"def reflection_chain(inputs) -> Reflection:\n",
" tool_choices = reflection_llm_chain.invoke(inputs)\n",
" reflection = tool_choices[0]\n",
" if not isinstance(inputs[\"candidate\"][-1], AIMessage):\n",
" reflection.found_solution = False\n",
" return reflection"
]
},
{
"cell_type": "markdown",
"id": "4e47dfb2-4ab3-4a31-b117-f07786b357cb",
"metadata": {},
"source": [
"### Initial Response\n",
"\n",
"We start with a single root node, generated by this first step. It responds to the user input either with a tool invocation or a response."
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "72fc5363-f0f3-4362-8499-14eb583bd75b",
"metadata": {},
"outputs": [],
"source": [
"from typing import List\n",
"\n",
"from langchain_core.prompt_values import ChatPromptValue\n",
"from langchain_core.pydantic_v1 import BaseModel, Field, ValidationError\n",
"from langchain_core.runnables import RunnableConfig\n",
"\n",
"prompt_template = ChatPromptTemplate.from_messages(\n",
" [\n",
" (\n",
" \"system\",\n",
" \"You are an AI assistant.\",\n",
" ),\n",
" (\"user\", \"{input}\"),\n",
" MessagesPlaceholder(variable_name=\"messages\", optional=True),\n",
" ]\n",
")\n",
"\n",
"\n",
"initial_answer_chain = prompt_template | llm.bind_tools(tools=tools).with_config(\n",
" run_name=\"GenerateInitialCandidate\"\n",
")\n",
"\n",
"\n",
"parser = JsonOutputToolsParser(return_id=True)"
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "7207f913-a6db-4ef9-a98d-ecb8612b23d5",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_APBQsd15wnSNPhyghCvFrNC8', 'function': {'arguments': '{\"query\":\"lithium pollution research report\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]})"
]
},
"execution_count": 16,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"initial_response = initial_answer_chain.invoke(\n",
" {\"input\": \"Write a research report on lithium pollution.\"}\n",
")\n",
"initial_response"
]
},
{
"cell_type": "markdown",
"id": "7a7d34a6-cee0-4321-989a-963ca4b2caeb",
"metadata": {},
"source": [
"#### Starting Node\n",
"\n",
"We will package up the candidate generation and reflection in a single node of our graph. This is represented by the following function:"
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "5b6b173c-78f5-4ae1-80b3-28c80e68f5c5",
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"\n",
"\n",
"# Define the node we will add to the graph\n",
"def generate_initial_response(state: TreeState) -> dict:\n",
" \"\"\"Generate the initial candidate response.\"\"\"\n",
" res = initial_answer_chain.invoke({\"input\": state[\"input\"]})\n",
" parsed = parser.invoke(res)\n",
" tool_responses = tool_executor.batch(\n",
" [ToolInvocation(tool=r[\"type\"], tool_input=r[\"args\"]) for r in parsed]\n",
" )\n",
" output_messages = [res] + [\n",
" ToolMessage(content=json.dumps(resp), tool_call_id=tool_call[\"id\"])\n",
" for resp, tool_call in zip(tool_responses, parsed)\n",
" ]\n",
" reflection = reflection_chain.invoke(\n",
" {\"input\": state[\"input\"], \"candidate\": output_messages}\n",
" )\n",
" root = Node(output_messages, reflection=reflection)\n",
" return {\n",
" **state,\n",
" \"root\": root,\n",
" }"
]
},
{
"cell_type": "markdown",
"id": "34452e88-e33a-474c-9623-075d1f434dda",
"metadata": {},
"source": [
"### Candidate Generation\n",
"\n",
"The following code prompts the same LLM to generate N additional candidates to check."
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "550bff9a-86aa-43ad-ad98-506e97c122d2",
"metadata": {},
"outputs": [],
"source": [
"# This generates N candidate values\n",
"# for a single input to sample actions from the environment\n",
"\n",
"\n",
"def generate_candidates(messages: ChatPromptValue, config: RunnableConfig):\n",
" n = config[\"configurable\"].get(\"N\", 5)\n",
" bound_kwargs = llm.bind_tools(tools=tools).kwargs\n",
" chat_result = llm.generate(\n",
" [messages.to_messages()],\n",
" n=n,\n",
" callbacks=config[\"callbacks\"],\n",
" run_name=\"GenerateCandidates\",\n",
" **bound_kwargs\n",
" )\n",
" return [gen.message for gen in chat_result.generations[0]]\n",
"\n",
"\n",
"expansion_chain = prompt_template | generate_candidates"
]
},
{
"cell_type": "code",
"execution_count": 19,
"id": "e368e61f-8150-4fd6-b3fd-208d1f0ddc9c",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_5DMq9O6BIden7lLraFH0NuYZ', 'function': {'arguments': '{\"query\":\"lithium pollution research report\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}),\n",
" AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_5DMq9O6BIden7lLraFH0NuYZ', 'function': {'arguments': '{\"query\":\"lithium pollution research report\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}),\n",
" AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_5DMq9O6BIden7lLraFH0NuYZ', 'function': {'arguments': '{\"query\":\"lithium pollution research report\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}),\n",
" AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_5DMq9O6BIden7lLraFH0NuYZ', 'function': {'arguments': '{\"query\":\"lithium pollution research report\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}),\n",
" AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_5DMq9O6BIden7lLraFH0NuYZ', 'function': {'arguments': '{\"query\":\"lithium pollution research report\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]})]"
]
},
"execution_count": 19,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"res = expansion_chain.invoke({\"input\": \"Write a research report on lithium pollution.\"})\n",
"res"
]
},
{
"cell_type": "markdown",
"id": "88ecf775-29ed-4ebd-8297-d1aa3cda3f9b",
"metadata": {},
"source": [
"#### Candidate generation node\n",
"\n",
"We will package the candidate generation and reflection steps in the following \"expand\" node.\n",
"We do all the operations as a batch process to speed up execution."
]
},
{
"cell_type": "code",
"execution_count": 20,
"id": "d32af859-53e8-46be-8182-7d522be31f54",
"metadata": {},
"outputs": [],
"source": [
"from collections import defaultdict, deque\n",
"\n",
"\n",
"def expand(state: TreeState, config: RunnableConfig) -> dict:\n",
" \"\"\"Starting from the \"best\" node in the tree, generate N candidates for the next step.\"\"\"\n",
" root = state[\"root\"]\n",
" best_candidate: Node = root.best_child if root.children else root\n",
" messages = best_candidate.get_trajectory()\n",
" # Generate N candidates from the single child candidate\n",
" new_candidates = expansion_chain.invoke(\n",
" {\"input\": state[\"input\"], \"messages\": messages}, config\n",
" )\n",
" parsed = parser.batch(new_candidates)\n",
" flattened = [\n",
" (i, tool_call)\n",
" for i, tool_calls in enumerate(parsed)\n",
" for tool_call in tool_calls\n",
" ]\n",
" tool_responses = tool_executor.batch(\n",
" [\n",
" ToolInvocation(tool=tool_call[\"type\"], tool_input=tool_call[\"args\"])\n",
" for _, tool_call in flattened\n",
" ]\n",
" )\n",
" collected_responses = defaultdict(list)\n",
" for (i, tool_call), resp in zip(flattened, tool_responses):\n",
" collected_responses[i].append(\n",
" ToolMessage(content=json.dumps(resp), tool_call_id=tool_call[\"id\"])\n",
" )\n",
" output_messages = []\n",
" for i, candidate in enumerate(new_candidates):\n",
" output_messages.append([candidate] + collected_responses[i])\n",
"\n",
" # Reflect on each candidate\n",
" # For tasks with external validation, you'd add that here.\n",
" reflections = reflection_chain.batch(\n",
" [{\"input\": state[\"input\"], \"candidate\": msges} for msges in output_messages],\n",
" config,\n",
" )\n",
" # Grow tree\n",
" child_nodes = [\n",
" Node(cand, parent=best_candidate, reflection=reflection)\n",
" for cand, reflection in zip(output_messages, reflections)\n",
" ]\n",
" best_candidate.children.extend(child_nodes)\n",
" # We have already extended the tree directly, so we just return the state\n",
" return state"
]
},
{
"cell_type": "markdown",
"id": "84bad5da-645d-4c6a-83dd-8c852f21f622",
"metadata": {},
"source": [
"## Create Graph\n",
"\n",
"With those two nodes defined, we are ready to define the graph. After each agent step, we have the option of finishing."
]
},
{
"cell_type": "code",
"execution_count": 21,
"id": "8aec0f20-f978-4df0-8900-e3a1f0544f6d",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import END, StateGraph\n",
"\n",
"\n",
"def should_loop(state: TreeState):\n",
" \"\"\"Determine whether to continue the tree search.\"\"\"\n",
" root = state[\"root\"]\n",
" if root.is_solved:\n",
" return END\n",
" if root.height > 5:\n",
" return END\n",
" return \"expand\"\n",
"\n",
"\n",
"builder = StateGraph(TreeState)\n",
"builder.add_node(\"start\", generate_initial_response)\n",
"builder.add_node(\"expand\", expand)\n",
"builder.set_entry_point(\"start\")\n",
"\n",
"\n",
"builder.add_conditional_edges(\n",
" \"start\",\n",
" # Either expand/rollout or finish\n",
" should_loop,\n",
")\n",
"builder.add_conditional_edges(\n",
" \"expand\",\n",
" # Either continue to rollout or finish\n",
" should_loop,\n",
")\n",
"\n",
"graph = builder.compile()"
]
},
{
"cell_type": "markdown",
"id": "1383d69c-1d90-43f5-987e-c7fc4c3a24f8",
"metadata": {},
"source": [
"## Invoke"
]
},
{
"cell_type": "code",
"execution_count": 22,
"id": "92392fb3-8431-4649-9e78-2cc160e96ec1",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"start\n",
"rolled out: 1\n",
"---\n",
"expand\n",
"rolled out: 2\n",
"---\n",
"expand\n",
"rolled out: 3\n",
"---\n",
"__end__\n",
"rolled out: 3\n",
"---\n"
]
}
],
"source": [
"question = \"Generate a table with the average size and weight, as well as the oldest recorded instance for each of the top 5 most common birds.\"\n",
"for step in graph.stream({\"input\": question}):\n",
" step_name, step_state = next(iter(step.items()))\n",
" print(step_name)\n",
" print(\"rolled out: \", step_state[\"root\"].height)\n",
" print(\"---\")"
]
},
{
"cell_type": "code",
"execution_count": 25,
"id": "37a9e785-9909-4b56-b9be-da484e3711e1",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The search results have provided detailed information on the average size and weight, as well as the oldest recorded instance for each of the top 5 most common birds: Northern Cardinal, Dark-eyed Junco, Mourning Dove, Downy Woodpecker, and House Finch. Now, I will compile this information into a table format for easy reference. Let's create the table with the average size and weight, as well as the oldest recorded instance for each of these birds.\n",
"Here is the table with the average size and weight, as well as the oldest recorded instance for each of the top 5 most common birds:\n",
"\n",
"| Bird Species | Average Size | Average Weight | Oldest Recorded Instance |\n",
"|---------------------|--------------------|------------------|--------------------------|\n",
"| Northern Cardinal | 21.5 cm (male), 21.25 cm (female) | 42-48 g | 15 years and 9 months |\n",
"| Dark-eyed Junco | 14-16 cm | 18-30 g | At least 11 years, 4 months old |\n",
"| Mourning Dove | 22.5-36 cm | 96-170 g | 19 years |\n",
"| Downy Woodpecker | 14-18 cm | 20-33 g | At least 11 years |\n",
"| House Finch | 13-14 cm | 16-27 g | 8-11 years |\n",
"\n",
"This table summarizes the average size and weight, as well as the oldest recorded instance for each of the top 5 most common birds.\n"
]
}
],
"source": [
"solution_node = step[\"__end__\"][\"root\"].get_best_solution()\n",
"best_trajectory = solution_node.get_trajectory(include_reflections=False)\n",
"print(best_trajectory[-1].content)"
]
},
{
"cell_type": "code",
"execution_count": 26,
"id": "1e084037-42e7-4f8e-962d-aaa3f04ab54c",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"start\n",
"rolled out: 1\n",
"---\n",
"expand\n",
"rolled out: 2\n",
"---\n",
"expand\n",
"rolled out: 3\n",
"---\n",
"__end__\n",
"rolled out: 3\n",
"---\n"
]
}
],
"source": [
"question = \"Write out magnus carlson series of moves in his game against Alireza Firouzja and propose an alternate strategy\"\n",
"for step in graph.stream({\"input\": question}):\n",
" step_name, step_state = next(iter(step.items()))\n",
" print(step_name)\n",
" print(\"rolled out: \", step_state[\"root\"].height)\n",
" print(\"---\")"
]
},
{
"cell_type": "code",
"execution_count": 27,
"id": "d403c1c8-b26b-4d79-87b1-d2d16c1a7673",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"In the game between Magnus Carlsen and Alireza Firouzja, Magnus Carlsen started with the move C4, and Alireza countered with H6. To propose an alternate strategy for Magnus Carlsen, focusing on positional play, creating strong pawn structures, and leveraging his endgame skills could be highly effective. Magnus could aim to control the center, develop his pieces harmoniously, and look for opportunities to gradually improve his position. By maintaining a solid pawn structure and maneuvering his pieces strategically, Magnus could aim to outmaneuver his opponent in the later stages of the game, utilizing his renowned endgame skills to secure a favorable outcome.\n"
]
}
],
"source": [
"solution_node = step[\"__end__\"][\"root\"].get_best_solution()\n",
"best_trajectory = solution_node.get_trajectory(include_reflections=False)\n",
"print(best_trajectory[-1].content)"
]
},
{
"cell_type": "markdown",
"id": "f1b5140d-f51e-4032-8bc8-d7153252e3bf",
"metadata": {},
"source": [
"## Conclusion\n",
"\n",
"Congrats on implementing LATS! This is a technique that can be reasonably ast and effective at solving complex reasoning tasks. A few notes that you probably observed above:\n",
"1. While effective , the tree rollout can take additional compute time. If you wanted to include this in a production app, you'd either want to ensure that intermediate steps are streamed (so the user sees the thinking process/has access to intermediate results) or use it for fine-tuning data to improve the single-shot accuracy and avoid long rollouts.\n",
"2. The candidate selection process is only as good as the reward you generate. Here we are using self-reflection exclusively, but if you have an external source of feedback (such as code test execution), that should be incorporated in the locations mentioned above."
]
},
{
"cell_type": "markdown",
"id": "6130dff9-4753-4556-a39e-330ac65ba9c6",
"metadata": {},
"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.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
+966
View File
@@ -0,0 +1,966 @@
{
"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 designed to **speed up** the execution of agentic tasks by eagerly-executed tasks within a DAG. It also saves costs on redundant token usage by reducing the number of calls to the LLM. Below is an overview of its computational graph:\n",
"\n",
"![LLMCompiler Graph](./img/llm-compiler.png)\n",
"\n",
"It has 3 main components:\n",
"\n",
"1. Planner: stream a DAG of tasks.\n",
"2. Task Fetching Unit: schedules and executes the tasks as soon as they are executable\n",
"3. Joiner: Responds to the user or triggers a second plan\n",
"\n",
"\n",
"This notebook walks through each component and shows how to wire them together using LangGraph. The end result will leave a trace [like the following](https://smith.langchain.com/public/218c2677-c719-4147-b0e9-7bc3b5bb2623/r).\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": 1,
"id": "abbd6948-e9a3-47ca-89c7-7ac2fc5eca8b",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import getpass\n",
"\n",
"\n",
"def _get_pass(var: str):\n",
" if var not in os.environ:\n",
" os.environ[var] = getpass.getpass(f\"{var}: \")\n",
"\n",
"\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": 3,
"id": "e7476bb2-1a51-42f6-b7ae-82a0300bbf84",
"metadata": {},
"outputs": [],
"source": [
"from langchain_openai import ChatOpenAI\n",
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"\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(\n",
" max_results=1,\n",
" description='tavily_search_results_json(query=\"the search query\") - a search engine.',\n",
")\n",
"\n",
"tools = [search, calculate]"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "152eecf3-6bef-4718-af71-a0b3c5a3b009",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"'37'"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"calculate.invoke(\n",
" {\n",
" \"problem\": \"What's the temp of sf + 5?\",\n",
" \"context\": [\"Thet empreature of sf is 32 degrees\"],\n",
" }\n",
")"
]
},
{
"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": 5,
"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 (\n",
" BaseMessage,\n",
" FunctionMessage,\n",
" HumanMessage,\n",
" SystemMessage,\n",
")\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": 6,
"id": "45689d40-d8df-4316-a121-6ea9c87d2efe",
"metadata": {},
"outputs": [],
"source": [
"def create_planner(\n",
" llm: BaseChatModel, tools: Sequence[BaseTool], base_prompt: ChatPromptTemplate\n",
"):\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": 7,
"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": 8,
"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 0x10f354ea0> {'problem': 'raise $0 to the 3rd power', '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",
"\n",
"The basic idea is to begin executing tools as soon as their dependencies are met. This is done through multi-threading. We will combine the task fetching unit and exector below:\n",
"\n",
"![diagram](./img/diagram.png)"
]
},
{
"cell_type": "code",
"execution_count": 9,
"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",
"\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 = {\n",
" key: _resolve_arg(val, observations) for key, val in args.items()\n",
" }\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",
"\n",
" observation = traceback.format_exception() # repr(e) +\n",
" observations[task[\"idx\"]] = observation\n",
"\n",
"\n",
"def schedule_pending_task(\n",
" task: Task, observations: Dict[int, Any], retry_after: float = 0.2\n",
"):\n",
" while True:\n",
" deps = task[\"dependencies\"]\n",
" if deps and (any([dep not in observations for dep in deps])):\n",
" # Dependencies not yet satisfied\n",
" time.sleep(retry_after)\n",
" continue\n",
" schedule_task.invoke({\"task\": task, \"observations\": observations})\n",
" break\n",
"\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\"]] = (\n",
" task[\"tool\"] if isinstance(task[\"tool\"], str) else task[\"tool\"].name\n",
" )\n",
" if (\n",
" # Depends on other tasks\n",
" deps\n",
" and (any([dep not in observations for dep in deps]))\n",
" ):\n",
" futures.append(\n",
" executor.submit(\n",
" schedule_pending_task, task, observations, retry_after\n",
" )\n",
" )\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 = {\n",
" k: (task_names[k], observations[k])\n",
" for k in sorted(observations.keys() - originals)\n",
" }\n",
" tool_messages = [\n",
" FunctionMessage(name=name, content=str(obs), additional_kwargs={\"idx\": k})\n",
" for k, (name, obs) in new_observations.items()\n",
" ]\n",
" return tool_messages"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "052f6b16-103a-40e9-94dd-8fcc37e77ba4",
"metadata": {},
"outputs": [],
"source": [
"import itertools\n",
"\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",
" {\n",
" \"messages\": messages,\n",
" \"tasks\": tasks,\n",
" },\n",
" config,\n",
" )\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": 11,
"id": "55142257-2674-4a47-988e-0d2810917329",
"metadata": {},
"outputs": [],
"source": [
"tool_messages = plan_and_schedule.invoke([HumanMessage(content=example_question)])"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "a98e0525-2fcf-4fa1-baf6-79858bb8a6bd",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[FunctionMessage(content='[]', additional_kwargs={'idx': 0}, name='tavily_search_results_json'),\n",
" FunctionMessage(content='ValueError(\\'Failed to evaluate \"N/A\". Raised error: KeyError(\\\\\\'A\\\\\\'). Please try again with a valid numerical expression\\')', additional_kwargs={'idx': 1}, name='math'),\n",
" FunctionMessage(content='join', additional_kwargs={'idx': 2}, name='join')]"
]
},
"execution_count": 12,
"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": 13,
"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",
"\n",
"class FinalResponse(BaseModel):\n",
" \"\"\"The final response/answer.\"\"\"\n",
"\n",
" response: str\n",
"\n",
"\n",
"class Replan(BaseModel):\n",
" feedback: str = Field(\n",
" description=\"Analysis of the previous attempts and recommendations on what needs to be fixed.\"\n",
" )\n",
"\n",
"\n",
"class JoinOutputs(BaseModel):\n",
" \"\"\"Decide whether to replan or whether you can return the final response.\"\"\"\n",
"\n",
" thought: str = Field(\n",
" description=\"The chain of thought reasoning for the selected action\"\n",
" )\n",
" action: Union[FinalResponse, Replan]\n",
"\n",
"\n",
"joiner_prompt = hub.pull(\"wfh/llm-compiler-joiner\").partial(\n",
" examples=\"\"\n",
") # 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": 14,
"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 + [\n",
" SystemMessage(\n",
" content=f\"Context from last attempt: {decision.action.feedback}\"\n",
" )\n",
" ]\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",
"\n",
"joiner = select_recent_messages | runnable | _parse_joiner_output"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "1e49d4b1-8266-4520-a566-1448b1c31c8f",
"metadata": {},
"outputs": [],
"source": [
"input_messages = [HumanMessage(content=example_question)] + tool_messages"
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "31854dfd-b82f-4c24-9b58-6bae66777909",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[AIMessage(content='Thought: The search did not return any results, and the attempt to calculate the temperature in San Francisco raised to the 3rd power failed due to missing temperature information.'),\n",
" SystemMessage(content='Context from last attempt: I need to find the current temperature in San Francisco before calculating its value raised to the 3rd power.')]"
]
},
"execution_count": 16,
"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": 17,
"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",
"\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": 18,
"id": "5bc4584a-e31c-4065-805e-76a6db30676a",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan_and_schedule': [FunctionMessage(content='[{\\'url\\': \\'https://www.governor.ny.gov/programs/fy-2024-new-york-state-budget\\', \\'content\\': \"The $229 billion FY 2024 New York State Budget reflects Governor Hochul\\'s bold agenda to make New York more affordable, FY 2024 Budget Assets FY 2024 New York State Budget Highlights Improving Public Safety GOVERNOR HOME GOVERNOR KATHY HOCHUL FY 2024 New York State Budget Transformative investments to support New York\\'s business community and boost the state economy.The $229 billion FY 2024 NYS Budget reflects Governor Hochul\\'s bold agenda to make New York more affordable, more livable, and safer.\"}]', additional_kwargs={'idx': 0}, name='tavily_search_results_json')]}\n",
"---\n",
"{'join': [AIMessage(content=\"Thought: The information provided does not specify the Gross Domestic Product (GDP) of New York, but instead provides details about the state's budget for fiscal year 2024, which is $229 billion. This budget figure cannot be accurately equated to the GDP.\"), SystemMessage(content=\"Context from last attempt: The search results provided information about New York's state budget rather than its GDP. To answer the user's question, we need to find specific data on New York's GDP, not its budget.\")]}\n",
"---\n",
"{'plan_and_schedule': [FunctionMessage(content=\"[{'url': 'https://en.wikipedia.org/wiki/Economy_of_New_York_(state)', 'content': 'The economy of the State of New York is reflected in its gross state product in 2022 of $2.053 trillion, ranking third Contents Economy of New York (state) New York City-centered metropolitan statistical area produced a gross metropolitan product (GMP) of $US2.0 trillion, of the items in which New York ranks high nationally:The economy of the State of New York is reflected in its gross state product in 2022 of $2.053 trillion, ranking third in size behind the larger states of\\\\xa0...'}]\", additional_kwargs={'idx': 1}, name='tavily_search_results_json')]}\n",
"---\n",
"{'join': [AIMessage(content=\"Thought: The required information about New York's GDP is provided in the search results. In 2022, New York had a Gross State Product (GSP) of $2.053 trillion.\"), AIMessage(content='The Gross Domestic Product (GDP) of New York in 2022 was $2.053 trillion.')]}\n",
"---\n",
"{'__end__': [HumanMessage(content=\"What's the GDP of New York?\"), FunctionMessage(content='[{\\'url\\': \\'https://www.governor.ny.gov/programs/fy-2024-new-york-state-budget\\', \\'content\\': \"The $229 billion FY 2024 New York State Budget reflects Governor Hochul\\'s bold agenda to make New York more affordable, FY 2024 Budget Assets FY 2024 New York State Budget Highlights Improving Public Safety GOVERNOR HOME GOVERNOR KATHY HOCHUL FY 2024 New York State Budget Transformative investments to support New York\\'s business community and boost the state economy.The $229 billion FY 2024 NYS Budget reflects Governor Hochul\\'s bold agenda to make New York more affordable, more livable, and safer.\"}]', additional_kwargs={'idx': 0}, name='tavily_search_results_json'), AIMessage(content=\"Thought: The information provided does not specify the Gross Domestic Product (GDP) of New York, but instead provides details about the state's budget for fiscal year 2024, which is $229 billion. This budget figure cannot be accurately equated to the GDP.\"), SystemMessage(content=\"Context from last attempt: The search results provided information about New York's state budget rather than its GDP. To answer the user's question, we need to find specific data on New York's GDP, not its budget. - Begin counting at : 1\"), FunctionMessage(content=\"[{'url': 'https://en.wikipedia.org/wiki/Economy_of_New_York_(state)', 'content': 'The economy of the State of New York is reflected in its gross state product in 2022 of $2.053 trillion, ranking third Contents Economy of New York (state) New York City-centered metropolitan statistical area produced a gross metropolitan product (GMP) of $US2.0 trillion, of the items in which New York ranks high nationally:The economy of the State of New York is reflected in its gross state product in 2022 of $2.053 trillion, ranking third in size behind the larger states of\\\\xa0...'}]\", additional_kwargs={'idx': 1}, name='tavily_search_results_json'), AIMessage(content=\"Thought: The required information about New York's GDP is provided in the search results. In 2022, New York had a Gross State Product (GSP) of $2.053 trillion.\"), AIMessage(content='The Gross Domestic Product (GDP) of New York in 2022 was $2.053 trillion.')]}\n",
"---\n"
]
}
],
"source": [
"for step in chain.stream([HumanMessage(content=\"What's the GDP of New York?\")]):\n",
" print(step)\n",
" print(\"---\")"
]
},
{
"cell_type": "code",
"execution_count": 19,
"id": "b96efd08-5314-44f0-a694-3073b638adad",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The Gross Domestic Product (GDP) of New York in 2022 was $2.053 trillion.\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": 20,
"id": "0b3a0916-d8ca-4092-b91c-d9e2b05259d8",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan_and_schedule': [FunctionMessage(content=\"[{'url': 'https://a-z-animals.com/blog/discover-the-worlds-oldest-parrot/', 'content': 'How Old Is the Worlds Oldest Parrot? Discover the Worlds Oldest Parrot Advertisement of debate, so well detail some other parrots whose lifespans may be longer but are hard to verify their exact age. Comparing Parrots Lifespans to Other BirdsSep 8, 2023 — Sep 8, 2023The oldest parrot on record is Cookie, a pink cockatoo that survived to the age of 83 and survived his entire life at the Brookfield Zoo.'}]\", additional_kwargs={'idx': 0}, name='tavily_search_results_json'), FunctionMessage(content=\"HTTPError('502 Server Error: Bad Gateway for url: https://api.tavily.com/search')\", additional_kwargs={'idx': 1}, name='tavily_search_results_json'), FunctionMessage(content='join', additional_kwargs={'idx': 2}, name='join')]}\n",
"---\n",
"{'join': [AIMessage(content='Thought: The oldest parrot on record is Cookie, a pink cockatoo, who lived to be 83 years old. However, there was an error fetching additional search results to compare this age to the average lifespan of parrots.'), SystemMessage(content='Context from last attempt: I found the age of the oldest parrot, Cookie, who lived to be 83 years old. However, I need to search again to find the average lifespan of parrots to complete the comparison.')]}\n",
"---\n",
"{'plan_and_schedule': [FunctionMessage(content='[{\\'url\\': \\'https://www.turlockvet.com/site/blog/2023/07/15/parrot-lifespan--how-long-pet-parrots-live\\', \\'content\\': \"Parrot Lifespan the lifespan of a parrot?\\'. Parrot Lifespan: How Long Do Pet Parrots Live? how long they actually live and what you should know about owning a parrot.Jul 15, 2023 — Jul 15, 2023Generally, the average lifespan of smaller species of parrots such as Budgies and Cockatiels is about 5 - 15 years, while larger parrots such as\\\\xa0...\"}]', additional_kwargs={'idx': 3}, name='tavily_search_results_json')]}\n",
"---\n",
"{'join': [AIMessage(content=\"Thought: I have found that the oldest parrot on record, Cookie, lived to be 83 years old. Additionally, I've found that the average lifespan of parrots varies by species, with smaller species like Budgies and Cockatiels living between 5-15 years, and larger parrots potentially living longer. This allows me to compare Cookie's age to the average lifespan of smaller parrot species.\"), AIMessage(content=\"The oldest parrot on record is Cookie, a pink cockatoo, who lived to be 83 years old. Compared to the average lifespan of smaller parrot species such as Budgies and Cockatiels, which is about 5-15 years, Cookie lived significantly longer. The average lifespan of larger parrot species wasn't specified, but it's implied that larger parrots may live longer than smaller species, yet likely still much less than 83 years.\")]}\n",
"---\n",
"{'__end__': [HumanMessage(content=\"What's the oldest parrot alive, and how much longer is that than the average?\"), FunctionMessage(content=\"[{'url': 'https://a-z-animals.com/blog/discover-the-worlds-oldest-parrot/', 'content': 'How Old Is the Worlds Oldest Parrot? Discover the Worlds Oldest Parrot Advertisement of debate, so well detail some other parrots whose lifespans may be longer but are hard to verify their exact age. Comparing Parrots Lifespans to Other BirdsSep 8, 2023 — Sep 8, 2023The oldest parrot on record is Cookie, a pink cockatoo that survived to the age of 83 and survived his entire life at the Brookfield Zoo.'}]\", additional_kwargs={'idx': 0}, name='tavily_search_results_json'), FunctionMessage(content=\"HTTPError('502 Server Error: Bad Gateway for url: https://api.tavily.com/search')\", additional_kwargs={'idx': 1}, name='tavily_search_results_json'), FunctionMessage(content='join', additional_kwargs={'idx': 2}, name='join'), AIMessage(content='Thought: The oldest parrot on record is Cookie, a pink cockatoo, who lived to be 83 years old. However, there was an error fetching additional search results to compare this age to the average lifespan of parrots.'), SystemMessage(content='Context from last attempt: I found the age of the oldest parrot, Cookie, who lived to be 83 years old. However, I need to search again to find the average lifespan of parrots to complete the comparison. - Begin counting at : 3'), FunctionMessage(content='[{\\'url\\': \\'https://www.turlockvet.com/site/blog/2023/07/15/parrot-lifespan--how-long-pet-parrots-live\\', \\'content\\': \"Parrot Lifespan the lifespan of a parrot?\\'. Parrot Lifespan: How Long Do Pet Parrots Live? how long they actually live and what you should know about owning a parrot.Jul 15, 2023 — Jul 15, 2023Generally, the average lifespan of smaller species of parrots such as Budgies and Cockatiels is about 5 - 15 years, while larger parrots such as\\\\xa0...\"}]', additional_kwargs={'idx': 3}, name='tavily_search_results_json'), AIMessage(content=\"Thought: I have found that the oldest parrot on record, Cookie, lived to be 83 years old. Additionally, I've found that the average lifespan of parrots varies by species, with smaller species like Budgies and Cockatiels living between 5-15 years, and larger parrots potentially living longer. This allows me to compare Cookie's age to the average lifespan of smaller parrot species.\"), AIMessage(content=\"The oldest parrot on record is Cookie, a pink cockatoo, who lived to be 83 years old. Compared to the average lifespan of smaller parrot species such as Budgies and Cockatiels, which is about 5-15 years, Cookie lived significantly longer. The average lifespan of larger parrot species wasn't specified, but it's implied that larger parrots may live longer than smaller species, yet likely still much less than 83 years.\")]}\n",
"---\n"
]
}
],
"source": [
"steps = chain.stream(\n",
" [\n",
" HumanMessage(\n",
" content=\"What's the oldest parrot alive, and how much longer is that than the average?\"\n",
" )\n",
" ],\n",
" {\n",
" \"recursion_limit\": 100,\n",
" },\n",
")\n",
"for step in steps:\n",
" print(step)\n",
" print(\"---\")"
]
},
{
"cell_type": "code",
"execution_count": 21,
"id": "6c65c414-7668-4fdf-ba97-f42f659b1317",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The oldest parrot on record is Cookie, a pink cockatoo, who lived to be 83 years old. Compared to the average lifespan of smaller parrot species such as Budgies and Cockatiels, which is about 5-15 years, Cookie lived significantly longer. The average lifespan of larger parrot species wasn't specified, but it's implied that larger parrots may live longer than smaller species, yet likely still much less than 83 years.\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": 22,
"id": "38d3ea91-59ba-4267-8060-ed75bbc840c6",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan_and_schedule': [FunctionMessage(content='3307.0', additional_kwargs={'idx': 1}, name='math'), FunctionMessage(content='7.565011820330969', additional_kwargs={'idx': 2}, name='math'), FunctionMessage(content='3314.565011820331', additional_kwargs={'idx': 3}, name='math'), FunctionMessage(content='join', additional_kwargs={'idx': 4}, name='join')]}\n",
"{'join': [AIMessage(content=\"Thought: The calculations for each part of the user's question have been successfully completed. The first calculation resulted in 3307.0, the second in 7.565011820330969, and the sum of those two values was correctly found to be 3314.565011820331.\"), AIMessage(content='The result of ((3*(4+5)/0.5)+3245) + 8 is 3307.0, the result of 32/4.23 is approximately 7.565, and the sum of those two values 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': 1}, name='math'), FunctionMessage(content='7.565011820330969', additional_kwargs={'idx': 2}, name='math'), FunctionMessage(content='3314.565011820331', additional_kwargs={'idx': 3}, name='math'), FunctionMessage(content='join', additional_kwargs={'idx': 4}, name='join'), AIMessage(content=\"Thought: The calculations for each part of the user's question have been successfully completed. The first calculation resulted in 3307.0, the second in 7.565011820330969, and the sum of those two values was correctly found to be 3314.565011820331.\"), AIMessage(content='The result of ((3*(4+5)/0.5)+3245) + 8 is 3307.0, the result of 32/4.23 is approximately 7.565, and the sum of those two values is approximately 3314.565.')]}\n"
]
}
],
"source": [
"for step in chain.stream(\n",
" [\n",
" HumanMessage(\n",
" content=\"What's ((3*(4+5)/0.5)+3245) + 8? What's 32/4.23? What's the sum of those two values?\"\n",
" )\n",
" ]\n",
"):\n",
" print(step)"
]
},
{
"cell_type": "code",
"execution_count": 23,
"id": "a6cf5fe0-f178-4197-950f-257711bff8d2",
"metadata": {
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"The result of ((3*(4+5)/0.5)+3245) + 8 is 3307.0, the result of 32/4.23 is approximately 7.565, and the sum of those two values is approximately 3314.565.\n"
]
}
],
"source": [
"# Final answer\n",
"print(step[END][-1].content)"
]
},
{
"cell_type": "markdown",
"id": "c647d5f3-5e00-4449-9cec-5a9f438c9cff",
"metadata": {},
"source": [
"## Conclusion\n",
"\n",
"Congrats on building your first LLMCompiler agent! I'll leave you with some known limitations to the implementation above:\n",
"\n",
"1. The planner output parsing format is fragile if your function requires more than 1 or 2 arguments. We could make it more robust by using streaming tool calling.\n",
"2. Variable substitution is fragile in the example above. It could be made more robust by using a fine-tuned model and a more robust syntax (using e.g., Lark or a tool calling schema)\n",
"3. The state can grow quite long if you require multiple re-planning runs. To handle, you could add a message compressor once you go above a certain token limit.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "431217e6-4c00-409f-a2bd-40ebff902489",
"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.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 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
+7 -10
View File
@@ -107,10 +107,7 @@
"from langchain_openai import ChatOpenAI\n",
"\n",
"\n",
"\n",
"def create_agent(\n",
" llm: ChatOpenAI, tools: list, system_prompt: str\n",
"):\n",
"def create_agent(llm: ChatOpenAI, tools: list, system_prompt: str):\n",
" # Each worker node will be given a name and some tools.\n",
" prompt = ChatPromptTemplate.from_messages(\n",
" [\n",
@@ -255,7 +252,11 @@
"research_node = functools.partial(agent_node, agent=research_agent, name=\"Researcher\")\n",
"\n",
"# NOTE: THIS PERFORMS ARBITRARY CODE EXECUTION. PROCEED WITH CAUTION\n",
"code_agent = create_agent(llm, [python_repl_tool], \"You may generate safe python code to analyze data and generate charts using matplotlib.\")\n",
"code_agent = create_agent(\n",
" llm,\n",
" [python_repl_tool],\n",
" \"You may generate safe python code to analyze data and generate charts using matplotlib.\",\n",
")\n",
"code_node = functools.partial(agent_node, agent=code_agent, name=\"Coder\")\n",
"\n",
"workflow = StateGraph(AgentState)\n",
@@ -369,11 +370,7 @@
],
"source": [
"for s in graph.stream(\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(content=\"Write a brief research report on pikas.\")\n",
" ]\n",
" },\n",
" {\"messages\": [HumanMessage(content=\"Write a brief research report on pikas.\")]},\n",
" {\"recursion_limit\": 100},\n",
"):\n",
" if \"__end__\" not in s:\n",
@@ -291,9 +291,7 @@
" return {\"messages\": [HumanMessage(content=result[\"output\"], name=name)]}\n",
"\n",
"\n",
"def create_team_supervisor(\n",
" llm: ChatOpenAI, system_prompt, members\n",
") -> str:\n",
"def create_team_supervisor(llm: ChatOpenAI, system_prompt, members) -> str:\n",
" \"\"\"An LLM-based router.\"\"\"\n",
" options = [\"FINISH\"] + members\n",
" function_def = {\n",
@@ -374,10 +372,18 @@
"\n",
"llm = ChatOpenAI(model=\"gpt-4-1106-preview\")\n",
"\n",
"search_agent = create_agent(llm, [tavily_tool], \"You are a research assistant who can search for up-to-date info using the tavily search engine.\")\n",
"search_agent = create_agent(\n",
" llm,\n",
" [tavily_tool],\n",
" \"You are a research assistant who can search for up-to-date info using the tavily search engine.\",\n",
")\n",
"search_node = functools.partial(agent_node, agent=search_agent, name=\"Search\")\n",
"\n",
"research_agent = create_agent(llm, [scrape_webpages], \"You are a research assistant who can scrape specified urls for more detailed information using the scrape_webpages function.\")\n",
"research_agent = create_agent(\n",
" llm,\n",
" [scrape_webpages],\n",
" \"You are a research assistant who can scrape specified urls for more detailed information using the scrape_webpages function.\",\n",
")\n",
"research_node = functools.partial(agent_node, agent=research_agent, name=\"Web Scraper\")\n",
"\n",
"supervisor_agent = create_team_supervisor(\n",
@@ -388,7 +394,7 @@
" \" task and respond with their results and status. When finished,\"\n",
" \" respond with FINISH.\",\n",
" [\"Search\", \"Web Scraper\"],\n",
")\n"
")"
]
},
{
@@ -417,17 +423,14 @@
"research_graph.add_conditional_edges(\n",
" \"supervisor\",\n",
" lambda x: x[\"next\"],\n",
" {\n",
" \"Search\": \"Search\",\n",
" \"Web Scraper\": \"Web Scraper\",\n",
" \"FINISH\": END\n",
" }\n",
" {\"Search\": \"Search\", \"Web Scraper\": \"Web Scraper\", \"FINISH\": END},\n",
")\n",
"\n",
"\n",
"research_graph.set_entry_point(\"supervisor\")\n",
"chain = research_graph.compile()\n",
"\n",
"\n",
"# The following functions interoperate between the top level graph state\n",
"# and the state of the research sub-graph\n",
"# this makes it so that the states of each graph don't get intermixed\n",
@@ -438,11 +441,7 @@
" return results\n",
"\n",
"\n",
"\n",
"research_chain = (\n",
" enter_chain\n",
" | chain\n",
")"
"research_chain = enter_chain | chain"
]
},
{
@@ -474,9 +473,8 @@
],
"source": [
"for s in research_chain.stream(\n",
" \"when is Taylor Swift's next tour?\",\n",
" {\"recursion_limit\": 100}\n",
" ):\n",
" \"when is Taylor Swift's next tour?\", {\"recursion_limit\": 100}\n",
"):\n",
" if \"__end__\" not in s:\n",
" print(s)\n",
" print(\"---\")"
@@ -539,7 +537,6 @@
" }\n",
"\n",
"\n",
"\n",
"llm = ChatOpenAI(model=\"gpt-4-1106-preview\")\n",
"\n",
"doc_writer_agent = create_agent(\n",
@@ -551,7 +548,9 @@
")\n",
"# Injects current directory working state before each call\n",
"context_aware_doc_writer_agent = prelude | doc_writer_agent\n",
"doc_writing_node = functools.partial(agent_node, agent=context_aware_doc_writer_agent, name=\"Doc Writer\")\n",
"doc_writing_node = functools.partial(\n",
" agent_node, agent=context_aware_doc_writer_agent, name=\"Doc Writer\"\n",
")\n",
"\n",
"note_taking_agent = create_agent(\n",
" llm,\n",
@@ -560,7 +559,9 @@
" \" taking notes to craft a perfect paper.{current_files}\",\n",
")\n",
"context_aware_note_taking_agent = prelude | note_taking_agent\n",
"note_taking_node = functools.partial(agent_node, agent=context_aware_note_taking_agent, name=\"Note Taker\")\n",
"note_taking_node = functools.partial(\n",
" agent_node, agent=context_aware_note_taking_agent, name=\"Note Taker\"\n",
")\n",
"\n",
"chart_generating_agent = create_agent(\n",
" llm,\n",
@@ -569,7 +570,9 @@
" \"{current_files}\",\n",
")\n",
"context_aware_chart_generating_agent = prelude | chart_generating_agent\n",
"chart_generating_node = functools.partial(agent_node, agent=context_aware_note_taking_agent, name=\"Chart Generator\")\n",
"chart_generating_node = functools.partial(\n",
" agent_node, agent=context_aware_note_taking_agent, name=\"Chart Generator\"\n",
")\n",
"\n",
"doc_writing_supervisor = create_team_supervisor(\n",
" llm,\n",
@@ -578,7 +581,7 @@
" \" respond with the worker to act next. Each worker will perform a\"\n",
" \" task and respond with their results and status. When finished,\"\n",
" \" respond with FINISH.\",\n",
" [\"Doc Writer\", \"Note Taker\", \"Chart Generator\"]\n",
" [\"Doc Writer\", \"Note Taker\", \"Chart Generator\"],\n",
")"
]
},
@@ -618,20 +621,21 @@
" \"Doc Writer\": \"Doc Writer\",\n",
" \"Note Taker\": \"Note Taker\",\n",
" \"Chart Generator\": \"Chart Generator\",\n",
" \"FINISH\": END\n",
" }\n",
" \"FINISH\": END,\n",
" },\n",
")\n",
"\n",
"authoring_graph.set_entry_point(\"supervisor\")\n",
"chain = research_graph.compile()\n",
"\n",
"\n",
"# The following functions interoperate between the top level graph state\n",
"# and the state of the research sub-graph\n",
"# this makes it so that the states of each graph don't get intermixed\n",
"def enter_chain(message: str, members: List[str]):\n",
" results = {\n",
" \"messages\": [HumanMessage(content=message)],\n",
" \"team_members\": \", \".join(members)\n",
" \"team_members\": \", \".join(members),\n",
" }\n",
" return results\n",
"\n",
@@ -664,9 +668,9 @@
],
"source": [
"for s in authoring_chain.stream(\n",
" \"Write an outline for poem and then write the poem to disk.\",\n",
" {\"recursion_limit\": 100}\n",
" ):\n",
" \"Write an outline for poem and then write the poem to disk.\",\n",
" {\"recursion_limit\": 100},\n",
"):\n",
" if \"__end__\" not in s:\n",
" print(s)\n",
" print(\"---\")"
@@ -720,6 +724,7 @@
" messages: Annotated[List[BaseMessage], operator.add]\n",
" next: str\n",
"\n",
"\n",
"def get_last_message(state: State) -> str:\n",
" return state[\"messages\"][-1].content\n",
"\n",
@@ -727,6 +732,7 @@
"def join_graph(response: dict):\n",
" return {\"messages\": [response[\"messages\"][-1]]}\n",
"\n",
"\n",
"# Define the graph.\n",
"super_graph = StateGraph(State)\n",
"# First add the nodes, which will do the work\n",
@@ -746,8 +752,8 @@
" {\n",
" \"Paper writing team\": \"Paper writing team\",\n",
" \"Research team\": \"Research team\",\n",
" \"FINISH\": END\n",
" }\n",
" \"FINISH\": END,\n",
" },\n",
")\n",
"super_graph.set_entry_point(\"supervisor\")\n",
"super_graph = super_graph.compile()"
@@ -814,13 +820,15 @@
],
"source": [
"for s in super_graph.stream(\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(content=\"Write a brief research report on the North American sturgeon. Include a chart.\")\n",
" ],\n",
" },\n",
" {\"recursion_limit\": 150},\n",
" ):\n",
" {\n",
" \"messages\": [\n",
" HumanMessage(\n",
" content=\"Write a brief research report on the North American sturgeon. Include a chart.\"\n",
" )\n",
" ],\n",
" },\n",
" {\"recursion_limit\": 150},\n",
"):\n",
" if \"__end__\" not in s:\n",
" print(s)\n",
" print(\"---\")"
@@ -118,9 +118,7 @@
" )\n",
" prompt = prompt.partial(system_message=system_message)\n",
" prompt = prompt.partial(tool_names=\", \".join([tool.name for tool in tools]))\n",
" return prompt | llm.bind_functions(functions)\n",
"\n",
"\n"
" return prompt | llm.bind_functions(functions)"
]
},
{
@@ -162,8 +160,7 @@
" result = repl.run(code)\n",
" except BaseException as e:\n",
" return f\"Failed to execute. Error: {repr(e)}\"\n",
" return f\"Succesfully executed:\\n```python\\n{code}\\n```\\nStdout: {result}\"\n",
"\n"
" return f\"Succesfully executed:\\n```python\\n{code}\\n```\\nStdout: {result}\""
]
},
{
@@ -251,8 +248,8 @@
"\n",
"# Research agent and node\n",
"research_agent = create_agent(\n",
" llm, \n",
" [tavily_tool], \n",
" llm,\n",
" [tavily_tool],\n",
" system_message=\"You should provide accurate data for the chart generator to use.\",\n",
")\n",
"research_node = functools.partial(agent_node, agent=research_agent, name=\"Researcher\")\n",
@@ -286,6 +283,7 @@
"tools = [tavily_tool, python_repl]\n",
"tool_executor = ToolExecutor(tools)\n",
"\n",
"\n",
"def tool_node(state):\n",
" \"\"\"This runs tools in the graph\n",
"\n",
+11 -4
View File
@@ -227,6 +227,7 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(messages):\n",
" last_message = messages[-1]\n",
@@ -237,12 +238,14 @@
" else:\n",
" return \"continue\"\n",
"\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",
"\n",
"# Define the function to execute tools\n",
"def call_tool(messages):\n",
" # Based on the continue condition\n",
@@ -251,7 +254,9 @@
" # 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",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" # We call the tool_executor and get back a response\n",
" response = tool_executor.invoke(action)\n",
@@ -279,6 +284,7 @@
"outputs": [],
"source": [
"from langgraph.graph import MessageGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = MessageGraph()\n",
"\n",
@@ -307,13 +313,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\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')"
"workflow.add_edge(\"action\", \"agent\")"
]
},
{
@@ -377,6 +383,7 @@
],
"source": [
"from langchain_core.messages import HumanMessage\n",
"\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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,525 @@
{
"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",
"\n",
"The general computational graph looks like the following:\n",
"\n",
"\n",
"![plan-and-execute diagram](./img/plan-and-execute.png)\n",
"\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\n",
"\n",
"\n",
"The following walkthrough demonstrates how to do so in LangGraph. The resulting agent will leave a trace like the following example: ([link](https://smith.langchain.com/public/d46e24d3-dda6-44d5-9550-b618fca4e0d4/r))."
]
},
{
"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": 3,
"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": 4,
"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:\")\n",
"os.environ[\"LANGCHAIN_PROJECT\"] = \"Plan-and-execute\""
]
},
{
"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": 5,
"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": 6,
"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",
"\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": 7,
"id": "a3ea9bd3-87d9-4a78-aec6-8ab4bf34479b",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.prebuilt import create_agent_executor"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "998aebde-c204-494f-930c-14747ed34861",
"metadata": {},
"outputs": [],
"source": [
"agent_executor = create_agent_executor(agent_runnable, tools)"
]
},
{
"cell_type": "code",
"execution_count": 9,
"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 as follows:\\n\\n- **Golf:** Wyndham Clark won the 2023 US Open in golf, holding his nerve against Rory McIlroy.\\n \\n- **Tennis:** The 2023 US Open tennis tournament details include information about the event and its prize money, but the winner has not been specified in the provided information. As of the last update, Carlos Alcaraz won the 2022 US Open tennis title.'}, log='The winners of the US Open in 2023 are as follows:\\n\\n- **Golf:** Wyndham Clark won the 2023 US Open in golf, holding his nerve against Rory McIlroy.\\n \\n- **Tennis:** The 2023 US Open tennis tournament details include information about the event and its prize money, but the winner has not been specified in the provided information. As of the last update, Carlos Alcaraz won the 2022 US Open tennis title.'),\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://en.wikipedia.org/wiki/2023_U.S._Open_(golf)\\', \\'content\\': \\'Contents 2023 U.S. Open (golf) was selected to host the 123rd U.S. Open in June 2023. The USGA had made overtures to the club for at least 26 years. Final round[edit] Sunday, June 18, 2023 Third round[edit] Saturday, June 17, 2023Rory McIlroy falls short as Wyndham Clark holds nerve to win 2023 US Open. The Guardian. Archived from the original on June 19, 2023. Retrieved June 20, 2023.\\'}, {\\'url\\': \\'https://en.wikipedia.org/wiki/2023_US_Open_(tennis)\\', \\'content\\': \"The 2023 US Open is the 143rd consecutive edition of the tournament and will take place at the USTA Billie Jean King The total overall prize money for the 2023 US Open totals $65 million, 8% more than the 2022 edition.[4] Contents 2023 US Open (tennis) Wheelchair boys\\' singles Dahnon Ward Wheelchair girls\\' singles Ksénia Chasteau contract with ESPN, in which the broadcaster holds exclusive rights to the entire tournament and the US Open Series.Carlos Alcaraz defeats Casper Ruud for 2022 US Open title, world No. 1 ranking. US Open. Archived from the original on September 12, 2022. Retrieved September\\\\xa0...\"}]')]}"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"agent_executor.invoke(\n",
" {\"input\": \"who is the winnner of the us open\", \"chat_history\": []}\n",
")"
]
},
{
"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": 10,
"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",
" 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": 11,
"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",
"\n",
" steps: List[str] = Field(\n",
" description=\"different steps to follow, should be in sorted order\"\n",
" )"
]
},
{
"cell_type": "code",
"execution_count": 12,
"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(\n",
" \"\"\"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",
")\n",
"planner = create_structured_output_runnable(\n",
" Plan, ChatOpenAI(model=\"gpt-4-turbo-preview\", temperature=0), planner_prompt\n",
")"
]
},
{
"cell_type": "code",
"execution_count": 13,
"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": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"planner.invoke(\n",
" {\"objective\": \"what is the hometown of the current Australia open winner?\"}\n",
")"
]
},
{
"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": 14,
"id": "ec2d12cc-016a-44d1-aa08-4c5ce1e8fe2a",
"metadata": {},
"outputs": [],
"source": [
"from langchain.chains.openai_functions import create_openai_fn_runnable\n",
"\n",
"\n",
"class Response(BaseModel):\n",
" \"\"\"Response to user.\"\"\"\n",
"\n",
" response: str\n",
"\n",
"\n",
"replanner_prompt = ChatPromptTemplate.from_template(\n",
" \"\"\"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",
"\n",
"replanner = create_openai_fn_runnable(\n",
" [Plan, Response],\n",
" ChatOpenAI(model=\"gpt-4-turbo-preview\", temperature=0),\n",
" 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": 15,
"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 {\n",
" \"past_steps\": (task, agent_response[\"agent_outcome\"].return_values[\"output\"])\n",
" }\n",
"\n",
"\n",
"async def plan_step(state: PlanExecute):\n",
" plan = await planner.ainvoke({\"objective\": state[\"input\"]})\n",
" return {\"plan\": plan.steps}\n",
"\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",
"\n",
"def should_end(state: PlanExecute):\n",
" if state[\"response\"]:\n",
" return True\n",
" else:\n",
" return False"
]
},
{
"cell_type": "code",
"execution_count": 16,
"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": 17,
"id": "b8ac1f67-e87a-427c-b4f7-44351295b788",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan': ['Wait until the 2024 Australian Open concludes.', 'Identify the winner of the 2024 Australian Open.', \"Research the winner's biography to find their hometown.\", 'The hometown of the 2024 Australian Open winner is the result found in the previous step.']}\n",
"{'past_steps': ('Wait until the 2024 Australian Open concludes.', \"I can't wait for real-time events. However, I can help you find out the schedule, expected dates, or any other information regarding the 2024 Australian Open. How can I assist you further?\")}\n",
"{'plan': ['Identify the winner of the 2024 Australian Open.', \"Research the winner's biography to find their hometown.\", 'The hometown of the 2024 Australian Open winner is the result found in the previous step.']}\n",
"{'past_steps': ('Identify the winner of the 2024 Australian Open.', \"The winners of the 2024 Australian Open were Jannik Sinner in the men's singles and Aryna Sabalenka in the women's singles. Jannik Sinner defeated Daniil Medvedev in the final, while specific details about Aryna Sabalenka's match are not provided in the information retrieved.\")}\n",
"{'plan': [\"Research Jannik Sinner's biography to find his hometown.\", \"Research Aryna Sabalenka's biography to find her hometown.\", 'The hometowns of the 2024 Australian Open winners are the results found in the previous steps.']}\n",
"{'past_steps': (\"Research Jannik Sinner's biography to find his hometown.\", 'Jannik Sinner was born in San Candido (Innichen), Italy, on August 16, 2001. This is considered his hometown.')}\n",
"{'plan': [\"Research Aryna Sabalenka's biography to find her hometown.\", 'The hometowns of the 2024 Australian Open winners are the results found in the previous steps.']}\n",
"{'past_steps': (\"Research Aryna Sabalenka's biography to find her hometown.\", 'Aryna Sabalenka was born in Minsk, the capital of Belarus.')}\n",
"{'response': 'The hometowns of the 2024 Australian Open winners are San Candido (Innichen), Italy for Jannik Sinner, and Minsk, Belarus for Aryna Sabalenka. No further steps are needed as the final answer has been reached.'}\n"
]
}
],
"source": [
"from langchain_core.messages import HumanMessage\n",
"\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": "markdown",
"id": "8bf585a9-0f1e-4910-bd00-65e7bb05b6e6",
"metadata": {},
"source": [
"## Conclusion\n",
"\n",
"Congrats on making a plan-and-execute agent! One known limitations of the above design is that each task is still executed in sequence, meaning embarassingly parallel operations all add to the total execution time. You could improve on this by having each task represented as a DAG (similar to LLMCompiler), rather than a regular list."
]
},
{
"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.2"
}
},
"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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1003 KiB

+552
View File
@@ -0,0 +1,552 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "22942f7e-3446-4009-b551-cca7fcc25d73",
"metadata": {},
"source": [
"# Reflexion\n",
"\n",
"[Reflexion](https://arxiv.org/abs/2303.11366) by Shinn, et. al., is an architecture designed to learn through verbal feedback and self-reflection. The agent explicitly critiques its responses for tasks to generate a higher quality final response, at the expense of longer execution time.\n",
"\n",
"![reflexion diagram](./img/reflexion.png)\n",
"\n",
"The paper outlines 3 main components:\n",
"\n",
"1. Actor (agent) with self-reflection\n",
"2. External evaluator (task-specific, e.g. code compilation steps)\n",
"3. Episodic memory that stores the reflections from (1).\n",
"\n",
"In their code, the last two components are very task-specific, so in this notebook, you will build the _actor_ in LangGraph.\n",
"\n",
"To skip to the graph definition, see the [Construct Graph section](#Construct-Graph) below."
]
},
{
"cell_type": "markdown",
"id": "906edf48-7c81-48b8-8250-fdc34043d01b",
"metadata": {},
"source": [
"## 0. Prerequisites\n",
"\n",
"Install `langgraph` (for the framework), `langchain_openai` (for the LLM), and `langchain` + `tavily-python` (for the search engine).\n",
"\n",
"We will use tavily search as a tool. You can get an API key [here](https://app.tavily.com/sign-in) or replace with a different tool of your choosing."
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "1b64a6f6-1d32-48be-92b5-66c3b04b17f7",
"metadata": {},
"outputs": [],
"source": [
"# %pip install -U --quiet langchain langgraph langchain_openai\n",
"# %pip install -U --quiet tavily-python"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "a917bb70-f84c-48e6-8d32-d14f9df2ca2f",
"metadata": {},
"outputs": [],
"source": [
"import getpass\n",
"import os\n",
"\n",
"\n",
"def _set_if_undefined(var: str) -> None:\n",
" if os.environ.get(var):\n",
" return\n",
" os.environ[var] = getpass.getpass(var)\n",
"\n",
"\n",
"# Optional: Configure tracing to visualize and debug the agent\n",
"_set_if_undefined(\"LANGCHAIN_API_KEY\")\n",
"os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n",
"os.environ[\"LANGCHAIN_PROJECT\"] = \"Reflexion\"\n",
"\n",
"_set_if_undefined(\"OPENAI_API_KEY\")\n",
"_set_if_undefined(\"TAVILY_API_KEY\")"
]
},
{
"cell_type": "markdown",
"id": "af543598-52d0-4ec3-a05f-d2954ff793ee",
"metadata": {},
"source": [
"## 1. Actor (with reflection)\n",
"\n",
"The main component of Reflexion is the \"actor\", which is an agent that reflects on its response and re-executes to improve based on self-critique. It's main sub-components include:\n",
"1. Tools/tool execution\n",
"2. Initial responder: generate an initial response (and self-reflection)\n",
"3. Revisor: re-respond (and reflec) based on previous reflections\n",
"\n",
"We'll first define the tool execution context.\n",
"\n",
"#### Construct tools"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "5a2ac853-b8a6-40de-b7fe-3f9f3c5ca4d2",
"metadata": {},
"outputs": [],
"source": [
"from langchain_community.tools.tavily_search import TavilySearchResults\n",
"from langchain_community.utilities.tavily_search import TavilySearchAPIWrapper\n",
"\n",
"search = TavilySearchAPIWrapper()\n",
"tavily_tool = TavilySearchResults(api_wrapper=search, max_results=5)"
]
},
{
"cell_type": "markdown",
"id": "737fd31c-b4e9-47f9-a4e8-99fab340a028",
"metadata": {},
"source": [
"The tools are invoked _in context_. Create a function that invokes all the requested tools."
]
},
{
"cell_type": "code",
"execution_count": 28,
"id": "82f144fc-e6fa-4e4f-a8af-1a0650e79fe3",
"metadata": {},
"outputs": [],
"source": [
"from collections import defaultdict\n",
"from typing import List\n",
"\n",
"from langchain.output_parsers.openai_tools import (\n",
" JsonOutputToolsParser,\n",
" PydanticToolsParser,\n",
")\n",
"from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage\n",
"from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation\n",
"\n",
"# This a helper class we have that is useful for running tools\n",
"# It takes in an agent action and calls that tool and returns the result\n",
"tool_executor = ToolExecutor([tavily_tool])\n",
"# Parse the tool messages for the execution / invocation\n",
"parser = JsonOutputToolsParser(return_id=True)\n",
"\n",
"\n",
"def execute_tools(state: List[BaseMessage]) -> List[BaseMessage]:\n",
" tool_invocation: AIMessage = state[-1]\n",
" parsed_tool_calls = parser.invoke(tool_invocation)\n",
" ids = []\n",
" tool_invocations = []\n",
" for parsed_call in parsed_tool_calls:\n",
" for query in parsed_call[\"args\"][\"search_queries\"]:\n",
" tool_invocations.append(\n",
" ToolInvocation(\n",
" # We only have this one for now. Would want to map it\n",
" # if we change\n",
" tool=\"tavily_search_results_json\",\n",
" tool_input=query,\n",
" )\n",
" )\n",
" ids.append(parsed_call[\"id\"])\n",
"\n",
" outputs = tool_executor.batch(tool_invocations)\n",
" outputs_map = defaultdict(dict)\n",
" for id_, output, invocation in zip(ids, outputs, tool_invocations):\n",
" outputs_map[id_][invocation.tool_input] = output\n",
"\n",
" return [\n",
" ToolMessage(content=json.dumps(query_outputs), tool_call_id=id_)\n",
" for id_, query_outputs in outputs_map.items()\n",
" ]"
]
},
{
"cell_type": "markdown",
"id": "093fbaa0-9a71-4c32-9872-02a9aec9b35d",
"metadata": {},
"source": [
"#### Initial responder"
]
},
{
"cell_type": "code",
"execution_count": 63,
"id": "5fffa8d5-068a-4f0b-adfc-b4daf30ef294",
"metadata": {},
"outputs": [],
"source": [
"import datetime\n",
"\n",
"from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder\n",
"from langchain_core.pydantic_v1 import BaseModel, Field, ValidationError\n",
"from langchain_openai import ChatOpenAI\n",
"from langsmith import traceable\n",
"\n",
"actor_prompt_template = ChatPromptTemplate.from_messages(\n",
" [\n",
" (\n",
" \"system\",\n",
" \"\"\"You are expert researcher.\n",
"Current time: {time}\n",
"\n",
"1. {first_instruction}\n",
"2. Reflect and critique your answer. Be severe to maximize improvement.\n",
"3. Recommend search queries to research information and improve your answer.\"\"\",\n",
" ),\n",
" MessagesPlaceholder(variable_name=\"messages\"),\n",
" (\"system\", \"Answer the user's question above using the required format.\"),\n",
" ]\n",
").partial(\n",
" time=lambda: datetime.datetime.now().isoformat(),\n",
")\n",
"\n",
"\n",
"class Reflection(BaseModel):\n",
" missing: str = Field(description=\"Critique of what is missing.\")\n",
" superfluous: str = Field(description=\"Critique of what is superfluous\")\n",
"\n",
"\n",
"class AnswerQuestion(BaseModel):\n",
" \"\"\"Answer the question.\"\"\"\n",
"\n",
" answer: str = Field(description=\"~250 word detailed answer to the question.\")\n",
" reflection: Reflection = Field(description=\"Your reflection on the initial answer.\")\n",
" search_queries: List[str] = Field(\n",
" description=\"1-3 search queries for researching improvements to address the critique of your current answer.\"\n",
" )\n",
"\n",
"\n",
"llm = ChatOpenAI(model=\"gpt-4-turbo-preview\")\n",
"initial_answer_chain = actor_prompt_template.partial(\n",
" first_instruction=\"Provide a detailed ~250 word answer.\"\n",
") | llm.bind_tools(tools=[AnswerQuestion], tool_choice=\"AnswerQuestion\")\n",
"validator = PydanticToolsParser(tools=[AnswerQuestion])\n",
"\n",
"\n",
"class ResponderWithRetries:\n",
" def __init__(self, runnable, validator):\n",
" self.runnable = runnable\n",
" self.validator = validator\n",
"\n",
" @traceable\n",
" def respond(self, state: List[BaseMessage]):\n",
" response = []\n",
" for attempt in range(3):\n",
" try:\n",
" response = self.runnable.invoke({\"messages\": state})\n",
" self.validator.invoke(response)\n",
" return response\n",
" except ValidationError as e:\n",
" state = state + [HumanMessage(content=repr(e))]\n",
" return response"
]
},
{
"cell_type": "code",
"execution_count": 64,
"id": "4a0264b8-ed2d-4f15-9d3c-085aa3a5edab",
"metadata": {},
"outputs": [],
"source": [
"first_responder = ResponderWithRetries(\n",
" runnable=initial_answer_chain, validator=validator\n",
")"
]
},
{
"cell_type": "code",
"execution_count": 65,
"id": "5922e1fe-7533-4f41-8b1d-d812707c1968",
"metadata": {},
"outputs": [],
"source": [
"example_question = \"Why is reflection useful in AI?\"\n",
"initial = first_responder.respond([HumanMessage(content=example_question)])"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "20901d77-0f5f-4596-90a4-412c0ac5f2c2",
"metadata": {},
"outputs": [],
"source": [
"parsed = parser.invoke(initial)\n",
"parsed"
]
},
{
"cell_type": "markdown",
"id": "c4c7af31-b469-46fc-b441-0acb28515c7a",
"metadata": {},
"source": [
"#### Revision\n",
"\n",
"The second part of the actor is a revision step."
]
},
{
"cell_type": "code",
"execution_count": 67,
"id": "2605fd8d-c663-446f-ba25-751190195749",
"metadata": {},
"outputs": [],
"source": [
"revise_instructions = \"\"\"Revise your previous answer using the new information.\n",
" - You should use the previous critique to add important information to your answer.\n",
" - You MUST include numerical citations in your revised answer to ensure it can be verified.\n",
" - Add a \"References\" section to the bottom of your answer (which does not count towards the word limit). In form of:\n",
" - [1] https://example.com\n",
" - [2] https://example.com\n",
" - You should use the previous critique to remove superfluous information from your answer and make SURE it is not more than 250 words.\n",
"\"\"\"\n",
"\n",
"\n",
"# Extend the initial answer schema to include references.\n",
"# Forcing citation in the model encourages grounded responses\n",
"class ReviseAnswer(AnswerQuestion):\n",
" \"\"\"Revise your original answer to your question.\"\"\"\n",
"\n",
" references: List[str] = Field(\n",
" description=\"Citations motivating your updated answer.\"\n",
" )\n",
"\n",
"\n",
"revision_chain = actor_prompt_template.partial(\n",
" first_instruction=revise_instructions\n",
") | llm.bind_tools(tools=[ReviseAnswer], tool_choice=\"ReviseAnswer\")\n",
"revision_validator = PydanticToolsParser(tools=[ReviseAnswer])\n",
"\n",
"revisor = ResponderWithRetries(runnable=revision_chain, validator=revision_validator)"
]
},
{
"cell_type": "code",
"execution_count": 68,
"id": "6fd51f17-c0b0-44b6-90e2-55a66cb8f5a7",
"metadata": {},
"outputs": [],
"source": [
"import json\n",
"\n",
"revised = revisor.respond(\n",
" [\n",
" HumanMessage(content=\"\"),\n",
" initial,\n",
" ToolMessage(\n",
" tool_call_id=initial.additional_kwargs[\"tool_calls\"][0][\"id\"],\n",
" content=json.dumps(\n",
" tavily_tool.invoke(str(parsed[0][\"args\"][\"search_queries\"]))\n",
" ),\n",
" ),\n",
" ]\n",
")"
]
},
{
"cell_type": "code",
"execution_count": 69,
"id": "28685e81-5461-47fe-bebd-2af6e552761b",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"[{'type': 'ReviseAnswer',\n",
" 'args': {'answer': \"Reflection in AI refers to the ability of AI systems to analyze and adapt their behavior and algorithms autonomously. This introspective capability enhances AI's performance and adaptability, making it crucial for learning, transparency, and optimization. \\n\\nReflection enables AI to learn from experiences, adjusting strategies for better decision-making. For example, Google DeepMind's AI has shown significant advancements in learning and adapting strategies in various environments [1]. Moreover, AI systems can explain their decisions, supporting the development of explainable AI (XAI), vital in sensitive sectors like healthcare and autonomous driving. This increases user trust and acceptance by providing insights into AI's decision-making processes. \\n\\nAdditionally, reflection aids in debugging and improving AI models by identifying weaknesses and suggesting enhancements. For instance, AI in healthcare, like the Mayo Clinic's use of medical data analytics, demonstrates how reflective AI can optimize algorithms to provide better patient care [2]. \\n\\nIn summary, reflection in AI fosters learning and adaptation, enhances transparency and trust, and facilitates model optimization, contributing to the development of sophisticated, reliable AI systems.\",\n",
" 'reflection': {'missing': 'The previous answer lacked specific examples and case studies to illustrate the benefits of reflection in AI. Including such examples would provide a more concrete understanding of the concept and its applications.',\n",
" 'superfluous': 'The initial answer was comprehensive but could benefit from direct examples to demonstrate the practical applications and benefits of reflection in AI, rather than a broad overview without concrete cases.'},\n",
" 'search_queries': ['Google DeepMind reflective AI examples',\n",
" 'Mayo Clinic AI case study',\n",
" 'Reflective AI benefits in healthcare'],\n",
" 'references': ['https://casestudybuddy.com/blog/best-ai-case-study-examples/',\n",
" 'https://indatalabs.com/blog/artificial-intelligence-case-studies']},\n",
" 'id': 'call_0kZkZgn5DP2Z8VhRtxGkXDp5'}]"
]
},
"execution_count": 69,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"parsed = parser.invoke(revised)\n",
"parsed"
]
},
{
"cell_type": "markdown",
"id": "e623a6c9-b69b-438c-9e6e-34a8883e0623",
"metadata": {},
"source": [
"## Construct Graph\n",
"\n",
"\n",
"Now we can wire all our components together."
]
},
{
"cell_type": "code",
"execution_count": 71,
"id": "3c57318f-a30c-4dbd-9b88-f2633e8cb3b1",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.graph import END, MessageGraph\n",
"\n",
"MAX_ITERATIONS = 5\n",
"builder = MessageGraph()\n",
"builder.add_node(\"draft\", first_responder.respond)\n",
"builder.add_node(\"execute_tools\", execute_tools)\n",
"builder.add_node(\"revise\", revisor.respond)\n",
"# draft -> execute_tools\n",
"builder.add_edge(\"draft\", \"execute_tools\")\n",
"# execute_tools -> revise\n",
"builder.add_edge(\"execute_tools\", \"revise\")\n",
"\n",
"# Define looping logic:\n",
"\n",
"\n",
"def _get_num_iterations(state: List[BaseMessage]):\n",
" i = 0\n",
" for m in state[::-1]:\n",
" if not isinstance(m, (ToolMessage, AIMessage)):\n",
" break\n",
" i += 1\n",
" return i\n",
"\n",
"\n",
"def event_loop(state: List[BaseMessage]) -> str:\n",
" # in our case, we'll just stop after N plans\n",
" num_iterations = _get_num_iterations(state)\n",
" if num_iterations > MAX_ITERATIONS:\n",
" return END\n",
" return \"execute_tools\"\n",
"\n",
"\n",
"# revise -> execute_tools OR end\n",
"builder.add_conditional_edges(\"revise\", event_loop)\n",
"builder.set_entry_point(\"draft\")\n",
"graph = builder.compile()"
]
},
{
"cell_type": "code",
"execution_count": 72,
"id": "2634a3ea-7423-4579-9f4e-390e439c3209",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"## 1. draft\n",
"content='' additional_kwargs={'tool_calls': [{'id': 'call_GOmUTyAeA8kLLm4G9sXZ4jGV', 'function': {'a ...\n",
"---\n",
"## 2. execute_tools\n",
"[ToolMessage(content='{\"successful climate policies examples\": [{\"url\": \"https://www.washingtonpost. ...\n",
"---\n",
"## 3. revise\n",
"content='' additional_kwargs={'tool_calls': [{'id': 'call_Z0dky70zr74bLi6dBfQTCj6j', 'function': {'a ...\n",
"---\n",
"## 4. execute_tools\n",
"[ToolMessage(content='{\"successful climate policies examples\": [{\"url\": \"https://www.washingtonpost. ...\n",
"---\n",
"## 5. revise\n",
"content='' additional_kwargs={'tool_calls': [{'id': 'call_tM6DgVvQoux8IDIkRlrUKwOj', 'function': {'a ...\n",
"---\n",
"## 6. execute_tools\n",
"[ToolMessage(content='{\"successful climate policies examples\": [{\"url\": \"https://www.washingtonpost. ...\n",
"---\n",
"## 7. revise\n",
"content='' additional_kwargs={'tool_calls': [{'id': 'call_XkarDDuEf43cOBPn9zNXN8vM', 'function': {'a ...\n",
"---\n",
"## 8. __end__\n",
"[HumanMessage(content='How should we handle the climate crisis?'), AIMessage(content='', additional_ ...\n",
"---\n"
]
}
],
"source": [
"events = graph.stream(\n",
" [HumanMessage(content=\"How should we handle the climate crisis?\")]\n",
")\n",
"for i, step in enumerate(events):\n",
" node, output = next(iter(step.items()))\n",
" print(f\"## {i+1}. {node}\")\n",
" print(str(output)[:100] + \" ...\")\n",
" print(\"---\")"
]
},
{
"cell_type": "code",
"execution_count": 77,
"id": "c9195436-aed9-4356-948b-2ca081a6d0bf",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Addressing the climate crisis requires a comprehensive approach, combining policy, technology, and finance. Successful policy initiatives include the U.S. Army's carbon footprint reduction, federal funding to plug methane-leaking wells, and Ithaca, NY's building decarbonization [1]. Renewable energy, particularly solar and wind, is forecasted to surpass coal by 2025, demonstrating the critical role of transitioning to sustainable energy sources [2]. Technological innovations are essential, with significant advancements in solar cell efficiency, data-driven climate adaptation technologies, and efforts to replace or mitigate major emission sources [3][4][5]. Financial mechanisms are pivotal, with climate finance needing a substantial increase to meet global warming limits. The U.S. has made progress by significantly enhancing its international public climate finance, exemplifying financial commitment to supporting global climate action [6]. This holistic strategy, integrating policy, technological innovation, and finance, represents the most effective way to tackle the climate crisis, emphasizing sustainability, innovation, and global cooperation.\n",
"\n",
"References:\n",
"[1] https://www.washingtonpost.com/climate-solutions/2022/04/21/climate-change-policy-examples-list/\n",
"[2] https://www.weforum.org/agenda/2024/01/climate-transition-tipping-point/\n",
"[3] https://www.technologyreview.com/2024/01/11/1086412/three-climate-technologies-breaking-through-in-2024/\n",
"[4] https://unfccc.int/news/how-climate-technology-is-being-ramped-up\n",
"[5] https://www.weforum.org/agenda/2024/02/ai-climate-adaptation-technologies/\n",
"[6] https://www.state.gov/progress-report-on-president-bidens-climate-finance-pledge/\n"
]
}
],
"source": [
"print(parser.invoke(step[END][-1])[0][\"args\"][\"answer\"])"
]
},
{
"cell_type": "markdown",
"id": "7159e30c-728e-480d-8252-915404cc756d",
"metadata": {},
"source": [
"## Conclusion\n",
"\n",
"Congrats on building a Reflexion actor! I'll leave you with a few observations to save you some time when choosing which parts of this agent ot adapt to your workflow:\n",
"1. This agent trades off execution time for quality. It explicitly forces the agent to critique and revise the output over several steps, which usually (not always) increases the response quality but takes much longer to return a final answer\n",
"2. The 'reflections' can be paired with additional external feedback (such as validators), to further guide the actor.\n",
"3. In the paper, 1 environment (AlfWorld) uses external memory. It does this by storing summaries of the reflections to an external store and using them in subsequent trials/invocations."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "140c1961-64f6-41f1-9b80-f09deffae21f",
"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.2"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11 -4
View File
@@ -240,6 +240,7 @@
"import json\n",
"from langchain_core.messages import FunctionMessage\n",
"\n",
"\n",
"# Define the function that determines whether to continue or not\n",
"def should_continue(messages):\n",
" last_message = messages[-1]\n",
@@ -250,12 +251,14 @@
" else:\n",
" return \"continue\"\n",
"\n",
"\n",
"# Define the function that calls the model\n",
"async def call_model(messages):\n",
" response = await model.ainvoke(messages)\n",
" # We return a list, because this will get added to the existing list\n",
" return response\n",
"\n",
"\n",
"# Define the function to execute tools\n",
"async def call_tool(messages):\n",
" # Based on the continue condition\n",
@@ -264,7 +267,9 @@
" # 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",
" tool_input=json.loads(\n",
" last_message.additional_kwargs[\"function_call\"][\"arguments\"]\n",
" ),\n",
" )\n",
" # We call the tool_executor and get back a response\n",
" response = await tool_executor.ainvoke(action)\n",
@@ -292,6 +297,7 @@
"outputs": [],
"source": [
"from langgraph.graph import MessageGraph, END\n",
"\n",
"# Define a new graph\n",
"workflow = MessageGraph()\n",
"\n",
@@ -320,13 +326,13 @@
" # If `tools`, then we call the tool node.\n",
" \"continue\": \"action\",\n",
" # Otherwise we finish.\n",
" \"end\": END\n",
" }\n",
" \"end\": END,\n",
" },\n",
")\n",
"\n",
"# We now add a normal edge from `tools` to `agent`.\n",
"# This means that after `tools` is called, `agent` node is called next.\n",
"workflow.add_edge('action', 'agent')\n",
"workflow.add_edge(\"action\", \"agent\")\n",
"\n",
"# Finally, we compile it!\n",
"# This compiles it into a LangChain Runnable,\n",
@@ -375,6 +381,7 @@
],
"source": [
"from langchain_core.messages import HumanMessage\n",
"\n",
"inputs = [HumanMessage(content=\"what is the weather in sf\")]\n",
"async for event in app.astream_events(inputs, version=\"v1\"):\n",
" kind = event[\"event\"]\n",
+3
View File
@@ -0,0 +1,3 @@
from langgraph.version import __version__
__all__ = ["__version__"]
+55
View File
@@ -0,0 +1,55 @@
from contextlib import contextmanager
from typing import Generator, Generic, Optional, Sequence, Type
from typing_extensions import Self
from langgraph.channels.base import BaseChannel, EmptyChannelError, Value
class AnyValue(Generic[Value], BaseChannel[Value, Value, Value]):
"""Stores the last value received, assumes that if multiple values are
received, they are all equal."""
def __init__(self, typ: Type[Value]) -> None:
self.typ = typ
@property
def ValueType(self) -> Type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
"""The type of the update received by the channel."""
return self.typ
@contextmanager
def empty(self, checkpoint: Optional[Value] = None) -> Generator[Self, None, None]:
empty = self.__class__(self.typ)
if checkpoint is not None:
empty.value = checkpoint
try:
yield empty
finally:
try:
del empty.value
except AttributeError:
pass
def update(self, values: Sequence[Value]) -> None:
if len(values) == 0:
return
self.value = values[-1]
def get(self) -> Value:
try:
return self.value
except AttributeError:
raise EmptyChannelError()
def checkpoint(self) -> Value:
try:
return self.value
except AttributeError:
raise EmptyChannelError()
+8 -8
View File
@@ -116,16 +116,16 @@ def create_checkpoint(
checkpoint: Checkpoint, channels: Mapping[str, BaseChannel]
) -> Checkpoint:
"""Create a checkpoint for the given channels."""
checkpoint = Checkpoint(
values: dict[str, Any] = {}
for k, v in channels.items():
try:
values[k] = v.checkpoint()
except EmptyChannelError:
pass
return Checkpoint(
v=1,
ts=datetime.now(timezone.utc).isoformat(),
channel_values=checkpoint["channel_values"],
channel_values=values,
channel_versions=checkpoint["channel_versions"],
versions_seen=checkpoint["versions_seen"],
)
for k, v in channels.items():
try:
checkpoint["channel_values"][k] = v.checkpoint()
except EmptyChannelError:
pass
return checkpoint
+67
View File
@@ -0,0 +1,67 @@
from contextlib import contextmanager
from typing import Generator, Generic, Optional, Sequence, Type
from typing_extensions import Self
from langgraph.channels.base import (
BaseChannel,
EmptyChannelError,
InvalidUpdateError,
Value,
)
class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
"""Stores the value received in the step immediately preceding, clears after."""
def __init__(self, typ: Type[Value], guard: bool = True) -> None:
self.typ = typ
self.guard = guard
@property
def ValueType(self) -> Type[Value]:
"""The type of the value stored in the channel."""
return self.typ
@property
def UpdateType(self) -> Type[Value]:
"""The type of the update received by the channel."""
return self.typ
@contextmanager
def empty(self, checkpoint: Optional[Value] = None) -> Generator[Self, None, None]:
empty = self.__class__(self.typ, self.guard)
if checkpoint is not None:
empty.value = checkpoint
try:
yield empty
finally:
try:
del empty.value
except AttributeError:
pass
def update(self, values: Sequence[Value]) -> None:
if len(values) == 0:
try:
del self.value
except AttributeError:
pass
finally:
return
if len(values) != 1 and self.guard:
raise InvalidUpdateError("LastValue can only receive one value per step.")
self.value = values[-1]
def get(self) -> Value:
try:
return self.value
except AttributeError:
raise EmptyChannelError()
def checkpoint(self) -> Value:
try:
return self.value
except AttributeError:
raise EmptyChannelError()
+85
View File
@@ -0,0 +1,85 @@
import pickle
from typing import Optional
import aiosqlite
from langchain_core.pydantic_v1 import Field
from langchain_core.runnables import RunnableConfig
from langchain_core.runnables.utils import ConfigurableFieldSpec
from langgraph.checkpoint.base import BaseCheckpointSaver, Checkpoint
class AsyncSqliteSaver(BaseCheckpointSaver):
conn: aiosqlite.Connection
is_setup: bool = Field(False, init=False, repr=False)
class Config:
arbitrary_types_allowed = True
@classmethod
def from_conn_string(cls, conn_string: str) -> "AsyncSqliteSaver":
return AsyncSqliteSaver(conn=aiosqlite.connect(conn_string))
@property
def config_specs(self) -> list[ConfigurableFieldSpec]:
return [
ConfigurableFieldSpec(
id="thread_id",
annotation=str,
name="Thread ID",
description=None,
default="",
is_shared=True,
),
]
async def setup(self) -> None:
print("hello")
if self.is_setup:
return
try:
await self.conn
await self.conn.executescript(
"""
CREATE TABLE IF NOT EXISTS checkpoints (
thread_id TEXT PRIMARY KEY,
checkpoint BLOB
);
"""
)
await self.conn.commit()
print("good bye")
self.is_setup = True
except BaseException as e:
print(e)
raise e
def get(self, config: RunnableConfig) -> Optional[Checkpoint]:
raise NotImplementedError
def put(self, config: RunnableConfig, checkpoint: Checkpoint) -> None:
raise NotImplementedError
async def aget(self, config: RunnableConfig) -> Optional[Checkpoint]:
await self.setup()
async with self.conn.execute(
"SELECT checkpoint FROM checkpoints WHERE thread_id = ?",
(config["configurable"]["thread_id"],),
) as cursor:
if value := await cursor.fetchone():
return pickle.loads(value[0])
async def aput(self, config: RunnableConfig, checkpoint: Checkpoint) -> None:
await self.setup()
await self.conn.execute(
"INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint) VALUES (?, ?)",
(
config["configurable"]["thread_id"],
pickle.dumps(checkpoint),
),
)
await self.conn.commit()
+11
View File
@@ -1,6 +1,7 @@
import asyncio
from abc import ABC, abstractmethod
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any, Optional, TypedDict
@@ -33,6 +34,16 @@ def empty_checkpoint() -> Checkpoint:
)
def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
return Checkpoint(
v=checkpoint["v"],
ts=checkpoint["ts"],
channel_values=checkpoint["channel_values"].copy(),
channel_versions=checkpoint["channel_versions"].copy(),
versions_seen=deepcopy(checkpoint["versions_seen"]),
)
class CheckpointAt(StrEnum):
END_OF_STEP = "end_of_step"
END_OF_RUN = "end_of_run"
+6
View File
@@ -79,3 +79,9 @@ class SqliteSaver(BaseCheckpointSaver):
pickle.dumps(checkpoint),
),
)
async def aget(self, config: RunnableConfig) -> Optional[Checkpoint]:
raise NotImplementedError
async def aput(self, config: RunnableConfig, checkpoint: Checkpoint) -> None:
raise NotImplementedError
+1
View File
@@ -1,2 +1,3 @@
CONFIG_KEY_SEND = "__pregel_send"
CONFIG_KEY_READ = "__pregel_read"
INTERRUPT = "__interrupt__"
+2 -2
View File
@@ -1,5 +1,5 @@
from langgraph.graph.graph import END, Graph, START
from langgraph.graph.graph import END, Graph
from langgraph.graph.message import MessageGraph
from langgraph.graph.state import StateGraph
__all__ = ["END", "START", "Graph", "StateGraph", "MessageGraph"]
__all__ = ["END", "Graph", "StateGraph", "MessageGraph"]
+154 -28
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 (
@@ -8,12 +9,17 @@ from langchain_core.runnables.base import (
RunnableLike,
coerce_to_runnable,
)
from langchain_core.runnables.config import RunnableConfig
from langchain_core.runnables.graph import Graph as RunnableGraph
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.checkpoint import BaseCheckpointSaver
from langgraph.pregel import Channel, Pregel
logger = logging.getLogger(__name__)
START = "__start__"
END = "__end__"
START = "START"
class Branch(NamedTuple):
@@ -35,9 +41,16 @@ class Graph:
self.edges = set[tuple[str, str]]()
self.branches: defaultdict[str, list[Branch]] = defaultdict(list)
self.support_multiple_edges = False
self.entry_point = None
self.compiled = False
self.entry_point: Optional[str] = None
self.entry_point_branch: Optional[Branch] = None
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:
@@ -46,6 +59,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:
@@ -66,6 +84,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):
@@ -83,47 +106,83 @@ 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
def set_entry_route(self, condition: Callable[..., str],
conditional_edge_mapping: Optional[Dict[str, str]] = None) -> None:
self.add_node(START, lambda x: None)
self.add_conditional_edges(START, condition, conditional_edge_mapping)
self.set_entry_point(START)
def set_conditional_entry_point(
self,
condition: Callable[..., str],
conditional_edge_mapping: Optional[Dict[str, str]] = None,
) -> 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 iscoroutinefunction(condition):
raise ValueError("Condition cannot be a coroutine function")
if conditional_edge_mapping and set(
conditional_edge_mapping.values()
).difference([END]).difference(self.nodes):
raise ValueError(
f"Missing nodes which are in conditional edge mapping. Mapping "
f"contains possible destinations: "
f"{list(conditional_edge_mapping.values())}. Possible nodes are "
f"{list(self.nodes.keys())}."
)
self.entry_point_branch = Branch(condition, conditional_edge_mapping)
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:
raise ValueError(f"Node `{node}` is a dead-end")
if all(
branch.ends is not None
for branch_list in self.branches.values()
for branch in branch_list
):
all_ends = (
{end for _, end in self.edges}
| {
end
for branch_list in self.branches.values()
for branch in branch_list
for end in branch.ends.values()
}
| {self.entry_point}
)
branches = [
branch for branch_list in self.branches.values() for branch in branch_list
]
if self.entry_point_branch is not None:
branches.append(self.entry_point_branch)
all_hard_ends = {end for _, end in self.edges}
if self.entry_point is not None:
all_hard_ends.add(self.entry_point)
if all(branch.ends is not None for branch in branches):
all_ends = all_hard_ends | {
end for branch in branches for end in branch.ends.values()
}
for node in self.nodes:
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,
debug: bool = False,
) -> "CompiledGraph":
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:
@@ -133,6 +192,11 @@ class Graph:
key: (Channel.subscribe_to(f"{key}:inbox") | node | Channel.write_to(key))
for key, node in self.nodes.items()
}
node_outboxes = {
# we clear outbox channels after each step
key: EphemeralValue(Any)
for key in self.nodes
}
for key in self.nodes:
outgoing = outgoing_edges[key]
@@ -147,10 +211,72 @@ class Graph:
branch.runnable, name=f"{key}_condition"
)
return Pregel(
if self.entry_point_branch:
nodes[f"{START}:edges"] = Channel.subscribe_to(
START, tags=["langsmith:hidden"]
) | RunnableLambda(
self.entry_point_branch.runnable, name=f"{START}_condition"
)
elif self.entry_point is None:
raise ValueError("No entry point set")
return CompiledGraph(
graph=self,
nodes=nodes,
input=f"{self.entry_point}:inbox",
channels={**node_outboxes},
input=f"{self.entry_point}:inbox" if self.entry_point else START,
output=END,
hidden=[f"{node}:inbox" for node in self.nodes],
snapshot_channels=list(self.nodes),
checkpointer=checkpointer,
interrupt_before_nodes=[f"{node}:inbox" for node in interrupt_before],
interrupt_after_nodes=interrupt_after,
debug=debug,
)
class CompiledGraph(Pregel):
graph: Graph
def get_graph(self, config: Optional[RunnableConfig] = None) -> RunnableGraph:
graph = RunnableGraph()
graph.add_node(self.get_input_schema(config), START)
graph.add_node(self.get_output_schema(config), END)
for key, node in self.graph.nodes.items():
graph.add_node(node, key)
for start, end in self.graph.edges:
graph.add_edge(graph.nodes[start], graph.nodes[end])
for start, branches in self.graph.branches.items():
for i, branch in enumerate(branches):
name = f"{start}_{branch.condition.__name__}"
if i > 0:
name += f"_{i}"
graph.add_node(
RunnableLambda(branch.runnable, name=branch.condition.__name__),
name,
)
graph.add_edge(graph.nodes[start], graph.nodes[name])
ends = branch.ends or {k: k for k in self.graph.nodes}
for label, end in ends.items():
graph.add_edge(graph.nodes[name], graph.nodes[end], label)
if self.graph.entry_point_branch:
graph.add_node(
RunnableLambda(
self.graph.entry_point_branch.runnable,
name=self.graph.entry_point_branch.condition.__name__,
),
f"{START}_condition",
)
graph.add_edge(graph.nodes[START], graph.nodes[f"{START}_condition"])
ends = self.graph.entry_point_branch.ends or {
k: k for k in self.graph.nodes
}
for label, end in ends.items():
graph.add_edge(
graph.nodes[f"{START}_condition"], graph.nodes[end], label
)
elif self.graph.entry_point:
graph.add_edge(graph.nodes[START], graph.nodes[self.graph.entry_point])
return graph
+82 -28
View File
@@ -1,21 +1,21 @@
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 import RunnableLambda
from langchain_core.runnables.base import RunnableLike
from langgraph.channels.any_value import AnyValue
from langgraph.channels.base import BaseChannel, InvalidUpdateError
from langgraph.channels.binop import BinaryOperatorAggregate
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
from langgraph.checkpoint import BaseCheckpointSaver
from langgraph.graph.graph import END, Graph
from langgraph.pregel import Channel, Pregel
from langgraph.pregel.read import ChannelRead
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite
START = "__start__"
from langgraph.graph.graph import END, START, CompiledGraph, Graph
from langgraph.pregel import Channel
from langgraph.pregel.read import ChannelInvoke
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite, ChannelWriteEntry
class StateGraph(Graph):
@@ -34,23 +34,38 @@ 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,
debug: bool = False,
) -> CompiledGraph:
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
state_channels = (
{chan: chan for chan in state_keys}
if isinstance(state_keys_read, list)
else {None: state_keys_read}
)
update_channels = (
[("__root__", None, True)]
[ChannelWriteEntry("__root__", None, True)]
if not isinstance(state_keys_read, list)
else [
(key, RunnableLambda(partial(_dict_getter, state_keys, key)), False)
ChannelWriteEntry(
key, RunnableLambda(partial(_dict_getter, state_keys, key)), False
)
for key in state_keys_read
]
)
coerce_state = (
partial(_coerce_state, self.schema)
if isinstance(state_keys_read, list)
else RunnablePassthrough()
else None
)
outgoing_edges = defaultdict(list)
@@ -59,23 +74,44 @@ class StateGraph(Graph):
nodes = {
key: (
Channel.subscribe_to(f"{key}:inbox")
| coerce_state # coerce/validate using schema
ChannelInvoke(
triggers=[f"{key}:inbox"],
channels=state_channels,
mapper=coerce_state,
)
| node
| ChannelWrite(channels=[(key, None, False)] + update_channels)
| ChannelWrite(
channels=[ChannelWriteEntry(key, None, False)] + update_channels
)
)
for key, node in self.nodes.items()
}
node_inboxes = {
# we take any value written to channel because all writers
# write the entire state as of that step, which is equal for all
f"{key}:inbox": AnyValue(self.schema)
for key in list(self.nodes) + [START]
}
node_outboxes = {
# we clear outbox channels after each step
key: EphemeralValue(Any)
for key in list(self.nodes) + [START]
}
for key in self.nodes:
outgoing = outgoing_edges[key]
edges_key = f"{key}:edges"
if outgoing or key in self.branches:
nodes[edges_key] = Channel.subscribe_to(
key, tags=["langsmith:hidden"]
) | ChannelRead(state_keys_read)
nodes[edges_key] = ChannelInvoke(
triggers=[key], tags=["langsmith:hidden"], channels=state_channels
)
if outgoing:
nodes[edges_key] |= Channel.write_to(*[dest for dest in outgoing])
nodes[edges_key] |= ChannelWrite(
channels=[
ChannelWriteEntry(dest, None if dest == END else key, True)
for dest in outgoing
]
)
if key in self.branches:
for branch in self.branches[key]:
nodes[edges_key] |= RunnableLambda(
@@ -84,20 +120,38 @@ class StateGraph(Graph):
nodes[START] = Channel.subscribe_to(
f"{START}:inbox", tags=["langsmith:hidden"]
) | ChannelWrite(channels=[(START, None, False)] + update_channels)
nodes[f"{START}:edges"] = (
Channel.subscribe_to(START, tags=["langsmith:hidden"])
| ChannelRead(state_keys_read)
| Channel.write_to(f"{self.entry_point}:inbox")
) | ChannelWrite(
channels=[ChannelWriteEntry(START, None, False)] + update_channels
)
nodes[f"{START}:edges"] = ChannelInvoke(
triggers=[START], tags=["langsmith:hidden"], channels=state_channels
)
if self.entry_point:
nodes[f"{START}:edges"] |= Channel.write_to(f"{self.entry_point}:inbox")
elif self.entry_point_branch:
nodes[f"{START}:edges"] |= RunnableLambda(
self.entry_point_branch.runnable, name=f"{START}_condition"
)
else:
raise ValueError("No entry point set")
return Pregel(
return CompiledGraph(
graph=self,
nodes=nodes,
channels=self.channels,
channels={
**self.channels,
**node_inboxes,
**node_outboxes,
END: LastValue(self.schema),
},
input=f"{START}:inbox",
output=END,
hidden=[f"{node}:inbox" for node in self.nodes] + [START] + state_keys,
snapshot_channels=state_keys_read,
checkpointer=checkpointer,
interrupt_before_nodes=[f"{node}:inbox" for node in interrupt_before],
interrupt_after_nodes=interrupt_after,
debug=debug,
)
@@ -105,7 +159,7 @@ def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
return schema(**input)
def _dict_getter(allowed_keys: str, key: str, input: dict) -> Any:
def _dict_getter(allowed_keys: list[str], key: str, input: dict) -> Any:
if input is not None:
if not isinstance(input, dict) or any(key not in allowed_keys for key in input):
raise InvalidUpdateError(
+155 -21
View File
@@ -1,17 +1,23 @@
import json
import operator
from typing import Annotated, Sequence, TypedDict
from typing import Annotated, Sequence, TypedDict, Union
from langchain_core.agents import AgentAction
from langchain_core.messages import BaseMessage, FunctionMessage
from langchain_core.language_models import LanguageModelLike
from langchain_core.messages import BaseMessage, FunctionMessage, ToolMessage
from langchain_core.runnables import RunnableLambda
from langchain_core.utils.function_calling import convert_to_openai_function
from langchain_core.tools import BaseTool
from langchain_core.utils.function_calling import (
convert_to_openai_function,
convert_to_openai_tool,
)
from langgraph.graph import END, StateGraph
from langgraph.prebuilt.tool_executor import ToolExecutor
from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation
def create_function_calling_executor(model, tools):
def create_function_calling_executor(
model: LanguageModelLike, tools: Union[ToolExecutor, Sequence[BaseTool]]
):
if isinstance(tools, ToolExecutor):
tool_executor = tools
tool_classes = tools.tools
@@ -20,8 +26,15 @@ def create_function_calling_executor(model, tools):
tool_classes = tools
model = model.bind(functions=[convert_to_openai_function(t) for t in tool_classes])
# We create the AgentState that we will pass around
# This simply involves a list of messages
# We want steps to return messages to append to the list
# So we annotate the messages attribute with operator.add
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
# Define the function that determines whether to continue or not
def should_continue(state):
def should_continue(state: AgentState):
messages = state["messages"]
last_message = messages[-1]
# If there is no function call, then we finish
@@ -32,34 +45,33 @@ def create_function_calling_executor(model, tools):
return "continue"
# Define the function that calls the model
def call_model(state):
def call_model(state: AgentState):
messages = state["messages"]
response = model.invoke(messages)
# We return a list, because this will get added to the existing list
return {"messages": [response]}
async def acall_model(state):
async def acall_model(state: AgentState):
messages = state["messages"]
response = await model.ainvoke(messages)
# We return a list, because this will get added to the existing list
return {"messages": [response]}
# Define the function to execute tools
def _get_action(state):
def _get_action(state: AgentState):
messages = state["messages"]
# Based on the continue condition
# we know the last message involves a function call
last_message = messages[-1]
# We construct an AgentAction from the function_call
return AgentAction(
return ToolInvocation(
tool=last_message.additional_kwargs["function_call"]["name"],
tool_input=json.loads(
last_message.additional_kwargs["function_call"]["arguments"]
),
log="",
)
def call_tool(state):
def call_tool(state: AgentState):
action = _get_action(state)
# We call the tool_executor and get back a response
response = tool_executor.invoke(action)
@@ -68,7 +80,7 @@ def create_function_calling_executor(model, tools):
# We return a list, because this will get added to the existing list
return {"messages": [function_message]}
async def acall_tool(state):
async def acall_tool(state: AgentState):
action = _get_action(state)
# We call the tool_executor and get back a response
response = await tool_executor.ainvoke(action)
@@ -77,13 +89,135 @@ def create_function_calling_executor(model, tools):
# We return a list, because this will get added to the existing list
return {"messages": [function_message]}
# We create the AgentState that we will pass around
# This simply involves a list of messages
# We want steps to return messages to append to the list
# So we annotate the messages attribute with operator.add
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
# Define a new graph
workflow = StateGraph(AgentState)
# Define the two nodes we will cycle between
workflow.add_node("agent", RunnableLambda(call_model, acall_model))
workflow.add_node("action", RunnableLambda(call_tool, acall_tool))
# Set the entrypoint as `agent`
# This means that this node is the first one called
workflow.set_entry_point("agent")
# We now add a conditional edge
workflow.add_conditional_edges(
# First, we define the start node. We use `agent`.
# This means these are the edges taken after the `agent` node is called.
"agent",
# Next, we pass in the function that will determine which node is called next.
should_continue,
# Finally we pass in a mapping.
# The keys are strings, and the values are other nodes.
# END is a special node marking that the graph should finish.
# What will happen is we will call `should_continue`, and then the output of that
# will be matched against the keys in this mapping.
# Based on which one it matches, that node will then be called.
{
# If `tools`, then we call the tool node.
"continue": "action",
# Otherwise we finish.
"end": END,
},
)
# We now add a normal edge from `tools` to `agent`.
# This means that after `tools` is called, `agent` node is called next.
workflow.add_edge("action", "agent")
# Finally, we compile it!
# This compiles it into a LangChain Runnable,
# meaning you can use it as you would any other runnable
return workflow.compile()
def create_tool_calling_executor(
model: LanguageModelLike, tools: Union[ToolExecutor, Sequence[BaseTool]]
):
if isinstance(tools, ToolExecutor):
tool_executor = tools
tool_classes = tools.tools
else:
tool_executor = ToolExecutor(tools)
tool_classes = tools
model = model.bind(tools=[convert_to_openai_tool(t) for t in tool_classes])
# We create the AgentState that we will pass around
# This simply involves a list of messages
# We want steps to return messages to append to the list
# So we annotate the messages attribute with operator.add
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
# Define the function that determines whether to continue or not
def should_continue(state: AgentState):
messages = state["messages"]
last_message = messages[-1]
# If there is no function call, then we finish
if "tool_calls" not in last_message.additional_kwargs:
return "end"
# Otherwise if there is, we continue
else:
return "continue"
# Define the function that calls the model
def call_model(state: AgentState):
messages = state["messages"]
response = model.invoke(messages)
# We return a list, because this will get added to the existing list
return {"messages": [response]}
async def acall_model(state: AgentState):
messages = state["messages"]
response = await model.ainvoke(messages)
# We return a list, because this will get added to the existing list
return {"messages": [response]}
# Define the function to execute tools
def _get_actions(state: AgentState):
messages = state["messages"]
# Based on the continue condition
# we know the last message involves a tool call
last_message = messages[-1]
# We construct an AgentAction from each of the tool_calls
return (
[
ToolInvocation(
tool=tool_call["function"]["name"],
tool_input=json.loads(tool_call["function"]["arguments"]),
)
for tool_call in last_message.additional_kwargs["tool_calls"]
],
[
tool_call["id"]
for tool_call in last_message.additional_kwargs["tool_calls"]
],
)
def call_tool(state: AgentState):
actions, ids = _get_actions(state)
# We call the tool_executor and get back a response
responses = tool_executor.batch(actions)
# We use the response to create a FunctionMessage
tool_messages = [
ToolMessage(content=str(response), tool_call_id=id)
for response, id in zip(responses, ids)
]
# We return a list, because this will get added to the existing list
return {"messages": tool_messages}
async def acall_tool(state: AgentState):
actions, ids = _get_actions(state)
# We call the tool_executor and get back a response
responses = await tool_executor.abatch(actions)
# We use the response to create a FunctionMessage
tool_messages = [
ToolMessage(content=str(response), tool_call_id=id)
for response, id in zip(responses, ids)
]
# We return a list, because this will get added to the existing list
return {"messages": tool_messages}
# Define a new graph
workflow = StateGraph(AgentState)
+324 -82
View File
@@ -11,6 +11,7 @@ from typing import (
Callable,
Iterator,
Mapping,
NamedTuple,
Optional,
Sequence,
Type,
@@ -24,7 +25,7 @@ from langchain_core.callbacks.manager import (
CallbackManagerForChainRun,
)
from langchain_core.globals import get_debug
from langchain_core.pydantic_v1 import BaseModel, Field, create_model, root_validator
from langchain_core.pydantic_v1 import BaseModel, Field, root_validator
from langchain_core.runnables import (
Runnable,
RunnableSerializable,
@@ -37,10 +38,12 @@ from langchain_core.runnables.config import (
)
from langchain_core.runnables.utils import (
ConfigurableFieldSpec,
create_model,
get_unique_config_specs,
)
from langchain_core.tracers.log_stream import LogStreamCallbackHandler
from langgraph.channels.any_value import AnyValue
from langgraph.channels.base import (
AsyncChannelsManager,
BaseChannel,
@@ -49,21 +52,23 @@ from langgraph.channels.base import (
InvalidUpdateError,
create_checkpoint,
)
from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.channels.last_value import LastValue
from langgraph.checkpoint.base import (
BaseCheckpointSaver,
Checkpoint,
CheckpointAt,
copy_checkpoint,
empty_checkpoint,
)
from langgraph.constants import CONFIG_KEY_READ, CONFIG_KEY_SEND
from langgraph.constants import CONFIG_KEY_READ, CONFIG_KEY_SEND, INTERRUPT
from langgraph.pregel.debug import print_checkpoint, print_step_start
from langgraph.pregel.io import map_input, map_output
from langgraph.pregel.log import logger
from langgraph.pregel.read import ChannelBatch, ChannelInvoke
from langgraph.pregel.reserved import ReservedChannels
from langgraph.pregel.reserved import AllReservedChannels, ReservedChannels
from langgraph.pregel.validate import validate_graph, validate_keys
from langgraph.pregel.write import ChannelWrite
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
WriteValue = Union[
Runnable[Input, Output],
@@ -149,12 +154,22 @@ class Channel:
"""Writes to channels the result of the lambda, or None to skip writing."""
return ChannelWrite(
channels=(
[(c, None, False) for c in channels]
+ [(k, _coerce_write_value(v), True) for k, v in kwargs.items()]
[ChannelWriteEntry(c, None, False) for c in channels]
+ [
ChannelWriteEntry(k, _coerce_write_value(v), True)
for k, v in kwargs.items()
]
)
)
class StateSnapshot(NamedTuple):
values: dict[str, Any] | Any
"""Current values of channels"""
next: tuple[str]
"""Nodes to execute in the next step, if any"""
class Pregel(
RunnableSerializable[Union[dict[str, Any], Any], Union[dict[str, Any], Any]]
):
@@ -162,12 +177,19 @@ class Pregel(
channels: Mapping[str, BaseChannel] = Field(default_factory=dict)
# TODO Rename to `output_channels`
output: Union[str, Sequence[str]] = "output"
# TODO Replace with `stream_channels`
hidden: Sequence[str] = Field(default_factory=list)
interrupt: Sequence[str] = Field(default_factory=list)
snapshot_channels: Union[str, Sequence[str]] = Field(default_factory=list)
interrupt_after_nodes: Sequence[str] = Field(default_factory=list)
interrupt_before_nodes: Sequence[str] = Field(default_factory=list)
# TODO Rename to `input_channels`
input: Union[str, Sequence[str]] = "input"
step_timeout: Optional[float] = None
@@ -189,8 +211,12 @@ class Pregel(
values["input"],
values["output"],
values["hidden"],
values["interrupt"],
values["interrupt_after_nodes"],
values["interrupt_before_nodes"],
)
if values["interrupt_after_nodes"] or values["interrupt_before_nodes"]:
if not values["checkpointer"]:
raise ValueError("Interrupts require a checkpointer")
return values
@property
@@ -244,31 +270,157 @@ class Pregel(
**{k: (self.channels[k].ValueType, None) for k in self.output},
)
@property
def snapshot_channels_list(self) -> Sequence[str]:
return (
[self.snapshot_channels]
if isinstance(self.snapshot_channels, str)
else self.snapshot_channels
or [k for k in self.channels if k not in AllReservedChannels]
)
def get_state(self, config: RunnableConfig) -> StateSnapshot:
if not self.checkpointer:
raise ValueError("No checkpointer set")
checkpoint = self.checkpointer.get(config)
checkpoint = checkpoint or empty_checkpoint()
with ChannelsManager(self.channels, checkpoint) as channels:
_, next_tasks = _prepare_next_tasks(
checkpoint, self.nodes, channels, update_seen=False
)
values = {
k: _read_channel(channels, k)
for k in channels
if k in self.snapshot_channels_list
}
return StateSnapshot(
values[self.snapshot_channels]
if isinstance(self.snapshot_channels, str)
else values,
tuple(name for _, _, name in next_tasks),
)
async def aget_state(self, config: RunnableConfig) -> StateSnapshot:
if not self.checkpointer:
raise ValueError("No checkpointer set")
checkpoint = await self.checkpointer.aget(config)
checkpoint = checkpoint or empty_checkpoint()
async with AsyncChannelsManager(self.channels, checkpoint) as channels:
_, next_tasks = _prepare_next_tasks(
checkpoint, self.nodes, channels, update_seen=False
)
values = {
k: _read_channel(channels, k)
for k in channels
if k in self.snapshot_channels_list
}
return StateSnapshot(
values[self.snapshot_channels]
if isinstance(self.snapshot_channels, str)
else values,
tuple(name for _, _, name in next_tasks),
)
def update_state(
self, config: RunnableConfig, values: dict[str, Any] | Any
) -> None:
if not self.checkpointer:
raise ValueError("No checkpointer set")
values = (
{self.snapshot_channels: values}
if isinstance(self.snapshot_channels, str)
else values
)
checkpoint = self.checkpointer.get(config)
checkpoint = copy_checkpoint(checkpoint) if checkpoint else empty_checkpoint()
with ChannelsManager(self.channels, checkpoint) as channels:
for k, v in values.items():
channels[k].update([v])
checkpoint["channel_versions"][k] += 1
for k in self.snapshot_channels or self.channels:
version = checkpoint["channel_versions"][k]
checkpoint["versions_seen"][INTERRUPT][k] = version
self.checkpointer.put(config, create_checkpoint(checkpoint, channels))
async def aupdate_state(
self, config: RunnableConfig, values: dict[str, Any] | Any
) -> None:
if not self.checkpointer:
raise ValueError("No checkpointer set")
values = (
{self.snapshot_channels: values}
if isinstance(self.snapshot_channels, str)
else values
)
checkpoint = await self.checkpointer.aget(config)
checkpoint = copy_checkpoint(checkpoint) if checkpoint else empty_checkpoint()
async with AsyncChannelsManager(self.channels, checkpoint) as channels:
for k, v in values.items():
channels[k].update([v])
checkpoint["channel_versions"][k] += 1
for k in self.snapshot_channels or self.channels:
version = checkpoint["channel_versions"][k]
checkpoint["versions_seen"][INTERRUPT][k] = version
await self.checkpointer.aput(
config, create_checkpoint(checkpoint, channels)
)
def _defaults(
self,
*,
input_keys: Optional[Union[str, Sequence[str]]] = None,
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before_nodes: Optional[Sequence[str]] = None,
interrupt_after_nodes: Optional[Sequence[str]] = None,
debug: Optional[bool] = None,
) -> tuple[
bool,
Union[str, Sequence[str]],
Union[str, Sequence[str]],
Optional[Sequence[str]],
Optional[Sequence[str]],
]:
debug = debug if debug is not None else self.debug
if output_keys is None:
output_keys = [chan for chan in self.channels if chan not in self.hidden]
else:
validate_keys(output_keys, self.channels)
if input_keys is None:
input_keys = self.input
else:
validate_keys(input_keys, self.channels)
interrupt_before_nodes = interrupt_before_nodes or self.interrupt_before_nodes
interrupt_after_nodes = interrupt_after_nodes or self.interrupt_after_nodes
return (
debug,
input_keys,
output_keys,
interrupt_before_nodes,
interrupt_after_nodes,
)
def _transform(
self,
input: Iterator[Union[dict[str, Any], Any]],
run_manager: CallbackManagerForChainRun,
config: RunnableConfig,
*,
input_keys: Optional[Union[str, Sequence[str]]] = None,
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt: Optional[Sequence[str]] = None,
**kwargs: Any,
) -> Iterator[Union[dict[str, Any], Any]]:
try:
if config["recursion_limit"] < 1:
raise ValueError("recursion_limit must be at least 1")
# assign defaults
if output_keys is None:
output_keys = [
chan for chan in self.channels if chan not in self.hidden
]
else:
validate_keys(output_keys, self.channels)
if input_keys is None:
input_keys = self.input
else:
validate_keys(input_keys, self.channels)
interrupt = interrupt or self.interrupt
(
debug,
input_keys,
output_keys,
interrupt_before_nodes,
interrupt_after_nodes,
) = self._defaults(**kwargs)
# copy nodes to ignore mutations during execution
processes = {**self.nodes}
# get checkpoint from saver, or create an empty one
@@ -283,7 +435,7 @@ class Pregel(
w for c in input for w in map_input(input_keys, c)
):
# discard any unfinished tasks from previous checkpoint
_prepare_next_tasks(checkpoint, processes, channels)
checkpoint, _ = _prepare_next_tasks(checkpoint, processes, channels)
# apply input writes
_apply_writes(
checkpoint,
@@ -301,7 +453,9 @@ class Pregel(
# channels are guaranteed to be immutable for the duration of the step,
# with channel updates applied only at the transition between steps
for step in range(config["recursion_limit"] + 1):
next_tasks = _prepare_next_tasks(checkpoint, processes, channels)
checkpoint, next_tasks = _prepare_next_tasks(
checkpoint, processes, channels
)
# if no more tasks, we're done
if not next_tasks:
@@ -313,7 +467,7 @@ class Pregel(
"by setting the `recursion_limit` config key."
)
if self.debug:
if debug:
print_step_start(step, next_tasks)
# collect all writes to channels, without applying them yet
@@ -351,15 +505,15 @@ class Pregel(
timeout=self.step_timeout,
)
# interrupt on failure or timeout
_interrupt_or_proceed(done, inflight, step)
# panic on failure or timeout
_panic_or_proceed(done, inflight, step)
# apply writes to channels
_apply_writes(
checkpoint, channels, pending_writes, config, step + 1
)
if self.debug:
if debug:
print_checkpoint(step, channels)
# yield current value and checkpoint view
@@ -370,43 +524,54 @@ class Pregel(
# if view was updated, apply writes to channels
_apply_writes_from_view(checkpoint, channels, step_output)
# with previous step's checkpoint
if do_interrupt_before := _should_interrupt(
checkpoint,
interrupt_before_nodes,
self.snapshot_channels_list,
pending_writes,
):
break
# save end of step checkpoint
if (
self.checkpointer is not None
and self.checkpointer.at == CheckpointAt.END_OF_STEP
if self.checkpointer is not None and (
self.checkpointer.at == CheckpointAt.END_OF_STEP
or interrupt_before_nodes
):
checkpoint = create_checkpoint(checkpoint, channels)
self.checkpointer.put(config, checkpoint)
# interrupt if any channel written to is in interrupt list
if any(chan for chan, _ in pending_writes if chan in interrupt):
# with this step's checkpoint,
if _should_interrupt(
checkpoint,
interrupt_after_nodes,
self.snapshot_channels_list,
pending_writes,
):
break
# save end of run checkpoint
if (
self.checkpointer is not None
and self.checkpointer.at == CheckpointAt.END_OF_RUN
and not do_interrupt_before
):
checkpoint = create_checkpoint(checkpoint, channels)
self.checkpointer.put(config, checkpoint)
finally:
# cancel any pending tasks when generator is interrupted
try:
futures
for task in futures:
task.cancel()
except NameError:
return
for task in futures:
task.cancel()
pass
async def _atransform(
self,
input: AsyncIterator[Union[dict[str, Any], Any]],
run_manager: AsyncCallbackManagerForChainRun,
config: RunnableConfig,
*,
input_keys: Optional[Union[str, Sequence[str]]] = None,
output_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt: Optional[Sequence[str]] = None,
**kwargs: Any,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
try:
if config["recursion_limit"] < 1:
@@ -421,17 +586,13 @@ class Pregel(
None,
)
# assign defaults
if output_keys is None:
output_keys = [
chan for chan in self.channels if chan not in self.hidden
]
else:
validate_keys(output_keys, self.channels)
if input_keys is None:
input_keys = self.input
else:
validate_keys(input_keys, self.channels)
interrupt = interrupt or self.interrupt
(
debug,
input_keys,
output_keys,
interrupt_before_nodes,
interrupt_after_nodes,
) = self._defaults(**kwargs)
# copy nodes to ignore mutations during execution
processes = {**self.nodes}
# get checkpoint from saver, or create an empty one
@@ -446,7 +607,7 @@ class Pregel(
[w async for c in input for w in map_input(input_keys, c)]
):
# discard any unfinished tasks from previous checkpoint
_prepare_next_tasks(checkpoint, processes, channels)
checkpoint, _ = _prepare_next_tasks(checkpoint, processes, channels)
# apply input writes
_apply_writes(
checkpoint,
@@ -464,7 +625,9 @@ class Pregel(
# channels are guaranteed to be immutable for the duration of the step,
# channel updates being applied only at the transition between steps
for step in range(config["recursion_limit"] + 1):
next_tasks = _prepare_next_tasks(checkpoint, processes, channels)
checkpoint, next_tasks = _prepare_next_tasks(
checkpoint, processes, channels
)
# if no more tasks, we're done
if not next_tasks:
@@ -476,7 +639,7 @@ class Pregel(
"by setting the `recursion_limit` config key."
)
if self.debug:
if debug:
print_step_start(step, next_tasks)
# collect all writes to channels, without applying them yet
@@ -521,15 +684,15 @@ class Pregel(
timeout=self.step_timeout,
)
# interrupt on failure or timeout
_interrupt_or_proceed(done, inflight, step)
# panic on failure or timeout
_panic_or_proceed(done, inflight, step)
# apply writes to channels
_apply_writes(
checkpoint, channels, pending_writes, config, step + 1
)
if self.debug:
if debug:
print_checkpoint(step, channels)
# yield current value and checkpoint view
@@ -540,6 +703,15 @@ class Pregel(
# if view was updated, apply writes to channels
_apply_writes_from_view(checkpoint, channels, step_output)
# with previous step's checkpoint
if do_interrupt_before := _should_interrupt(
checkpoint,
interrupt_before_nodes,
self.snapshot_channels_list,
pending_writes,
):
break
# save end of step checkpoint
if (
self.checkpointer is not None
@@ -548,25 +720,30 @@ class Pregel(
checkpoint = create_checkpoint(checkpoint, channels)
await self.checkpointer.aput(config, checkpoint)
# interrupt if any channel written to is in interrupt list
if any(chan for chan, _ in pending_writes if chan in interrupt):
# with this step's checkpoint
if _should_interrupt(
checkpoint,
interrupt_after_nodes,
self.snapshot_channels_list,
pending_writes,
):
break
# save end of run checkpoint
if (
self.checkpointer is not None
and self.checkpointer.at == CheckpointAt.END_OF_RUN
and not do_interrupt_before
):
checkpoint = create_checkpoint(checkpoint, channels)
await self.checkpointer.aput(config, checkpoint)
finally:
# cancel any pending tasks when generator is interrupted
try:
futures
for task in futures:
task.cancel()
except NameError:
return
for task in futures:
task.cancel()
pass
def invoke(
self,
@@ -575,6 +752,9 @@ class Pregel(
*,
output_keys: Optional[Union[str, Sequence[str]]] = None,
input_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before_nodes: Optional[Sequence[str]] = None,
interrupt_after_nodes: Optional[Sequence[str]] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
latest: Union[dict[str, Any], Any] = None
@@ -583,6 +763,9 @@ class Pregel(
config,
output_keys=output_keys if output_keys is not None else self.output,
input_keys=input_keys,
interrupt_before_nodes=interrupt_before_nodes,
interrupt_after_nodes=interrupt_after_nodes,
debug=debug,
**kwargs,
):
latest = chunk
@@ -595,6 +778,9 @@ class Pregel(
*,
output_keys: Optional[Union[str, Sequence[str]]] = None,
input_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before_nodes: Optional[Sequence[str]] = None,
interrupt_after_nodes: Optional[Sequence[str]] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Iterator[Union[dict[str, Any], Any]]:
return self.transform(
@@ -602,6 +788,9 @@ class Pregel(
config,
output_keys=output_keys,
input_keys=input_keys,
interrupt_before_nodes=interrupt_before_nodes,
interrupt_after_nodes=interrupt_after_nodes,
debug=debug,
**kwargs,
)
@@ -612,6 +801,9 @@ class Pregel(
*,
output_keys: Optional[Union[str, Sequence[str]]] = None,
input_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before_nodes: Optional[Sequence[str]] = None,
interrupt_after_nodes: Optional[Sequence[str]] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Iterator[Union[dict[str, Any], Any]]:
for chunk in self._transform_stream_with_config(
@@ -620,6 +812,9 @@ class Pregel(
config,
output_keys=output_keys,
input_keys=input_keys,
interrupt_before_nodes=interrupt_before_nodes,
interrupt_after_nodes=interrupt_after_nodes,
debug=debug,
**kwargs,
):
yield chunk
@@ -631,6 +826,9 @@ class Pregel(
*,
output_keys: Optional[Union[str, Sequence[str]]] = None,
input_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before_nodes: Optional[Sequence[str]] = None,
interrupt_after_nodes: Optional[Sequence[str]] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> Union[dict[str, Any], Any]:
latest: Union[dict[str, Any], Any] = None
@@ -639,6 +837,9 @@ class Pregel(
config,
output_keys=output_keys if output_keys is not None else self.output,
input_keys=input_keys,
interrupt_before_nodes=interrupt_before_nodes,
interrupt_after_nodes=interrupt_after_nodes,
debug=debug,
**kwargs,
):
latest = chunk
@@ -651,6 +852,9 @@ class Pregel(
*,
output_keys: Optional[Union[str, Sequence[str]]] = None,
input_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before_nodes: Optional[Sequence[str]] = None,
interrupt_after_nodes: Optional[Sequence[str]] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
async def input_stream() -> AsyncIterator[Union[dict[str, Any], Any]]:
@@ -661,6 +865,9 @@ class Pregel(
config,
output_keys=output_keys,
input_keys=input_keys,
interrupt_before_nodes=interrupt_before_nodes,
interrupt_after_nodes=interrupt_after_nodes,
debug=debug,
**kwargs,
):
yield chunk
@@ -672,6 +879,9 @@ class Pregel(
*,
output_keys: Optional[Union[str, Sequence[str]]] = None,
input_keys: Optional[Union[str, Sequence[str]]] = None,
interrupt_before_nodes: Optional[Sequence[str]] = None,
interrupt_after_nodes: Optional[Sequence[str]] = None,
debug: Optional[bool] = None,
**kwargs: Any,
) -> AsyncIterator[Union[dict[str, Any], Any]]:
async for chunk in self._atransform_stream_with_config(
@@ -680,12 +890,15 @@ class Pregel(
config,
output_keys=output_keys,
input_keys=input_keys,
interrupt_before_nodes=interrupt_before_nodes,
interrupt_after_nodes=interrupt_after_nodes,
debug=debug,
**kwargs,
):
yield chunk
def _interrupt_or_proceed(
def _panic_or_proceed(
done: Union[set[concurrent.futures.Future[Any]], set[asyncio.Task[Any]]],
inflight: Union[set[concurrent.futures.Future[Any]], set[asyncio.Task[Any]]],
step: int,
@@ -709,6 +922,24 @@ def _interrupt_or_proceed(
raise TimeoutError(f"Timed out at step {step}")
def _should_interrupt(
checkpoint: Checkpoint,
interrupt_nodes: Sequence[str],
snapshot_channels: Sequence[str],
pending_writes: Sequence[tuple[str, Any]],
) -> bool:
return (
# interrupt if any of snapshopt_channels has been updated since last interrupt
any(
checkpoint["channel_versions"][chan]
> checkpoint["versions_seen"][INTERRUPT][chan]
for chan in snapshot_channels
)
# and any channel written to is in interrupt_nodes list
and any(chan for chan, _ in pending_writes if chan in interrupt_nodes)
)
def _read_channel(
channels: Mapping[str, BaseChannel], chan: str, catch: bool = True
) -> Any:
@@ -731,7 +962,7 @@ def _apply_writes(
pending_writes_by_channel: dict[str, list[Any]] = defaultdict(list)
# Group writes by channel
for chan, val in pending_writes:
if chan in [c.value for c in ReservedChannels]:
if chan in AllReservedChannels:
raise ValueError(f"Can't write to reserved channel {chan}")
pending_writes_by_channel[chan].append(val)
@@ -763,11 +994,12 @@ def _apply_writes(
def _apply_writes_from_view(
checkpoint: Checkpoint, channels: Mapping[str, BaseChannel], values: dict[str, Any]
) -> None:
# Apply writes to channels
for chan, value in values.items():
if value == _read_channel(channels, chan):
continue
assert isinstance(channels[chan], LastValue), (
assert isinstance(channels[chan], (LastValue, EphemeralValue, AnyValue)), (
f"Can't modify channel {chan} of type "
f"{channels[chan].__class__.__name__}"
)
@@ -779,7 +1011,9 @@ def _prepare_next_tasks(
checkpoint: Checkpoint,
processes: Mapping[str, Union[ChannelInvoke, ChannelBatch]],
channels: Mapping[str, BaseChannel],
) -> list[tuple[Runnable, Any, str]]:
update_seen: bool = True,
) -> tuple[Checkpoint, list[tuple[Runnable, Any, str]]]:
checkpoint = copy_checkpoint(checkpoint) if update_seen else checkpoint
tasks: list[tuple[Runnable, Any, str]] = []
# Check if any processes should be run in next step
# If so, prepare the values to be passed to them
@@ -791,7 +1025,8 @@ def _prepare_next_tasks(
checkpoint["channel_versions"][chan] > seen[chan]
for chan in proc.triggers
):
# If all channels subscribed by this process have been initialized
# If all trigger channels subscribed by this process are not empty
# then invoke the process with the values of all non-empty channels
try:
val: Any = {
k: _read_channel(
@@ -802,18 +1037,23 @@ def _prepare_next_tasks(
except EmptyChannelError:
continue
# If the process has a mapper, apply it to the value
if proc.mapper is not None:
val = proc.mapper(val)
# Processes that subscribe to a single keyless channel get
# the value directly, instead of a dict
if list(proc.channels.keys()) == [None]:
val = val[None]
# update seen versions
seen.update(
{
chan: checkpoint["channel_versions"][chan]
for chan in proc.triggers
}
)
if update_seen:
seen.update(
{
chan: checkpoint["channel_versions"][chan]
for chan in proc.triggers
}
)
# skip if condition is not met
if proc.when is None or proc.when(val):
@@ -821,16 +1061,18 @@ def _prepare_next_tasks(
elif isinstance(proc, ChannelBatch):
# If the channel read by this process was updated
if checkpoint["channel_versions"][proc.channel] > seen[proc.channel]:
# Here we don't catch EmptyChannelError because the channel
# must be intialized if the previous `if` condition is true
val = channels[proc.channel].get()
# If the channel subscribed by this process is not empty
try:
val = channels[proc.channel].get()
except EmptyChannelError:
continue
if proc.key is not None:
val = [{proc.key: v} for v in val]
tasks.append((proc, val, name))
seen[proc.channel] = checkpoint["channel_versions"][proc.channel]
return tasks
if update_seen:
seen[proc.channel] = checkpoint["channel_versions"][proc.channel]
return checkpoint, tasks
async def _aconsume(iterator: AsyncIterator[Any]) -> None:
+7
View File
@@ -79,6 +79,8 @@ class ChannelInvoke(RunnableBindingBase):
triggers: list[str] = Field(default_factory=list)
mapper: Optional[Callable[[Any], Any]] = None
when: Optional[Callable[[Any], bool]] = None
bound: Runnable[Any, Any] = Field(default=default_bound)
@@ -89,6 +91,7 @@ class ChannelInvoke(RunnableBindingBase):
self,
channels: Mapping[None, str] | Mapping[str, str],
triggers: Sequence[str],
mapper: Optional[Callable[[Any], Any]] = None,
when: Optional[Callable[[Any], bool]] = None,
tags: Optional[list[str]] = None,
*,
@@ -100,6 +103,7 @@ class ChannelInvoke(RunnableBindingBase):
super().__init__(
channels=channels,
triggers=triggers,
mapper=mapper,
when=when,
bound=bound or default_bound,
kwargs=kwargs or {},
@@ -120,6 +124,7 @@ class ChannelInvoke(RunnableBindingBase):
**{chan: chan for chan in channels},
},
triggers=self.triggers,
mapper=self.mapper,
when=self.when,
bound=self.bound,
kwargs=self.kwargs,
@@ -138,6 +143,7 @@ class ChannelInvoke(RunnableBindingBase):
return ChannelInvoke(
channels=self.channels,
triggers=self.triggers,
mapper=self.mapper,
when=self.when,
bound=coerce_to_runnable(other),
kwargs=self.kwargs,
@@ -147,6 +153,7 @@ class ChannelInvoke(RunnableBindingBase):
return ChannelInvoke(
channels=self.channels,
triggers=self.triggers,
mapper=self.mapper,
when=self.when,
# delegate to __or__ in self.bound
bound=self.bound | other,
+3
View File
@@ -6,3 +6,6 @@ class ReservedChannels(StrEnum):
is_last_step = "is_last_step"
"""A channel that is True if the current step is the last step, False otherwise."""
AllReservedChannels = {channel.value for channel in ReservedChannels}
+8 -3
View File
@@ -2,6 +2,7 @@ from typing import Any, Mapping, Sequence, Union
from langgraph.channels.base import BaseChannel
from langgraph.channels.last_value import LastValue
from langgraph.constants import INTERRUPT
from langgraph.pregel.read import ChannelBatch, ChannelInvoke
from langgraph.pregel.reserved import ReservedChannels
@@ -12,10 +13,13 @@ def validate_graph(
input: Union[str, Sequence[str]],
output: Union[str, Sequence[str]],
hidden: Sequence[str],
interrupt: Sequence[str],
interrupt_after: Sequence[str],
interrupt_before: Sequence[str],
) -> None:
subscribed_channels = set[str]()
for node in nodes.values():
for name, node in nodes.items():
if name == INTERRUPT:
raise ValueError(f"Node name {INTERRUPT} is reserved")
if isinstance(node, ChannelInvoke):
subscribed_channels.update(node.channels.values())
elif isinstance(node, ChannelBatch):
@@ -56,7 +60,8 @@ def validate_graph(
channels[chan] = LastValue(Any) # type: ignore[arg-type]
validate_keys(hidden, channels)
validate_keys(interrupt, channels)
validate_keys(interrupt_after, channels)
validate_keys(interrupt_before, channels)
def validate_keys(
+26 -11
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
import asyncio
from typing import Any, Callable, Optional, Sequence
from typing import Any, Callable, NamedTuple, Optional, Sequence, Union
from langchain_core.runnables import (
Runnable,
@@ -18,21 +18,25 @@ TYPE_SEND = Callable[[Sequence[tuple[str, Any]]], None]
SKIP_WRITE = object()
class ChannelWriteEntry(NamedTuple):
channel: str
value: Optional[Union[Any, Runnable]]
skip_none: bool
class ChannelWrite(RunnablePassthrough):
channels: Sequence[tuple[str, Optional[Runnable], bool]]
channels: Sequence[ChannelWriteEntry]
"""
Mapping of write channels to Runnables that return the value to be written,
or None to skip writing.
Sequence of write entries, each of which is a tuple of:
- channel name
- runnable to map input, or None to use the input, or any other value to use instead
- whether to skip writing if the mapped value is None
"""
class Config:
arbitrary_types_allowed = True
def __init__(
self,
*,
channels: Sequence[tuple[str, Optional[Runnable], bool]],
):
def __init__(self, *, channels: Sequence[ChannelWriteEntry]):
super().__init__(func=self._write, afunc=self._awrite, channels=channels)
self.name = f"ChannelWrite<{','.join(chan for chan, _, _ in self.channels)}>"
@@ -53,7 +57,14 @@ class ChannelWrite(RunnablePassthrough):
def _write(self, input: Any, config: RunnableConfig) -> None:
values = [
(chan, r.invoke(input, config) if r else input)
(
chan,
r.invoke(input, config)
if isinstance(r, Runnable)
else r
if r is not None
else input,
)
for chan, r, _ in self.channels
]
values = [
@@ -67,7 +78,11 @@ class ChannelWrite(RunnablePassthrough):
async def _awrite(self, input: Any, config: RunnableConfig) -> None:
values = await asyncio.gather(
*(
r.ainvoke(input, config) if r else _mk_future(input)
r.ainvoke(input, config)
if isinstance(r, Runnable)
else _mk_future(r)
if r is not None
else _mk_future(input)
for _, r, _ in self.channels
)
)
+9
View File
@@ -0,0 +1,9 @@
"""Main entrypoint into package."""
from importlib import metadata
try:
__version__ = metadata.version(__package__)
except metadata.PackageNotFoundError:
# Case where package metadata is not available.
__version__ = ""
del metadata # optional, avoids polluting the results of dir(__package__)
Generated
+71 -26
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
[[package]]
name = "aiohttp"
@@ -110,6 +110,21 @@ files = [
[package.dependencies]
frozenlist = ">=1.1.0"
[[package]]
name = "aiosqlite"
version = "0.19.0"
description = "asyncio bridge to the standard sqlite3 module"
optional = false
python-versions = ">=3.7"
files = [
{file = "aiosqlite-0.19.0-py3-none-any.whl", hash = "sha256:edba222e03453e094a3ce605db1b970c4b3376264e56f32e2a4959f948d66a96"},
{file = "aiosqlite-0.19.0.tar.gz", hash = "sha256:95ee77b91c8d2808bd08a59fbebf66270e9090c3d92ffbf260dc0db0b979577d"},
]
[package.extras]
dev = ["aiounittest (==1.4.1)", "attribution (==1.6.2)", "black (==23.3.0)", "coverage[toml] (==7.2.3)", "flake8 (==5.0.4)", "flake8-bugbear (==23.3.12)", "flit (==3.7.1)", "mypy (==1.2.0)", "ufmt (==2.1.0)", "usort (==1.0.6)"]
docs = ["sphinx (==6.1.3)", "sphinx-mdinclude (==0.5.3)"]
[[package]]
name = "annotated-types"
version = "0.6.0"
@@ -828,6 +843,23 @@ files = [
{file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
]
[[package]]
name = "grandalf"
version = "0.8"
description = "Graph and drawing algorithms framework"
optional = false
python-versions = "*"
files = [
{file = "grandalf-0.8-py3-none-any.whl", hash = "sha256:793ca254442f4a79252ea9ff1ab998e852c1e071b863593e5383afee906b4185"},
{file = "grandalf-0.8.tar.gz", hash = "sha256:2813f7aab87f0d20f334a3162ccfbcbf085977134a17a5b516940a93a77ea974"},
]
[package.dependencies]
pyparsing = "*"
[package.extras]
full = ["numpy", "ply"]
[[package]]
name = "greenlet"
version = "3.0.3"
@@ -1483,13 +1515,13 @@ files = [
[[package]]
name = "langchain"
version = "0.1.4"
version = "0.1.8"
description = "Building applications with LLMs through composability"
optional = false
python-versions = ">=3.8.1,<4.0"
files = [
{file = "langchain-0.1.4-py3-none-any.whl", hash = "sha256:6befdd6221f5f326092e31a3c19efdc7ce3d7d1f2e2cab065141071451730ed7"},
{file = "langchain-0.1.4.tar.gz", hash = "sha256:8767a9461e2b717ce9a35b1fa20659de89ea86ba9c2a4ff516e05d47ab2d195d"},
{file = "langchain-0.1.8-py3-none-any.whl", hash = "sha256:19e951b0e2be099ff048ee483acecb47e1a39c33a47dadfee70fcfa20f45cc19"},
{file = "langchain-0.1.8.tar.gz", hash = "sha256:c8b1c2954a07cd6422c9027459473bafae90c78f07015bf2fc6262fadf97ea44"},
]
[package.dependencies]
@@ -1497,9 +1529,9 @@ aiohttp = ">=3.8.3,<4.0.0"
async-timeout = {version = ">=4.0.0,<5.0.0", markers = "python_version < \"3.11\""}
dataclasses-json = ">=0.5.7,<0.7"
jsonpatch = ">=1.33,<2.0"
langchain-community = ">=0.0.14,<0.1"
langchain-core = ">=0.1.16,<0.2"
langsmith = ">=0.0.83,<0.1"
langchain-community = ">=0.0.21,<0.1"
langchain-core = ">=0.1.24,<0.2"
langsmith = ">=0.1.0,<0.2.0"
numpy = ">=1,<2"
pydantic = ">=1,<3"
PyYAML = ">=5.3"
@@ -1514,7 +1546,7 @@ cli = ["typer (>=0.9.0,<0.10.0)"]
cohere = ["cohere (>=4,<5)"]
docarray = ["docarray[hnswlib] (>=0.32.0,<0.33.0)"]
embeddings = ["sentence-transformers (>=2,<3)"]
extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "aleph-alpha-client (>=2.15.0,<3.0.0)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.0,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "cohere (>=4,<5)", "couchbase (>=4.1.9,<5.0.0)", "dashvector (>=1.0.1,<2.0.0)", "databricks-vectorsearch (>=0.21,<0.22)", "datasets (>=2.15.0,<3.0.0)", "dgml-utils (>=0.3.0,<0.4.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "fireworks-ai (>=0.9.0,<0.10.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "google-cloud-documentai (>=2.20.1,<3.0.0)", "gql (>=3.4.1,<4.0.0)", "hologres-vector (>=0.0.6,<0.0.7)", "html2text (>=2020.1.16,<2021.0.0)", "javelin-sdk (>=0.1.8,<0.2.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "jsonschema (>1)", "langchain-openai (>=0.0.2,<0.1)", "lxml (>=4.9.2,<5.0.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "msal (>=1.25.0,<2.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "openai (<2)", "openapi-pydantic (>=0.3.2,<0.4.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "praw (>=7.7.1,<8.0.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "upstash-redis (>=0.15.0,<0.16.0)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)"]
extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "aleph-alpha-client (>=2.15.0,<3.0.0)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.0,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "cohere (>=4,<5)", "couchbase (>=4.1.9,<5.0.0)", "dashvector (>=1.0.1,<2.0.0)", "databricks-vectorsearch (>=0.21,<0.22)", "datasets (>=2.15.0,<3.0.0)", "dgml-utils (>=0.3.0,<0.4.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "fireworks-ai (>=0.9.0,<0.10.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "google-cloud-documentai (>=2.20.1,<3.0.0)", "gql (>=3.4.1,<4.0.0)", "hologres-vector (>=0.0.6,<0.0.7)", "html2text (>=2020.1.16,<2021.0.0)", "javelin-sdk (>=0.1.8,<0.2.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "jsonschema (>1)", "langchain-openai (>=0.0.2,<0.1)", "lxml (>=4.9.2,<5.0.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "msal (>=1.25.0,<2.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "openai (<2)", "openapi-pydantic (>=0.3.2,<0.4.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "praw (>=7.7.1,<8.0.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "rdflib (==7.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "upstash-redis (>=0.15.0,<0.16.0)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)"]
javascript = ["esprima (>=4.0.1,<5.0.0)"]
llms = ["clarifai (>=9.1.0)", "cohere (>=4,<5)", "huggingface_hub (>=0,<1)", "manifest-ml (>=0.0.1,<0.0.2)", "nlpcloud (>=1,<2)", "openai (<2)", "openlm (>=0.0.5,<0.0.6)", "torch (>=1,<3)", "transformers (>=4,<5)"]
openai = ["openai (<2)", "tiktoken (>=0.3.2,<0.6.0)"]
@@ -1523,20 +1555,20 @@ text-helpers = ["chardet (>=5.1.0,<6.0.0)"]
[[package]]
name = "langchain-community"
version = "0.0.16"
version = "0.0.21"
description = "Community contributed LangChain integrations."
optional = false
python-versions = ">=3.8.1,<4.0"
files = [
{file = "langchain_community-0.0.16-py3-none-any.whl", hash = "sha256:0f1dfc1a6205ce8d39931d3515974a208a9f69c16157c649f83490a7cc830b73"},
{file = "langchain_community-0.0.16.tar.gz", hash = "sha256:c06512a93013a06fba7679cd5a1254ff8b927cddd2d1fbe0cc444bf7bbdf0b8c"},
{file = "langchain_community-0.0.21-py3-none-any.whl", hash = "sha256:120977485d244eb472ad3618a31222fe6c2bce08026f4caa96bd6dae2e316ac0"},
{file = "langchain_community-0.0.21.tar.gz", hash = "sha256:1c310a7e2663d5f6464a433981504894f97c12783cbeb8bdf4159a574f88c18d"},
]
[package.dependencies]
aiohttp = ">=3.8.3,<4.0.0"
dataclasses-json = ">=0.5.7,<0.7"
langchain-core = ">=0.1.16,<0.2"
langsmith = ">=0.0.83,<0.1"
langchain-core = ">=0.1.24,<0.2"
langsmith = ">=0.1.0,<0.2.0"
numpy = ">=1,<2"
PyYAML = ">=5.3"
requests = ">=2,<3"
@@ -1545,23 +1577,23 @@ tenacity = ">=8.1.0,<9.0.0"
[package.extras]
cli = ["typer (>=0.9.0,<0.10.0)"]
extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "aleph-alpha-client (>=2.15.0,<3.0.0)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "azure-ai-documentintelligence (>=1.0.0b1,<2.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.0,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "cohere (>=4,<5)", "dashvector (>=1.0.1,<2.0.0)", "databricks-vectorsearch (>=0.21,<0.22)", "datasets (>=2.15.0,<3.0.0)", "dgml-utils (>=0.3.0,<0.4.0)", "elasticsearch (>=8.12.0,<9.0.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "fireworks-ai (>=0.9.0,<0.10.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "google-cloud-documentai (>=2.20.1,<3.0.0)", "gql (>=3.4.1,<4.0.0)", "gradientai (>=1.4.0,<2.0.0)", "hdbcli (>=2.19.21,<3.0.0)", "hologres-vector (>=0.0.6,<0.0.7)", "html2text (>=2020.1.16,<2021.0.0)", "javelin-sdk (>=0.1.8,<0.2.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "jsonschema (>1)", "lxml (>=4.9.2,<5.0.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "msal (>=1.25.0,<2.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "oci (>=2.119.1,<3.0.0)", "openai (<2)", "openapi-pydantic (>=0.3.2,<0.4.0)", "oracle-ads (>=2.9.1,<3.0.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "praw (>=7.7.1,<8.0.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "upstash-redis (>=0.15.0,<0.16.0)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)", "zhipuai (>=1.0.7,<2.0.0)"]
extended-testing = ["aiosqlite (>=0.19.0,<0.20.0)", "aleph-alpha-client (>=2.15.0,<3.0.0)", "anthropic (>=0.3.11,<0.4.0)", "arxiv (>=1.4,<2.0)", "assemblyai (>=0.17.0,<0.18.0)", "atlassian-python-api (>=3.36.0,<4.0.0)", "azure-ai-documentintelligence (>=1.0.0b1,<2.0.0)", "beautifulsoup4 (>=4,<5)", "bibtexparser (>=1.4.0,<2.0.0)", "cassio (>=0.1.0,<0.2.0)", "chardet (>=5.1.0,<6.0.0)", "cohere (>=4,<5)", "databricks-vectorsearch (>=0.21,<0.22)", "datasets (>=2.15.0,<3.0.0)", "dgml-utils (>=0.3.0,<0.4.0)", "elasticsearch (>=8.12.0,<9.0.0)", "esprima (>=4.0.1,<5.0.0)", "faiss-cpu (>=1,<2)", "feedparser (>=6.0.10,<7.0.0)", "fireworks-ai (>=0.9.0,<0.10.0)", "geopandas (>=0.13.1,<0.14.0)", "gitpython (>=3.1.32,<4.0.0)", "google-cloud-documentai (>=2.20.1,<3.0.0)", "gql (>=3.4.1,<4.0.0)", "gradientai (>=1.4.0,<2.0.0)", "hdbcli (>=2.19.21,<3.0.0)", "hologres-vector (>=0.0.6,<0.0.7)", "html2text (>=2020.1.16,<2021.0.0)", "httpx (>=0.24.1,<0.25.0)", "javelin-sdk (>=0.1.8,<0.2.0)", "jinja2 (>=3,<4)", "jq (>=1.4.1,<2.0.0)", "jsonschema (>1)", "lxml (>=4.9.2,<5.0.0)", "markdownify (>=0.11.6,<0.12.0)", "motor (>=3.3.1,<4.0.0)", "msal (>=1.25.0,<2.0.0)", "mwparserfromhell (>=0.6.4,<0.7.0)", "mwxml (>=0.3.3,<0.4.0)", "newspaper3k (>=0.2.8,<0.3.0)", "numexpr (>=2.8.6,<3.0.0)", "nvidia-riva-client (>=2.14.0,<3.0.0)", "oci (>=2.119.1,<3.0.0)", "openai (<2)", "openapi-pydantic (>=0.3.2,<0.4.0)", "oracle-ads (>=2.9.1,<3.0.0)", "pandas (>=2.0.1,<3.0.0)", "pdfminer-six (>=20221105,<20221106)", "pgvector (>=0.1.6,<0.2.0)", "praw (>=7.7.1,<8.0.0)", "psychicapi (>=0.8.0,<0.9.0)", "py-trello (>=0.19.0,<0.20.0)", "pymupdf (>=1.22.3,<2.0.0)", "pypdf (>=3.4.0,<4.0.0)", "pypdfium2 (>=4.10.0,<5.0.0)", "pyspark (>=3.4.0,<4.0.0)", "rank-bm25 (>=0.2.2,<0.3.0)", "rapidfuzz (>=3.1.1,<4.0.0)", "rapidocr-onnxruntime (>=1.3.2,<2.0.0)", "rdflib (==7.0.0)", "requests-toolbelt (>=1.0.0,<2.0.0)", "rspace_client (>=2.5.0,<3.0.0)", "scikit-learn (>=1.2.2,<2.0.0)", "sqlite-vss (>=0.1.2,<0.2.0)", "streamlit (>=1.18.0,<2.0.0)", "sympy (>=1.12,<2.0)", "telethon (>=1.28.5,<2.0.0)", "timescale-vector (>=0.0.1,<0.0.2)", "tqdm (>=4.48.0)", "tree-sitter (>=0.20.2,<0.21.0)", "tree-sitter-languages (>=1.8.0,<2.0.0)", "upstash-redis (>=0.15.0,<0.16.0)", "xata (>=1.0.0a7,<2.0.0)", "xmltodict (>=0.13.0,<0.14.0)", "zhipuai (>=1.0.7,<2.0.0)"]
[[package]]
name = "langchain-core"
version = "0.1.17"
version = "0.1.25"
description = "Building applications with LLMs through composability"
optional = false
python-versions = ">=3.8.1,<4.0"
files = [
{file = "langchain_core-0.1.17-py3-none-any.whl", hash = "sha256:026155cf97867bde410ab1834799ab4c5ba64c39380f2a4328bcf9c78623ca64"},
{file = "langchain_core-0.1.17.tar.gz", hash = "sha256:59016e457cd6a1708d83a3a454acc97cf02c2a2c3af95626d13f83894fd4e777"},
{file = "langchain_core-0.1.25-py3-none-any.whl", hash = "sha256:ff0a0ad1ed877878e7b9c7601870cd12145abf3c814aae41995968d05ea6c09d"},
{file = "langchain_core-0.1.25.tar.gz", hash = "sha256:065ff8b4e383c5645d175b20ae44b258330ed06457b0fc0179efee310b6f2af6"},
]
[package.dependencies]
anyio = ">=3,<5"
jsonpatch = ">=1.33,<2.0"
langsmith = ">=0.0.83,<0.1"
langsmith = ">=0.1.0,<0.2.0"
packaging = ">=23.2,<24.0"
pydantic = ">=1,<3"
PyYAML = ">=5.3"
@@ -1605,13 +1637,13 @@ types-requests = ">=2.31.0.2,<3.0.0.0"
[[package]]
name = "langsmith"
version = "0.0.85"
version = "0.1.4"
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
optional = false
python-versions = ">=3.8.1,<4.0"
files = [
{file = "langsmith-0.0.85-py3-none-any.whl", hash = "sha256:9d0ccbcda7b69c83828060603a51bb4319e43b8dc807fbd90b6355f8ec709500"},
{file = "langsmith-0.0.85.tar.gz", hash = "sha256:fefc631fc30d836b54d4e3f99961c41aea497633898b8f09e305b6c7216c2c54"},
{file = "langsmith-0.1.4-py3-none-any.whl", hash = "sha256:13ea90c030a3ef472e00f4dd31b9c89f165f98f0c870309eca3366c93fcaa29f"},
{file = "langsmith-0.1.4.tar.gz", hash = "sha256:b45ea1001f67c4c233b3521eb578326863e32e0eb738e52900c035261deec368"},
]
[package.dependencies]
@@ -2382,6 +2414,20 @@ files = [
plugins = ["importlib-metadata"]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pyparsing"
version = "3.1.1"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
optional = false
python-versions = ">=3.6.8"
files = [
{file = "pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb"},
{file = "pyparsing-3.1.1.tar.gz", hash = "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pytest"
version = "7.4.4"
@@ -2474,13 +2520,13 @@ dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]]
name = "pytest-watcher"
version = "0.3.5"
version = "0.4.1"
description = "Automatically rerun your tests on file modifications"
optional = false
python-versions = ">=3.7.0,<4.0.0"
files = [
{file = "pytest_watcher-0.3.5-py3-none-any.whl", hash = "sha256:af00ca52c7be22dc34c0fd3d7ffef99057207a73b05dc5161fe3b2fe91f58130"},
{file = "pytest_watcher-0.3.5.tar.gz", hash = "sha256:8896152460ba2b1a8200c12117c6611008ec96c8b2d811f0a05ab8a82b043ff8"},
{file = "pytest_watcher-0.4.1-py3-none-any.whl", hash = "sha256:29435669cb0124fb32d6de649fe9b1350f6dac94176313fff559ee4c2a66fd6e"},
{file = "pytest_watcher-0.4.1.tar.gz", hash = "sha256:5a793c4c883e3a55ab2abbfa3a8cd6fa6495b3767d5f6644052cc5f3236f511a"},
]
[package.dependencies]
@@ -2589,7 +2635,6 @@ files = [
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
@@ -3714,4 +3759,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
[metadata]
lock-version = "2.0"
python-versions = ">=3.9.0,<4.0"
content-hash = "faf7cebfb8e64c2edefb69bacdbdd10d8399ea8b3ba9f46c4383e72bd3cd925a"
content-hash = "2d35e923bf3902e0e11a305f58d17b0efc3fbb444dff8d6cb92e070a993115c9"
+11 -3
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.0.23"
version = "0.0.26"
description = "langgraph"
authors = []
license = "LangGraph License"
@@ -9,7 +9,7 @@ repository = "https://www.github.com/langchain-ai/langgraph"
[tool.poetry.dependencies]
python = ">=3.9.0,<4.0"
langchain-core = "^0.1.16"
langchain-core = "^0.1.25"
[tool.poetry.group.test.dependencies]
@@ -23,8 +23,10 @@ pytest-asyncio = "^0.20.3"
pytest-mock = "^3.10.0"
syrupy = "^4.0.2"
httpx = "^0.26.0"
pytest-watcher = "^0.3.4"
pytest-watcher = "^0.4.1"
langchain = "^0.1.0"
aiosqlite = "^0.19.0"
grandalf = "^0.8"
[tool.poetry.group.lint.dependencies]
ruff = "^0.1.4"
@@ -53,6 +55,12 @@ exclude = ["notebooks", "examples", "example_data"]
[tool.coverage.run]
omit = ["tests/*"]
[tool.pytest-watcher]
now = true
delay = 0.1
runner_args = ["-x", "--ff", "-vv", "--snapshot-update"]
patterns = ["*.py"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
from langchain_core.pydantic_v1 import Field
from langgraph.checkpoint.base import Checkpoint, CheckpointAt, copy_checkpoint
from langgraph.checkpoint.memory import MemorySaver
class MemorySaverAssertImmutable(MemorySaver):
storage_for_copies: dict[str, Checkpoint] = Field(default_factory=dict)
at = CheckpointAt.END_OF_STEP
def put(self, config: dict, checkpoint: dict) -> None:
# assert checkpoint hasn't been modified since last written
thread_id = config["configurable"]["thread_id"]
if saved := super().get(config):
assert self.storage_for_copies[thread_id] == saved
self.storage_for_copies[thread_id] = copy_checkpoint(checkpoint)
# call super to write checkpoint
super().put(config, checkpoint)
+1011 -11
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff