Merge branch 'main' into isaac/nodelogging

This commit is contained in:
Isaac Francisco
2024-09-03 13:39:05 -07:00
committed by GitHub
40 changed files with 7018 additions and 4616 deletions
@@ -0,0 +1,200 @@
# How to Set Up a LangGraph.js Application for Deployment
A [LangGraph.js](https://langchain-ai.github.io/langgraphjs/) application must be configured with a [LangGraph API configuration file](../reference/cli.md#configuration-file) in order to be deployed to LangGraph Cloud (or to be self-hosted). This how-to guide discusses the basic steps to setup a LangGraph.js application for deployment using `package.json` to specify project dependencies.
This walkthrough is based on [this repository](https://github.com/langchain-ai/langgraphjs-studio-starter), which you can play around with to learn more about how to setup your LangGraph application for deployment.
The final repo structure will look something like this:
```bash
my-app/
├── src # all project code lies within here
│ ├── utils # optional utilities for your graph
│ │ ├── tools.ts # tools for your graph
│ │ ├── nodes.ts # node functions for you graph
│ │ └── state.ts # state definition of your graph
│   └── agent.ts # code for constructing your graph
├── package.json # package dependencies
├── .env # environment variables
└── langgraph.json # configuration file for LangGraph
```
After each step, an example file directory is provided to demonstrate how code can be organized.
## Specify Dependencies
Dependencies can be specified in a `package.json`. If none of these files is created, then dependencies can be specified later in the [LangGraph API configuration file](#create-langgraph-api-config).
Example `package.json` file:
```json
{
"name": "langgraphjs-studio-starter",
"packageManager": "yarn@1.22.22",
"dependencies": {
"@langchain/community": "^0.2.31",
"@langchain/core": "^0.2.31",
"@langchain/langgraph": "^0.2.0",
"@langchain/openai": "^0.2.8"
}
}
```
Example file directory:
```bash
my-app/
└── package.json # package dependencies
```
## Specify Environment Variables
Environment variables can optionally be specified in a file (e.g. `.env`). See the [Environment Variables reference](../reference/env_var.md) to configure additional variables for a deployment.
Example `.env` file:
```
MY_ENV_VAR_1=foo
MY_ENV_VAR_2=bar
OPENAI_API_KEY=key
TAVILY_API_KEY=key_2
```
Example file directory:
```bash
my-app/
├── package.json
└── .env # environment variables
```
## Define Graphs
Implement your graphs! Graphs can be defined in a single file or multiple files. Make note of the variable names of each compiled graph to be included in the LangGraph application. The variable names will be used later when creating the [LangGraph API configuration file](../reference/cli.md#configuration-file).
Here is an example `agent.ts`:
```ts
import type { AIMessage } from "@langchain/core/messages";
import { TavilySearchResults } from "@langchain/community/tools/tavily_search";
import { ChatOpenAI } from "@langchain/openai";
import { MessagesAnnotation, StateGraph } from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
const tools = [
new TavilySearchResults({ maxResults: 3, }),
];
// Define the function that calls the model
async function callModel(
state: typeof MessagesAnnotation.State,
) {
/**
* Call the LLM powering our agent.
* Feel free to customize the prompt, model, and other logic!
*/
const model = new ChatOpenAI({
model: "gpt-4o",
}).bindTools(tools);
const response = await model.invoke([
{
role: "system",
content: `You are a helpful assistant. The current date is ${new Date().getTime()}.`
},
...state.messages
]);
// MessagesAnnotation supports returning a single message or array of messages
return { messages: response };
}
// Define the function that determines whether to continue or not
function routeModelOutput(state: typeof MessagesAnnotation.State) {
const messages = state.messages;
const lastMessage: AIMessage = messages[messages.length - 1];
// If the LLM is invoking tools, route there.
if ((lastMessage?.tool_calls?.length ?? 0) > 0) {
return "tools";
}
// Otherwise end the graph.
return "__end__";
}
// Define a new graph.
// See https://langchain-ai.github.io/langgraphjs/how-tos/define-state/#getting-started for
// more on defining custom graph states.
const workflow = new StateGraph(MessagesAnnotation)
// Define the two nodes we will cycle between
.addNode("callModel", callModel)
.addNode("tools", new ToolNode(tools))
// Set the entrypoint as `callModel`
// This means that this node is the first one called
.addEdge("__start__", "callModel")
.addConditionalEdges(
// First, we define the edges' source node. We use `callModel`.
// This means these are the edges taken after the `callModel` node is called.
"callModel",
// Next, we pass in the function that will determine the sink node(s), which
// will be called after the source node is called.
routeModelOutput,
// List of the possible destinations the conditional edge can route to.
// Required for conditional edges to properly render the graph in Studio
[
"tools",
"__end__"
],
)
// This means that after `tools` is called, `callModel` node is called next.
.addEdge("tools", "callModel");
// Finally, we compile it!
// This compiles it into a graph you can invoke and deploy.
export const graph = workflow.compile();
```
!!! info "Assign `CompiledGraph` to Variable"
The build process for LangGraph Cloud requires that the `CompiledGraph` object be assigned to a variable at the top-level of a JavaScript module (alternatively, you can provide [a function that creates a graph](./graph_rebuild.md)).
Example file directory:
```bash
my-app/
├── src # all project code lies within here
│ ├── utils # optional utilities for your graph
│ │ ├── tools.ts # tools for your graph
│ │ ├── nodes.ts # node functions for you graph
│ │ └── state.ts # state definition of your graph
│   └── agent.ts # code for constructing your graph
├── package.json # package dependencies
├── .env # environment variables
└── langgraph.json # configuration file for LangGraph
```
## Create LangGraph API Config
Create a [LangGraph API configuration file](../reference/cli.md#configuration-file) called `langgraph.json`. See the [LangGraph CLI reference](../reference/cli.md#configuration-file) for detailed explanations of each key in the JSON object of the configuration file.
Example `langgraph.json` file:
```json
{
"node_version": "20",
"dockerfile_lines": [],
"dependencies": ["."],
"graphs": {
"agent": "./src/agent.ts:graph"
},
"env": ".env"
}
```
Note that the variable name of the `CompiledGraph` appears at the end of the value of each subkey in the top-level `graphs` key (i.e. `:<variable_name>`).
!!! info "Configuration Location"
The LangGraph API configuration file must be placed in a directory that is at the same level or higher than the TypeScript files that contain compiled graphs and associated dependencies.
## Next
After you setup your project and place it in a github repo, it's time to [deploy your app](./cloud.md).
+13 -5
View File
@@ -43,15 +43,23 @@ If you don't define your conditional edges carefully, you might notice extra edg
### Solution 1: Include a path map
The first way to solve this is to add path maps to your conditional edges. A path map is just a dictionary that maps the possible outputs of your router function with the names of the nodes that each output corresponds to. The path map is passed as the third argument to the `add_conditional_edges` function like so:
The first way to solve this is to add path maps to your conditional edges. A path map is just a dictionary or array that maps the possible outputs of your router function with the names of the nodes that each output corresponds to. The path map is passed as the third argument to the `add_conditional_edges` function like so:
```python
graph.add_conditional_edges("node_a", routing_function, {True: "node_b", False: "node_c"})
```
=== "Python"
```python
graph.add_conditional_edges("node_a", routing_function, {True: "node_b", False: "node_c"})
```
=== "Javascript"
```ts
graph.addConditionalEdges("node_a", routingFunction, ["node_b", "node_c"]);
```
In this case, the routing function returns either True or False, which map to `node_b` and `node_c` respectively.
### Solution 2: Update the typing of the router
### Solution 2: Update the typing of the router (Python only)
Instead of passing a path map, you can also be explicit about the typing of your routing function by specifying the nodes it can map to using the `Literal` python definition. Here is an example of how to define a routing function in that way:
+1
View File
@@ -13,6 +13,7 @@ LangGraph Cloud gives you best in class observability, testing, and hosting serv
- [How to set up app for deployment (requirements.txt)](../deployment/setup.md)
- [How to set up app for deployment (pyproject.toml)](../deployment/setup_pyproject.md)
- [How to set up app for deployment (JavaScript)](../deployment/setup_javascript.md)
- [How to test locally](../deployment/test_locally.md)
- [How to deploy to LangGraph cloud](../deployment/cloud.md)
- [How to self-host](../deployment/self_hosted.md)
@@ -44,7 +44,7 @@ Now, let's import our required packages and instantiate our client, assistant, a
const thread = await client.threads.create();
```
Now we can start our two runs and join the second on euntil it has completed:
Now we can start our two runs and join the second one until it has completed:
=== "Python"
+168 -31
View File
@@ -14,13 +14,25 @@ This tutorial will use:
1. Create a new application with the following directory and files:
=== "Python"
<my-app>/
|-- agent.py # code for your LangGraph agent
|-- requirements.txt # Python packages required for your graph
|-- langgraph.json # configuration file for LangGraph
|-- .env # environment files with API keys
2. The `agent.py` file should contain Python code for defining your graph. The following code is a simple example, the important thing is that at some point in your file you compile your graph and assign the compiled graph to a variable (in this case the `graph` variable). This example code uses `create_react_agent`, a prebuilt agent, read more about it [here](..//concepts/agentic_concepts.md#react-agent).
=== "Javascript"
<my-app>/
|-- agent.ts # code for your LangGraph agent
|-- package.json # Javascript packages required for your graph
|-- langgraph.json # configuration file for LangGraph
|-- .env # environment files with API keys
2. The `agent.py`/`agent.ts` file should contain code for defining your graph. The following code is a simple example, the important thing is that at some point in your file you compile your graph and assign the compiled graph to a variable (in this case the `graph` variable). This example code uses `create_react_agent`, a prebuilt agent. You can read more about it [here](../concepts/agentic_concepts.md#react-agent).
=== "Python"
```python
from langchain_anthropic import ChatAnthropic
@@ -34,14 +46,53 @@ This tutorial will use:
graph = create_react_agent(model, tools)
```
3. The `requirements.txt` file should contain any dependencies for your graph(s). In this case we only require four packages for our graph to run:
=== "Javascript"
langgraph
langchain_anthropic
tavily-python
langchain_community
```ts
import { ChatAnthropic } from "@langchain/anthropic";
import { TavilySearchResults } from "@langchain/community/tools/tavily_search";
import { createReactAgent } from "@langchain/langgraph/prebuilt";
4. The [`langgraph.json`][langgraph.json] file is a configuration file that describes what graph(s) you are going to host. In this case we only have one graph to host: the compiled `graph` object from `agent.py`.
const model = new ChatAnthropic({
model: "claude-3-5-sonnet-20240620",
});
const tools = [
new TavilySearchResults({ maxResults: 3, }),
];
export const graph = createReactAgent({ llm: model, tools });
```
3. The `requirements.txt`/`package.json` file should contain any dependencies for your graph(s). In this case we only require four packages for our graph to run:
=== "Python"
```python
langgraph
langchain_anthropic
tavily-python
langchain_community
```
=== "Javascript"
```js
{
"name": "my-app",
"packageManager": "yarn@1.22.22",
"dependencies": {
"@langchain/community": "^0.2.31",
"@langchain/core": "^0.2.31",
"@langchain/langgraph": "0.2.0",
"@langchain/openai": "^0.2.8"
}
}
```
4. The [`langgraph.json`][langgraph.json] file is a configuration file that describes what graph(s) you are going to host. In this case we only have one graph to host: the compiled `graph` object from `agent.py`/`agent.ts`.
=== "Python"
```json
{
@@ -53,7 +104,21 @@ This tutorial will use:
}
```
Learn more about the LangGraph CLI configuration file [here](./reference/cli.md#configuration-file).
=== "Javascript"
```json
{
"node_version": "20",
"dockerfile_lines": [],
"dependencies": ["."],
"graphs": {
"agent": "./src/agent.ts:graph"
},
"env": ".env"
}
```
Learn more about the LangGraph CLI configuration file [here](./reference/cli.md#configuration-file).
5. The `.env` file should have any environment variables needed to run your graph. This will only be used for local testing, so if you are not testing locally you can skip this step. NOTE: if you do add this, you should NOT check this into git. For this graph, we need two environment variables:
@@ -206,36 +271,108 @@ export LANGSMITH_API_KEY=...
The first thing to do when using the SDK is to setup our client, access our assistant, and create a thread to execute a run on:
```python
from langgraph_sdk import get_client
=== "Python"
# Replace this with the URL of your own deployed graph
URL = "https://chatbot-23a570f3210f52a7b167f09f6158e3b3-ffoprvkqsa-uc.a.run.app"
client = get_client(url=URL)
```python
from langgraph_sdk import get_client
# Search all hosted graphs
assistants = await client.assistants.search()
# In this example we select the first assistant since we are only hosting a single graph
assistant = assistants[0]
client = get_client(url=<DEPLOYMENT_URL>)
# get default assistant
assistants = await client.assistants.search()
assistant = [a for a in assistants if not a["config"]][0]
# create thread
thread = await client.threads.create()
print(thread)
```
# We create a thread for tracking the state of our run
thread = await client.threads.create()
```
=== "Javascript"
```js
import { Client } from "@langchain/langgraph-sdk";
const client = new Client({ apiUrl: <DEPLOYMENT_URL> });
// get default assistant
const assistants = await client.assistants.search();
const assistant = assistants.find(a => !a.config);
// create thread
const thread = await client.threads.create();
console.log(thread)
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/assistants/search \
--header 'Content-Type: application/json' \
--data '{
"limit": 10,
"offset": 0
}' | jq -c 'map(select(.config == null or .config == {})) | .[0]' && \
curl --request POST \
--url <DEPLOYMENT_URL>/threads \
--header 'Content-Type: application/json' \
--data '{}'
```
We can then execute a run on the thread:
```python
input = {"messages":[{"role": "user", "content": "Hello! My name is Bagatur and I am 26 years old."}]}
=== "Python"
async for chunk in client.runs.stream(
thread['thread_id'],
assistant["assistant_id"],
input=input,
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
```python
input = {"messages":[{"role": "user", "content": "Hello! My name is Bagatur and I am 26 years old."}]}
async for chunk in client.runs.stream(
thread['thread_id'],
assistant["assistant_id"],
input=input,
stream_mode="updates",
):
if chunk.data and chunk.event != "metadata":
print(chunk.data)
```
=== "Javascript"
```js
const input = { "messages":[{ "role": "user", "content": "Hello! My name is Bagatur and I am 26 years old." }] };
const streamResponse = client.runs.stream(
thread["thread_id"],
assistant["assistant_id"],
{
input,
}
);
for await (const chunk of streamResponse) {
if (chunk.data && chunk.event !== "metadata" ) {
console.log(chunk.data);
}
}
```
=== "CURL"
```bash
curl --request POST \
--url <DEPLOYMENT_URL>/threads/<THREAD_ID>/runs/stream \
--header 'Content-Type: application/json' \
--data "{
\"assistant_id\": <ASSISTANT_ID>,
\"input\": {\"messages\": [{\"role\": \"human\", \"content\": \"Hello! My name is Bagatur and I am 26 years old.\"}]},
}" | sed 's/\r$//' | awk '
/^event:/ { event = $2 }
/^data:/ {
json_data = substr($0, index($0, $2))
if (event != "metadata") {
print json_data
}
}'
```
Output:
{'agent': {'messages': [{'content': "Hi Bagatur! It's nice to meet you. How can I assist you today?", 'additional_kwargs': {}, 'response_metadata': {'finish_reason': 'stop', 'model_name': 'gpt-4o-2024-05-13', 'system_fingerprint': 'fp_9cb5d38cf7'}, 'type': 'ai', 'name': None, 'id': 'run-c89118b7-1b1e-42b9-a85d-c43fe99881cd', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]}}
+1
View File
@@ -197,6 +197,7 @@ nav:
- Setup:
- Setup App: "cloud/deployment/setup.md"
- Setup App (pyproject.toml): "cloud/deployment/setup_pyproject.md"
- Setup App (JavaScript): "cloud/deployment/setup_javascript.md"
- Rebuild Graph at Runtime: "cloud/deployment/graph_rebuild.md"
- Test App Locally: "cloud/deployment/test_locally.md"
- Deployment:
+1 -1
View File
@@ -1504,7 +1504,7 @@
"from langgraph.checkpoint.memory import MemorySaver\n",
"from langgraph.graph import StateGraph\n",
"from langgraph.graph.message import add_messages\n",
"from langgraph.prebuilt import ToolNode\n",
"from langgraph.prebuilt import ToolNode, tools_condition\n",
"\n",
"\n",
"class State(TypedDict):\n",
+42 -1
View File
@@ -646,11 +646,12 @@
"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",
" return {\"messages\": response + [\n",
" SystemMessage(\n",
" content=f\"Context from last attempt: {decision.action.feedback}\"\n",
" )\n",
" ]\n",
" }\n",
" else:\n",
" return {\"messages\": response + [AIMessage(content=decision.action.response)]}\n",
"\n",
@@ -933,6 +934,46 @@
"print(step['join']['messages'][-1].content)"
]
},
{
"cell_type": "markdown",
"id": "f9487866",
"metadata": {},
"source": [
"#### Complex Replanning Example\n",
"\n",
"This question is likely to prompt the Replan functionality, but it may need to be run multiple times to see this in action."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "391d6931",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'plan_and_schedule': {'messages': [FunctionMessage(content=\"[{'url': 'https://www.timeanddate.com/weather/japan/tokyo', 'content': '88 / 84 °F. 13. 87 / 82 °F. 14. 84 / 80 °F. Detailed forecast for 14 days. Need some help? Current weather in Tokyo and forecast for today, tomorrow, and next 14 days.'}]\", additional_kwargs={'idx': 1, 'args': {'query': 'current temperature in Tokyo'}}, name='tavily_search_results_json', tool_call_id=1), FunctionMessage(content='join', additional_kwargs={'idx': 2, 'args': ()}, name='join', tool_call_id=2)]}}\n",
"{'join': {'messages': [AIMessage(content=\"Thought: The search result provides the current temperature in Tokyo but does not explicitly state which temperature (88 / 84 °F) corresponds to the current condition. It seems to be a range, possibly the day's high and low. Without a clear indication of the exact current temperature, it's challenging to provide a precise flashcard summary.\", id='8ef2a131-69db-4180-a76e-fd9d6f4037c1'), SystemMessage(content='Context from last attempt: The information provided does not explicitly state the current temperature in Tokyo; it provides a temperature range without specifying which is the current temperature. Need to find a source that gives the exact current temperature in Tokyo for a precise flashcard summary.', id='f5bd752c-b068-459a-8d9e-bd1f1b5fa4fe')]}}\n",
"{'plan_and_schedule': {'messages': [FunctionMessage(content='join', additional_kwargs={'idx': 3, 'args': ()}, name='join', tool_call_id=3)]}}\n",
"{'join': {'messages': [AIMessage(content=\"Thought: The search result provides a temperature range for Tokyo but does not specify the current temperature. This makes it challenging to create a precise flashcard without an exact current temperature. The user's request cannot be fully satisfied without this detail.\", id='3cc41891-4f47-4453-8edf-b989926ab25e'), SystemMessage(content='Context from last attempt: The search did not provide an exact current temperature for Tokyo, making it impossible to create a precise flashcard. A source that explicitly states the current temperature is needed for an accurate response.', id='96290b41-a4c4-4ab5-829a-89cc31dfe6c8')]}}\n",
"{'plan_and_schedule': {'messages': [FunctionMessage(content='join', additional_kwargs={'idx': 4, 'args': ()}, name='join', tool_call_id=4)]}}\n",
"{'join': {'messages': [AIMessage(content=\"Thought: The search result provides a temperature range for Tokyo but does not specify the current temperature. This makes it challenging to create a precise flashcard without an exact current temperature. The user's request cannot be fully satisfied without this detail.\", id='4724b242-ddb8-47e6-b235-de25de54fe45'), AIMessage(content='I was unable to find the exact current temperature in Tokyo. However, the temperature range for today in Tokyo is between 88°F and 84°F. For the most accurate and up-to-date temperature, I recommend checking a reliable weather forecasting website or app.', id='40e29a47-a001-4f65-a18f-65c2931d1ae5')]}}\n"
]
}
],
"source": [
"for step in chain.stream({\"messages\":\n",
" [\n",
" HumanMessage(\n",
" content=\"Find the current temperature in Tokyo, then, respond with a flashcard summarizing this information\"\n",
" )\n",
" ]}\n",
"):\n",
" print(step)"
]
},
{
"cell_type": "markdown",
"id": "c647d5f3-5e00-4449-9cec-5a9f438c9cff",
+1 -1
View File
@@ -219,7 +219,7 @@
"workflow = StateGraph(AgentState)\n",
"workflow.add_node(\"Researcher\", research_node)\n",
"workflow.add_node(\"Coder\", code_node)\n",
"workflow.add_node(\"supervisor\", supervisor_chain)"
"workflow.add_node(\"supervisor\", supervisor_agent)"
]
},
{
+5 -9
View File
@@ -132,8 +132,6 @@
"metadata": {},
"outputs": [],
"source": [
"from psycopg.rows import dict_row\n",
"\n",
"connection_kwargs = {\n",
" \"autocommit\": True,\n",
" \"prepare_threshold\": 0,\n",
@@ -161,15 +159,13 @@
"source": [
"from psycopg_pool import ConnectionPool\n",
"\n",
"pool = ConnectionPool(\n",
"with ConnectionPool(\n",
" # Example configuration\n",
" conninfo=DB_URI,\n",
" max_size=20,\n",
" kwargs=connection_kwargs,\n",
")\n",
"\n",
"with pool.connection() as conn:\n",
" checkpointer = PostgresSaver(conn)\n",
") as pool:\n",
" checkpointer = PostgresSaver(pool)\n",
"\n",
" # NOTE: you need to call .setup() the first time you're using your checkpointer\n",
" checkpointer.setup()\n",
@@ -394,8 +390,8 @@
" conninfo=DB_URI,\n",
" max_size=20,\n",
" kwargs=connection_kwargs,\n",
") as pool, pool.connection() as conn:\n",
" checkpointer = AsyncPostgresSaver(conn)\n",
") as pool:\n",
" checkpointer = AsyncPostgresSaver(pool)\n",
"\n",
" # NOTE: you need to call .setup() the first time you're using your checkpointer\n",
" # await checkpointer.setup()\n",
+8
View File
@@ -429,6 +429,14 @@
"]\n",
"\n",
"\n",
"def find_tool_calls_react(messages):\n",
" \"\"\"\n",
" Find all tool calls in the messages returned\n",
" \"\"\"\n",
" tool_calls = [tc['name'] for m in messages['messages'] for tc in getattr(m, 'tool_calls', [])]\n",
" return tool_calls\n",
"\n",
"\n",
"def check_trajectory_react(root_run: Run, example: Example) -> dict:\n",
" \"\"\"\n",
" Check if all expected tools are called in exact order and without any additional tool calls.\n",
@@ -1,6 +1,6 @@
import asyncio
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator, Optional, Union
from typing import Any, AsyncIterator, Iterator, List, Optional, Union
from langchain_core.runnables import RunnableConfig
from psycopg import AsyncConnection, AsyncCursor, AsyncPipeline
@@ -51,6 +51,7 @@ class AsyncPostgresSaver(BasePostgresSaver):
self.conn = conn
self.pipe = pipe
self.lock = asyncio.Lock()
self.loop = asyncio.get_running_loop()
@classmethod
@asynccontextmanager
@@ -329,3 +330,96 @@ class AsyncPostgresSaver(BasePostgresSaver):
binary=True, row_factory=dict_row
) as cur:
yield cur
def list(
self,
config: Optional[RunnableConfig],
*,
filter: Optional[dict[str, Any]] = None,
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database.
This method retrieves a list of checkpoint tuples from the Postgres database based
on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first).
Args:
config (Optional[RunnableConfig]): Base configuration for filtering checkpoints.
filter (Optional[Dict[str, Any]]): Additional filtering criteria for metadata.
before (Optional[RunnableConfig]): If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit (Optional[int]): Maximum number of checkpoints to return.
Yields:
Iterator[CheckpointTuple]: An iterator of matching checkpoint tuples.
"""
aiter_ = self.alist(config, filter=filter, before=before, limit=limit)
while True:
try:
yield asyncio.run_coroutine_threadsafe(
anext(aiter_), self.loop
).result()
except StopAsyncIteration:
break
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database.
This method retrieves a checkpoint tuple from the Postgres database based on the
provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and "checkpoint_id" is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved.
Args:
config (RunnableConfig): The config to use for retrieving the checkpoint.
Returns:
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
"""
return asyncio.run_coroutine_threadsafe(
self.aget_tuple(config), self.loop
).result()
def put(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
"""Save a checkpoint to the database.
This method saves a checkpoint to the Postgres database. The checkpoint is associated
with the provided config and its parent config (if any).
Args:
config (RunnableConfig): The config to associate with the checkpoint.
checkpoint (Checkpoint): The checkpoint to save.
metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
new_versions (ChannelVersions): New channel versions as of this write.
Returns:
RunnableConfig: Updated configuration after storing the checkpoint.
"""
return asyncio.run_coroutine_threadsafe(
self.aput(config, checkpoint, metadata, new_versions), self.loop
).result()
def put_writes(
self,
config: RunnableConfig,
writes: List[tuple[str, Any]],
task_id: str,
) -> None:
"""Store intermediate writes linked to a checkpoint.
This method saves intermediate writes associated with a checkpoint to the database.
Args:
config (RunnableConfig): Configuration of the related checkpoint.
writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
task_id (str): Identifier for the task creating the writes.
"""
return asyncio.run_coroutine_threadsafe(
self.aput_writes(config, writes, task_id), self.loop
).result()
+3 -4
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "annotated-types"
@@ -266,7 +266,7 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0"
[[package]]
name = "langgraph-checkpoint"
version = "1.0.6"
version = "1.0.8"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -766,7 +766,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"},
@@ -972,4 +971,4 @@ watchmedo = ["PyYAML (>=3.10)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0,<4.0"
content-hash = "b139531e8c6f4e24cea4bdfc29d111d1ea000a6a8a604ba81be4bff0977a2466"
content-hash = "e294b6996aa6c8f671e6aaf65be8b4aba94c18e2f237dd3ee0e1b777849ce8a8"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint-postgres"
version = "1.0.5"
version = "1.0.6"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
license = "MIT"
@@ -10,7 +10,7 @@ packages = [{ include = "langgraph" }]
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
langgraph-checkpoint = "^1.0.1"
langgraph-checkpoint = "^1.0.8"
orjson = ">=3.10.1"
psycopg = "^3.0.0"
psycopg-pool = "^3.0.0"
@@ -104,10 +104,12 @@ class SqliteSaver(BaseCheckpointSaver):
with SqliteSaver.from_conn_string("checkpoints.sqlite") as memory:
...
"""
with sqlite3.connect(
conn_string,
# https://ricardoanderegg.com/posts/python-sqlite-thread-safety/
check_same_thread=False,
with closing(
sqlite3.connect(
conn_string,
# https://ricardoanderegg.com/posts/python-sqlite-thread-safety/
check_same_thread=False,
)
) as conn:
yield SqliteSaver(conn)
@@ -163,14 +165,15 @@ class SqliteSaver(BaseCheckpointSaver):
Yields:
sqlite3.Cursor: A cursor for the SQLite database.
"""
self.setup()
cur = self.conn.cursor()
try:
yield cur
finally:
if transaction:
self.conn.commit()
cur.close()
with self.lock:
self.setup()
cur = self.conn.cursor()
try:
yield cur
finally:
if transaction:
self.conn.commit()
cur.close()
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database.
@@ -396,7 +399,7 @@ class SqliteSaver(BaseCheckpointSaver):
checkpoint_ns = config["configurable"]["checkpoint_ns"]
type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint)
serialized_metadata = self.jsonplus_serde.dumps(metadata)
with self.lock, self.cursor() as cur:
with self.cursor() as cur:
cur.execute(
"INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
@@ -432,7 +435,7 @@ class SqliteSaver(BaseCheckpointSaver):
writes (Sequence[Tuple[str, Any]]): List of writes to store, each as (channel, value) pair.
task_id (str): Identifier for the task creating the writes.
"""
with self.lock, self.cursor() as cur:
with self.cursor() as cur:
cur.executemany(
"INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
@@ -1,11 +1,11 @@
import asyncio
import functools
from contextlib import asynccontextmanager
from typing import (
Any,
AsyncIterator,
Dict,
Iterator,
List,
Optional,
Sequence,
Tuple,
@@ -31,20 +31,6 @@ from langgraph.checkpoint.sqlite.utils import search_where
T = TypeVar("T", bound=callable)
def not_implemented_sync_method(func: T) -> T:
@functools.wraps(func)
def wrapper(*args, **kwargs):
raise NotImplementedError(
"The AsyncSqliteSaver does not support synchronous methods. "
"Consider using the SqliteSaver instead.\n"
"from langgraph.checkpoint.sqlite import SqliteSaver\n"
"See https://langchain-ai.github.io/langgraph/reference/checkpoints/langgraph.checkpoint.sqlite.SqliteSaver "
"for more information."
)
return wrapper
class AsyncSqliteSaver(BaseCheckpointSaver):
"""An asynchronous checkpoint saver that stores checkpoints in a SQLite database.
@@ -132,6 +118,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver):
self.jsonplus_serde = JsonPlusSerializer()
self.conn = conn
self.lock = asyncio.Lock()
self.loop = asyncio.get_running_loop()
self.is_setup = False
@classmethod
@@ -150,16 +137,24 @@ class AsyncSqliteSaver(BaseCheckpointSaver):
async with aiosqlite.connect(conn_string) as conn:
yield AsyncSqliteSaver(conn)
@not_implemented_sync_method
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
"""Get a checkpoint tuple from the database.
Note:
This method is not implemented for the AsyncSqliteSaver. Use `aget` instead.
Or consider using the [SqliteSaver][sqlitesaver] checkpointer.
"""
This method retrieves a checkpoint tuple from the SQLite database based on the
provided config. If the config contains a "checkpoint_id" key, the checkpoint with
the matching thread ID and checkpoint ID is retrieved. Otherwise, the latest checkpoint
for the given thread ID is retrieved.
Args:
config (RunnableConfig): The config to use for retrieving the checkpoint.
Returns:
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
"""
return asyncio.run_coroutine_threadsafe(
self.aget_tuple(config), self.loop
).result()
@not_implemented_sync_method
def list(
self,
config: Optional[RunnableConfig],
@@ -168,21 +163,60 @@ class AsyncSqliteSaver(BaseCheckpointSaver):
before: Optional[RunnableConfig] = None,
limit: Optional[int] = None,
) -> Iterator[CheckpointTuple]:
"""List checkpoints from the database.
"""List checkpoints from the database asynchronously.
Note:
This method is not implemented for the AsyncSqliteSaver. Use `alist` instead.
Or consider using the [SqliteSaver][sqlitesaver] checkpointer.
This method retrieves a list of checkpoint tuples from the SQLite database based
on the provided config. The checkpoints are ordered by checkpoint ID in descending order (newest first).
Args:
config (Optional[RunnableConfig]): Base configuration for filtering checkpoints.
filter (Optional[Dict[str, Any]]): Additional filtering criteria for metadata.
before (Optional[RunnableConfig]): If provided, only checkpoints before the specified checkpoint ID are returned. Defaults to None.
limit (Optional[int]): Maximum number of checkpoints to return.
Yields:
Iterator[CheckpointTuple]: An iterator of matching checkpoint tuples.
"""
aiter_ = self.alist(config, filter=filter, before=before, limit=limit)
while True:
try:
yield asyncio.run_coroutine_threadsafe(
anext(aiter_), self.loop
).result()
except StopAsyncIteration:
break
@not_implemented_sync_method
def put(
self,
config: RunnableConfig,
checkpoint: Checkpoint,
metadata: CheckpointMetadata,
new_versions: ChannelVersions,
) -> RunnableConfig:
"""Save a checkpoint to the database. FOO"""
"""Save a checkpoint to the database.
This method saves a checkpoint to the SQLite database. The checkpoint is associated
with the provided config and its parent config (if any).
Args:
config (RunnableConfig): The config to associate with the checkpoint.
checkpoint (Checkpoint): The checkpoint to save.
metadata (CheckpointMetadata): Additional metadata to save with the checkpoint.
new_versions (ChannelVersions): New channel versions as of this write.
Returns:
RunnableConfig: Updated configuration after storing the checkpoint.
"""
return asyncio.run_coroutine_threadsafe(
self.aput(config, checkpoint, metadata, new_versions), self.loop
).result()
def put_writes(
self, config: RunnableConfig, writes: List[Tuple[str, Any]], task_id: str
) -> None:
return asyncio.run_coroutine_threadsafe(
self.aput_writes(config, writes, task_id), self.loop
).result()
async def setup(self) -> None:
"""Set up the checkpoint database asynchronously.
@@ -242,7 +276,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver):
"""
await self.setup()
checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
async with self.conn.cursor() as cur:
async with self.lock, self.conn.cursor() as cur:
# find the latest checkpoint for the thread_id
if checkpoint_id := get_checkpoint_id(config):
await cur.execute(
@@ -337,7 +371,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver):
ORDER BY checkpoint_id DESC"""
if limit:
query += f" LIMIT {limit}"
async with self.conn.execute(query, params) as cur, self.conn.cursor() as wcur:
async with self.lock, self.conn.execute(
query, params
) as cur, self.conn.cursor() as wcur:
async for (
thread_id,
checkpoint_ns,
@@ -404,7 +440,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver):
checkpoint_ns = config["configurable"]["checkpoint_ns"]
type_, serialized_checkpoint = self.serde.dumps_typed(checkpoint)
serialized_metadata = self.jsonplus_serde.dumps(metadata)
async with self.conn.execute(
async with self.lock, self.conn.execute(
"INSERT OR REPLACE INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata) VALUES (?, ?, ?, ?, ?, ?, ?)",
(
str(config["configurable"]["thread_id"]),
@@ -441,7 +477,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver):
task_id (str): Identifier for the task creating the writes.
"""
await self.setup()
async with self.conn.cursor() as cur:
async with self.lock, self.conn.cursor() as cur:
await cur.executemany(
"INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[
+3 -4
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aiosqlite"
@@ -252,7 +252,7 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<9.0.0"
[[package]]
name = "langgraph-checkpoint"
version = "1.0.1"
version = "1.0.8"
description = "Library with base interfaces for LangGraph checkpoint savers."
optional = false
python-versions = "^3.9.0,<4.0"
@@ -651,7 +651,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"},
@@ -835,4 +834,4 @@ watchmedo = ["PyYAML (>=3.10)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0"
content-hash = "d50ec7c6b55075d19193e080cc95d3b0998b9efa1a0ed407bd6055b5b5e867e0"
content-hash = "752a22dc2b57a0818a3a4d9bf5f62226ba7f8e0c458892551bf8b4c93723dbc1"
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint-sqlite"
version = "1.0.1"
version = "1.0.3"
description = "Library with a SQLite implementation of LangGraph checkpoint saver."
authors = []
license = "MIT"
@@ -10,7 +10,7 @@ packages = [{ include = "langgraph" }]
[tool.poetry.dependencies]
python = "^3.9.0"
langgraph-checkpoint = "^1.0.1"
langgraph-checkpoint = "^1.0.8"
aiosqlite = "^0.20.0"
[tool.poetry.group.dev.dependencies]
+1 -1
View File
@@ -15,7 +15,7 @@ coverage:
--cov-report term-missing:skip-covered
start-postgres:
docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait
docker compose -f tests/compose-postgres.yml up -V --force-recreate --wait --remove-orphans
stop-postgres:
docker compose -f tests/compose-postgres.yml down -v
+4
View File
@@ -424,6 +424,10 @@ class Graph:
class CompiledGraph(Pregel):
builder: Graph
def __init__(self, *, builder: Graph, **kwargs):
super().__init__(**kwargs)
self.builder = builder
def attach_node(self, key: str, node: NodeSpec) -> None:
self.channels[key] = EphemeralValue(Any)
self.nodes[key] = (
+14 -14
View File
@@ -17,12 +17,10 @@ from typing import (
overload,
)
from langchain_core.pydantic_v1 import BaseModel
from langchain_core.runnables import Runnable, RunnableConfig
from langchain_core.runnables.base import RunnableLike
from langchain_core.runnables.utils import (
create_model,
)
from langchain_core.runnables.utils import create_model
from pydantic import BaseModel
from langgraph.channels.base import BaseChannel
from langgraph.channels.binop import BinaryOperatorAggregate
@@ -33,14 +31,7 @@ from langgraph.channels.named_barrier_value import NamedBarrierValue
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.constants import NS_END, NS_SEP, TAG_HIDDEN
from langgraph.errors import InvalidUpdateError
from langgraph.graph.graph import (
END,
START,
Branch,
CompiledGraph,
Graph,
Send,
)
from langgraph.graph.graph import END, START, Branch, CompiledGraph, Graph, Send
from langgraph.managed.base import (
ChannelKeyPlaceholder,
ChannelTypePlaceholder,
@@ -53,7 +44,7 @@ from langgraph.pregel.read import ChannelRead, PregelNode
from langgraph.pregel.types import All, RetryPolicy
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite, ChannelWriteEntry
from langgraph.store.base import BaseStore
from langgraph.utils import RunnableCallable, coerce_to_runnable
from langgraph.utils import RunnableCallable, coerce_to_runnable, get_field_default
logger = logging.getLogger(__name__)
@@ -498,7 +489,16 @@ class CompiledStateGraph(CompiledGraph):
return create_model( # type: ignore[call-overload]
self.get_name("Input"),
**{
k: (self.channels[k].UpdateType, None)
k: (
self.channels[k].UpdateType,
(
get_field_default(
k,
self.channels[k].UpdateType,
self.builder.input,
)
),
)
for k in self.builder.schemas[self.builder.input]
if isinstance(self.channels[k], BaseChannel)
},
+48 -41
View File
@@ -25,12 +25,10 @@ from uuid import UUID, uuid5
from langchain_core.globals import get_debug
from langchain_core.load.dump import dumpd
from langchain_core.pydantic_v1 import BaseModel, Field, root_validator
from langchain_core.runnables import (
Runnable,
RunnableLambda,
RunnableSequence,
RunnableSerializable,
)
from langchain_core.runnables.base import Input, Output, coerce_to_runnable
from langchain_core.runnables.config import (
@@ -48,6 +46,7 @@ from langchain_core.runnables.utils import (
get_unique_config_specs,
)
from langchain_core.tracers._streaming import _StreamingCallbackHandler
from pydantic import BaseModel
from typing_extensions import Self
from langgraph.channels.base import (
@@ -186,16 +185,10 @@ class Channel:
)
class Pregel(
RunnableSerializable[Union[dict[str, Any], Any], Union[dict[str, Any], Any]]
):
class Pregel(Runnable[Union[dict[str, Any], Any], Union[dict[str, Any], Any]]):
nodes: Mapping[str, PregelNode]
channels: Mapping[str, Union[BaseChannel, ManagedValueSpec]] = Field(
default_factory=dict
)
auto_validate: bool = True
channels: Mapping[str, Union[BaseChannel, ManagedValueSpec]]
stream_mode: StreamMode = "values"
"""Mode to stream output, defaults to 'values'."""
@@ -205,16 +198,16 @@ class Pregel(
stream_channels: Optional[Union[str, Sequence[str]]] = None
"""Channels to stream, defaults to all channels not in reserved channels"""
interrupt_after_nodes: Union[All, Sequence[str]] = Field(default_factory=list)
interrupt_after_nodes: Union[All, Sequence[str]]
interrupt_before_nodes: Union[All, Sequence[str]] = Field(default_factory=list)
interrupt_before_nodes: Union[All, Sequence[str]]
input_channels: Union[str, Sequence[str]]
step_timeout: Optional[float] = None
"""Maximum time to wait for a step to complete, in seconds. Defaults to None."""
debug: bool = Field(default_factory=get_debug)
debug: bool
"""Whether to print debug information during execution. Defaults to False."""
checkpointer: Optional[BaseCheckpointSaver] = None
@@ -232,36 +225,50 @@ class Pregel(
name: str = "LangGraph"
class Config:
arbitrary_types_allowed = True
def __init__(
self,
*,
nodes: Mapping[str, PregelNode],
channels: Mapping[str, Union[BaseChannel, ManagedValueSpec]] = None,
auto_validate: bool = True,
stream_mode: StreamMode = "values",
output_channels: Union[str, Sequence[str]],
stream_channels: Optional[Union[str, Sequence[str]]] = None,
interrupt_after_nodes: Union[All, Sequence[str]] = (),
interrupt_before_nodes: Union[All, Sequence[str]] = (),
input_channels: Union[str, Sequence[str]],
step_timeout: Optional[float] = None,
debug: Optional[bool] = None,
checkpointer: Optional[BaseCheckpointSaver] = None,
store: Optional[BaseStore] = None,
retry_policy: Optional[RetryPolicy] = None,
config_type: Optional[Type[Any]] = None,
config: Optional[RunnableConfig] = None,
name: str = "LangGraph",
) -> None:
self.nodes = nodes
self.channels = channels or {}
self.stream_mode = stream_mode
self.output_channels = output_channels
self.stream_channels = stream_channels
self.interrupt_after_nodes = interrupt_after_nodes
self.interrupt_before_nodes = interrupt_before_nodes
self.input_channels = input_channels
self.step_timeout = step_timeout
self.debug = debug if debug is not None else get_debug()
self.checkpointer = checkpointer
self.store = store
self.retry_policy = retry_policy
self.config_type = config_type
self.config = config
self.name = name
if auto_validate:
self.validate()
def with_config(self, config: RunnableConfig | None = None, **kwargs: Any) -> Self:
return self.copy(
update={"config": cast(RunnableConfig, {**(config or {}), **kwargs})}
)
@classmethod
def is_lc_serializable(cls) -> bool:
"""Return whether the graph can be serialized by Langchain."""
return True
@root_validator(skip_on_failure=True)
def validate_on_init(cls, values: dict[str, Any]) -> dict[str, Any]:
if not values["auto_validate"]:
return values
validate_graph(
values["nodes"],
values["channels"],
values["input_channels"],
values["output_channels"],
values["stream_channels"],
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
attrs = {**self.__dict__}
attrs["config"] = merge_configs(self.config, config, kwargs)
return self.__class__(**attrs)
def validate(self) -> Self:
validate_graph(
+84 -42
View File
@@ -1,8 +1,16 @@
from __future__ import annotations
from typing import Any, Callable, Mapping, Optional, Sequence, Union
from typing import (
Any,
AsyncIterator,
Callable,
Iterator,
Mapping,
Optional,
Sequence,
Union,
)
from langchain_core.pydantic_v1 import Field
from langchain_core.runnables import (
Runnable,
RunnableConfig,
@@ -10,7 +18,7 @@ from langchain_core.runnables import (
RunnableSequence,
RunnableSerializable,
)
from langchain_core.runnables.base import Other, RunnableBindingBase, coerce_to_runnable
from langchain_core.runnables.base import Input, Other, Output, coerce_to_runnable
from langchain_core.runnables.config import merge_configs
from langchain_core.runnables.utils import ConfigurableFieldSpec
@@ -99,20 +107,47 @@ class ChannelRead(RunnableCallable):
DEFAULT_BOUND: RunnablePassthrough = RunnablePassthrough()
class PregelNode(RunnableBindingBase):
class PregelNode(Runnable):
channels: Union[list[str], Mapping[str, str]]
triggers: list[str] = Field(default_factory=list)
triggers: list[str]
mapper: Optional[Callable[[Any], Any]] = None
mapper: Optional[Callable[[Any], Any]]
writers: list[Runnable] = Field(default_factory=list)
writers: list[Runnable]
bound: Runnable[Any, Any] = Field(default=DEFAULT_BOUND)
bound: Runnable[Any, Any]
kwargs: Mapping[str, Any] = Field(default_factory=dict)
retry_policy: Optional[RetryPolicy]
retry_policy: Optional[RetryPolicy] = None
config: RunnableConfig
def __init__(
self,
*,
channels: Union[list[str], Mapping[str, str]],
triggers: Sequence[str],
mapper: Optional[Callable[[Any], Any]] = None,
writers: Optional[list[Runnable]] = None,
tags: Optional[list[str]] = None,
metadata: Optional[Mapping[str, Any]] = None,
bound: Optional[Runnable[Any, Any]] = None,
retry_policy: Optional[RetryPolicy] = None,
config: Optional[RunnableConfig] = None,
) -> None:
self.channels = channels
self.triggers = list(triggers)
self.mapper = mapper
self.writers = writers or []
self.bound = bound if bound is not None else DEFAULT_BOUND
self.retry_policy = retry_policy
self.config = merge_configs(
config, {"tags": tags or [], "metadata": metadata or {}}
)
def copy(self, update: dict[str, Any]) -> PregelNode:
attrs = {**self.__dict__, **update}
return PregelNode(**attrs)
def get_writers(self) -> list[Runnable]:
"""Get writers with optimizations applied."""
@@ -145,38 +180,6 @@ class PregelNode(RunnableBindingBase):
else:
return self.bound
def __init__(
self,
*,
channels: Union[list[str], Mapping[str, str]],
triggers: Sequence[str],
mapper: Optional[Callable[[Any], Any]] = None,
writers: Optional[list[Runnable]] = None,
tags: Optional[list[str]] = None,
metadata: Optional[Mapping[str, Any]] = None,
bound: Optional[Runnable[Any, Any]] = None,
kwargs: Optional[Mapping[str, Any]] = None,
config: Optional[RunnableConfig] = None,
retry_policy: Optional[RetryPolicy] = None,
**other_kwargs: Any,
) -> None:
super().__init__(
channels=channels,
triggers=triggers,
mapper=mapper,
writers=writers or [],
bound=bound or DEFAULT_BOUND,
kwargs=kwargs or {},
retry_policy=retry_policy,
config=merge_configs(
config, {"tags": tags or [], "metadata": metadata or {}}
),
**other_kwargs,
)
def __repr_args__(self) -> Any:
return [(k, v) for k, v in super().__repr_args__() if k != "bound"]
def join(self, channels: Sequence[str]) -> PregelNode:
assert isinstance(channels, list) or isinstance(
channels, tuple
@@ -226,3 +229,42 @@ class PregelNode(RunnableBindingBase):
],
) -> RunnableSerializable:
raise NotImplementedError()
def invoke(
self,
input: Input,
config: Optional[RunnableConfig] = None,
**kwargs: Optional[Any],
) -> Output:
return self.bound.invoke(input, merge_configs(self.config, config), **kwargs)
async def ainvoke(
self,
input: Input,
config: Optional[RunnableConfig] = None,
**kwargs: Optional[Any],
) -> Output:
return await self.bound.ainvoke(
input, merge_configs(self.config, config), **kwargs
)
def stream(
self,
input: Input,
config: Optional[RunnableConfig] = None,
**kwargs: Optional[Any],
) -> Iterator[Output]:
yield from self.bound.stream(
input, merge_configs(self.config, config), **kwargs
)
async def astream(
self,
input: Input,
config: Optional[RunnableConfig] = None,
**kwargs: Optional[Any],
) -> AsyncIterator[Output]:
async for item in self.bound.astream(
input, merge_configs(self.config, config), **kwargs
):
yield item
+101 -4
View File
@@ -4,7 +4,7 @@ import inspect
import sys
from contextvars import copy_context
from functools import partial, wraps
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
from typing import Any, AsyncIterator, Awaitable, Callable, Optional, Type, Union
from langchain_core.runnables.base import (
Runnable,
@@ -19,7 +19,14 @@ from langchain_core.runnables.config import (
var_child_runnable_config,
)
from langchain_core.runnables.utils import accepts_config
from typing_extensions import TypeGuard
from typing_extensions import (
Annotated,
NotRequired,
ReadOnly,
Required,
TypeGuard,
get_origin,
)
try:
from langchain_core.runnables.config import _set_config_context
@@ -34,8 +41,6 @@ except ImportError:
class StrEnum(str, enum.Enum):
"""A string enum."""
pass
class RunnableCallable(Runnable):
"""A much simpler version of RunnableLambda that requires sync and async functions."""
@@ -183,3 +188,95 @@ def coerce_to_runnable(thing: RunnableLike, *, name: str, trace: bool) -> Runnab
f"Expected a Runnable, callable or dict."
f"Instead got an unsupported type: {type(thing)}"
)
def _is_optional_type(type_: Any) -> bool:
"""Check if a type is Optional."""
if hasattr(type_, "__origin__") and hasattr(type_, "__args__"):
origin = get_origin(type_)
if origin is Optional:
return True
if origin is Union:
return any(
arg is type(None) or _is_optional_type(arg) for arg in type_.__args__
)
if origin is Annotated:
return _is_optional_type(type_.__args__[0])
return origin is None
if hasattr(type_, "__bound__") and type_.__bound__ is not None:
return _is_optional_type(type_.__bound__)
return type_ is None
def _is_required_type(type_: Any) -> Optional[bool]:
"""Check if an annotation is marked as Required/NotRequired.
Returns:
- True if required
- False if not required
- None if not annotated with either
"""
origin = get_origin(type_)
if origin is Required:
return True
if origin is NotRequired:
return False
if origin is Annotated or getattr(origin, "__args__", None):
# See https://typing.readthedocs.io/en/latest/spec/typeddict.html#interaction-with-annotated
return _is_required_type(type_.__args__[0])
return None
def _is_readonly_type(type_: Any) -> bool:
"""Check if an annotation is marked as ReadOnly.
Returns:
- True if is read only
- False if not read only
"""
# See: https://typing.readthedocs.io/en/latest/spec/typeddict.html#typing-readonly-type-qualifier
origin = get_origin(type_)
if origin is Annotated:
return _is_readonly_type(type_.__args__[0])
if origin is ReadOnly:
return True
return False
_DEFAULT_KEYS = frozenset()
def get_field_default(name: str, type_: Any, schema: Type[Any]) -> Any:
"""Determine the default value for a field in a state schema.
This is based on:
If TypedDict:
- Required/NotRequired
- total=False -> everything optional
- Type annotation (Optional/Union[None])
"""
optional_keys = getattr(schema, "__optional_keys__", _DEFAULT_KEYS)
irq = _is_required_type(type_)
if name in optional_keys:
# Either total=False or explicit NotRequired.
# No type annotation trumps this.
if irq:
# Unless it's earlier versions of python & explicit Required
return ...
return None
if irq is not None:
if irq:
# Handle Required[<type>]
# (we already handled NotRequired and total=False)
return ...
# Handle NotRequired[<type>] for earlier versions of python
return None
# Note, we ignore ReadOnly attributes,
# as they don't make much sense. (we don't care if you mutate the state in your node)
# and mutating state in your node has no effect on our graph state.
# Base case is the annotation
if _is_optional_type(type_):
return None
return ...
+8 -27
View File
@@ -2829,13 +2829,13 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pytest"
version = "7.4.4"
version = "8.3.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.7"
python-versions = ">=3.8"
files = [
{file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"},
{file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"},
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
]
[package.dependencies]
@@ -2843,29 +2843,11 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*"
packaging = "*"
pluggy = ">=0.12,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
pluggy = ">=1.5,<2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "0.20.3"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-asyncio-0.20.3.tar.gz", hash = "sha256:83cbf01169ce3e8eb71c6c278ccb0574d1a7a3bb8eaaf5e50e0ad342afb33b36"},
{file = "pytest_asyncio-0.20.3-py3-none-any.whl", hash = "sha256:f129998b209d04fcc65c96fc85c11e5316738358909a8399e93be553d7656442"},
]
[package.dependencies]
pytest = ">=6.1.0"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-cov"
@@ -3069,7 +3051,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"},
@@ -4310,4 +4291,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools",
[metadata]
lock-version = "2.0"
python-versions = ">=3.9.0,<4.0"
content-hash = "7e0d6fd967987fcf46b038c47b5ca81d86cb8fff89704eac2074f2ce55336a2b"
content-hash = "e0787721e6cb80996a39284c74f1ddee4789c6060d6c2272cb1957f4686bd478"
+2 -4
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.2.15"
version = "0.2.16"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
license = "MIT"
@@ -14,10 +14,9 @@ langgraph-checkpoint = "^1.0.2"
[tool.poetry.group.dev.dependencies]
pytest = "^7.3.0"
pytest = "^8.3.2"
pytest-cov = "^4.0.0"
pytest-dotenv = "^0.5.2"
pytest-asyncio = "^0.20.3"
pytest-mock = "^3.10.0"
syrupy = "^4.0.2"
httpx = "^0.26.0"
@@ -73,7 +72,6 @@ requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
asyncio_mode = "auto"
# --strict-markers will raise errors on unknown marks.
# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
#
@@ -366,7 +366,7 @@
'''
# ---
# name: test_conditional_entrypoint_to_multiple_state_graph
'{"title": "LangGraphInput", "type": "object", "properties": {"locations": {"title": "Locations", "type": "array", "items": {"type": "string"}}, "results": {"title": "Results", "type": "array", "items": {"type": "string"}}}}'
'{"title": "LangGraphInput", "type": "object", "properties": {"locations": {"title": "Locations", "type": "array", "items": {"type": "string"}}, "results": {"title": "Results", "type": "array", "items": {"type": "string"}}}, "required": ["locations", "results"]}'
# ---
# name: test_conditional_entrypoint_to_multiple_state_graph.1
'{"title": "LangGraphOutput", "type": "object", "properties": {"locations": {"title": "Locations", "type": "array", "items": {"type": "string"}}, "results": {"title": "Results", "type": "array", "items": {"type": "string"}}}}'
@@ -3304,7 +3304,7 @@
'query',
'inner',
]),
'title': 'Input',
'title': 'LangGraphInput',
'type': 'object',
})
# ---
@@ -3323,11 +3323,7 @@
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'title': 'LangGraphOutput',
'type': 'object',
})
# ---
@@ -3374,7 +3370,7 @@
'query',
'inner',
]),
'title': 'Input',
'title': 'LangGraphInput',
'type': 'object',
})
# ---
@@ -3393,11 +3389,7 @@
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'title': 'LangGraphOutput',
'type': 'object',
})
# ---
@@ -3444,7 +3436,7 @@
'query',
'inner',
]),
'title': 'Input',
'title': 'LangGraphInput',
'type': 'object',
})
# ---
@@ -3463,11 +3455,7 @@
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'title': 'LangGraphOutput',
'type': 'object',
})
# ---
@@ -3514,7 +3502,7 @@
'query',
'inner',
]),
'title': 'Input',
'title': 'LangGraphInput',
'type': 'object',
})
# ---
@@ -3533,11 +3521,7 @@
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'title': 'LangGraphOutput',
'type': 'object',
})
# ---
@@ -3584,7 +3568,7 @@
'query',
'inner',
]),
'title': 'Input',
'title': 'LangGraphInput',
'type': 'object',
})
# ---
@@ -3603,11 +3587,7 @@
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'title': 'LangGraphOutput',
'type': 'object',
})
# ---
@@ -4855,7 +4835,7 @@
'''
# ---
# name: test_prebuilt_tool_chat
'{"title": "LangGraphInput", "type": "object", "properties": {"messages": {"title": "Messages", "type": "array", "items": {"$ref": "#/definitions/BaseMessage"}}}, "definitions": {"BaseMessage": {"title": "BaseMessage", "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of ChatModels.", "type": "object", "properties": {"content": {"title": "Content", "anyOf": [{"type": "string"}, {"type": "array", "items": {"anyOf": [{"type": "string"}, {"type": "object"}]}}]}, "additional_kwargs": {"title": "Additional Kwargs", "type": "object"}, "response_metadata": {"title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"title": "Name", "type": "string"}, "id": {"title": "Id", "type": "string"}}, "required": ["content", "type"]}}}'
'{"title": "LangGraphInput", "type": "object", "properties": {"messages": {"title": "Messages", "type": "array", "items": {"$ref": "#/definitions/BaseMessage"}}}, "required": ["messages"], "definitions": {"BaseMessage": {"title": "BaseMessage", "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of ChatModels.", "type": "object", "properties": {"content": {"title": "Content", "anyOf": [{"type": "string"}, {"type": "array", "items": {"anyOf": [{"type": "string"}, {"type": "object"}]}}]}, "additional_kwargs": {"title": "Additional Kwargs", "type": "object"}, "response_metadata": {"title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"title": "Name", "type": "string"}, "id": {"title": "Id", "type": "string"}}, "required": ["content", "type"]}}}'
# ---
# name: test_prebuilt_tool_chat.1
'{"title": "LangGraphOutput", "type": "object", "properties": {"messages": {"title": "Messages", "type": "array", "items": {"$ref": "#/definitions/BaseMessage"}}}, "definitions": {"BaseMessage": {"title": "BaseMessage", "description": "Base abstract message class.\\n\\nMessages are the inputs and outputs of ChatModels.", "type": "object", "properties": {"content": {"title": "Content", "anyOf": [{"type": "string"}, {"type": "array", "items": {"anyOf": [{"type": "string"}, {"type": "object"}]}}]}, "additional_kwargs": {"title": "Additional Kwargs", "type": "object"}, "response_metadata": {"title": "Response Metadata", "type": "object"}, "type": {"title": "Type", "type": "string"}, "name": {"title": "Name", "type": "string"}, "id": {"title": "Id", "type": "string"}}, "required": ["content", "type"]}}}'
@@ -5094,7 +5074,7 @@
'{"title": "LangGraphConfig", "type": "object", "properties": {"configurable": {"$ref": "#/definitions/Configurable"}}, "definitions": {"Configurable": {"title": "Configurable", "type": "object", "properties": {"tools": {"title": "Tools", "type": "array", "items": {"type": "string"}}}}}}'
# ---
# name: test_state_graph_w_config_inherited_state_keys.1
'{"title": "LangGraphInput", "type": "object", "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"title": "Agent Outcome", "anyOf": [{"$ref": "#/definitions/AgentAction"}, {"$ref": "#/definitions/AgentFinish"}]}, "intermediate_steps": {"title": "Intermediate Steps", "type": "array", "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": [{"$ref": "#/definitions/AgentAction"}, {"type": "string"}]}}}, "definitions": {"AgentAction": {"title": "AgentAction", "description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "type": "object", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"title": "Tool Input", "anyOf": [{"type": "string"}, {"type": "object"}]}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentAction", "enum": ["AgentAction"], "type": "string"}}, "required": ["tool", "tool_input", "log"]}, "AgentFinish": {"title": "AgentFinish", "description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "type": "object", "properties": {"return_values": {"title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentFinish", "enum": ["AgentFinish"], "type": "string"}}, "required": ["return_values", "log"]}}}'
'{"title": "LangGraphInput", "type": "object", "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"title": "Agent Outcome", "anyOf": [{"$ref": "#/definitions/AgentAction"}, {"$ref": "#/definitions/AgentFinish"}]}, "intermediate_steps": {"title": "Intermediate Steps", "type": "array", "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": [{"$ref": "#/definitions/AgentAction"}, {"type": "string"}]}}}, "required": ["input"], "definitions": {"AgentAction": {"title": "AgentAction", "description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "type": "object", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"title": "Tool Input", "anyOf": [{"type": "string"}, {"type": "object"}]}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentAction", "enum": ["AgentAction"], "type": "string"}}, "required": ["tool", "tool_input", "log"]}, "AgentFinish": {"title": "AgentFinish", "description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "type": "object", "properties": {"return_values": {"title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentFinish", "enum": ["AgentFinish"], "type": "string"}}, "required": ["return_values", "log"]}}}'
# ---
# name: test_state_graph_w_config_inherited_state_keys.2
'{"title": "LangGraphOutput", "type": "object", "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"title": "Agent Outcome", "anyOf": [{"$ref": "#/definitions/AgentAction"}, {"$ref": "#/definitions/AgentFinish"}]}, "intermediate_steps": {"title": "Intermediate Steps", "type": "array", "items": {"type": "array", "minItems": 2, "maxItems": 2, "items": [{"$ref": "#/definitions/AgentAction"}, {"type": "string"}]}}}, "definitions": {"AgentAction": {"title": "AgentAction", "description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "type": "object", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"title": "Tool Input", "anyOf": [{"type": "string"}, {"type": "object"}]}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentAction", "enum": ["AgentAction"], "type": "string"}}, "required": ["tool", "tool_input", "log"]}, "AgentFinish": {"title": "AgentFinish", "description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "type": "object", "properties": {"return_values": {"title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"title": "Type", "default": "AgentFinish", "enum": ["AgentFinish"], "type": "string"}}, "required": ["return_values", "log"]}}}'
File diff suppressed because it is too large Load Diff
+42 -63
View File
@@ -1,8 +1,6 @@
import asyncio
import sys
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, contextmanager
from typing import AsyncIterator, Iterator, TypeVar
from contextlib import asynccontextmanager
from typing import AsyncIterator, Optional
from uuid import UUID, uuid4
import pytest
@@ -10,6 +8,7 @@ from psycopg import AsyncConnection, Connection
from psycopg_pool import AsyncConnectionPool, ConnectionPool
from pytest_mock import MockerFixture
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.checkpoint.sqlite import SqliteSaver
@@ -19,6 +18,11 @@ from tests.memory_assert import MemorySaverAssertImmutable
DEFAULT_POSTGRES_URI = "postgres://postgres:postgres@localhost:5442/"
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture()
def deterministic_uuids(mocker: MockerFixture) -> MockerFixture:
side_effect = (
@@ -27,35 +31,6 @@ def deterministic_uuids(mocker: MockerFixture) -> MockerFixture:
return mocker.patch("uuid.uuid4", side_effect=side_effect)
"""
pytest-asyncio doesn't support calling async fixtures with getfixturevalue
so we need to use ThreadPoolExecutor to run the async fixture in a thread
https://github.com/pytest-dev/pytest-asyncio/issues/112#issuecomment-462062890
"""
T = TypeVar("T")
def close_loop(loop: asyncio.AbstractEventLoop) -> None:
loop.run_until_complete(loop.shutdown_asyncgens())
loop.run_until_complete(loop.shutdown_default_executor())
asyncio.set_event_loop(None)
loop.close()
@contextmanager
def agen_to_gen(agen: AsyncIterator[T]) -> Iterator[T]:
with ThreadPoolExecutor(1) as bg:
loop = asyncio.new_event_loop()
bg.submit(asyncio.set_event_loop, loop).result()
try:
yield bg.submit(loop.run_until_complete, agen.__aenter__()).result()
finally:
bg.submit(
loop.run_until_complete, agen.__aexit__(None, None, None)
).result()
bg.submit(close_loop, loop).result()
# checkpointer fixtures
@@ -70,12 +45,6 @@ def checkpointer_sqlite():
yield checkpointer
@pytest.fixture(scope="function")
def checkpointer_sqlite_aio():
with agen_to_gen(_checkpointer_sqlite_aio()) as checkpointer:
yield checkpointer
@asynccontextmanager
async def _checkpointer_sqlite_aio():
async with AsyncSqliteSaver.from_conn_string(":memory:") as checkpointer:
@@ -143,16 +112,10 @@ def checkpointer_postgres_pool():
conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function")
def checkpointer_postgres_aio():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
with agen_to_gen(_checkpointer_postgres_aio()) as checkpointer:
yield checkpointer
@asynccontextmanager
async def _checkpointer_postgres_aio():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid4().hex[:16]}"
# create unique db
async with await AsyncConnection.connect(
@@ -174,16 +137,10 @@ async def _checkpointer_postgres_aio():
await conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function")
def checkpointer_postgres_aio_pipe():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
with agen_to_gen(_checkpointer_postgres_aio_pipe()) as checkpointer:
yield checkpointer
@asynccontextmanager
async def _checkpointer_postgres_aio_pipe():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid4().hex[:16]}"
# create unique db
async with await AsyncConnection.connect(
@@ -208,16 +165,10 @@ async def _checkpointer_postgres_aio_pipe():
await conn.execute(f"DROP DATABASE {database}")
@pytest.fixture(scope="function")
def checkpointer_postgres_aio_pool():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
with agen_to_gen(_checkpointer_postgres_aio_pool()) as checkpointer:
yield checkpointer
@asynccontextmanager
async def _checkpointer_postgres_aio_pool():
if sys.version_info < (3, 10):
pytest.skip("Async Postgres tests require Python 3.10+")
database = f"test_{uuid4().hex[:16]}"
# create unique db
async with await AsyncConnection.connect(
@@ -240,6 +191,30 @@ async def _checkpointer_postgres_aio_pool():
await conn.execute(f"DROP DATABASE {database}")
@asynccontextmanager
async def awith_checkpointer(
checkpointer_name: Optional[str],
) -> AsyncIterator[BaseCheckpointSaver]:
if checkpointer_name is None:
yield None
elif checkpointer_name == "memory":
yield MemorySaverAssertImmutable()
elif checkpointer_name == "sqlite_aio":
async with _checkpointer_sqlite_aio() as checkpointer:
yield checkpointer
elif checkpointer_name == "postgres_aio":
async with _checkpointer_postgres_aio() as checkpointer:
yield checkpointer
elif checkpointer_name == "postgres_aio_pipe":
async with _checkpointer_postgres_aio_pipe() as checkpointer:
yield checkpointer
elif checkpointer_name == "postgres_aio_pool":
async with _checkpointer_postgres_aio_pool() as checkpointer:
yield checkpointer
else:
raise NotImplementedError(f"Unknown checkpointer: {checkpointer_name}")
ALL_CHECKPOINTERS_SYNC = [
"memory",
"sqlite",
@@ -254,3 +229,7 @@ ALL_CHECKPOINTERS_ASYNC = [
"postgres_aio_pipe",
"postgres_aio_pool",
]
ALL_CHECKPOINTERS_ASYNC_PLUS_NONE = [
*ALL_CHECKPOINTERS_ASYNC,
None,
]
+2
View File
@@ -8,6 +8,8 @@ from langgraph.channels.last_value import LastValue
from langgraph.channels.topic import Topic
from langgraph.errors import EmptyChannelError, InvalidUpdateError
pytestmark = pytest.mark.anyio
def test_last_value() -> None:
with LastValue(int).from_checkpoint(None, {}) as channel:
+21 -20
View File
@@ -4,12 +4,16 @@ import pytest
from pytest_mock import MockerFixture
from langgraph.graph import END, START, StateGraph
@pytest.mark.parametrize(
"checkpointer_name",
["memory", "sqlite", "postgres", "postgres_pipe"],
from tests.conftest import (
ALL_CHECKPOINTERS_ASYNC,
ALL_CHECKPOINTERS_SYNC,
awith_checkpointer,
)
pytestmark = pytest.mark.anyio
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_interruption_without_state_updates(
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
) -> None:
@@ -47,12 +51,9 @@ def test_interruption_without_state_updates(
assert graph.get_state(thread).next == ()
@pytest.mark.parametrize(
"checkpointer_name",
["memory", "sqlite_aio", "postgres_aio", "postgres_aio_pipe"],
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_interruption_without_state_updates_async(
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
checkpointer_name: str, mocker: MockerFixture
):
"""Test interruption without state updates. This test confirms that
interrupting doesn't require a state key having been updated in the prev step"""
@@ -72,17 +73,17 @@ async def test_interruption_without_state_updates_async(
builder.add_edge("step_2", "step_3")
builder.add_edge("step_3", END)
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
graph = builder.compile(checkpointer=checkpointer, interrupt_after="*")
async with awith_checkpointer(checkpointer_name) as checkpointer:
graph = builder.compile(checkpointer=checkpointer, interrupt_after="*")
initial_input = {"input": "hello world"}
thread = {"configurable": {"thread_id": "1"}}
initial_input = {"input": "hello world"}
thread = {"configurable": {"thread_id": "1"}}
await graph.ainvoke(initial_input, thread, debug=True)
assert (await graph.aget_state(thread)).next == ("step_2",)
await graph.ainvoke(initial_input, thread, debug=True)
assert (await graph.aget_state(thread)).next == ("step_2",)
await graph.ainvoke(None, thread, debug=True)
assert (await graph.aget_state(thread)).next == ("step_3",)
await graph.ainvoke(None, thread, debug=True)
assert (await graph.aget_state(thread)).next == ("step_3",)
await graph.ainvoke(None, thread, debug=True)
assert (await graph.aget_state(thread)).next == ()
await graph.ainvoke(None, thread, debug=True)
assert (await graph.aget_state(thread)).next == ()
+35 -39
View File
@@ -23,8 +23,15 @@ from pydantic import BaseModel as BaseModelV2
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.prebuilt import ToolNode, ValidationNode, create_react_agent
from langgraph.prebuilt.tool_node import InjectedState
from tests.conftest import (
ALL_CHECKPOINTERS_ASYNC,
ALL_CHECKPOINTERS_SYNC,
awith_checkpointer,
)
from tests.messages import _AnyIdHumanMessage
pytestmark = pytest.mark.anyio
class FakeToolCallingModel(BaseChatModel):
def _generate(
@@ -53,10 +60,7 @@ class FakeToolCallingModel(BaseChatModel):
return self
@pytest.mark.parametrize(
"checkpointer_name",
["memory", "sqlite", "postgres", "postgres_pipe"],
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_no_modifier(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
checkpointer: BaseCheckpointSaver = request.getfixturevalue(
"checkpointer_" + checkpointer_name
@@ -89,43 +93,35 @@ def test_no_modifier(request: pytest.FixtureRequest, checkpointer_name: str) ->
assert saved.pending_writes == []
@pytest.mark.parametrize(
"checkpointer_name",
["memory", "sqlite_aio", "postgres_aio", "postgres_aio_pipe"],
)
async def test_no_modifier_async(
request: pytest.FixtureRequest, checkpointer_name: str
) -> None:
checkpointer: BaseCheckpointSaver = request.getfixturevalue(
f"checkpointer_{checkpointer_name}"
)
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_no_modifier_async(checkpointer_name: str) -> None:
async with awith_checkpointer(checkpointer_name) as checkpointer:
model = FakeToolCallingModel()
model = FakeToolCallingModel()
agent = create_react_agent(model, [], checkpointer=checkpointer)
inputs = [HumanMessage("hi?")]
thread = {"configurable": {"thread_id": "123"}}
response = await agent.ainvoke({"messages": inputs}, thread, debug=True)
expected_response = {"messages": inputs + [AIMessage(content="hi?", id="0")]}
assert response == expected_response
agent = create_react_agent(model, [], checkpointer=checkpointer)
inputs = [HumanMessage("hi?")]
thread = {"configurable": {"thread_id": "123"}}
response = await agent.ainvoke({"messages": inputs}, thread, debug=True)
expected_response = {"messages": inputs + [AIMessage(content="hi?", id="0")]}
assert response == expected_response
if checkpointer:
saved = await checkpointer.aget_tuple(thread)
assert saved is not None
assert saved.checkpoint["channel_values"] == {
"messages": [
_AnyIdHumanMessage(content="hi?"),
AIMessage(content="hi?", id="0"),
],
"agent": "agent",
}
assert saved.metadata == {
"parents": {},
"source": "loop",
"writes": {"agent": {"messages": [AIMessage(content="hi?", id="0")]}},
"step": 1,
}
assert saved.pending_writes == []
if checkpointer:
saved = await checkpointer.aget_tuple(thread)
assert saved is not None
assert saved.checkpoint["channel_values"] == {
"messages": [
_AnyIdHumanMessage(content="hi?"),
AIMessage(content="hi?", id="0"),
],
"agent": "agent",
}
assert saved.metadata == {
"parents": {},
"source": "loop",
"writes": {"agent": {"messages": [AIMessage(content="hi?", id="0")]}},
"step": 1,
}
assert saved.pending_writes == []
def test_passing_two_modifiers():
+8 -32
View File
@@ -74,10 +74,7 @@ from langgraph.store.memory import MemoryStore
from tests.any_str import AnyDict, AnyStr, AnyVersion, UnsortedSequence
from tests.conftest import ALL_CHECKPOINTERS_SYNC
from tests.fake_tracer import FakeTracer
from tests.memory_assert import (
MemorySaverAssertCheckpointMetadata,
MemorySaverNoPending,
)
from tests.memory_assert import MemorySaverAssertCheckpointMetadata
from tests.messages import _AnyIdAIMessage, _AnyIdHumanMessage
@@ -1626,29 +1623,6 @@ def test_cond_edge_after_send() -> None:
assert graph.invoke(["0"]) == ["0", "1", "2", "2", "3"]
async def test_checkpointer_null_pending_writes() -> None:
class Node:
def __init__(self, name: str):
self.name = name
setattr(self, "__name__", name)
def __call__(self, state):
return [self.name]
builder = StateGraph(Annotated[list, operator.add])
builder.add_node(Node("1"))
builder.add_edge(START, "1")
graph = builder.compile(checkpointer=MemorySaverNoPending())
assert graph.invoke([], {"configurable": {"thread_id": "foo"}}) == ["1"]
assert graph.invoke([], {"configurable": {"thread_id": "foo"}}) == ["1"] * 2
assert (await graph.ainvoke([], {"configurable": {"thread_id": "foo"}})) == [
"1"
] * 3
assert (await graph.ainvoke([], {"configurable": {"thread_id": "foo"}})) == [
"1"
] * 4
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_invoke_checkpoint_three(
mocker: MockerFixture, request: pytest.FixtureRequest, checkpointer_name: str
@@ -1929,7 +1903,7 @@ def test_invoke_two_processes_no_in(mocker: MockerFixture) -> None:
one = Channel.subscribe_to("between") | add_one | Channel.write_to("output")
two = Channel.subscribe_to("between") | add_one
with pytest.raises(ValueError):
with pytest.raises(TypeError):
Pregel(nodes={"one": one, "two": two})
@@ -9969,10 +9943,12 @@ def test_send_to_nested_graphs(
graph.update_state(outer_state.tasks[1].state, {"subject": "turtles - hohoho"})
# continue past interrupt
assert graph.invoke(None, config=config) == {
"subjects": ["cats", "dogs"],
"jokes": ["Joke about cats - hohoho", "Joke about turtles - hohoho"],
}
assert sorted(
graph.stream(None, config=config), key=lambda d: d["generate_joke"]["jokes"][0]
) == [
{"generate_joke": {"jokes": ["Joke about cats - hohoho"]}},
{"generate_joke": {"jokes": ["Joke about turtles - hohoho"]}},
]
actual_snapshot = graph.get_state(config)
expected_snapshot = StateSnapshot(
File diff suppressed because it is too large Load Diff
+47 -3
View File
@@ -1,10 +1,11 @@
import warnings
from typing import Annotated as Annotated2
from typing import Any
from typing import Any, Optional
import pytest
from langchain_core.runnables import RunnableConfig
from pydantic.v1 import BaseModel
from typing_extensions import Annotated, TypedDict
from typing_extensions import Annotated, NotRequired, Required, TypedDict
from langgraph.graph.state import StateGraph, _warn_invalid_state_schema
@@ -45,7 +46,8 @@ def test_warns_invalid_schema(schema: Any):
)
def test_doesnt_warn_valid_schema(schema: Any):
# Assert the function does not raise a warning
with pytest.warns(None):
with warnings.catch_warnings():
warnings.simplefilter("error")
_warn_invalid_state_schema(schema)
@@ -86,3 +88,45 @@ def test_state_schema_with_type_hint():
for i, c in enumerate(graph.stream(input_state, stream_mode="updates")):
node_name = actions[i].__name__
assert c[node_name] == output_state
@pytest.mark.parametrize("total_", [True, False])
def test_state_schema_optional_values(total_: bool):
class SomeParentState(TypedDict):
val0a: str
val0b: Optional[str]
class InputState(SomeParentState, total=total_): # type: ignore
val1: str
val2: Optional[str]
val3: Required[str]
val4: NotRequired[dict]
val5: Annotated[Required[str], "foo"]
val6: Annotated[NotRequired[str], "bar"]
class State(InputState): # this would be ignored
val4: dict
builder = StateGraph(State, input=InputState)
builder.add_node("n", lambda x: x)
builder.add_edge("__start__", "n")
graph = builder.compile()
model = graph.input_schema
json_schema = model.schema()
if total_ is False:
expected_required = set()
expected_optional = {"val2", "val1"}
else:
expected_required = {"val1"}
expected_optional = {"val2"}
# The others should always have precedence based on the required annotation
expected_required |= {"val0a", "val3", "val5"}
expected_optional |= {"val0b", "val4", "val6"}
assert set(json_schema.get("required", set())) == expected_required
assert (
set(json_schema["properties"].keys()) == expected_required | expected_optional
)
+3
View File
@@ -1,11 +1,14 @@
import asyncio
from typing import Any, Optional
import pytest
from pytest_mock import MockerFixture
from langgraph.store.base import BaseStore
from langgraph.store.batch import AsyncBatchedStore
pytestmark = pytest.mark.anyio
async def test_async_batch_store(mocker: MockerFixture) -> None:
aget = mocker.stub()
+114 -2
View File
@@ -1,15 +1,34 @@
import functools
import sys
import uuid
from typing import TypedDict
from typing import (
Any,
Callable,
Dict,
ForwardRef,
List,
Literal,
Optional,
TypedDict,
TypeVar,
Union,
)
from unittest.mock import patch
import langsmith
import pytest
from typing_extensions import Annotated, NotRequired, Required
from langgraph.graph import END, StateGraph
from langgraph.graph.graph import CompiledGraph
from langgraph.utils import is_async_callable, is_async_generator
from langgraph.utils import (
_is_optional_type,
get_field_default,
is_async_callable,
is_async_generator,
)
pytestmark = pytest.mark.anyio
def test_is_async() -> None:
@@ -119,3 +138,96 @@ async def test_runnable_callable_tracing_nested_async(rt_graph: CompiledGraph) -
with langsmith.tracing_context(enabled=True):
res = await rt_graph.ainvoke({"foo": 1})
assert isinstance(res["node_run_id"], uuid.UUID)
def test_is_optional_type():
assert _is_optional_type(None)
assert not _is_optional_type(type(None))
assert _is_optional_type(Optional[list])
assert not _is_optional_type(int)
assert _is_optional_type(Optional[Literal[1, 2, 3]])
assert not _is_optional_type(Literal[1, 2, 3])
assert _is_optional_type(Optional[List[int]])
assert _is_optional_type(Optional[Dict[str, int]])
assert not _is_optional_type(List[Optional[int]])
assert _is_optional_type(Union[Optional[str], Optional[int]])
assert _is_optional_type(
Union[
Union[Optional[str], Optional[int]], Union[Optional[float], Optional[dict]]
]
)
assert not _is_optional_type(Union[Union[str, int], Union[float, dict]])
assert _is_optional_type(Union[int, None])
assert _is_optional_type(Union[str, None, int])
assert _is_optional_type(Union[None, str, int])
assert not _is_optional_type(Union[int, str])
assert not _is_optional_type(Any) # Do we actually want this?
assert _is_optional_type(Optional[Any])
class MyClass:
pass
assert _is_optional_type(Optional[MyClass])
assert not _is_optional_type(MyClass)
assert _is_optional_type(Optional[ForwardRef("MyClass")])
assert not _is_optional_type(ForwardRef("MyClass"))
assert _is_optional_type(Optional[Union[List[int], Dict[str, Optional[int]]]])
assert not _is_optional_type(Union[List[int], Dict[str, Optional[int]]])
assert _is_optional_type(Optional[Callable[[int], str]])
assert not _is_optional_type(Callable[[int], Optional[str]])
T = TypeVar("T")
assert _is_optional_type(Optional[T])
assert not _is_optional_type(T)
U = TypeVar("U", bound=Optional[T]) # type: ignore
assert _is_optional_type(U)
def test_is_required():
class MyBaseTypedDict(TypedDict):
val_1: Required[Optional[str]]
val_2: Required[str]
val_3: NotRequired[str]
val_4: NotRequired[Optional[str]]
val_5: Annotated[NotRequired[int], "foo"]
val_6: NotRequired[Annotated[int, "foo"]]
val_7: Annotated[Required[int], "foo"]
val_8: Required[Annotated[int, "foo"]]
val_9: Optional[str]
val_10: str
annos = MyBaseTypedDict.__annotations__
assert get_field_default("val_1", annos["val_1"], MyBaseTypedDict) == ...
assert get_field_default("val_2", annos["val_2"], MyBaseTypedDict) == ...
assert get_field_default("val_3", annos["val_3"], MyBaseTypedDict) is None
assert get_field_default("val_4", annos["val_4"], MyBaseTypedDict) is None
# See https://peps.python.org/pep-0655/#interaction-with-annotated
assert get_field_default("val_5", annos["val_5"], MyBaseTypedDict) is None
assert get_field_default("val_6", annos["val_6"], MyBaseTypedDict) is None
assert get_field_default("val_7", annos["val_7"], MyBaseTypedDict) == ...
assert get_field_default("val_8", annos["val_8"], MyBaseTypedDict) == ...
assert get_field_default("val_9", annos["val_9"], MyBaseTypedDict) is None
assert get_field_default("val_10", annos["val_10"], MyBaseTypedDict) == ...
class MyChildDict(MyBaseTypedDict):
val_11: int
val_11b: Optional[int]
val_11c: Union[int, None, str]
class MyGrandChildDict(MyChildDict, total=False):
val_12: int
val_13: Required[str]
cannos = MyChildDict.__annotations__
gcannos = MyGrandChildDict.__annotations__
assert get_field_default("val_11", cannos["val_11"], MyChildDict) == ...
assert get_field_default("val_11b", cannos["val_11b"], MyChildDict) is None
assert get_field_default("val_11c", cannos["val_11c"], MyChildDict) is None
assert get_field_default("val_12", gcannos["val_12"], MyGrandChildDict) is None
assert get_field_default("val_9", gcannos["val_9"], MyGrandChildDict) is None
assert get_field_default("val_13", gcannos["val_13"], MyGrandChildDict) == ...
+18 -14
View File
@@ -133,6 +133,7 @@ export class CronsClient extends BaseClient {
interrupt_before: payload?.interruptBefore,
interrupt_after: payload?.interruptAfter,
webhook: payload?.webhook,
multitask_strategy: payload?.multitaskStrategy,
};
return this.fetch<Run>(`/threads/${threadId}/runs/crons`, {
method: "POST",
@@ -159,6 +160,7 @@ export class CronsClient extends BaseClient {
interrupt_before: payload?.interruptBefore,
interrupt_after: payload?.interruptAfter,
webhook: payload?.webhook,
multitask_strategy: payload?.multitaskStrategy,
};
return this.fetch<Run>(`/runs/crons`, {
method: "POST",
@@ -237,6 +239,8 @@ export class AssistantsClient extends BaseClient {
graphId: string;
config?: Config;
metadata?: Metadata;
assistantId?: string;
ifExists?: OnConflictBehavior;
}): Promise<Assistant> {
return this.fetch<Assistant>("/assistants", {
method: "POST",
@@ -244,6 +248,8 @@ export class AssistantsClient extends BaseClient {
graph_id: payload.graphId,
config: payload.config,
metadata: payload.metadata,
assistant_id: payload.assistantId,
if_exists: payload.ifExists,
},
});
}
@@ -257,7 +263,7 @@ export class AssistantsClient extends BaseClient {
async update(
assistantId: string,
payload: {
graphId: string;
graphId?: string;
config?: Config;
metadata?: Metadata;
},
@@ -518,7 +524,7 @@ export class RunsClient extends BaseClient {
stream(
threadId: null,
assistantId: string,
payload?: Omit<RunsStreamPayload, "multitaskStrategy">,
payload?: Omit<RunsStreamPayload, "multitaskStrategy" | "onCompletion">,
): AsyncGenerator<{
event: StreamEvent;
data: any;
@@ -546,8 +552,6 @@ export class RunsClient extends BaseClient {
payload?: RunsStreamPayload,
): AsyncGenerator<{
event: StreamEvent;
// TODO: figure out a better way to
// type this without any
data: any;
}> {
const json: Record<string, any> = {
@@ -560,10 +564,11 @@ export class RunsClient extends BaseClient {
interrupt_before: payload?.interruptBefore,
interrupt_after: payload?.interruptAfter,
checkpoint_id: payload?.checkpointId,
webhook: payload?.webhook,
multitask_strategy: payload?.multitaskStrategy,
on_completion: payload?.onCompletion,
on_disconnect: payload?.onDisconnect,
};
if (payload?.multitaskStrategy != null) {
json["multitask_strategy"] = payload?.multitaskStrategy;
}
const endpoint =
threadId == null ? `/runs/stream` : `/threads/${threadId}/runs/stream`;
@@ -640,10 +645,8 @@ export class RunsClient extends BaseClient {
interrupt_after: payload?.interruptAfter,
webhook: payload?.webhook,
checkpoint_id: payload?.checkpointId,
multitask_strategy: payload?.multitaskStrategy,
};
if (payload?.multitaskStrategy != null) {
json["multitask_strategy"] = payload?.multitaskStrategy;
}
return this.fetch<Run>(`/threads/${threadId}/runs`, {
method: "POST",
json,
@@ -654,7 +657,7 @@ export class RunsClient extends BaseClient {
async wait(
threadId: null,
assistantId: string,
payload?: Omit<RunsWaitPayload, "multitaskStrategy">,
payload?: Omit<RunsWaitPayload, "multitaskStrategy" | "onCompletion">,
): Promise<ThreadState["values"]>;
async wait(
@@ -684,10 +687,11 @@ export class RunsClient extends BaseClient {
interrupt_before: payload?.interruptBefore,
interrupt_after: payload?.interruptAfter,
checkpoint_id: payload?.checkpointId,
webhook: payload?.webhook,
multitask_strategy: payload?.multitaskStrategy,
on_completion: payload?.onCompletion,
on_disconnect: payload?.onDisconnect,
};
if (payload?.multitaskStrategy != null) {
json["multitask_strategy"] = payload?.multitaskStrategy;
}
const endpoint =
threadId == null ? `/runs/wait` : `/threads/${threadId}/runs/wait`;
return this.fetch<ThreadState["values"]>(endpoint, {
+24 -6
View File
@@ -3,6 +3,8 @@ import { Config, Metadata } from "./schema.js";
export type StreamMode = "values" | "messages" | "updates" | "events" | "debug";
export type MultitaskStrategy = "reject" | "interrupt" | "rollback" | "enqueue";
export type OnConflictBehavior = "raise" | "do_nothing";
export type OnCompletionBehavior = "complete" | "continue";
export type DisconnectMode = "cancel" | "continue";
export type StreamEvent =
| "events"
| "metadata"
@@ -61,6 +63,27 @@ interface RunsInvokePayload {
* Abort controller signal to cancel the run.
*/
signal?: AbortController["signal"];
/**
* Behavior to handle run completion. Only relevant if
* there is a pending/inflight run on the same thread. One of:
* - "complete": Complete the run.
* - "continue": Continue the run.
*/
onCompletion?: OnCompletionBehavior;
/**
* Webhook to call when the run is complete.
*/
webhook?: string;
/**
* Behavior to handle disconnection. Only relevant if
* there is a pending/inflight run on the same thread. One of:
* - "cancel": Cancel the run.
* - "continue": Continue the run.
*/
onDisconnect?: DisconnectMode;
}
export interface RunsStreamPayload extends RunsInvokePayload {
@@ -82,12 +105,7 @@ export interface RunsStreamPayload extends RunsInvokePayload {
feedbackKeys?: string[];
}
export interface RunsCreatePayload extends RunsInvokePayload {
/**
* Webhook to call when the run is complete.
*/
webhook?: string;
}
export interface RunsCreatePayload extends RunsInvokePayload {}
export interface CronsCreatePayload extends RunsCreatePayload {
/**