mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-18 13:45:44 +02:00
Compare commits
59
Commits
cli==0.2.2
...
cli==0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ee112851c | ||
|
|
960f612dd7 | ||
|
|
43af618bb5 | ||
|
|
9b87b45322 | ||
|
|
39adc05357 | ||
|
|
c5ac80d2f0 | ||
|
|
2fc941c1df | ||
|
|
7bafc5dd36 | ||
|
|
c78588b995 | ||
|
|
ecb15acb80 | ||
|
|
07ba931105 | ||
|
|
88ccde6274 | ||
|
|
5cca153b72 | ||
|
|
63ebb3a846 | ||
|
|
48c08421fa | ||
|
|
cd967c40ac | ||
|
|
854b76addd | ||
|
|
73b3535c4d | ||
|
|
3e0629c56c | ||
|
|
04dd69b1cd | ||
|
|
ff22eb6495 | ||
|
|
07ca03ff15 | ||
|
|
6eea15ec3b | ||
|
|
0e111b2f44 | ||
|
|
b526fe0a4b | ||
|
|
062bf4d717 | ||
|
|
173f4f6ccf | ||
|
|
9a45a5b0f2 | ||
|
|
2c557e9e46 | ||
|
|
6c34e599ab | ||
|
|
d4224a7abb | ||
|
|
c700dab97c | ||
|
|
704b78b8fe | ||
|
|
2ed453debe | ||
|
|
62b2580ad5 | ||
|
|
dfbf0ddbcb | ||
|
|
41bb20ee5e | ||
|
|
560d6a1f65 | ||
|
|
dc6fa9ed30 | ||
|
|
a9be75f745 | ||
|
|
20e3469296 | ||
|
|
233cca1357 | ||
|
|
d1ac0a0e13 | ||
|
|
5071a6cd97 | ||
|
|
72d7b23638 | ||
|
|
64aa1e6cd8 | ||
|
|
d6f2f0c90d | ||
|
|
5a7edead8c | ||
|
|
8ff5c43cf0 | ||
|
|
0eb32a4251 | ||
|
|
3d12a2df59 | ||
|
|
04d3c9d30f | ||
|
|
cddcf35c09 | ||
|
|
5eefc1d55d | ||
|
|
c9d4f1d77d | ||
|
|
1e2888ce39 | ||
|
|
4d1b3370df | ||
|
|
bf5017f6e0 | ||
|
|
64086aa814 |
@@ -40,6 +40,9 @@ agent.invoke(
|
||||
)
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Check out [this guide](https://langchain-ai.github.io/langgraph/tutorials/workflows/) that walks through implementing common patterns (workflows and agents) in LangGraph.
|
||||
|
||||
## Why use LangGraph?
|
||||
|
||||
LangGraph is built for developers who want to build powerful, adaptable AI agents. Developers choose LangGraph for:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# How to Deploy to Cloud SaaS
|
||||
# How to Deploy to Cloud SaaS (Beta)
|
||||
|
||||
Before deploying, review the [conceptual guide for the Cloud SaaS](../../concepts/langgraph_cloud.md) deployment option.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# How to Deploy Self-Hosted Control Plane
|
||||
# How to Deploy Self-Hosted Control Plane (Beta)
|
||||
|
||||
Before deploying, review the [conceptual guide for the Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployment option.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# How to Deploy Self-Hosted Data Plane
|
||||
# How to Deploy Self-Hosted Data Plane (Beta)
|
||||
|
||||
Before deploying, review the [conceptual guide for the Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) deployment option.
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function HomePage() {
|
||||
}
|
||||
```
|
||||
|
||||
Under the hood, the `useStream()` hook will use the `streamMode: "messages-key"` to receive a stream of messages (i.e. individual LLM tokens) from any LangChain chat model invocations inside your graph nodes. Learn more about messages streaming in the [How to stream messages from your graph](./stream_messages.md) guide.
|
||||
Under the hood, the `useStream()` hook will use the `streamMode: "messages-tuple"` to receive a stream of messages (i.e. individual LLM tokens) from any LangChain chat model invocations inside your graph nodes. Learn more about messages streaming in the [How to stream messages from your graph](./stream_messages.md) guide.
|
||||
|
||||
### Interrupts
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Environment Variables
|
||||
|
||||
The LangGraph Cloud Server supports specific environment variables for configuring a deployment.
|
||||
The LangGraph Server supports specific environment variables for configuring a deployment.
|
||||
|
||||
## `BG_JOB_ISOLATED_LOOPS`
|
||||
|
||||
@@ -32,7 +32,7 @@ See <a href="https://docs.smith.langchain.com/how_to_guides/tracing/sample_trace
|
||||
|
||||
## `LANGGRAPH_AUTH_TYPE`
|
||||
|
||||
Type of authentication for the LangGraph Cloud Server deployment. Valid values: `langsmith`, `noop`.
|
||||
Type of authentication for the LangGraph Server deployment. Valid values: `langsmith`, `noop`.
|
||||
|
||||
For deployments to LangGraph Cloud, this environment variable is set automatically. For local development or deployments where authentication is handled externally (e.g. self-hosted), set this environment variable to `noop`.
|
||||
|
||||
@@ -44,19 +44,27 @@ Set this environment variable to have a BYOC deployment send traces to a self-ho
|
||||
|
||||
`SELF_HOSTED_LANGSMITH_HOSTNAME` is the hostname of the self-hosted LangSmith instance. It must be accessible to the BYOC deployment. `LANGSMITH_API_KEY` is a LangSmith API generated from the self-hosted LangSmith instance.
|
||||
|
||||
## `LANGSMITH_TRACING`
|
||||
|
||||
!!! info "Only for Self-Hosted Data Plane, Self-Hosted Control Plane, and Standalone Container"
|
||||
Disabling LangSmith tracing is only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md), [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md), and [Standalone Container](../../concepts/langgraph_standalone_container.md) deployments.
|
||||
|
||||
Set `LANGSMITH_TRACING` to `false` to disable tracing to LangSmith.
|
||||
|
||||
## `LOG_LEVEL`
|
||||
|
||||
Configure [log level](https://docs.python.org/3/library/logging.html#logging-levels). Defaults to `INFO`.
|
||||
|
||||
## `N_JOBS_PER_WORKER`
|
||||
|
||||
Number of jobs per worker for the LangGraph Cloud task queue. Defaults to `10`.
|
||||
Number of jobs per worker for the LangGraph Server task queue. Defaults to `10`.
|
||||
|
||||
## `POSTGRES_URI_CUSTOM`
|
||||
|
||||
For [Bring Your Own Cloud (BYOC)](../../concepts/bring_your_own_cloud.md) deployments only.
|
||||
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
|
||||
Custom Postgres instances are only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployments.
|
||||
|
||||
Specify `POSTGRES_URI_CUSTOM` to use an externally managed Postgres instance. The value of `POSTGRES_URI_CUSTOM` must be a valid [Postgres connection URI](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS).
|
||||
Specify `POSTGRES_URI_CUSTOM` to use a custom Postgres instance. The value of `POSTGRES_URI_CUSTOM` must be a valid [Postgres connection URI](https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS).
|
||||
|
||||
Postgres:
|
||||
|
||||
@@ -73,11 +81,11 @@ Control Plane Functionality:
|
||||
|
||||
Database Connectivity:
|
||||
|
||||
- The externally managed Postgres instance must be accessible by the LangGraph Server service in the ECS cluster. The BYOC user is responsible for ensuring connectivity.
|
||||
- For example, if an AWS RDS Postgres instance is provisioned, it can be provisioned in the same VPC (`langgraph-cloud-vpc`) as the ECS cluster with the `langgraph-cloud-service-sg` security group to ensure connectivity.
|
||||
- The custom Postgres instance must be accessible by the LangGraph Server. The user is responsible for ensuring connectivity.
|
||||
|
||||
## `REDIS_URI_CUSTOM`
|
||||
|
||||
For [Bring Your Own Cloud (BYOC)](../../concepts/bring_your_own_cloud.md) deployments only.
|
||||
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
|
||||
Custom Redis instances are only available for [Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployments.
|
||||
|
||||
Specify `REDIS_URI_CUSTOM` to use an externally managed Redis instance. The value of `REDIS_URI_CUSTOM` must be a valid [Redis connection URI](https://redis-py.readthedocs.io/en/stable/connections.html#redis.Redis.from_url).
|
||||
Specify `REDIS_URI_CUSTOM` to use a custom Redis instance. The value of `REDIS_URI_CUSTOM` must be a valid [Redis connection URI](https://redis-py.readthedocs.io/en/stable/connections.html#redis.Redis.from_url).
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
|
||||
There are 4 main options for deploying with the LangGraph Platform:
|
||||
|
||||
1. **[Cloud SaaS](#cloud-saas)**: Available for **Plus** and **Enterprise** plans.
|
||||
1. **<a href="#cloud-saas">Cloud SaaS<sup>(Beta)</sup></a>**: Available for **Plus** and **Enterprise** plans.
|
||||
|
||||
1. **[Self-Hosted Data Plane](#self-hosted-data-plane)**: Available for the **Enterprise** plan.
|
||||
1. **<a href="#self-hosted-data-plane">Self-Hosted Data Plane<sup>(Beta)</sup></a>**: Available for the **Enterprise** plan.
|
||||
|
||||
1. **[Self-Hosted Control Plane](#self-hosted-control-plane)**: Available for the **Enterprise** plan.
|
||||
1. **<a href="#self-hosted-control-plane">Self-Hosted Control Plane<sup>(Beta)</sup></a>**: Available for the **Enterprise** plan.
|
||||
|
||||
1. **[Standalone Container](#standalone-container)**: Available for all plans.
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ The LangGraph Platform comprises several components that work together to suppor
|
||||
|
||||
### Deployment Options
|
||||
|
||||
- [Cloud SaaS](../concepts/langgraph_cloud.md): Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
|
||||
- [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md): Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
|
||||
- [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md#control-plane-ui): Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md) and deploy LangGraph Servers to your cloud. You manage everything.
|
||||
- <a href="./langgraph_cloud/">Cloud SaaS<sup>(Beta)</sup></a>: Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
|
||||
- <a href="./langgraph_self_hosted_data_plane/">Self-Hosted Data Plane<sup>(Beta)</sup></a>: Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
|
||||
- <a href="./langgraph_self_hosted_control_plane/">Self-Hosted Control Plane<sup>(Beta)</sup></a>: Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. You manage everything.
|
||||
- [Standalone Container](../concepts/langgraph_standalone_container.md): Deploy LangGraph Server Docker images however you like.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Cloud SaaS
|
||||
# Cloud SaaS (Beta)
|
||||
|
||||
To deploy a [LangGraph Server](../concepts/langgraph_server.md), follow the how-to guide for [how to deploy to Cloud SaaS](../cloud/deployment/cloud.md).
|
||||
|
||||
|
||||
@@ -44,21 +44,24 @@ Feature Differences:
|
||||
|
||||
### Autoscaling
|
||||
|
||||
[`Production` type](../concepts/langgraph_control_plane.md#deployment-types) deployments automatically scale up to 10 containers. Scaling is based on the current request load for a single container. Specifically, the autoscaling implementation scales the deployment so that each container is processing about 10 concurrent requests. For example...
|
||||
[`Production` type](../concepts/langgraph_control_plane.md#deployment-types) deployments automatically scale up to 10 containers. Scaling is based on 3 metrics:
|
||||
|
||||
- If the deployment is processing 20 concurrent requests, the deployment will scale up from 1 container to 2 containers (20 requests / 2 containers = 10 requests per container).
|
||||
- If a deployment of 2 containers is processing 10 requests, the deployment will scale down from 2 containers to 1 container (10 requests / 1 container = 10 requests per container).
|
||||
1. CPU utilization
|
||||
1. Memory utilization
|
||||
1. Number of pending (in progress) [runs](../concepts/langgraph_server.md#runs)
|
||||
|
||||
10 concurrent requests per container is the target threshold. However, 10 concurrent requests per container is not a hard limit. The number of concurrent requests can exceed 10 if there is a sudden burst of requests.
|
||||
For CPU utilization, the autoscaler targets 75% utilization. This means the autoscaler will scale the number of containers up or down to ensure that CPU utilization is at or near 75%. For memory utilization, the autoscaler targets 75% utilization as well.
|
||||
|
||||
Scale down actions are delayed for 30 minutes before any action is taken. In other words, if the autoscaling implementation decides to scale down a deployment, it will first wait for 30 minutes before scaling down. After 30 minutes, the concurrency metric is recomputed and the deployment will scale down if the concurrency metric has met the target threshold. Otherwise, the deployment remains scaled up. This "cool down" period ensures that deployments do not scale up and down too frequently.
|
||||
For number of pending runs, the autoscaler targets 10 pending runs. For example, if the current number of containers is 1, but the number of pending runs in 20, the autoscaler will scale up the deployment to 2 containers (20 pending runs / 2 containers = 10 pending runs per container).
|
||||
|
||||
In the future, the autoscaling implementation may evolve to accommodate other metrics such as background run queue size.
|
||||
Each metric is computed independently and the autoscaler will determine the scaling action based on the metric that results in the most number of containers.
|
||||
|
||||
Scale down actions are delayed for 30 minutes before any action is taken. In other words, if the autoscaler decides to scale down a deployment, it will first wait for 30 minutes before scaling down. After 30 minutes, the metrics are recomputed and the deployment will scale down if the recomputed metrics result in a lower number of containers than the current number. Otherwise, the deployment remains scaled up. This "cool down" period ensures that deployments do not scale up and down too frequently.
|
||||
|
||||
### Static IP Addresses
|
||||
|
||||
!!! info "Only for Cloud SaaS"
|
||||
Static IP addresses are only available for [Cloud SaaS](../concepts/langgraph_cloud.md).
|
||||
Static IP addresses are only available for [Cloud SaaS](../concepts/langgraph_cloud.md) deployments.
|
||||
|
||||
All traffic from deployments created after January 6th 2025 will come through a NAT gateway. This NAT gateway will have several static IP addresses depending on the data region. Refer to the table below for the list of static IP addresses:
|
||||
|
||||
@@ -72,3 +75,46 @@ All traffic from deployments created after January 6th 2025 will come through a
|
||||
| 34.169.88.30 | 34.91.238.184 |
|
||||
| 34.19.93.202 | 35.204.101.241 |
|
||||
| 34.19.34.50 | 35.204.48.32 |
|
||||
|
||||
### Custom Postgres
|
||||
|
||||
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
|
||||
Custom Postgres instances are only available for [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md) deployments.
|
||||
|
||||
A custom Postgres instance can be used instead of the [one automatically created by the control plane](./langgraph_control_plane.md#database-provisioning). Specify the [`POSTGRES_URI_CUSTOM`](../cloud/reference/env_var.md#postgres_uri_custom) environment variable to use a custom Postgres instance.
|
||||
|
||||
Multiple deployments can share the same Postgres instance. For example, for `Deployment A`, `POSTGRES_URI_CUSTOM` can be set to `postgres://<user>:<password>@/<database_name_1>?host=<hostname_1>` and for `Deployment B`, `POSTGRES_URI_CUSTOM` can be set to `postgres://<user>:<password>@/<database_name_2>?host=<hostname_1>`. `<database_name_1>` and `database_name_2` are different databases within the same instance, but `<hostname_1>` is shared. **The same database cannot be used for separate deployments**.
|
||||
|
||||
### Custom Redis
|
||||
|
||||
!!! info "Only for Self-Hosted Data Plane and Self-Hosted Control Plane"
|
||||
Custom Redis instances are only available for [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md) and [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md) deployments.
|
||||
|
||||
A custom Redis instance can be used instead of the one automatically created by the control plane. Specify the [REDIS_URI_CUSTOM](../cloud/reference/env_var.md#redis_uri_custom) environment variable to use a custom Redis instance.
|
||||
|
||||
|
||||
Multiple deployments can share the same Redis instance. For example, for `Deployment A`, `REDIS_URI_CUSTOM` can be set to `redis://<hostname_1>:<port>/1` and for `Deployment B`, `REDIS_URI_CUSTOM` can be set to `redis://<hostname_1>:<port>/2`. `1` and `2` are different database numbers within the same instance, but `<hostname_1>` is shared. **The same database number cannot be used for separate deployments**.
|
||||
|
||||
### LangSmith Tracing
|
||||
|
||||
LangGraph Server is automatically configured to send traces to LangSmith. See the table below for details with respect to each deployment option.
|
||||
|
||||
| Cloud SaaS | Self-Hosted Data Plane | Self-Hosted Control Plane | Standalone Container |
|
||||
|------------|------------------------|---------------------------|----------------------|
|
||||
| Required<br><br>Trace to LangSmith SaaS. | Optional<br><br>Disable tracing or trace to LangSmith SaaS. | Optional<br><br>Disable tracing or trace to Self-Hosted LangSmith. | Optional<br><br>Disable tracing, trace to LangSmith SaaS, or trace to Self-Hosted LangSmith. |
|
||||
|
||||
### Telemetry
|
||||
|
||||
LangGraph Server is automatically configured to report telemetry metadata for billing purposes. See the table below for details with respect to each deployment option.
|
||||
|
||||
| Cloud SaaS | Self-Hosted Data Plane | Self-Hosted Control Plane | Standalone Container |
|
||||
|------------|------------------------|---------------------------|----------------------|
|
||||
| Telemetry sent to LangSmith SaaS. | Telemetry sent to LangSmith SaaS. | Self-reported usage (audit) for air-gapped license key.<br><br>Telemetry sent to LangSmith SaaS for LangGraph Platform License Key. | Self-reported usage (audit) for air-gapped license key.<br><br>Telemetry sent to LangSmith SaaS for LangGraph Platform License Key. |
|
||||
|
||||
### Licensing
|
||||
|
||||
LangGraph Server is automatically configured to perform license key validation. See the table below for details with respect to each deployment option.
|
||||
|
||||
| Cloud SaaS | Self-Hosted Data Plane | Self-Hosted Control Plane | Standalone Container |
|
||||
|------------|------------------------|---------------------------|----------------------|
|
||||
| LangSmith API Key validated against LangSmith SaaS. | LangSmith API Key validated against LangSmith SaaS. | Air-gapped license key or LangGraph Platform License Key validated against LangSmith SaaS. | Air-gapped license key or LangGraph Platform License Key validated against LangSmith SaaS. |
|
||||
|
||||
@@ -5,6 +5,10 @@ search:
|
||||
|
||||
# LangGraph Platform
|
||||
|
||||
Watch this 4-minute overview of LangGraph Platform to see how it helps you build, deploy, and evaluate agentic applications.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/pfAQxBS5z88?si=XGS6Chydn6lhSO1S" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||
|
||||
## Overview
|
||||
|
||||
LangGraph Platform is a commercial solution for deploying agentic applications to production, built on the open-source [LangGraph framework](./high_level.md).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Self-Hosted Control Plane
|
||||
# Self-Hosted Control Plane (Beta)
|
||||
|
||||
To deploy a [LangGraph Server](../concepts/langgraph_server.md), follow the how-to guide for [how to deploy the Self-Hosted Control Plane](../cloud/deployment/self_hosted_control_plane.md).
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Self-Hosted Data Plane
|
||||
# Self-Hosted Data Plane (Beta)
|
||||
|
||||
To deploy a [LangGraph Server](../concepts/langgraph_server.md), follow the how-to guide for [how to deploy the Self-Hosted Data Plane](../cloud/deployment/self_hosted_data_plane.md).
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## How we use Postgres
|
||||
|
||||
Postgres is the persistence layer for all user and run data in LGP. This stores both checkpoints (see more info [here](./persistence.md)) as well as the server resources (threads, runs, assistants and crons).
|
||||
Postgres is the persistence layer for all user, run, and long-term memory data in LGP. This stores both checkpoints (see more info [here](./persistence.md)), server resources (threads, runs, assistants and crons), as well as items saved in the long-term memory store (see more info [here](./persistence.md#memory-store)).
|
||||
|
||||
## How we use Redis
|
||||
|
||||
|
||||
@@ -8,10 +8,6 @@ Defining a custom app object lets you add any routes you'd like, so you can do a
|
||||
|
||||
Below is an example using FastAPI.
|
||||
|
||||
???+ note "Python only"
|
||||
|
||||
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.26`.
|
||||
|
||||
## Create app
|
||||
|
||||
Starting from an **existing** LangGraph Platform application, add the following custom route code to your `webapp.py` file. If you are starting from scratch, you can create a new app from a template using the CLI.
|
||||
|
||||
@@ -17,9 +17,9 @@ Get started deploying your LangGraph applications locally or on the cloud with
|
||||
|
||||
## Deployment Options
|
||||
|
||||
- [Cloud SaaS](../concepts/langgraph_cloud.md): Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
|
||||
- [Self-Hosted Data Plane](../concepts/langgraph_self_hosted_data_plane.md): Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
|
||||
- [Self-Hosted Control Plane](../concepts/langgraph_self_hosted_control_plane.md#control-plane-ui): Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md) and deploy LangGraph Servers to your cloud. You manage everything.
|
||||
- <a href="../../concepts/langgraph_cloud/">Cloud SaaS<sup>(Beta)</sup></a>: Connect to your GitHub repositories and deploy LangGraph Servers to LangChain's cloud. We manage everything.
|
||||
- <a href="../../concepts/langgraph_self_hosted_data_plane/">Self-Hosted Data Plane<sup>(Beta)</sup></a>: Create deployments from the [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. We manage the [control plane](../concepts/langgraph_control_plane.md), you manage the deployments.
|
||||
- <a href="../../concepts/langgraph_self_hosted_control_plane/">Self-Hosted Control Plane<sup>(Beta)</sup></a>: Create deployments from a self-hosted [Control Plane UI](../concepts/langgraph_control_plane.md#control-plane-ui) and deploy LangGraph Servers to your cloud. You manage everything.
|
||||
- [Standalone Container](../concepts/langgraph_standalone_container.md): Deploy LangGraph Server Docker images however you like.
|
||||
|
||||
A quick comparison...
|
||||
|
||||
@@ -222,7 +222,7 @@ As noted in the Anthropic blog on `Building Effective Agents`:
|
||||
|
||||
|
||||
@entrypoint()
|
||||
def parallel_workflow(topic: str):
|
||||
def prompt_chaining_workflow(topic: str):
|
||||
original_joke = generate_joke(topic).result()
|
||||
if check_punchline(original_joke) == "Pass":
|
||||
return original_joke
|
||||
@@ -231,7 +231,7 @@ As noted in the Anthropic blog on `Building Effective Agents`:
|
||||
return polish_joke(improved_joke).result()
|
||||
|
||||
# Invoke
|
||||
for step in parallel_workflow.stream("cats", stream_mode="updates"):
|
||||
for step in prompt_chaining_workflow.stream("cats", stream_mode="updates"):
|
||||
print(step)
|
||||
print("\n")
|
||||
```
|
||||
|
||||
+24
-10
@@ -57,12 +57,16 @@ plugins:
|
||||
separator: '[\s\u200b\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;'
|
||||
- autorefs
|
||||
- mkdocstrings:
|
||||
custom_templates: templates
|
||||
handlers:
|
||||
python:
|
||||
import:
|
||||
- https://docs.python.org/3/objects.inv
|
||||
- https://python.langchain.com/api_reference/objects.inv
|
||||
options:
|
||||
preload_modules:
|
||||
- langchain
|
||||
- langchain_core
|
||||
enable_inventory: true
|
||||
members_order: source
|
||||
allow_inspection: true
|
||||
@@ -75,7 +79,10 @@ plugins:
|
||||
docstring_style: google
|
||||
docstring_section_style: list
|
||||
show_root_toc_entry: false
|
||||
show_signature: true
|
||||
show_signature_annotations: true
|
||||
separate_signature: true
|
||||
line_length: 60
|
||||
show_symbol_type_heading: true
|
||||
show_symbol_type_toc: true
|
||||
signature_crossrefs: true
|
||||
@@ -213,8 +220,8 @@ nav:
|
||||
- how-tos/ttl/configure_ttl.md
|
||||
- Authentication & Access Control:
|
||||
- Authentication & Access Control: how-tos#authentication-access-control
|
||||
- cloud/how-tos/auth/custom_auth_new.md
|
||||
- cloud/how-tos/auth/openapi_security_new.md
|
||||
- how-tos/auth/custom_auth.md
|
||||
- how-tos/auth/openapi_security.md
|
||||
- Assistants:
|
||||
- Assistants: how-tos#assistants
|
||||
- cloud/how-tos/configuration_cloud.md
|
||||
@@ -256,6 +263,11 @@ nav:
|
||||
- cloud/how-tos/webhooks.md
|
||||
- Cron Jobs:
|
||||
- cloud/how-tos/cron_jobs.md
|
||||
- Modifying the API:
|
||||
- Modifying the API: how-tos#modifying-the-api
|
||||
- how-tos/http/custom_lifespan.md
|
||||
- how-tos/http/custom_middleware.md
|
||||
- how-tos/http/custom_routes.md
|
||||
- LangGraph Studio:
|
||||
- LangGraph Studio: how-tos#langgraph-studio
|
||||
- cloud/how-tos/test_deployment.md
|
||||
@@ -265,6 +277,7 @@ nav:
|
||||
- cloud/how-tos/datasets_studio.md
|
||||
- cloud/how-tos/iterate_graph_studio.md
|
||||
- cloud/how-tos/clone_traces_studio.md
|
||||
- how-tos/local-studio.md
|
||||
- Concepts:
|
||||
- concepts/index.md
|
||||
- LangGraph:
|
||||
@@ -273,8 +286,9 @@ nav:
|
||||
- concepts/low_level.md
|
||||
- concepts/agentic_concepts.md
|
||||
- concepts/multi_agent.md
|
||||
- concepts/breakpoints
|
||||
- concepts/breakpoints.md
|
||||
- concepts/human_in_the_loop.md
|
||||
- concepts/v0-human-in-the-loop.md
|
||||
- concepts/time-travel.md
|
||||
- concepts/persistence.md
|
||||
- concepts/memory.md
|
||||
@@ -287,7 +301,10 @@ nav:
|
||||
- High Level:
|
||||
- High Level: concepts#high-level
|
||||
- concepts/langgraph_platform.md
|
||||
- concepts/platform_architecture.md
|
||||
- concepts/scalability_and_resilience.md
|
||||
- concepts/deployment_options.md
|
||||
- concepts/bring_your_own_cloud.md
|
||||
- concepts/plans.md
|
||||
- concepts/template_applications.md
|
||||
- Components:
|
||||
@@ -311,6 +328,7 @@ nav:
|
||||
- concepts/langgraph_self_hosted_data_plane.md
|
||||
- concepts/langgraph_self_hosted_control_plane.md
|
||||
- concepts/langgraph_standalone_container.md
|
||||
- concepts/self_hosted.md
|
||||
- Tutorials:
|
||||
- tutorials/index.md
|
||||
- Quick Start:
|
||||
@@ -387,6 +405,7 @@ nav:
|
||||
- LangGraph Academy Course: https://academy.langchain.com/courses/intro-to-langgraph
|
||||
|
||||
- API reference:
|
||||
- reference/index.md
|
||||
- Library:
|
||||
- Graphs: reference/graphs.md
|
||||
- Checkpointing: reference/checkpoints.md
|
||||
@@ -497,13 +516,8 @@ extra:
|
||||
Thanks for your feedback! Please help us improve this page by adding to the discussion below.
|
||||
validation:
|
||||
# https://www.mkdocs.org/user-guide/configuration/
|
||||
# We're `ignoring` nav.omitted_files because we are going to rely
|
||||
# on files being properly links to from the index pages of:
|
||||
# - tutorials
|
||||
# - concepts
|
||||
# - how-tos
|
||||
# - reference
|
||||
omitted_files: ignore
|
||||
# We are still raising for omitted files because they determine the breadcrumbs for pages.
|
||||
omitted_files: warn
|
||||
absolute_links: warn
|
||||
unrecognized_links: warn
|
||||
# TODO: figure out how to enable 'warn' for this
|
||||
|
||||
Generated
+457
-69
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ python = "^3.10"
|
||||
aiohappyeyeballs = "2.4.3"
|
||||
hub = "^3.0.1"
|
||||
xxhash = "^3.5.0"
|
||||
black = "^25.1.0"
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
langgraph = { path = "../libs/langgraph/", develop = true }
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
{#- Template for Python functions.
|
||||
|
||||
This template renders a Python function or method.
|
||||
|
||||
Context:
|
||||
function (griffe.Function): The function to render.
|
||||
root (bool): Whether this is the root object, injected with `:::` in a Markdown page.
|
||||
heading_level (int): The HTML heading level to use.
|
||||
config (dict): The configuration options.
|
||||
-#}
|
||||
|
||||
{% block logs scoped %}
|
||||
{{ log.debug("Rendering " + function.path) }}
|
||||
{% endblock logs %}
|
||||
|
||||
{% import "language"|get_template as lang with context %}
|
||||
{#- Language module providing the `t` translation method. -#}
|
||||
|
||||
<div class="doc doc-object doc-function">
|
||||
{% with obj = function, html_id = function.path %}
|
||||
{% if root %}
|
||||
{% set show_full_path = config.show_root_full_path %}
|
||||
{% set root_members = True %}
|
||||
{% elif root_members %}
|
||||
{% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %}
|
||||
{% set root_members = False %}
|
||||
{% else %}
|
||||
{% set show_full_path = config.show_object_full_path %}
|
||||
{% endif %}
|
||||
|
||||
{% set function_name = function.path if show_full_path else function.name %}
|
||||
{#- Brief or full function name depending on configuration. -#}
|
||||
{% set symbol_type = "method" if function.parent.is_class else "function" %}
|
||||
{#- Symbol type: method when parent is a class, function otherwise. -#}
|
||||
|
||||
{% if not root or config.show_root_heading %}
|
||||
{% filter heading(
|
||||
heading_level,
|
||||
role="function",
|
||||
id=html_id,
|
||||
class="doc doc-heading",
|
||||
toc_label=(('<code class="doc-symbol doc-symbol-toc doc-symbol-' + symbol_type + '"></code> ')|safe if config.show_symbol_type_toc else '') + function.name,
|
||||
) %}
|
||||
|
||||
{% block heading scoped %}
|
||||
{% if config.show_symbol_type_heading %}<code class="doc-symbol doc-symbol-heading doc-symbol-{{ symbol_type }}"></code>{% endif %}
|
||||
{% if config.separate_signature %}
|
||||
<span class="doc doc-object-name doc-function-name">{{ config.heading if config.heading and root else function_name }}</span>
|
||||
{% else %}
|
||||
{%+ filter highlight(language="python", inline=True) %}
|
||||
{{ function_name }}{% include "signature"|get_template with context %}
|
||||
{% endfilter %}
|
||||
{% endif %}
|
||||
{% endblock heading %}
|
||||
|
||||
{% block labels scoped %}
|
||||
{% with labels = function.labels %}
|
||||
{% include "labels"|get_template with context %}
|
||||
{% endwith %}
|
||||
{% endblock labels %}
|
||||
|
||||
{% endfilter %}
|
||||
|
||||
{% block signature scoped %}
|
||||
{#- Signature block.
|
||||
|
||||
This block renders only the main signature and deliberately omits the overloads.
|
||||
-#}
|
||||
{% if config.separate_signature %}
|
||||
{% filter format_signature(function, config.line_length, crossrefs=config.signature_crossrefs) %}
|
||||
{{ function.name }}
|
||||
{% endfilter %}
|
||||
{% endif %}
|
||||
{% endblock signature %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{% if config.show_root_toc_entry %}
|
||||
{% filter heading(
|
||||
heading_level,
|
||||
role="function",
|
||||
id=html_id,
|
||||
toc_label=(('<code class="doc-symbol doc-symbol-toc doc-symbol-' + symbol_type + '"></code> ')|safe if config.show_symbol_type_toc else '') + (config.toc_label if config.toc_label and root else function.name),
|
||||
hidden=True,
|
||||
) %}
|
||||
{% endfilter %}
|
||||
{% endif %}
|
||||
{% set heading_level = heading_level - 1 %}
|
||||
{% endif %}
|
||||
|
||||
<div class="doc doc-contents {% if root %}first{% endif %}">
|
||||
{% block contents scoped %}
|
||||
{#- Contents block.
|
||||
|
||||
This block renders the function’s docstring and source.
|
||||
-#}
|
||||
{% block docstring scoped %}
|
||||
{% with docstring_sections = function.docstring.parsed %}
|
||||
{% include "docstring"|get_template with context %}
|
||||
{% endwith %}
|
||||
{% endblock docstring %}
|
||||
|
||||
{% block source scoped %}
|
||||
{% if config.show_source and function.source %}
|
||||
<details class="quote">
|
||||
<summary>{{ lang.t("Source code in") }} <code>
|
||||
{%- if function.relative_filepath.is_absolute() -%}
|
||||
{{ function.relative_package_filepath }}
|
||||
{%- else -%}
|
||||
{{ function.relative_filepath }}
|
||||
{%- endif -%}
|
||||
</code></summary>
|
||||
{{ function.source|highlight(language="python", linestart=function.lineno or 0, linenums=True) }}
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endblock source %}
|
||||
{% endblock contents %}
|
||||
</div>
|
||||
|
||||
{% endwith %}
|
||||
</div>
|
||||
@@ -443,16 +443,22 @@ def _parse_node_version(version_str: str) -> int:
|
||||
) from None
|
||||
|
||||
|
||||
def _is_python_graph(spec: Union[str, dict]) -> bool:
|
||||
"""Check if a graph is a Python graph based on the file extension."""
|
||||
|
||||
# handle new style config
|
||||
def _is_node_graph(spec: Union[str, dict]) -> bool:
|
||||
"""Check if a graph is a Node.js graph based on the file extension."""
|
||||
if isinstance(spec, dict):
|
||||
spec = spec.get("path")
|
||||
|
||||
file_path = spec.split(":")[0]
|
||||
file_ext = os.path.splitext(file_path)[1]
|
||||
return file_ext in [".py", ".pyx", ".pyd", ".pyi"]
|
||||
|
||||
return file_ext in [
|
||||
".ts",
|
||||
".mts",
|
||||
".cts",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
]
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
@@ -460,8 +466,8 @@ def validate_config(config: Config) -> Config:
|
||||
|
||||
graphs = config.get("graphs", {})
|
||||
|
||||
some_python = any(_is_python_graph(spec) for spec in graphs.values())
|
||||
some_node = any(not _is_python_graph(spec) for spec in graphs.values())
|
||||
some_node = any(_is_node_graph(spec) for spec in graphs.values())
|
||||
some_python = any(not _is_node_graph(spec) for spec in graphs.values())
|
||||
|
||||
node_version = config.get(
|
||||
"node_version", DEFAULT_NODE_VERSION if some_node else None
|
||||
|
||||
@@ -44,21 +44,30 @@ class Progress:
|
||||
sys.stdout.flush()
|
||||
|
||||
def __enter__(self) -> Callable[[str], None]:
|
||||
self.thread = threading.Thread(target=self.spinner_task)
|
||||
self.thread.start()
|
||||
if sys.stdout.isatty():
|
||||
self.thread = threading.Thread(target=self.spinner_task)
|
||||
self.thread.start()
|
||||
|
||||
def set_message(message):
|
||||
self.message = message
|
||||
if not message:
|
||||
self.thread.join()
|
||||
def set_message(message):
|
||||
self.message = message
|
||||
if not message:
|
||||
self.thread.join()
|
||||
|
||||
return set_message
|
||||
return set_message
|
||||
else:
|
||||
|
||||
def set_message(message):
|
||||
sys.stderr.write(message + "\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
return set_message
|
||||
|
||||
def __exit__(self, exception, value, tb):
|
||||
self.message = ""
|
||||
try:
|
||||
self.thread.join()
|
||||
finally:
|
||||
del self.thread
|
||||
if exception is not None:
|
||||
return False
|
||||
if sys.stdout.isatty():
|
||||
self.message = ""
|
||||
try:
|
||||
self.thread.join()
|
||||
finally:
|
||||
del self.thread
|
||||
if exception is not None:
|
||||
return False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.2.2"
|
||||
version = "0.2.4"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -234,6 +234,17 @@ def test_validate_config_multiplatform():
|
||||
assert config["node_version"] == "20"
|
||||
assert config["python_version"] == "3.12"
|
||||
|
||||
# no known extension (assumes python)
|
||||
config = validate_config(
|
||||
{
|
||||
"dependencies": ["./local", "./shared_utils"],
|
||||
"graphs": {"agent": "local.workflow:graph"},
|
||||
"env": ".env",
|
||||
}
|
||||
)
|
||||
assert config["node_version"] is None
|
||||
assert config["python_version"] == "3.11"
|
||||
|
||||
|
||||
# config_to_docker
|
||||
def test_config_to_docker_simple():
|
||||
|
||||
@@ -40,6 +40,9 @@ agent.invoke(
|
||||
)
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> Check out [this guide](https://langchain-ai.github.io/langgraph/tutorials/workflows/) that walks through implementing common patterns (workflows and agents) in LangGraph.
|
||||
|
||||
## Why use LangGraph?
|
||||
|
||||
LangGraph is built for developers who want to build powerful, adaptable AI agents. Developers choose LangGraph for:
|
||||
|
||||
@@ -106,6 +106,7 @@ def fanout_to_subgraph_sync() -> StateGraph:
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
import random
|
||||
import time
|
||||
|
||||
import uvloop
|
||||
|
||||
@@ -123,4 +124,7 @@ if __name__ == "__main__":
|
||||
len([c async for c in graph.astream(input, config=config)])
|
||||
|
||||
uvloop.install()
|
||||
start = time.time()
|
||||
asyncio.run(run())
|
||||
end = time.time()
|
||||
print(f"Time taken: {end - start:.4f} seconds")
|
||||
|
||||
@@ -69,6 +69,8 @@ CONFIG_KEY_ENSURE_LATEST = sys.intern("__pregel_ensure_latest")
|
||||
# (for distributed mode)
|
||||
CONFIG_KEY_DELEGATE = sys.intern("__pregel_delegate")
|
||||
# holds a boolean indicating whether to delegate subgraphs (for distributed mode)
|
||||
CONFIG_KEY_THREAD_ID = sys.intern("thread_id")
|
||||
# holds the thread ID for the current invocation
|
||||
CONFIG_KEY_CHECKPOINT_MAP = sys.intern("checkpoint_map")
|
||||
# holds a mapping of checkpoint_ns -> checkpoint_id for parent graphs
|
||||
CONFIG_KEY_CHECKPOINT_ID = sys.intern("checkpoint_id")
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import (
|
||||
Callable,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
Union,
|
||||
get_args,
|
||||
@@ -19,7 +20,7 @@ from typing import (
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from langgraph.constants import END, PREVIOUS, START, TAG_HIDDEN
|
||||
from langgraph.constants import END, PREVIOUS, START
|
||||
from langgraph.pregel import Pregel
|
||||
from langgraph.pregel.call import (
|
||||
P,
|
||||
@@ -38,7 +39,7 @@ from langgraph.types import _DC_KWARGS, RetryPolicy, StreamMode
|
||||
def task(
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
|
||||
) -> Callable[
|
||||
[Union[Callable[P, Awaitable[T]], Callable[P, T]]],
|
||||
Callable[P, SyncAsyncFuture[T]],
|
||||
@@ -55,7 +56,7 @@ def task(
|
||||
__func_or_none__: Optional[Union[Callable[P, Awaitable[T]], Callable[P, T]]] = None,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
|
||||
) -> Union[
|
||||
Callable[
|
||||
[Union[Callable[P, Awaitable[T]], Callable[P, T]]],
|
||||
@@ -119,6 +120,10 @@ def task(
|
||||
await add_one.ainvoke([1, 2, 3]) # Returns [2, 3, 4]
|
||||
```
|
||||
"""
|
||||
if isinstance(retry, RetryPolicy):
|
||||
retry_policies: Optional[Sequence[RetryPolicy]] = (retry,)
|
||||
else:
|
||||
retry_policies = retry
|
||||
|
||||
def decorator(
|
||||
func: Union[Callable[P, Awaitable[T]], Callable[P, T]],
|
||||
@@ -137,7 +142,7 @@ def task(
|
||||
# handle regular functions / partials / callable classes, etc.
|
||||
func.__name__ = name
|
||||
|
||||
call_func = functools.partial(call, func, retry=retry)
|
||||
call_func = functools.partial(call, func, retry=retry_policies)
|
||||
object.__setattr__(call_func, "_is_pregel_task", True)
|
||||
return functools.update_wrapper(call_func, func)
|
||||
|
||||
@@ -429,8 +434,7 @@ class entrypoint:
|
||||
[
|
||||
ChannelWriteEntry(END, mapper=_pluck_return_value),
|
||||
ChannelWriteEntry(PREVIOUS, mapper=_pluck_save_value),
|
||||
],
|
||||
tags=[TAG_HIDDEN],
|
||||
]
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
from inspect import (
|
||||
isfunction,
|
||||
ismethod,
|
||||
@@ -178,7 +177,7 @@ class Branch(NamedTuple):
|
||||
],
|
||||
) -> Runnable:
|
||||
if reader:
|
||||
value = await asyncio.to_thread(reader, config)
|
||||
value = reader(config)
|
||||
# passthrough additional keys from node to branch
|
||||
# only doable when using dict states
|
||||
if (
|
||||
|
||||
@@ -366,16 +366,14 @@ class CompiledGraph(Pregel):
|
||||
self.nodes[key] = (
|
||||
PregelNode(channels=[], triggers=[], metadata=node.metadata)
|
||||
| node.runnable
|
||||
| ChannelWrite([ChannelWriteEntry(key)], tags=[TAG_HIDDEN])
|
||||
| ChannelWrite([ChannelWriteEntry(key)])
|
||||
)
|
||||
cast(list[str], self.stream_channels).append(key)
|
||||
|
||||
def attach_edge(self, start: str, end: str) -> None:
|
||||
if end == END:
|
||||
# publish to end channel
|
||||
self.nodes[start].writers.append(
|
||||
ChannelWrite([ChannelWriteEntry(END)], tags=[TAG_HIDDEN])
|
||||
)
|
||||
self.nodes[start].writers.append(ChannelWrite([ChannelWriteEntry(END)]))
|
||||
else:
|
||||
# subscribe to start channel
|
||||
self.nodes[end].triggers.append(start)
|
||||
@@ -393,10 +391,7 @@ class CompiledGraph(Pregel):
|
||||
)
|
||||
for p in packets
|
||||
]
|
||||
return ChannelWrite(
|
||||
cast(Sequence[Union[ChannelWriteEntry, Send]], writes),
|
||||
tags=[TAG_HIDDEN],
|
||||
)
|
||||
return ChannelWrite(cast(Sequence[Union[ChannelWriteEntry, Send]], writes))
|
||||
|
||||
# add hidden start node
|
||||
if start == START and start not in self.nodes:
|
||||
|
||||
@@ -43,8 +43,8 @@ from langgraph.constants import (
|
||||
MISSING,
|
||||
NS_END,
|
||||
NS_SEP,
|
||||
SELF,
|
||||
TAG_HIDDEN,
|
||||
TASKS,
|
||||
)
|
||||
from langgraph.errors import (
|
||||
ErrorCode,
|
||||
@@ -77,9 +77,9 @@ from langgraph.pregel.write import (
|
||||
)
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import All, Checkpointer, Command, RetryPolicy
|
||||
from langgraph.utils.fields import get_field_default
|
||||
from langgraph.utils.fields import get_field_default, get_update_as_tuples
|
||||
from langgraph.utils.pydantic import create_model
|
||||
from langgraph.utils.runnable import RunnableCallable, RunnableLike, coerce_to_runnable
|
||||
from langgraph.utils.runnable import RunnableLike, coerce_to_runnable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -109,7 +109,7 @@ class StateNodeSpec(NamedTuple):
|
||||
runnable: Runnable
|
||||
metadata: Optional[dict[str, Any]]
|
||||
input: Type[Any]
|
||||
retry_policy: Optional[RetryPolicy]
|
||||
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]]
|
||||
ends: Optional[Union[tuple[str, ...], dict[str, str]]] = EMPTY_SEQ
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ class StateGraph(Graph):
|
||||
*,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
input: Optional[Type[Any]] = None,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
|
||||
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
|
||||
) -> Self:
|
||||
"""Adds a new node to the state graph.
|
||||
@@ -276,7 +276,7 @@ class StateGraph(Graph):
|
||||
*,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
input: Optional[Type[Any]] = None,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
|
||||
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
|
||||
) -> Self:
|
||||
"""Adds a new node to the state graph.
|
||||
@@ -300,7 +300,7 @@ class StateGraph(Graph):
|
||||
*,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
input: Optional[Type[Any]] = None,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
|
||||
destinations: Optional[Union[dict[str, str], tuple[str, ...]]] = None,
|
||||
) -> Self:
|
||||
"""Adds a new node to the state graph.
|
||||
@@ -312,7 +312,8 @@ class StateGraph(Graph):
|
||||
action (Optional[RunnableLike]): The action associated with the node. (default: None)
|
||||
metadata (Optional[dict[str, Any]]): The metadata associated with the node. (default: None)
|
||||
input (Optional[Type[Any]]): The input schema for the node. (default: the graph's input schema)
|
||||
retry (Optional[RetryPolicy]): The policy for retrying the node. (default: None)
|
||||
retry (Optional[Union[RetryPolicy, Sequence[RetryPolicy]]]): The policy for retrying the node. (default: None)
|
||||
If a sequence is provided, the first matching policy will be applied.
|
||||
destinations (Optional[Union[dict[str, str], tuple[str, ...]]]): Destinations that indicate where a node can route to.
|
||||
This is useful for edgeless graphs with nodes that return `Command` objects.
|
||||
If a dict is provided, the keys will be used as the target node names and the values will be used as the labels for the edges.
|
||||
@@ -638,6 +639,7 @@ class StateGraph(Graph):
|
||||
|
||||
compiled = CompiledStateGraph(
|
||||
builder=self,
|
||||
schema_to_mapper={},
|
||||
config_type=self.config_schema,
|
||||
input_model=(
|
||||
self.input
|
||||
@@ -669,10 +671,6 @@ class StateGraph(Graph):
|
||||
for key, node in self.nodes.items():
|
||||
compiled.attach_node(key, node)
|
||||
|
||||
compiled.attach_branch(START, SELF, CONTROL_BRANCH, with_reader=False)
|
||||
for key, node in self.nodes.items():
|
||||
compiled.attach_branch(key, SELF, CONTROL_BRANCH, with_reader=False)
|
||||
|
||||
for start, end in self.edges:
|
||||
compiled.attach_edge(start, end)
|
||||
|
||||
@@ -688,6 +686,16 @@ class StateGraph(Graph):
|
||||
|
||||
class CompiledStateGraph(CompiledGraph):
|
||||
builder: StateGraph
|
||||
schema_to_mapper: dict[Type[Any], Optional[Callable[[Any], Any]]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
schema_to_mapper: dict[Type[Any], Optional[Callable[[Any], Any]]],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.schema_to_mapper = schema_to_mapper
|
||||
|
||||
def get_input_schema(
|
||||
self, config: Optional[RunnableConfig] = None
|
||||
@@ -723,28 +731,6 @@ class CompiledStateGraph(CompiledGraph):
|
||||
if is_writable_managed_value(v)
|
||||
]
|
||||
|
||||
def _get_root(input: Any) -> Optional[Sequence[tuple[str, Any]]]:
|
||||
if isinstance(input, Command):
|
||||
if input.graph == Command.PARENT:
|
||||
return ()
|
||||
return input._update_as_tuples()
|
||||
elif (
|
||||
isinstance(input, (list, tuple))
|
||||
and input
|
||||
and any(isinstance(i, Command) for i in input)
|
||||
):
|
||||
updates: list[tuple[str, Any]] = []
|
||||
for i in input:
|
||||
if isinstance(i, Command):
|
||||
if i.graph == Command.PARENT:
|
||||
continue
|
||||
updates.extend(i._update_as_tuples())
|
||||
else:
|
||||
updates.append(("__root__", i))
|
||||
return updates
|
||||
elif input is not None:
|
||||
return [("__root__", input)]
|
||||
|
||||
def _get_updates(
|
||||
input: Union[None, dict, Any],
|
||||
) -> Optional[Sequence[tuple[str, Any]]]:
|
||||
@@ -775,32 +761,7 @@ class CompiledStateGraph(CompiledGraph):
|
||||
updates.extend(_get_updates(i) or ())
|
||||
return updates
|
||||
elif (t := type(input)) and get_type_hints(t):
|
||||
# Pydantic v2
|
||||
if isinstance(input, BaseModelV1):
|
||||
keep: Optional[set[str]] = input.__fields_set__
|
||||
defaults = {k: v.default for k, v in t.__fields__.items()}
|
||||
elif isinstance(input, BaseModel):
|
||||
keep = input.model_fields_set
|
||||
defaults = {k: v.default for k, v in input.model_fields.items()}
|
||||
# Pydantic v1
|
||||
else:
|
||||
keep = None
|
||||
defaults = {}
|
||||
|
||||
# NOTE: This behavior for Pydantic is somewhat inelegant,
|
||||
# but we keep around for backwards compatibility
|
||||
# if input is a Pydantic model, only update values
|
||||
# that are different from the default values or in the keep set
|
||||
return [
|
||||
(k, value)
|
||||
for k in output_keys
|
||||
if (value := getattr(input, k, MISSING)) is not MISSING
|
||||
and (
|
||||
value is not None
|
||||
or defaults.get(k, MISSING) is not None
|
||||
or (keep is not None and k in keep)
|
||||
)
|
||||
]
|
||||
return get_update_as_tuples(input, output_keys)
|
||||
else:
|
||||
msg = create_error_message(
|
||||
message=f"Expected dict, got {input}",
|
||||
@@ -813,6 +774,7 @@ class CompiledStateGraph(CompiledGraph):
|
||||
ChannelWriteTupleEntry(
|
||||
mapper=_get_root if output_keys == ["__root__"] else _get_updates
|
||||
),
|
||||
ChannelWriteTupleEntry(mapper=_control_branch),
|
||||
)
|
||||
|
||||
# add node and output channel
|
||||
@@ -821,12 +783,21 @@ class CompiledStateGraph(CompiledGraph):
|
||||
tags=[TAG_HIDDEN],
|
||||
triggers=[START],
|
||||
channels=[START],
|
||||
writers=[ChannelWrite(write_entries, tags=[TAG_HIDDEN])],
|
||||
writers=[ChannelWrite(write_entries)],
|
||||
)
|
||||
elif node is not None:
|
||||
input_schema = node.input if node else self.builder.schema
|
||||
input_values = {k: k for k in self.builder.schemas[input_schema]}
|
||||
is_single_input = len(input_values) == 1 and "__root__" in input_values
|
||||
if input_schema in self.schema_to_mapper:
|
||||
mapper = self.schema_to_mapper[input_schema]
|
||||
else:
|
||||
mapper = _pick_mapper(
|
||||
list(input_values),
|
||||
input_schema,
|
||||
self.builder.type_hints[input_schema],
|
||||
)
|
||||
self.schema_to_mapper[input_schema] = mapper
|
||||
|
||||
branch_channel = CHANNEL_BRANCH_TO.format(key)
|
||||
self.channels[branch_channel] = EphemeralValue(Any, guard=False)
|
||||
@@ -835,13 +806,9 @@ class CompiledStateGraph(CompiledGraph):
|
||||
# read state keys and managed values
|
||||
channels=(list(input_values) if is_single_input else input_values),
|
||||
# coerce state dict to schema class (eg. pydantic model)
|
||||
mapper=_pick_mapper(
|
||||
list(input_values),
|
||||
input_schema,
|
||||
self.builder.type_hints[input_schema],
|
||||
),
|
||||
mapper=mapper,
|
||||
# publish to state keys
|
||||
writers=[ChannelWrite(write_entries, tags=[TAG_HIDDEN])],
|
||||
writers=[ChannelWrite(write_entries)],
|
||||
metadata=node.metadata,
|
||||
retry_policy=node.retry_policy,
|
||||
bound=node.runnable,
|
||||
@@ -867,9 +834,7 @@ class CompiledStateGraph(CompiledGraph):
|
||||
# publish to channel
|
||||
for start in starts:
|
||||
self.nodes[start].writers.append(
|
||||
ChannelWrite(
|
||||
(ChannelWriteEntry(channel_name, start),), tags=[TAG_HIDDEN]
|
||||
)
|
||||
ChannelWrite((ChannelWriteEntry(channel_name, start),))
|
||||
)
|
||||
|
||||
def attach_branch(
|
||||
@@ -900,19 +865,33 @@ class CompiledStateGraph(CompiledGraph):
|
||||
config, cast(Sequence[Union[Send, ChannelWriteEntry]], writes)
|
||||
)
|
||||
|
||||
schema = branch.input_schema or (
|
||||
self.builder.nodes[start].input
|
||||
if start in self.builder.nodes
|
||||
else self.builder.schema
|
||||
)
|
||||
if with_reader:
|
||||
# get schema
|
||||
schema = branch.input_schema or (
|
||||
self.builder.nodes[start].input
|
||||
if start in self.builder.nodes
|
||||
else self.builder.schema
|
||||
)
|
||||
channels = list(self.builder.schemas[schema])
|
||||
# get mapper
|
||||
if schema in self.schema_to_mapper:
|
||||
mapper = self.schema_to_mapper[schema]
|
||||
else:
|
||||
mapper = _pick_mapper(channels, schema, self.builder.type_hints[schema])
|
||||
self.schema_to_mapper[schema] = mapper
|
||||
# create reader
|
||||
reader: Optional[Callable[[RunnableConfig], Any]] = partial(
|
||||
ChannelRead.do_read,
|
||||
select=channels[0] if channels == ["__root__"] else channels,
|
||||
fresh=True,
|
||||
# coerce state dict to schema class (eg. pydantic model)
|
||||
mapper=mapper,
|
||||
)
|
||||
else:
|
||||
reader = None
|
||||
|
||||
# attach branch publisher
|
||||
self.nodes[start].writers.append(
|
||||
branch.run(
|
||||
branch_writer,
|
||||
_get_state_reader(self.builder, schema) if with_reader else None,
|
||||
)
|
||||
)
|
||||
self.nodes[start].writers.append(branch.run(branch_writer, reader))
|
||||
|
||||
# attach then subscriber
|
||||
if branch.then and branch.then != END:
|
||||
@@ -927,9 +906,7 @@ class CompiledStateGraph(CompiledGraph):
|
||||
for end in ends:
|
||||
if end != END:
|
||||
self.nodes[end].writers.append(
|
||||
ChannelWrite(
|
||||
[ChannelWriteEntry(channel_name, end)], tags=[TAG_HIDDEN]
|
||||
)
|
||||
ChannelWrite((ChannelWriteEntry(channel_name, end),))
|
||||
)
|
||||
|
||||
def _migrate_checkpoint(self, checkpoint: Checkpoint) -> None:
|
||||
@@ -1037,20 +1014,6 @@ class CompiledStateGraph(CompiledGraph):
|
||||
seen[INTERRUPT].pop(k, MISSING)
|
||||
|
||||
|
||||
def _get_state_reader(
|
||||
builder: StateGraph, schema: Type[Any]
|
||||
) -> Callable[[RunnableConfig], Any]:
|
||||
state_keys = list(builder.channels)
|
||||
select = list(builder.schemas[schema])
|
||||
return partial(
|
||||
ChannelRead.do_read,
|
||||
select=select[0] if select == ["__root__"] else select,
|
||||
fresh=True,
|
||||
# coerce state dict to schema class (eg. pydantic model)
|
||||
mapper=_pick_mapper(state_keys, schema, builder.type_hints[schema]),
|
||||
)
|
||||
|
||||
|
||||
def _pick_mapper(
|
||||
state_keys: Sequence[str], schema: Type[Any], type_hints: Optional[dict[str, Any]]
|
||||
) -> Optional[Callable[[Any], Any]]:
|
||||
@@ -1068,9 +1031,9 @@ def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
|
||||
return schema(**input)
|
||||
|
||||
|
||||
def _control_branch(value: Any) -> Sequence[Union[str, Send]]:
|
||||
def _control_branch(value: Any) -> Sequence[tuple[str, Any]]:
|
||||
if isinstance(value, Send):
|
||||
return [value]
|
||||
return ((TASKS, value),)
|
||||
commands: list[Command] = []
|
||||
if isinstance(value, Command):
|
||||
commands.append(value)
|
||||
@@ -1078,51 +1041,45 @@ def _control_branch(value: Any) -> Sequence[Union[str, Send]]:
|
||||
for cmd in value:
|
||||
if isinstance(cmd, Command):
|
||||
commands.append(cmd)
|
||||
rtn: list[Union[str, Send]] = []
|
||||
rtn: list[tuple[str, Any]] = []
|
||||
for command in commands:
|
||||
if command.graph == Command.PARENT:
|
||||
raise ParentCommand(command)
|
||||
if isinstance(command.goto, Send):
|
||||
rtn.append(command.goto)
|
||||
rtn.append((TASKS, command.goto))
|
||||
elif isinstance(command.goto, str):
|
||||
rtn.append(command.goto)
|
||||
rtn.append((CHANNEL_BRANCH_TO.format(command.goto), None))
|
||||
else:
|
||||
rtn.extend(command.goto)
|
||||
rtn.extend(
|
||||
(TASKS, go)
|
||||
if isinstance(go, Send)
|
||||
else (CHANNEL_BRANCH_TO.format(go), None)
|
||||
for go in command.goto
|
||||
)
|
||||
return rtn
|
||||
|
||||
|
||||
async def _acontrol_branch(value: Any) -> Sequence[Union[str, Send]]:
|
||||
if isinstance(value, Send):
|
||||
return [value]
|
||||
commands: list[Command] = []
|
||||
if isinstance(value, Command):
|
||||
commands.append(value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for cmd in value:
|
||||
if isinstance(cmd, Command):
|
||||
commands.append(cmd)
|
||||
rtn: list[Union[str, Send]] = []
|
||||
for command in commands:
|
||||
if command.graph == Command.PARENT:
|
||||
raise ParentCommand(command)
|
||||
if isinstance(command.goto, Send):
|
||||
rtn.append(command.goto)
|
||||
elif isinstance(command.goto, str):
|
||||
rtn.append(command.goto)
|
||||
else:
|
||||
rtn.extend(command.goto)
|
||||
return rtn
|
||||
|
||||
|
||||
CONTROL_BRANCH_PATH = RunnableCallable(
|
||||
_control_branch,
|
||||
_acontrol_branch,
|
||||
tags=[TAG_HIDDEN],
|
||||
trace=False,
|
||||
recurse=False,
|
||||
func_accepts_config=False,
|
||||
)
|
||||
CONTROL_BRANCH = Branch(CONTROL_BRANCH_PATH, None)
|
||||
def _get_root(input: Any) -> Optional[Sequence[tuple[str, Any]]]:
|
||||
if isinstance(input, Command):
|
||||
if input.graph == Command.PARENT:
|
||||
return ()
|
||||
return input._update_as_tuples()
|
||||
elif (
|
||||
isinstance(input, (list, tuple))
|
||||
and input
|
||||
and any(isinstance(i, Command) for i in input)
|
||||
):
|
||||
updates: list[tuple[str, Any]] = []
|
||||
for i in input:
|
||||
if isinstance(i, Command):
|
||||
if i.graph == Command.PARENT:
|
||||
continue
|
||||
updates.extend(i._update_as_tuples())
|
||||
else:
|
||||
updates.append(("__root__", i))
|
||||
return updates
|
||||
elif input is not None:
|
||||
return [("__root__", input)]
|
||||
|
||||
|
||||
def _get_channels(
|
||||
|
||||
@@ -66,6 +66,7 @@ from langgraph.constants import (
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_STREAM_WRITER,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
END,
|
||||
ERROR,
|
||||
INPUT,
|
||||
@@ -498,8 +499,8 @@ class Pregel(PregelProtocol):
|
||||
store: Optional[BaseStore] = None
|
||||
"""Memory store to use for SharedValues. Defaults to None."""
|
||||
|
||||
retry_policy: Optional[RetryPolicy] = None
|
||||
"""Retry policy to use when running tasks. Set to None to disable."""
|
||||
retry_policy: Optional[Sequence[RetryPolicy]] = None
|
||||
"""Retry policies to use when running tasks. Set to None to disable."""
|
||||
|
||||
config_type: Optional[Type[Any]] = None
|
||||
|
||||
@@ -528,7 +529,7 @@ class Pregel(PregelProtocol):
|
||||
debug: Optional[bool] = None,
|
||||
checkpointer: Optional[BaseCheckpointSaver] = None,
|
||||
store: Optional[BaseStore] = None,
|
||||
retry_policy: Optional[RetryPolicy] = None,
|
||||
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
|
||||
config_type: Optional[Type[Any]] = None,
|
||||
input_model: Optional[Type[BaseModel]] = None,
|
||||
config: Optional[RunnableConfig] = None,
|
||||
@@ -548,7 +549,10 @@ class Pregel(PregelProtocol):
|
||||
self.debug = debug if debug is not None else get_debug()
|
||||
self.checkpointer = checkpointer
|
||||
self.store = store
|
||||
self.retry_policy = retry_policy
|
||||
if isinstance(retry_policy, RetryPolicy):
|
||||
self.retry_policy: Sequence[RetryPolicy] = (retry_policy,)
|
||||
else:
|
||||
self.retry_policy = retry_policy
|
||||
self.config_type = config_type
|
||||
self.input_model = input_model
|
||||
self.config = config
|
||||
@@ -1041,6 +1045,9 @@ class Pregel(PregelProtocol):
|
||||
config = merge_configs(
|
||||
config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
|
||||
)
|
||||
thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
|
||||
if not isinstance(thread_id, str):
|
||||
config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
|
||||
|
||||
saved = checkpointer.get_tuple(config)
|
||||
return self._prepare_state_snapshot(
|
||||
@@ -1080,6 +1087,9 @@ class Pregel(PregelProtocol):
|
||||
config = merge_configs(
|
||||
config, {CONF: {CONFIG_KEY_CHECKPOINT_NS: recast_checkpoint_ns(ns)}}
|
||||
)
|
||||
thread_id = config[CONF][CONFIG_KEY_THREAD_ID]
|
||||
if not isinstance(thread_id, str):
|
||||
config[CONF][CONFIG_KEY_THREAD_ID] = str(thread_id)
|
||||
|
||||
saved = await checkpointer.aget_tuple(config)
|
||||
return await self._aprepare_state_snapshot(
|
||||
@@ -1125,7 +1135,12 @@ class Pregel(PregelProtocol):
|
||||
config = merge_configs(
|
||||
self.config,
|
||||
config,
|
||||
{CONF: {CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns}},
|
||||
{
|
||||
CONF: {
|
||||
CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
|
||||
CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
|
||||
}
|
||||
},
|
||||
)
|
||||
# eagerly consume list() to avoid holding up the db cursor
|
||||
for checkpoint_tuple in list(
|
||||
@@ -1172,7 +1187,12 @@ class Pregel(PregelProtocol):
|
||||
config = merge_configs(
|
||||
self.config,
|
||||
config,
|
||||
{CONF: {CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns}},
|
||||
{
|
||||
CONF: {
|
||||
CONFIG_KEY_CHECKPOINT_NS: checkpoint_ns,
|
||||
CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID]),
|
||||
}
|
||||
},
|
||||
)
|
||||
# eagerly consume list() to avoid holding up the db cursor
|
||||
for checkpoint_tuple in [
|
||||
@@ -1592,7 +1612,9 @@ class Pregel(PregelProtocol):
|
||||
|
||||
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
||||
|
||||
current_config = config
|
||||
current_config = patch_configurable(
|
||||
config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
|
||||
)
|
||||
for superstep in supersteps:
|
||||
current_config = perform_superstep(current_config, superstep)
|
||||
return current_config
|
||||
@@ -2002,7 +2024,9 @@ class Pregel(PregelProtocol):
|
||||
await checkpointer.aput_writes(next_config, push_writes, task_id)
|
||||
return patch_checkpoint_map(next_config, saved.metadata if saved else None)
|
||||
|
||||
current_config = config
|
||||
current_config = patch_configurable(
|
||||
config, {CONFIG_KEY_THREAD_ID: str(config[CONF][CONFIG_KEY_THREAD_ID])}
|
||||
)
|
||||
for superstep in supersteps:
|
||||
current_config = await aperform_superstep(current_config, superstep)
|
||||
return current_config
|
||||
@@ -2547,11 +2571,12 @@ class Pregel(PregelProtocol):
|
||||
do_stream = (
|
||||
next(
|
||||
(
|
||||
cast(_StreamingCallbackHandler, h)
|
||||
True
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
and not isinstance(h, StreamMessagesHandler)
|
||||
),
|
||||
None,
|
||||
False,
|
||||
)
|
||||
if _StreamingCallbackHandler is not None
|
||||
else False
|
||||
@@ -2621,7 +2646,7 @@ class Pregel(PregelProtocol):
|
||||
),
|
||||
put_writes=weakref.WeakMethod(loop.put_writes),
|
||||
schedule_task=weakref.WeakMethod(loop.accept_push),
|
||||
use_astream=do_stream is not None,
|
||||
use_astream=do_stream,
|
||||
node_finished=config[CONF].get(CONFIG_KEY_NODE_FINISHED),
|
||||
)
|
||||
# enable subgraph streaming
|
||||
|
||||
@@ -3,13 +3,13 @@ import itertools
|
||||
import sys
|
||||
import threading
|
||||
from collections import defaultdict, deque
|
||||
from copy import copy
|
||||
from functools import partial
|
||||
from hashlib import sha1
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Literal,
|
||||
Mapping,
|
||||
NamedTuple,
|
||||
@@ -49,6 +49,7 @@ from langgraph.constants import (
|
||||
EMPTY_SEQ,
|
||||
ERROR,
|
||||
INTERRUPT,
|
||||
MISSING,
|
||||
NO_WRITES,
|
||||
NS_END,
|
||||
NS_SEP,
|
||||
@@ -63,12 +64,12 @@ from langgraph.constants import (
|
||||
TASKS,
|
||||
Send,
|
||||
)
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
from langgraph.errors import InvalidUpdateError
|
||||
from langgraph.managed.base import ManagedValueMapping
|
||||
from langgraph.pregel.call import get_runnable_for_task
|
||||
from langgraph.pregel.io import read_channel, read_channels
|
||||
from langgraph.pregel.io import read_channels
|
||||
from langgraph.pregel.log import logger
|
||||
from langgraph.pregel.read import PregelNode
|
||||
from langgraph.pregel.read import INPUT_CACHE_KEY_TYPE, PregelNode
|
||||
from langgraph.store.base import BaseStore
|
||||
from langgraph.types import (
|
||||
All,
|
||||
@@ -115,7 +116,7 @@ class Call:
|
||||
|
||||
func: Callable
|
||||
input: Any
|
||||
retry: Optional[RetryPolicy]
|
||||
retry: Optional[Sequence[RetryPolicy]]
|
||||
callbacks: Callbacks
|
||||
|
||||
def __init__(
|
||||
@@ -123,7 +124,7 @@ class Call:
|
||||
func: Callable,
|
||||
input: Any,
|
||||
*,
|
||||
retry: Optional[RetryPolicy],
|
||||
retry: Optional[Sequence[RetryPolicy]],
|
||||
callbacks: Callbacks,
|
||||
) -> None:
|
||||
self.func = func
|
||||
@@ -423,6 +424,7 @@ def prepare_next_tasks(
|
||||
are the tasks themselves. This is the union of all PUSH tasks (Sends)
|
||||
and PULL tasks (nodes triggered by edges).
|
||||
"""
|
||||
input_cache: dict[INPUT_CACHE_KEY_TYPE, Any] = {}
|
||||
checkpoint_id_bytes = binascii.unhexlify(checkpoint["id"].replace("-", ""))
|
||||
null_version = checkpoint_null_version(checkpoint)
|
||||
tasks: list[Union[PregelTask, PregelExecutableTask]] = []
|
||||
@@ -444,6 +446,7 @@ def prepare_next_tasks(
|
||||
store=store,
|
||||
checkpointer=checkpointer,
|
||||
manager=manager,
|
||||
input_cache=input_cache,
|
||||
):
|
||||
tasks.append(task)
|
||||
|
||||
@@ -486,6 +489,7 @@ def prepare_next_tasks(
|
||||
store=store,
|
||||
checkpointer=checkpointer,
|
||||
manager=manager,
|
||||
input_cache=input_cache,
|
||||
):
|
||||
tasks.append(task)
|
||||
return {t.id: t for t in tasks}
|
||||
@@ -511,6 +515,7 @@ def prepare_single_task(
|
||||
store: Optional[BaseStore] = None,
|
||||
checkpointer: Optional[BaseCheckpointSaver] = None,
|
||||
manager: Union[None, ParentRunManager, AsyncParentRunManager] = None,
|
||||
input_cache: Optional[dict[INPUT_CACHE_KEY_TYPE, Any]] = None,
|
||||
) -> Union[None, PregelTask, PregelExecutableTask]:
|
||||
"""Prepares a single task for the next Pregel step, given a task path, which
|
||||
uniquely identifies a PUSH or PULL task within the graph."""
|
||||
@@ -729,11 +734,15 @@ def prepare_single_task(
|
||||
):
|
||||
triggers = tuple(sorted(proc.triggers))
|
||||
try:
|
||||
val = next(
|
||||
_proc_input(proc, managed, channels, for_execution=for_execution)
|
||||
val = _proc_input(
|
||||
proc,
|
||||
managed,
|
||||
channels,
|
||||
for_execution=for_execution,
|
||||
input_cache=input_cache,
|
||||
)
|
||||
except StopIteration:
|
||||
return
|
||||
if val is MISSING:
|
||||
return
|
||||
except Exception as exc:
|
||||
if SUPPORTS_EXC_NOTES:
|
||||
exc.add_note(
|
||||
@@ -926,34 +935,32 @@ def _proc_input(
|
||||
channels: Mapping[str, BaseChannel],
|
||||
*,
|
||||
for_execution: bool,
|
||||
) -> Iterator[Any]:
|
||||
input_cache: Optional[dict[INPUT_CACHE_KEY_TYPE, Any]],
|
||||
) -> Any:
|
||||
"""Prepare input for a PULL task, based on the process's channels and triggers."""
|
||||
# if in cache return shallow copy
|
||||
if input_cache is not None and proc.input_cache_key in input_cache:
|
||||
return copy(input_cache[proc.input_cache_key])
|
||||
# If all trigger channels subscribed by this process are not empty
|
||||
# then invoke the process with the values of all non-empty channels
|
||||
if isinstance(proc.channels, dict):
|
||||
try:
|
||||
val: dict[str, Any] = {}
|
||||
for k, chan in proc.channels.items():
|
||||
if chan in proc.triggers:
|
||||
val[k] = read_channel(channels, chan, catch=False)
|
||||
elif chan in channels:
|
||||
try:
|
||||
val[k] = read_channel(channels, chan, catch=False)
|
||||
except EmptyChannelError:
|
||||
continue
|
||||
else:
|
||||
val[k] = managed[k]()
|
||||
except EmptyChannelError:
|
||||
return
|
||||
val: dict[str, Any] = {}
|
||||
for k, chan in proc.channels.items():
|
||||
if chan in channels:
|
||||
if channels[chan].is_available():
|
||||
val[k] = channels[chan].get()
|
||||
else:
|
||||
val[k] = managed[k]()
|
||||
elif isinstance(proc.channels, list):
|
||||
for chan in proc.channels:
|
||||
try:
|
||||
val = read_channel(channels, chan, catch=False)
|
||||
break
|
||||
except EmptyChannelError:
|
||||
pass
|
||||
if chan in channels:
|
||||
if channels[chan].is_available():
|
||||
val = channels[chan].get()
|
||||
break
|
||||
else:
|
||||
val[k] = managed[k]()
|
||||
else:
|
||||
return
|
||||
return MISSING
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Invalid channels type, expected list or dict, got {proc.channels}"
|
||||
@@ -963,7 +970,11 @@ def _proc_input(
|
||||
if for_execution and proc.mapper is not None:
|
||||
val = proc.mapper(val)
|
||||
|
||||
yield val
|
||||
# Cache the input value
|
||||
if input_cache is not None:
|
||||
input_cache[proc.input_cache_key] = val
|
||||
|
||||
return val
|
||||
|
||||
|
||||
def _uuid5_str(namespace: bytes, *parts: str) -> str:
|
||||
|
||||
@@ -5,12 +5,12 @@ import functools
|
||||
import inspect
|
||||
import sys
|
||||
import types
|
||||
from typing import Any, Callable, Generator, Generic, Optional, TypeVar, cast
|
||||
from typing import Any, Callable, Generator, Generic, Optional, Sequence, TypeVar, cast
|
||||
|
||||
from langchain_core.runnables import Runnable
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from langgraph.constants import CONF, CONFIG_KEY_CALL, RETURN, TAG_HIDDEN
|
||||
from langgraph.constants import CONF, CONFIG_KEY_CALL, RETURN
|
||||
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.types import RetryPolicy
|
||||
from langgraph.utils.config import get_config
|
||||
@@ -197,7 +197,7 @@ def get_runnable_for_task(func: Callable[..., Any]) -> RunnableSeq:
|
||||
)
|
||||
seq = RunnableSeq(
|
||||
run,
|
||||
ChannelWrite([ChannelWriteEntry(RETURN)], tags=[TAG_HIDDEN]),
|
||||
ChannelWrite([ChannelWriteEntry(RETURN)]),
|
||||
name=name,
|
||||
trace_inputs=functools.partial(
|
||||
_explode_args_trace_inputs, inspect.signature(func)
|
||||
@@ -224,7 +224,7 @@ class SyncAsyncFuture(Generic[T], concurrent.futures.Future[T]):
|
||||
def call(
|
||||
func: Callable[P, T],
|
||||
*args: Any,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Sequence[RetryPolicy]] = None,
|
||||
**kwargs: Any,
|
||||
) -> SyncAsyncFuture[T]:
|
||||
config = get_config()
|
||||
|
||||
@@ -52,6 +52,7 @@ from langgraph.constants import (
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
EMPTY_SEQ,
|
||||
ERROR,
|
||||
INPUT,
|
||||
@@ -285,6 +286,12 @@ class PregelLoop(LoopProtocol):
|
||||
)
|
||||
else:
|
||||
self.checkpoint_config = self.config
|
||||
if thread_id := self.checkpoint_config[CONF].get(CONFIG_KEY_THREAD_ID):
|
||||
if not isinstance(thread_id, str):
|
||||
self.checkpoint_config = patch_configurable(
|
||||
self.checkpoint_config,
|
||||
{CONFIG_KEY_THREAD_ID: str(thread_id)},
|
||||
)
|
||||
self.checkpoint_ns = (
|
||||
tuple(cast(str, self.config[CONF][CONFIG_KEY_CHECKPOINT_NS]).split(NS_SEP))
|
||||
if self.config[CONF].get(CONFIG_KEY_CHECKPOINT_NS)
|
||||
@@ -1043,16 +1050,16 @@ class SyncPregelLoop(PregelLoop, ContextManager):
|
||||
saved = None
|
||||
if saved is None:
|
||||
saved = CheckpointTuple(
|
||||
self.config, empty_checkpoint(), {"step": -2}, None, []
|
||||
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
|
||||
)
|
||||
elif self._migrate_checkpoint is not None:
|
||||
self._migrate_checkpoint(saved.checkpoint)
|
||||
self.checkpoint_config = {
|
||||
**self.config,
|
||||
**self.checkpoint_config,
|
||||
**saved.config,
|
||||
CONF: {
|
||||
CONFIG_KEY_CHECKPOINT_NS: "",
|
||||
**self.config.get(CONF, {}),
|
||||
**self.checkpoint_config.get(CONF, {}),
|
||||
**saved.config.get(CONF, {}),
|
||||
},
|
||||
}
|
||||
@@ -1193,16 +1200,16 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
|
||||
saved = None
|
||||
if saved is None:
|
||||
saved = CheckpointTuple(
|
||||
self.config, empty_checkpoint(), {"step": -2}, None, []
|
||||
self.checkpoint_config, empty_checkpoint(), {"step": -2}, None, []
|
||||
)
|
||||
elif self._migrate_checkpoint is not None:
|
||||
self._migrate_checkpoint(saved.checkpoint)
|
||||
self.checkpoint_config = {
|
||||
**self.config,
|
||||
**self.checkpoint_config,
|
||||
**saved.config,
|
||||
CONF: {
|
||||
CONFIG_KEY_CHECKPOINT_NS: "",
|
||||
**self.config.get(CONF, {}),
|
||||
**self.checkpoint_config.get(CONF, {}),
|
||||
**saved.config.get(CONF, {}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import ChatGenerationChunk, LLMResult
|
||||
|
||||
from langgraph.constants import NS_SEP, TAG_HIDDEN, TAG_NOSTREAM
|
||||
from langgraph.types import StreamChunk
|
||||
from langgraph.types import Command, StreamChunk
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
@@ -153,6 +153,17 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
if meta := self.metadata.pop(run_id, None):
|
||||
if isinstance(response, Command):
|
||||
response = response.update
|
||||
|
||||
if isinstance(response, Sequence) and any(
|
||||
isinstance(value, Command) for value in response
|
||||
):
|
||||
response = [
|
||||
value.update if isinstance(value, Command) else value
|
||||
for value in response
|
||||
]
|
||||
|
||||
if isinstance(response, BaseMessage):
|
||||
self._emit(meta, response, dedupe=True)
|
||||
elif isinstance(response, Sequence):
|
||||
|
||||
@@ -30,6 +30,7 @@ from langgraph.utils.config import merge_configs
|
||||
from langgraph.utils.runnable import RunnableCallable, RunnableSeq
|
||||
|
||||
READ_TYPE = Callable[[Union[str, Sequence[str]], bool], Union[Any, dict[str, Any]]]
|
||||
INPUT_CACHE_KEY_TYPE = tuple[Callable[..., Any], tuple[str, ...]]
|
||||
|
||||
|
||||
class ChannelRead(RunnableCallable):
|
||||
@@ -67,6 +68,7 @@ class ChannelRead(RunnableCallable):
|
||||
afunc=self._aread,
|
||||
tags=tags,
|
||||
name=None,
|
||||
trace=False,
|
||||
func_accepts_config=True,
|
||||
)
|
||||
self.fresh = fresh
|
||||
@@ -144,8 +146,8 @@ class PregelNode(Runnable):
|
||||
"""The main logic of the node. This will be invoked with the input from
|
||||
`channels`."""
|
||||
|
||||
retry_policy: Optional[RetryPolicy]
|
||||
"""The retry policy to use when invoking the node."""
|
||||
retry_policy: Optional[Sequence[RetryPolicy]]
|
||||
"""The retry policies to use when invoking the node."""
|
||||
|
||||
tags: Optional[Sequence[str]]
|
||||
"""Tags to attach to the node for tracing."""
|
||||
@@ -166,7 +168,7 @@ class PregelNode(Runnable):
|
||||
tags: Optional[list[str]] = None,
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
bound: Optional[Runnable[Any, Any]] = None,
|
||||
retry_policy: Optional[RetryPolicy] = None,
|
||||
retry_policy: Optional[Union[RetryPolicy, Sequence[RetryPolicy]]] = None,
|
||||
subgraphs: Optional[Sequence[PregelProtocol]] = None,
|
||||
) -> None:
|
||||
self.channels = channels
|
||||
@@ -174,7 +176,10 @@ class PregelNode(Runnable):
|
||||
self.mapper = mapper
|
||||
self.writers = writers or []
|
||||
self.bound = bound if bound is not None else DEFAULT_BOUND
|
||||
self.retry_policy = retry_policy
|
||||
if isinstance(retry_policy, RetryPolicy):
|
||||
self.retry_policy: Sequence[RetryPolicy] = (retry_policy,)
|
||||
else:
|
||||
self.retry_policy = retry_policy
|
||||
self.tags = tags
|
||||
self.metadata = metadata
|
||||
if subgraphs is not None:
|
||||
@@ -228,6 +233,17 @@ class PregelNode(Runnable):
|
||||
else:
|
||||
return self.bound
|
||||
|
||||
@cached_property
|
||||
def input_cache_key(self) -> INPUT_CACHE_KEY_TYPE:
|
||||
"""Get a cache key for the input to the node.
|
||||
This is used to avoid calculating the same input multiple times."""
|
||||
return (
|
||||
self.mapper,
|
||||
tuple(f"{key}:{value}" for key, value in self.channels.items())
|
||||
if isinstance(self.channels, dict)
|
||||
else tuple(self.channels),
|
||||
)
|
||||
|
||||
def join(self, channels: Sequence[str]) -> PregelNode:
|
||||
assert isinstance(channels, list) or isinstance(
|
||||
channels, tuple
|
||||
|
||||
@@ -22,12 +22,11 @@ SUPPORTS_EXC_NOTES = sys.version_info >= (3, 11)
|
||||
|
||||
def run_with_retry(
|
||||
task: PregelExecutableTask,
|
||||
retry_policy: Optional[RetryPolicy],
|
||||
retry_policy: Optional[Sequence[RetryPolicy]],
|
||||
configurable: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Run a task with retries."""
|
||||
retry_policy = task.retry_policy or retry_policy
|
||||
interval = retry_policy.initial_interval if retry_policy else 0
|
||||
attempts = 0
|
||||
config = task.config
|
||||
if configurable is not None:
|
||||
@@ -63,38 +62,39 @@ def run_with_retry(
|
||||
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
|
||||
if retry_policy is None:
|
||||
raise
|
||||
|
||||
# Check which retry policy applies to this exception
|
||||
matching_policy = None
|
||||
for policy in retry_policy:
|
||||
if _should_retry_on(policy, exc):
|
||||
matching_policy = policy
|
||||
break
|
||||
|
||||
if not matching_policy:
|
||||
raise
|
||||
|
||||
# increment attempts
|
||||
attempts += 1
|
||||
# check if we should retry
|
||||
if isinstance(retry_policy.retry_on, Sequence):
|
||||
if not isinstance(exc, tuple(retry_policy.retry_on)):
|
||||
raise
|
||||
elif isinstance(retry_policy.retry_on, type) and issubclass(
|
||||
retry_policy.retry_on, Exception
|
||||
):
|
||||
if not isinstance(exc, retry_policy.retry_on):
|
||||
raise
|
||||
elif callable(retry_policy.retry_on):
|
||||
if not retry_policy.retry_on(exc): # type: ignore[call-arg]
|
||||
raise
|
||||
else:
|
||||
raise TypeError(
|
||||
"retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
|
||||
)
|
||||
# check if we should give up
|
||||
if attempts >= retry_policy.max_attempts:
|
||||
if attempts >= matching_policy.max_attempts:
|
||||
raise
|
||||
# sleep before retrying
|
||||
interval = matching_policy.initial_interval
|
||||
# Apply backoff factor based on attempt count
|
||||
interval = min(
|
||||
retry_policy.max_interval,
|
||||
interval * retry_policy.backoff_factor,
|
||||
matching_policy.max_interval,
|
||||
interval * (matching_policy.backoff_factor ** (attempts - 1)),
|
||||
)
|
||||
time.sleep(
|
||||
interval + random.uniform(0, 1) if retry_policy.jitter else interval
|
||||
|
||||
# Apply jitter if configured
|
||||
sleep_time = (
|
||||
interval + random.uniform(0, 1) if matching_policy.jitter else interval
|
||||
)
|
||||
time.sleep(sleep_time)
|
||||
|
||||
# log the retry
|
||||
logger.info(
|
||||
f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
|
||||
f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
|
||||
exc_info=exc,
|
||||
)
|
||||
# signal subgraphs to resume (if available)
|
||||
@@ -103,13 +103,12 @@ def run_with_retry(
|
||||
|
||||
async def arun_with_retry(
|
||||
task: PregelExecutableTask,
|
||||
retry_policy: Optional[RetryPolicy],
|
||||
retry_policies: Optional[Sequence[RetryPolicy]],
|
||||
stream: bool = False,
|
||||
configurable: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Run a task asynchronously with retries."""
|
||||
retry_policy = task.retry_policy or retry_policy
|
||||
interval = retry_policy.initial_interval if retry_policy else 0
|
||||
retry_policies = task.retry_policy or retry_policies
|
||||
attempts = 0
|
||||
config = task.config
|
||||
if configurable is not None:
|
||||
@@ -149,41 +148,58 @@ async def arun_with_retry(
|
||||
except Exception as exc:
|
||||
if SUPPORTS_EXC_NOTES:
|
||||
exc.add_note(f"During task with name '{task.name}' and id '{task.id}'")
|
||||
if retry_policy is None:
|
||||
if retry_policies is None:
|
||||
raise
|
||||
|
||||
# Check which retry policy applies to this exception
|
||||
matching_policy = None
|
||||
for policy in retry_policies:
|
||||
if _should_retry_on(policy, exc):
|
||||
matching_policy = policy
|
||||
break
|
||||
|
||||
if not matching_policy:
|
||||
raise
|
||||
|
||||
# increment attempts
|
||||
attempts += 1
|
||||
# check if we should retry
|
||||
if isinstance(retry_policy.retry_on, Sequence):
|
||||
if not isinstance(exc, tuple(retry_policy.retry_on)):
|
||||
raise
|
||||
elif isinstance(retry_policy.retry_on, type) and issubclass(
|
||||
retry_policy.retry_on, Exception
|
||||
):
|
||||
if not isinstance(exc, retry_policy.retry_on):
|
||||
raise
|
||||
elif callable(retry_policy.retry_on):
|
||||
if not retry_policy.retry_on(exc): # type: ignore[call-arg]
|
||||
raise
|
||||
else:
|
||||
raise TypeError(
|
||||
"retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
|
||||
)
|
||||
# check if we should give up
|
||||
if attempts >= retry_policy.max_attempts:
|
||||
if attempts >= matching_policy.max_attempts:
|
||||
raise
|
||||
# sleep before retrying
|
||||
interval = matching_policy.initial_interval
|
||||
# Apply backoff factor based on attempt count
|
||||
interval = min(
|
||||
retry_policy.max_interval,
|
||||
interval * retry_policy.backoff_factor,
|
||||
matching_policy.max_interval,
|
||||
interval * (matching_policy.backoff_factor ** (attempts - 1)),
|
||||
)
|
||||
await asyncio.sleep(
|
||||
interval + random.uniform(0, 1) if retry_policy.jitter else interval
|
||||
|
||||
# Apply jitter if configured
|
||||
sleep_time = (
|
||||
interval + random.uniform(0, 1) if matching_policy.jitter else interval
|
||||
)
|
||||
await asyncio.sleep(sleep_time)
|
||||
|
||||
# log the retry
|
||||
logger.info(
|
||||
f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
|
||||
f"Retrying task {task.name} after {sleep_time:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}",
|
||||
exc_info=exc,
|
||||
)
|
||||
# signal subgraphs to resume (if available)
|
||||
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
|
||||
|
||||
|
||||
def _should_retry_on(retry_policy: RetryPolicy, exc: Exception) -> bool:
|
||||
"""Check if the given exception should be retried based on the retry policy."""
|
||||
if isinstance(retry_policy.retry_on, Sequence):
|
||||
return isinstance(exc, tuple(retry_policy.retry_on))
|
||||
elif isinstance(retry_policy.retry_on, type) and issubclass(
|
||||
retry_policy.retry_on, Exception
|
||||
):
|
||||
return isinstance(exc, retry_policy.retry_on)
|
||||
elif callable(retry_policy.retry_on):
|
||||
return retry_policy.retry_on(exc) # type: ignore[call-arg]
|
||||
else:
|
||||
raise TypeError(
|
||||
"retry_on must be an Exception class, a list or tuple of Exception classes, or a callable"
|
||||
)
|
||||
|
||||
@@ -140,7 +140,7 @@ class PregelRunner:
|
||||
*,
|
||||
reraise: bool = True,
|
||||
timeout: Optional[float] = None,
|
||||
retry_policy: Optional[RetryPolicy] = None,
|
||||
retry_policy: Optional[Sequence[RetryPolicy]] = None,
|
||||
get_waiter: Optional[Callable[[], concurrent.futures.Future[None]]] = None,
|
||||
) -> Iterator[None]:
|
||||
tasks = tuple(tasks)
|
||||
@@ -269,7 +269,7 @@ class PregelRunner:
|
||||
*,
|
||||
reraise: bool = True,
|
||||
timeout: Optional[float] = None,
|
||||
retry_policy: Optional[RetryPolicy] = None,
|
||||
retry_policy: Optional[Sequence[RetryPolicy]] = None,
|
||||
get_waiter: Optional[Callable[[], asyncio.Future[None]]] = None,
|
||||
) -> AsyncIterator[None]:
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -519,7 +519,7 @@ def _call(
|
||||
func: Callable[[Any], Union[Awaitable[Any], Any]],
|
||||
input: Any,
|
||||
*,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Sequence[RetryPolicy]] = None,
|
||||
callbacks: Callbacks = None,
|
||||
futures: weakref.ref[FuturesDict],
|
||||
schedule_task: weakref.ref[
|
||||
@@ -600,7 +600,7 @@ def _acall(
|
||||
func: Callable[[Any], Union[Awaitable[Any], Any]],
|
||||
input: Any,
|
||||
*,
|
||||
retry: Optional[RetryPolicy] = None,
|
||||
retry: Optional[Sequence[RetryPolicy]] = None,
|
||||
callbacks: Callbacks = None,
|
||||
# injected dependencies
|
||||
futures: weakref.ref[FuturesDict],
|
||||
|
||||
@@ -54,14 +54,14 @@ class ChannelWrite(RunnableCallable):
|
||||
self,
|
||||
writes: Sequence[Union[ChannelWriteEntry, ChannelWriteTupleEntry, Send]],
|
||||
*,
|
||||
tags: Optional[Sequence[str]] = None,
|
||||
tags: Optional[Sequence[str]] = None, # ignored
|
||||
require_at_least_one_of: Optional[Sequence[str]] = None, # ignored
|
||||
):
|
||||
super().__init__(
|
||||
func=self._write,
|
||||
afunc=self._awrite,
|
||||
name=None,
|
||||
tags=tags,
|
||||
trace=False,
|
||||
func_accepts_config=True,
|
||||
)
|
||||
self.writes = cast(
|
||||
@@ -152,6 +152,8 @@ class ChannelWrite(RunnableCallable):
|
||||
tuples.append((w.channel, value))
|
||||
else:
|
||||
raise ValueError(f"Invalid write entry: {w}")
|
||||
# if we want to persist writes found before hitting a ParentCommand
|
||||
# can move this to a finally block
|
||||
write: TYPE_SEND = config[CONF][CONFIG_KEY_SEND]
|
||||
write(tuples)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
|
||||
from langgraph.utils.fields import get_update_as_tuples
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.pregel.protocol import PregelProtocol
|
||||
@@ -75,6 +76,10 @@ def default_retry_on(exc: Exception) -> bool:
|
||||
|
||||
if isinstance(exc, ConnectionError):
|
||||
return True
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return 500 <= exc.response.status_code < 600
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
return 500 <= exc.response.status_code < 600 if exc.response else True
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
@@ -93,10 +98,6 @@ def default_retry_on(exc: Exception) -> bool:
|
||||
),
|
||||
):
|
||||
return False
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return 500 <= exc.response.status_code < 600
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
return 500 <= exc.response.status_code < 600 if exc.response else True
|
||||
return True
|
||||
|
||||
|
||||
@@ -172,7 +173,7 @@ class PregelExecutableTask:
|
||||
writes: deque[tuple[str, Any]]
|
||||
config: RunnableConfig
|
||||
triggers: Sequence[str]
|
||||
retry_policy: Optional[RetryPolicy]
|
||||
retry_policy: Optional[Sequence[RetryPolicy]]
|
||||
cache_policy: Optional[CachePolicy]
|
||||
id: str
|
||||
path: tuple[Union[str, int, tuple], ...]
|
||||
@@ -318,7 +319,7 @@ class Command(Generic[N], ToolOutputMixin):
|
||||
):
|
||||
return self.update
|
||||
elif hints := get_type_hints(type(self.update)):
|
||||
return [(k, getattr(self.update, k)) for k in hints]
|
||||
return get_update_as_tuples(self.update, tuple(hints.keys()))
|
||||
elif self.update is not None:
|
||||
return [("__root__", self.update)]
|
||||
else:
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import dataclasses
|
||||
from typing import Any, Generator, Optional, Type, Union, get_type_hints
|
||||
from typing import Any, Generator, Optional, Sequence, Type, Union, get_type_hints
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
from typing_extensions import Annotated, NotRequired, ReadOnly, Required, get_origin
|
||||
|
||||
# NOTE: this is redefined here separately from langgraph.constants
|
||||
# to avoid a circular import
|
||||
MISSING = object()
|
||||
|
||||
|
||||
def _is_optional_type(type_: Any) -> bool:
|
||||
"""Check if a type is Optional."""
|
||||
@@ -147,3 +153,33 @@ def get_enhanced_type_hints(
|
||||
pass
|
||||
|
||||
yield name, typ, default, description
|
||||
|
||||
|
||||
def get_update_as_tuples(input: Any, keys: Sequence[str]) -> list[tuple[str, Any]]:
|
||||
"""Get Pydantic state update as a list of (key, value) tuples."""
|
||||
# Pydantic v1
|
||||
if isinstance(input, BaseModelV1):
|
||||
keep: Optional[set[str]] = input.__fields_set__
|
||||
defaults = {k: v.default for k, v in input.__fields__.items()}
|
||||
# Pydantic v2
|
||||
elif isinstance(input, BaseModel):
|
||||
keep = input.model_fields_set
|
||||
defaults = {k: v.default for k, v in input.model_fields.items()}
|
||||
else:
|
||||
keep = None
|
||||
defaults = {}
|
||||
|
||||
# NOTE: This behavior for Pydantic is somewhat inelegant,
|
||||
# but we keep around for backwards compatibility
|
||||
# if input is a Pydantic model, only update values
|
||||
# that are different from the default values or in the keep set
|
||||
return [
|
||||
(k, value)
|
||||
for k in keys
|
||||
if (value := getattr(input, k, MISSING)) is not MISSING
|
||||
and (
|
||||
value is not None
|
||||
or defaults.get(k, MISSING) is not None
|
||||
or (keep is not None and k in keep)
|
||||
)
|
||||
]
|
||||
|
||||
@@ -36,6 +36,7 @@ from langchain_core.runnables.config import (
|
||||
var_child_runnable_config,
|
||||
)
|
||||
from langchain_core.runnables.utils import Input, Output
|
||||
from langchain_core.tracers.langchain import LangChainTracer
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
from langgraph.constants import (
|
||||
@@ -60,58 +61,34 @@ except ImportError:
|
||||
|
||||
|
||||
def _set_config_context(
|
||||
config: RunnableConfig,
|
||||
) -> tuple[Token[Optional[RunnableConfig]], Optional[dict[str, Any]]]:
|
||||
config: RunnableConfig, run: Any = None
|
||||
) -> Token[Optional[RunnableConfig]]:
|
||||
"""Set the child Runnable config + tracing context.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to set.
|
||||
"""
|
||||
from langchain_core.tracers.langchain import LangChainTracer
|
||||
|
||||
config_token = var_child_runnable_config.set(config)
|
||||
current_context = None
|
||||
if (
|
||||
(callbacks := config.get("callbacks"))
|
||||
and (
|
||||
parent_run_id := getattr(callbacks, "parent_run_id", None)
|
||||
) # Is callback manager
|
||||
and (
|
||||
tracer := next(
|
||||
(
|
||||
handler
|
||||
for handler in getattr(callbacks, "handlers", [])
|
||||
if isinstance(handler, LangChainTracer)
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
and (run := tracer.run_map.get(str(parent_run_id)))
|
||||
):
|
||||
from langsmith.run_helpers import _set_tracing_context, get_tracing_context
|
||||
if run is not None:
|
||||
from langsmith.run_helpers import _set_tracing_context
|
||||
|
||||
current_context = get_tracing_context()
|
||||
_set_tracing_context({"parent": run})
|
||||
return config_token, current_context
|
||||
return config_token
|
||||
|
||||
|
||||
@contextmanager
|
||||
def set_config_context(config: RunnableConfig) -> Generator[Context, None, None]:
|
||||
def _unset_config_context(
|
||||
token: Token[Optional[RunnableConfig]], run: Any = None
|
||||
) -> None:
|
||||
"""Set the child Runnable config + tracing context.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to set.
|
||||
"""
|
||||
from langsmith.run_helpers import _set_tracing_context
|
||||
var_child_runnable_config.reset(token)
|
||||
if run is not None:
|
||||
from langsmith.run_helpers import _set_tracing_context
|
||||
|
||||
ctx = copy_context()
|
||||
config_token, _ = ctx.run(_set_config_context, config)
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
ctx.run(var_child_runnable_config.reset, config_token)
|
||||
ctx.run(
|
||||
_set_tracing_context,
|
||||
_set_tracing_context(
|
||||
{
|
||||
"parent": None,
|
||||
"project_name": None,
|
||||
@@ -119,10 +96,27 @@ def set_config_context(config: RunnableConfig) -> Generator[Context, None, None]
|
||||
"metadata": None,
|
||||
"enabled": None,
|
||||
"client": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def set_config_context(
|
||||
config: RunnableConfig, run: Any = None
|
||||
) -> Generator[Context, None, None]:
|
||||
"""Set the child Runnable config + tracing context.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to set.
|
||||
"""
|
||||
ctx = copy_context()
|
||||
config_token = ctx.run(_set_config_context, config, run)
|
||||
try:
|
||||
yield ctx
|
||||
finally:
|
||||
ctx.run(_unset_config_context, config_token, run)
|
||||
|
||||
|
||||
# Before Python 3.11 native StrEnum is not available
|
||||
class StrEnum(str, enum.Enum):
|
||||
"""A string enum."""
|
||||
@@ -363,7 +357,15 @@ class RunnableCallable(Runnable):
|
||||
)
|
||||
try:
|
||||
child_config = patch_config(config, callbacks=run_manager.get_child())
|
||||
with set_config_context(child_config) as context:
|
||||
# get the run
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, LangChainTracer):
|
||||
run = h.run_map.get(str(run_manager.run_id))
|
||||
break
|
||||
else:
|
||||
run = None
|
||||
# run in context
|
||||
with set_config_context(child_config, run) as context:
|
||||
ret = context.run(self.func, *args, **kwargs)
|
||||
except BaseException as e:
|
||||
run_manager.on_chain_error(e)
|
||||
@@ -371,9 +373,8 @@ class RunnableCallable(Runnable):
|
||||
else:
|
||||
run_manager.on_chain_end(ret)
|
||||
else:
|
||||
with set_config_context(config) as context:
|
||||
ret = context.run(self.func, *args, **kwargs)
|
||||
if isinstance(ret, Runnable) and self.recurse:
|
||||
ret = self.func(*args, **kwargs)
|
||||
if self.recurse and isinstance(ret, Runnable):
|
||||
return ret.invoke(input, config)
|
||||
return ret
|
||||
|
||||
@@ -417,25 +418,26 @@ class RunnableCallable(Runnable):
|
||||
)
|
||||
try:
|
||||
child_config = patch_config(config, callbacks=run_manager.get_child())
|
||||
with set_config_context(child_config) as context:
|
||||
coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
|
||||
if ASYNCIO_ACCEPTS_CONTEXT:
|
||||
ret = await asyncio.create_task(coro, context=context)
|
||||
coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
|
||||
if ASYNCIO_ACCEPTS_CONTEXT:
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, LangChainTracer):
|
||||
run = h.run_map.get(str(run_manager.run_id))
|
||||
break
|
||||
else:
|
||||
ret = await coro
|
||||
run = None
|
||||
with set_config_context(child_config, run) as context:
|
||||
ret = await asyncio.create_task(coro, context=context)
|
||||
else:
|
||||
ret = await coro
|
||||
except BaseException as e:
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
await run_manager.on_chain_end(ret)
|
||||
else:
|
||||
with set_config_context(config) as context:
|
||||
if ASYNCIO_ACCEPTS_CONTEXT:
|
||||
coro = cast(Coroutine[None, None, Any], self.afunc(*args, **kwargs))
|
||||
ret = await asyncio.create_task(coro, context=context)
|
||||
else:
|
||||
ret = await self.afunc(*args, **kwargs)
|
||||
if isinstance(ret, Runnable) and self.recurse:
|
||||
ret = await self.afunc(*args, **kwargs)
|
||||
if self.recurse and isinstance(ret, Runnable):
|
||||
return await ret.ainvoke(input, config)
|
||||
return ret
|
||||
|
||||
@@ -598,7 +600,6 @@ class RunnableSeq(Runnable):
|
||||
name=config.get("run_name") or self.get_name(),
|
||||
run_id=config.pop("run_id", None),
|
||||
)
|
||||
|
||||
# invoke all steps in sequence
|
||||
try:
|
||||
for i, step in enumerate(self.steps):
|
||||
@@ -606,8 +607,19 @@ class RunnableSeq(Runnable):
|
||||
config = patch_config(
|
||||
config, callbacks=run_manager.get_child(f"seq:step:{i + 1}")
|
||||
)
|
||||
# 1st step is the actual node,
|
||||
# others are writers which don't need to be run in context
|
||||
if i == 0:
|
||||
input = step.invoke(input, config, **kwargs)
|
||||
# get the run object
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, LangChainTracer):
|
||||
run = h.run_map.get(str(run_manager.run_id))
|
||||
break
|
||||
else:
|
||||
run = None
|
||||
# run in context
|
||||
with set_config_context(config, run) as context:
|
||||
input = context.run(step.invoke, input, config, **kwargs)
|
||||
else:
|
||||
input = step.invoke(input, config)
|
||||
# finish the root run
|
||||
@@ -643,8 +655,24 @@ class RunnableSeq(Runnable):
|
||||
config = patch_config(
|
||||
config, callbacks=run_manager.get_child(f"seq:step:{i + 1}")
|
||||
)
|
||||
# 1st step is the actual node,
|
||||
# others are writers which don't need to be run in context
|
||||
if i == 0:
|
||||
input = await step.ainvoke(input, config, **kwargs)
|
||||
if ASYNCIO_ACCEPTS_CONTEXT:
|
||||
# get the run object
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, LangChainTracer):
|
||||
run = h.run_map.get(str(run_manager.run_id))
|
||||
break
|
||||
else:
|
||||
run = None
|
||||
# run in context
|
||||
with set_config_context(config, run) as context:
|
||||
input = await asyncio.create_task(
|
||||
step.ainvoke(input, config, **kwargs), context=context
|
||||
)
|
||||
else:
|
||||
input = await step.ainvoke(input, config, **kwargs)
|
||||
else:
|
||||
input = await step.ainvoke(input, config)
|
||||
# finish the root run
|
||||
@@ -672,53 +700,48 @@ class RunnableSeq(Runnable):
|
||||
name=config.get("run_name") or self.get_name(),
|
||||
run_id=config.pop("run_id", None),
|
||||
)
|
||||
|
||||
try:
|
||||
# stream the last steps
|
||||
# transform the input stream of each step with the next
|
||||
# steps that don't natively support transforming an input stream will
|
||||
# buffer input in memory until all available, and then start emitting output
|
||||
for idx, step in enumerate(self.steps):
|
||||
config = patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
|
||||
)
|
||||
if idx == 0:
|
||||
iterator = step.stream(input, config, **kwargs)
|
||||
else:
|
||||
iterator = step.transform(iterator, config)
|
||||
if _StreamingCallbackHandler is not None and (
|
||||
stream_handler := next(
|
||||
(
|
||||
cast(_StreamingCallbackHandler, h)
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
),
|
||||
None,
|
||||
)
|
||||
):
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
iterator = stream_handler.tap_output_iter(run_manager.run_id, iterator)
|
||||
output: Any = None
|
||||
add_supported = False
|
||||
for chunk in iterator:
|
||||
yield chunk
|
||||
# collect final output
|
||||
if output is None:
|
||||
output = chunk
|
||||
elif add_supported:
|
||||
try:
|
||||
output = output + chunk
|
||||
except TypeError:
|
||||
output = chunk
|
||||
add_supported = False
|
||||
else:
|
||||
output = chunk
|
||||
except BaseException as e:
|
||||
run_manager.on_chain_error(e)
|
||||
raise
|
||||
# get the run object
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, LangChainTracer):
|
||||
run = h.run_map.get(str(run_manager.run_id))
|
||||
break
|
||||
else:
|
||||
run_manager.on_chain_end(output)
|
||||
run = None
|
||||
# create first step config
|
||||
config = patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(f"seq:step:{1}"),
|
||||
)
|
||||
# run all in context
|
||||
with set_config_context(config, run) as context:
|
||||
try:
|
||||
# stream the last steps
|
||||
# transform the input stream of each step with the next
|
||||
# steps that don't natively support transforming an input stream will
|
||||
# buffer input in memory until all available, and then start emitting output
|
||||
for idx, step in enumerate(self.steps):
|
||||
if idx == 0:
|
||||
iterator = step.stream(input, config, **kwargs)
|
||||
else:
|
||||
config = patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
|
||||
)
|
||||
iterator = step.transform(iterator, config)
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
if _StreamingCallbackHandler is not None:
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, _StreamingCallbackHandler):
|
||||
iterator = h.tap_output_iter(run_manager.run_id, iterator)
|
||||
# consume into final output
|
||||
output = context.run(_consume_iter, iterator)
|
||||
# sequence doesn't emit output, yield to mark as generator
|
||||
yield
|
||||
except BaseException as e:
|
||||
run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
run_manager.on_chain_end(output)
|
||||
|
||||
async def astream(
|
||||
self,
|
||||
@@ -737,53 +760,121 @@ class RunnableSeq(Runnable):
|
||||
name=config.get("run_name") or self.get_name(),
|
||||
run_id=config.pop("run_id", None),
|
||||
)
|
||||
|
||||
try:
|
||||
async with AsyncExitStack() as stack:
|
||||
# stream the last steps
|
||||
# transform the input stream of each step with the next
|
||||
# steps that don't natively support transforming an input stream will
|
||||
# buffer input in memory until all available, and then start emitting output
|
||||
for idx, step in enumerate(self.steps):
|
||||
config = patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
|
||||
)
|
||||
if idx == 0:
|
||||
aiterator = step.astream(input, config, **kwargs)
|
||||
else:
|
||||
aiterator = step.atransform(aiterator, config)
|
||||
if hasattr(aiterator, "aclose"):
|
||||
stack.push_async_callback(aiterator.aclose)
|
||||
if _StreamingCallbackHandler is not None and (
|
||||
stream_handler := next(
|
||||
(
|
||||
cast(_StreamingCallbackHandler, h)
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
),
|
||||
None,
|
||||
)
|
||||
):
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
aiterator = stream_handler.tap_output_aiter(
|
||||
run_manager.run_id, aiterator
|
||||
)
|
||||
output: Any = None
|
||||
add_supported = False
|
||||
async for chunk in aiterator:
|
||||
yield chunk
|
||||
# collect final output
|
||||
if add_supported:
|
||||
try:
|
||||
output = output + chunk
|
||||
except TypeError:
|
||||
output = chunk
|
||||
add_supported = False
|
||||
else:
|
||||
output = chunk
|
||||
except BaseException as e:
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
# stream the last steps
|
||||
# transform the input stream of each step with the next
|
||||
# steps that don't natively support transforming an input stream will
|
||||
# buffer input in memory until all available, and then start emitting output
|
||||
if ASYNCIO_ACCEPTS_CONTEXT:
|
||||
# get the run object
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, LangChainTracer):
|
||||
run = h.run_map.get(str(run_manager.run_id))
|
||||
break
|
||||
else:
|
||||
run = None
|
||||
# create first step config
|
||||
config = patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(f"seq:step:{1}"),
|
||||
)
|
||||
# run all in context
|
||||
with set_config_context(config, run) as context:
|
||||
try:
|
||||
async with AsyncExitStack() as stack:
|
||||
for idx, step in enumerate(self.steps):
|
||||
if idx == 0:
|
||||
aiterator = step.astream(input, config, **kwargs)
|
||||
else:
|
||||
config = patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(
|
||||
f"seq:step:{idx + 1}"
|
||||
),
|
||||
)
|
||||
aiterator = step.atransform(aiterator, config)
|
||||
if hasattr(aiterator, "aclose"):
|
||||
stack.push_async_callback(aiterator.aclose)
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
if _StreamingCallbackHandler is not None:
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, _StreamingCallbackHandler):
|
||||
aiterator = h.tap_output_aiter(
|
||||
run_manager.run_id, aiterator
|
||||
)
|
||||
# consume into final output
|
||||
output = await asyncio.create_task(
|
||||
_consume_aiter(aiterator), context=context
|
||||
)
|
||||
# sequence doesn't emit output, yield to mark as generator
|
||||
yield
|
||||
except BaseException as e:
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
await run_manager.on_chain_end(output)
|
||||
else:
|
||||
await run_manager.on_chain_end(output)
|
||||
try:
|
||||
async with AsyncExitStack() as stack:
|
||||
for idx, step in enumerate(self.steps):
|
||||
config = patch_config(
|
||||
config,
|
||||
callbacks=run_manager.get_child(f"seq:step:{idx + 1}"),
|
||||
)
|
||||
if idx == 0:
|
||||
aiterator = step.astream(input, config, **kwargs)
|
||||
else:
|
||||
aiterator = step.atransform(aiterator, config)
|
||||
if hasattr(aiterator, "aclose"):
|
||||
stack.push_async_callback(aiterator.aclose)
|
||||
# populates streamed_output in astream_log() output if needed
|
||||
if _StreamingCallbackHandler is not None:
|
||||
for h in run_manager.handlers:
|
||||
if isinstance(h, _StreamingCallbackHandler):
|
||||
aiterator = h.tap_output_aiter(
|
||||
run_manager.run_id, aiterator
|
||||
)
|
||||
# consume into final output
|
||||
output = await _consume_aiter(aiterator)
|
||||
# sequence doesn't emit output, yield to mark as generator
|
||||
yield
|
||||
except BaseException as e:
|
||||
await run_manager.on_chain_error(e)
|
||||
raise
|
||||
else:
|
||||
await run_manager.on_chain_end(output)
|
||||
|
||||
|
||||
def _consume_iter(it: Iterator[Any]) -> Any:
|
||||
"""Consume an iterator."""
|
||||
output: Any = None
|
||||
add_supported = False
|
||||
for chunk in it:
|
||||
# collect final output
|
||||
if output is None:
|
||||
output = chunk
|
||||
elif add_supported:
|
||||
try:
|
||||
output = output + chunk
|
||||
except TypeError:
|
||||
output = chunk
|
||||
add_supported = False
|
||||
else:
|
||||
output = chunk
|
||||
return output
|
||||
|
||||
|
||||
async def _consume_aiter(it: AsyncIterator[Any]) -> Any:
|
||||
"""Consume an async iterator."""
|
||||
output: Any = None
|
||||
add_supported = False
|
||||
async for chunk in it:
|
||||
# collect final output
|
||||
if add_supported:
|
||||
try:
|
||||
output = output + chunk
|
||||
except TypeError:
|
||||
output = chunk
|
||||
add_supported = False
|
||||
else:
|
||||
output = chunk
|
||||
return output
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph"
|
||||
version = "0.3.27"
|
||||
version = "0.3.30"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -4660,7 +4660,7 @@ def test_root_graph(
|
||||
content="result for query",
|
||||
name="search_api",
|
||||
tool_call_id="tool_call123",
|
||||
id="00000000-0000-4000-8000-000000000040",
|
||||
id="00000000-0000-4000-8000-000000000024",
|
||||
)
|
||||
]
|
||||
},
|
||||
@@ -4683,7 +4683,7 @@ def test_root_graph(
|
||||
content="result for another",
|
||||
name="search_api",
|
||||
tool_call_id="tool_call456",
|
||||
id="00000000-0000-4000-8000-000000000049",
|
||||
id="00000000-0000-4000-8000-000000000030",
|
||||
)
|
||||
]
|
||||
},
|
||||
@@ -5387,7 +5387,7 @@ def test_root_graph(
|
||||
"__root__": [
|
||||
HumanMessage(
|
||||
content="what is weather in sf",
|
||||
id="00000000-0000-4000-8000-000000000083",
|
||||
id="00000000-0000-4000-8000-000000000051",
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
@@ -5407,7 +5407,7 @@ def test_root_graph(
|
||||
),
|
||||
AIMessage(content="answer", id="ai2"),
|
||||
AIMessage(
|
||||
content="an extra message", id="00000000-0000-4000-8000-000000000107"
|
||||
content="an extra message", id="00000000-0000-4000-8000-000000000066"
|
||||
),
|
||||
HumanMessage(content="what is weather in la"),
|
||||
],
|
||||
|
||||
@@ -6156,7 +6156,7 @@ def test_falsy_return_from_task(
|
||||
falsy_task().result()
|
||||
interrupt("test")
|
||||
|
||||
configurable = {"configurable": {"thread_id": str(uuid.uuid4())}}
|
||||
configurable = {"configurable": {"thread_id": uuid.uuid4()}}
|
||||
assert [
|
||||
chunk for chunk in graph.stream({"a": 5}, configurable, stream_mode="debug")
|
||||
] == [
|
||||
@@ -6169,7 +6169,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -6177,7 +6177,6 @@ def test_falsy_return_from_task(
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"thread_id": AnyStr(),
|
||||
"writes": {
|
||||
"__start__": {
|
||||
"a": 5,
|
||||
@@ -6268,6 +6267,7 @@ def test_falsy_return_from_task(
|
||||
"type": "task_result",
|
||||
},
|
||||
]
|
||||
print(type(configurable["configurable"]["thread_id"]))
|
||||
assert [
|
||||
c
|
||||
for c in graph.stream(Command(resume="123"), configurable, stream_mode="debug")
|
||||
@@ -6281,7 +6281,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -6376,7 +6376,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -6384,7 +6384,6 @@ def test_falsy_return_from_task(
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"thread_id": AnyStr(),
|
||||
"writes": {
|
||||
"falsy_task": False,
|
||||
"graph": None,
|
||||
@@ -6398,7 +6397,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -7168,6 +7167,52 @@ def test_tags_stream_mode_messages() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_stream_mode_messages_command() -> None:
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
def my_node(state):
|
||||
return {"messages": HumanMessage(content="foo")}
|
||||
|
||||
def my_other_node(state):
|
||||
return Command(update={"messages": HumanMessage(content="bar")})
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_sequence([my_node, my_other_node])
|
||||
.add_edge(START, "my_node")
|
||||
.compile()
|
||||
)
|
||||
assert list(
|
||||
graph.stream(
|
||||
{
|
||||
"messages": [],
|
||||
},
|
||||
stream_mode="messages",
|
||||
)
|
||||
) == [
|
||||
(
|
||||
_AnyIdHumanMessage(content="foo"),
|
||||
{
|
||||
"langgraph_step": 1,
|
||||
"langgraph_node": "my_node",
|
||||
"langgraph_triggers": ("branch:to:my_node",),
|
||||
"langgraph_path": ("__pregel_pull", "my_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("my_node:"),
|
||||
},
|
||||
),
|
||||
(
|
||||
_AnyIdHumanMessage(content="bar"),
|
||||
{
|
||||
"langgraph_step": 2,
|
||||
"langgraph_node": "my_other_node",
|
||||
"langgraph_triggers": ("branch:to:my_other_node",),
|
||||
"langgraph_path": ("__pregel_pull", "my_other_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("my_other_node:"),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_node_destinations() -> None:
|
||||
class State(TypedDict):
|
||||
foo: Annotated[str, operator.add]
|
||||
@@ -7246,6 +7291,39 @@ def test_pydantic_none_state_update() -> None:
|
||||
assert graph.invoke({"foo": ""}) == {"foo": None}
|
||||
|
||||
|
||||
def test_pydantic_state_update_command() -> None:
|
||||
from pydantic import BaseModel
|
||||
|
||||
class State(BaseModel):
|
||||
foo: Optional[str]
|
||||
|
||||
def node_a(state: State) -> State:
|
||||
return Command(update=State(foo=None))
|
||||
|
||||
graph = StateGraph(State).add_node(node_a).add_edge(START, "node_a").compile()
|
||||
assert graph.invoke({"foo": ""}) == {"foo": None}
|
||||
|
||||
class State(BaseModel):
|
||||
foo: Optional[str] = None
|
||||
bar: Optional[str] = None
|
||||
|
||||
def node_a(state: State):
|
||||
return State(foo="foo")
|
||||
|
||||
def node_b(state: State):
|
||||
return Command(update=State(bar="bar"))
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_node(node_a)
|
||||
builder.add_node(node_b)
|
||||
builder.add_edge(START, "node_a")
|
||||
builder.add_edge("node_a", "node_b")
|
||||
builder.add_edge("node_b", END)
|
||||
graph = builder.compile()
|
||||
|
||||
assert graph.invoke(State()) == {"foo": "foo", "bar": "bar"}
|
||||
|
||||
|
||||
def test_pydantic_state_mutation() -> None:
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -7280,6 +7358,40 @@ def test_pydantic_state_mutation() -> None:
|
||||
assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)}
|
||||
|
||||
|
||||
def test_pydantic_state_mutation_command() -> None:
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class Inner(BaseModel):
|
||||
a: int = 0
|
||||
|
||||
class State(BaseModel):
|
||||
inner: Inner = Inner()
|
||||
outer: int = 0
|
||||
|
||||
def my_node(state: State) -> State:
|
||||
state.inner.a = 5
|
||||
state.outer = 10
|
||||
return Command(update=state)
|
||||
|
||||
graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile()
|
||||
|
||||
assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)}
|
||||
|
||||
# test w/ default_factory
|
||||
class State(BaseModel):
|
||||
inner: Inner = Field(default_factory=Inner)
|
||||
outer: int = 0
|
||||
|
||||
def my_node(state: State) -> State:
|
||||
state.inner.a = 5
|
||||
state.outer = 10
|
||||
return Command(update=state)
|
||||
|
||||
graph = StateGraph(State).add_node(my_node).add_edge(START, "my_node").compile()
|
||||
|
||||
assert graph.invoke({"outer": 1}) == {"outer": 10, "inner": Inner(a=5)}
|
||||
|
||||
|
||||
def test_get_stream_writer() -> None:
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
@@ -7894,6 +7894,53 @@ async def test_tags_stream_mode_messages() -> None:
|
||||
]
|
||||
|
||||
|
||||
async def test_stream_mode_messages_command() -> None:
|
||||
from langchain_core.messages import HumanMessage
|
||||
|
||||
async def my_node(state):
|
||||
return {"messages": HumanMessage(content="foo")}
|
||||
|
||||
async def my_other_node(state):
|
||||
return Command(update={"messages": HumanMessage(content="bar")})
|
||||
|
||||
graph = (
|
||||
StateGraph(MessagesState)
|
||||
.add_sequence([my_node, my_other_node])
|
||||
.add_edge(START, "my_node")
|
||||
.compile()
|
||||
)
|
||||
assert [
|
||||
c
|
||||
async for c in graph.astream(
|
||||
{
|
||||
"messages": [],
|
||||
},
|
||||
stream_mode="messages",
|
||||
)
|
||||
] == [
|
||||
(
|
||||
_AnyIdHumanMessage(content="foo"),
|
||||
{
|
||||
"langgraph_step": 1,
|
||||
"langgraph_node": "my_node",
|
||||
"langgraph_triggers": ("branch:to:my_node",),
|
||||
"langgraph_path": ("__pregel_pull", "my_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("my_node:"),
|
||||
},
|
||||
),
|
||||
(
|
||||
_AnyIdHumanMessage(content="bar"),
|
||||
{
|
||||
"langgraph_step": 2,
|
||||
"langgraph_node": "my_other_node",
|
||||
"langgraph_triggers": ("branch:to:my_other_node",),
|
||||
"langgraph_path": ("__pregel_pull", "my_other_node"),
|
||||
"langgraph_checkpoint_ns": AnyStr("my_other_node:"),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def test_stream_messages_dedupe_inputs() -> None:
|
||||
from langchain_core.messages import AIMessage
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.pregel.retry import _should_retry_on
|
||||
from langgraph.types import RetryPolicy
|
||||
|
||||
|
||||
def test_should_retry_on_single_exception():
|
||||
"""Test retry with a single exception type."""
|
||||
policy = RetryPolicy(retry_on=ValueError)
|
||||
|
||||
# Should retry on ValueError
|
||||
assert _should_retry_on(policy, ValueError("test error")) is True
|
||||
|
||||
# Should not retry on other exceptions
|
||||
assert _should_retry_on(policy, TypeError("test error")) is False
|
||||
assert _should_retry_on(policy, Exception("test error")) is False
|
||||
|
||||
|
||||
def test_should_retry_on_sequence_of_exceptions():
|
||||
"""Test retry with a sequence of exception types."""
|
||||
policy = RetryPolicy(retry_on=(ValueError, KeyError))
|
||||
|
||||
# Should retry on listed exceptions
|
||||
assert _should_retry_on(policy, ValueError("test error")) is True
|
||||
assert _should_retry_on(policy, KeyError("test error")) is True
|
||||
|
||||
# Should not retry on other exceptions
|
||||
assert _should_retry_on(policy, TypeError("test error")) is False
|
||||
assert _should_retry_on(policy, Exception("test error")) is False
|
||||
|
||||
|
||||
def test_should_retry_on_subclass_of_exception():
|
||||
"""Test retry on subclass of specified exception."""
|
||||
|
||||
class CustomError(ValueError):
|
||||
pass
|
||||
|
||||
policy = RetryPolicy(retry_on=ValueError)
|
||||
|
||||
# Should retry on subclass of specified exception
|
||||
assert _should_retry_on(policy, CustomError("test error")) is True
|
||||
|
||||
|
||||
def test_should_retry_on_callable():
|
||||
"""Test retry with a callable predicate."""
|
||||
|
||||
# Only retry on ValueError with message containing 'retry'
|
||||
def should_retry(exc: Exception) -> bool:
|
||||
return isinstance(exc, ValueError) and "retry" in str(exc)
|
||||
|
||||
policy = RetryPolicy(retry_on=should_retry)
|
||||
|
||||
# Should retry when predicate returns True
|
||||
assert _should_retry_on(policy, ValueError("please retry this")) is True
|
||||
|
||||
# Should not retry when predicate returns False
|
||||
assert _should_retry_on(policy, ValueError("other error")) is False
|
||||
assert _should_retry_on(policy, TypeError("please retry this")) is False
|
||||
|
||||
|
||||
def test_should_retry_on_invalid_type():
|
||||
"""Test retry with an invalid retry_on type."""
|
||||
policy = RetryPolicy(retry_on=123) # type: ignore
|
||||
|
||||
with pytest.raises(TypeError, match="retry_on must be an Exception class"):
|
||||
_should_retry_on(policy, ValueError("test error"))
|
||||
|
||||
|
||||
def test_should_retry_on_empty_sequence():
|
||||
"""Test retry with an empty sequence."""
|
||||
policy = RetryPolicy(retry_on=())
|
||||
|
||||
# Should not retry when sequence is empty
|
||||
assert _should_retry_on(policy, ValueError("test error")) is False
|
||||
|
||||
|
||||
def test_should_retry_default_retry_on():
|
||||
"""Test the default retry_on function."""
|
||||
import httpx
|
||||
import requests
|
||||
|
||||
# Create a RetryPolicy with default_retry_on
|
||||
policy = RetryPolicy()
|
||||
|
||||
# Should retry on ConnectionError
|
||||
assert _should_retry_on(policy, ConnectionError("connection refused")) is True
|
||||
|
||||
# Should not retry on common programming errors
|
||||
assert _should_retry_on(policy, ValueError("invalid value")) is False
|
||||
assert _should_retry_on(policy, TypeError("invalid type")) is False
|
||||
assert _should_retry_on(policy, ArithmeticError("division by zero")) is False
|
||||
assert _should_retry_on(policy, ImportError("module not found")) is False
|
||||
assert _should_retry_on(policy, LookupError("key not found")) is False
|
||||
assert _should_retry_on(policy, NameError("name not defined")) is False
|
||||
assert _should_retry_on(policy, SyntaxError("invalid syntax")) is False
|
||||
assert _should_retry_on(policy, RuntimeError("runtime error")) is False
|
||||
assert _should_retry_on(policy, ReferenceError("weak reference")) is False
|
||||
assert _should_retry_on(policy, StopIteration()) is False
|
||||
assert _should_retry_on(policy, StopAsyncIteration()) is False
|
||||
assert _should_retry_on(policy, OSError("file not found")) is False
|
||||
|
||||
# Should retry on httpx.HTTPStatusError with 5xx status code
|
||||
response_5xx = Mock()
|
||||
response_5xx.status_code = 503
|
||||
http_error_5xx = httpx.HTTPStatusError(
|
||||
"server error", request=Mock(), response=response_5xx
|
||||
)
|
||||
assert _should_retry_on(policy, http_error_5xx) is True
|
||||
|
||||
# Should not retry on httpx.HTTPStatusError with 4xx status code
|
||||
response_4xx = Mock()
|
||||
response_4xx.status_code = 404
|
||||
http_error_4xx = httpx.HTTPStatusError(
|
||||
"not found", request=Mock(), response=response_4xx
|
||||
)
|
||||
assert _should_retry_on(policy, http_error_4xx) is False
|
||||
|
||||
# Should retry on requests.HTTPError with 5xx status code
|
||||
response_req_5xx = Mock()
|
||||
response_req_5xx.status_code = 502
|
||||
req_error_5xx = requests.HTTPError("bad gateway")
|
||||
req_error_5xx.response = response_req_5xx
|
||||
assert _should_retry_on(policy, req_error_5xx) is True
|
||||
|
||||
# Should not retry on requests.HTTPError with 4xx status code
|
||||
response_req_4xx = Mock()
|
||||
response_req_4xx.status_code = 400
|
||||
req_error_4xx = requests.HTTPError("bad request")
|
||||
req_error_4xx.response = response_req_4xx
|
||||
assert _should_retry_on(policy, req_error_4xx) is False
|
||||
|
||||
# Should retry on requests.HTTPError with no response
|
||||
req_error_no_resp = requests.HTTPError("connection error")
|
||||
req_error_no_resp.response = None
|
||||
assert _should_retry_on(policy, req_error_no_resp) is True
|
||||
|
||||
# Should retry on other exceptions by default
|
||||
class CustomException(Exception):
|
||||
pass
|
||||
|
||||
assert _should_retry_on(policy, CustomException("custom error")) is True
|
||||
|
||||
|
||||
def test_graph_with_single_retry_policy():
|
||||
"""Test a simple graph with a single RetryPolicy for a node."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
attempt_count = 0
|
||||
|
||||
def failing_node(state: State):
|
||||
nonlocal attempt_count
|
||||
attempt_count += 1
|
||||
if attempt_count < 3: # Fail the first two attempts
|
||||
raise ValueError("Intentional failure")
|
||||
return {"foo": "success"}
|
||||
|
||||
def other_node(state: State):
|
||||
return {"foo": "other_node"}
|
||||
|
||||
# Create a retry policy with specific parameters
|
||||
retry_policy = RetryPolicy(
|
||||
max_attempts=3,
|
||||
initial_interval=0.01, # Short interval for tests
|
||||
backoff_factor=2.0,
|
||||
jitter=False, # Disable jitter for predictable timing
|
||||
retry_on=ValueError,
|
||||
)
|
||||
|
||||
# Create and compile the graph
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("failing_node", failing_node, retry=retry_policy)
|
||||
.add_node("other_node", other_node)
|
||||
.add_edge(START, "failing_node")
|
||||
.add_edge("failing_node", "other_node")
|
||||
.compile()
|
||||
)
|
||||
|
||||
with patch("time.sleep") as mock_sleep:
|
||||
result = graph.invoke({"foo": ""})
|
||||
|
||||
# Verify retry behavior
|
||||
assert attempt_count == 3 # The node should have been tried 3 times
|
||||
assert result["foo"] == "other_node" # Final result should be from other_node
|
||||
|
||||
# Verify the sleep intervals
|
||||
call_args_list = [args[0][0] for args in mock_sleep.call_args_list]
|
||||
assert call_args_list == [0.01, 0.02]
|
||||
|
||||
|
||||
def test_graph_with_jitter_retry_policy():
|
||||
"""Test a graph with a RetryPolicy that uses jitter."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
attempt_count = 0
|
||||
|
||||
def failing_node(state):
|
||||
nonlocal attempt_count
|
||||
attempt_count += 1
|
||||
if attempt_count < 2: # Fail the first attempt
|
||||
raise ValueError("Intentional failure")
|
||||
return {"foo": "success"}
|
||||
|
||||
# Create a retry policy with jitter enabled
|
||||
retry_policy = RetryPolicy(
|
||||
max_attempts=3,
|
||||
initial_interval=0.01,
|
||||
jitter=True, # Enable jitter for randomized backoff
|
||||
retry_on=ValueError,
|
||||
)
|
||||
|
||||
# Create and compile the graph
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("failing_node", failing_node, retry=retry_policy)
|
||||
.add_edge(START, "failing_node")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Test graph execution with mocked random and sleep
|
||||
with patch("random.uniform", return_value=0.05) as mock_random, patch(
|
||||
"time.sleep"
|
||||
) as mock_sleep:
|
||||
result = graph.invoke({"foo": ""})
|
||||
|
||||
# Verify retry behavior
|
||||
assert attempt_count == 2 # The node should have been tried twice
|
||||
assert result["foo"] == "success"
|
||||
|
||||
# Verify jitter was applied
|
||||
mock_random.assert_called_with(0, 1) # Jitter should use random.uniform(0, 1)
|
||||
mock_sleep.assert_called_with(0.01 + 0.05) # Sleep should include jitter
|
||||
|
||||
|
||||
def test_graph_with_multiple_retry_policies():
|
||||
"""Test a graph with multiple retry policies for a node."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
error_type: str
|
||||
|
||||
attempt_counts = {"value_error": 0, "key_error": 0}
|
||||
|
||||
def failing_node(state):
|
||||
error_type = state["error_type"]
|
||||
|
||||
if error_type == "value_error":
|
||||
attempt_counts["value_error"] += 1
|
||||
if attempt_counts["value_error"] < 2:
|
||||
raise ValueError("Value error")
|
||||
elif error_type == "key_error":
|
||||
attempt_counts["key_error"] += 1
|
||||
if attempt_counts["key_error"] < 3:
|
||||
raise KeyError("Key error")
|
||||
|
||||
return {"foo": f"recovered_from_{error_type}"}
|
||||
|
||||
# Create multiple retry policies
|
||||
value_error_policy = RetryPolicy(
|
||||
max_attempts=2,
|
||||
initial_interval=0.01,
|
||||
jitter=False,
|
||||
retry_on=ValueError,
|
||||
)
|
||||
|
||||
key_error_policy = RetryPolicy(
|
||||
max_attempts=3,
|
||||
initial_interval=0.02,
|
||||
jitter=False,
|
||||
retry_on=KeyError,
|
||||
)
|
||||
|
||||
# Create and compile the graph with a list of retry policies
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node(
|
||||
"failing_node",
|
||||
failing_node,
|
||||
retry=(value_error_policy, key_error_policy),
|
||||
)
|
||||
.add_edge(START, "failing_node")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Test ValueError scenario
|
||||
with patch("time.sleep"):
|
||||
result_value_error = graph.invoke({"foo": "", "error_type": "value_error"})
|
||||
|
||||
assert attempt_counts["value_error"] == 2
|
||||
assert result_value_error["foo"] == "recovered_from_value_error"
|
||||
|
||||
# Reset attempt counts
|
||||
attempt_counts = {"value_error": 0, "key_error": 0}
|
||||
|
||||
# Test KeyError scenario
|
||||
with patch("time.sleep"):
|
||||
result_key_error = graph.invoke({"foo": "", "error_type": "key_error"})
|
||||
|
||||
assert attempt_counts["key_error"] == 3
|
||||
assert result_key_error["foo"] == "recovered_from_key_error"
|
||||
|
||||
|
||||
def test_graph_with_max_attempts_exceeded():
|
||||
"""Test a graph where max_attempts is exceeded."""
|
||||
|
||||
class State(TypedDict):
|
||||
foo: str
|
||||
|
||||
def always_failing_node(state):
|
||||
raise ValueError("Always fails")
|
||||
|
||||
# Create a retry policy with limited attempts
|
||||
retry_policy = RetryPolicy(
|
||||
max_attempts=2,
|
||||
initial_interval=0.01,
|
||||
jitter=False,
|
||||
retry_on=ValueError,
|
||||
)
|
||||
|
||||
# Create and compile the graph
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("always_failing", always_failing_node, retry=retry_policy)
|
||||
.add_edge(START, "always_failing")
|
||||
.compile()
|
||||
)
|
||||
|
||||
# Test graph execution
|
||||
with patch("time.sleep") as mock_sleep, pytest.raises(
|
||||
ValueError, match="Always fails"
|
||||
):
|
||||
graph.invoke({"foo": ""})
|
||||
|
||||
mock_sleep.assert_called_with(0.01)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@langchain/langgraph-sdk",
|
||||
"version": "0.0.66",
|
||||
"version": "0.0.67",
|
||||
"description": "Client library for interacting with the LangGraph API",
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.19",
|
||||
|
||||
@@ -84,18 +84,37 @@ class BaseClient {
|
||||
protected defaultHeaders: Record<string, string | null | undefined>;
|
||||
|
||||
constructor(config?: ClientConfig) {
|
||||
this.asyncCaller = new AsyncCaller({
|
||||
const callerOptions = {
|
||||
maxRetries: 4,
|
||||
maxConcurrency: 4,
|
||||
...config?.callerOptions,
|
||||
});
|
||||
};
|
||||
|
||||
let defaultApiUrl = "http://localhost:8123";
|
||||
if (
|
||||
!config?.apiUrl &&
|
||||
typeof globalThis === "object" &&
|
||||
globalThis != null
|
||||
) {
|
||||
const fetchSmb = Symbol.for("langgraph_api:fetch");
|
||||
const urlSmb = Symbol.for("langgraph_api:url");
|
||||
|
||||
const global = globalThis as unknown as {
|
||||
[fetchSmb]?: typeof fetch;
|
||||
[urlSmb]?: string;
|
||||
};
|
||||
|
||||
if (global[fetchSmb]) callerOptions.fetch ??= global[fetchSmb];
|
||||
if (global[urlSmb]) defaultApiUrl = global[urlSmb];
|
||||
}
|
||||
|
||||
this.asyncCaller = new AsyncCaller(callerOptions);
|
||||
this.timeoutMs = config?.timeoutMs;
|
||||
|
||||
// default limit being capped by Chrome
|
||||
// https://github.com/nodejs/undici/issues/1373
|
||||
// Regex to remove trailing slash, if present
|
||||
this.apiUrl = config?.apiUrl?.replace(/\/$/, "") || "http://localhost:8123";
|
||||
this.apiUrl = config?.apiUrl?.replace(/\/$/, "") || defaultApiUrl;
|
||||
this.defaultHeaders = config?.defaultHeaders || {};
|
||||
const apiKey = getApiKey(config?.apiKey);
|
||||
if (apiKey) {
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export { useStream, type MessageMetadata } from "./stream.js";
|
||||
export {
|
||||
useStream,
|
||||
type MessageMetadata,
|
||||
type UseStream,
|
||||
type UseStreamOptions,
|
||||
} from "./stream.js";
|
||||
|
||||
@@ -405,7 +405,7 @@ type GetCustomEventType<Bag extends BagTemplate> = Bag extends {
|
||||
? Bag["CustomEventType"]
|
||||
: unknown;
|
||||
|
||||
interface UseStreamOptions<
|
||||
export interface UseStreamOptions<
|
||||
StateType extends Record<string, unknown> = Record<string, unknown>,
|
||||
Bag extends BagTemplate = BagTemplate,
|
||||
> {
|
||||
|
||||
Reference in New Issue
Block a user