mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-19 14:15:44 +02:00
Compare commits
137
Commits
| 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 | ||
|
|
2a7d48582f | ||
|
|
b7bd87a063 | ||
|
|
2a825cc0e0 | ||
|
|
436902e5a3 | ||
|
|
0ac6a96c6e | ||
|
|
d9856d92af | ||
|
|
b3487cbc49 | ||
|
|
d06075cbcf | ||
|
|
e0be9ae2ef | ||
|
|
19cfe3a0a9 | ||
|
|
99a87abaa5 | ||
|
|
a03cb0b16d | ||
|
|
4be86b2a51 | ||
|
|
06e660a845 | ||
|
|
794dc1ae92 | ||
|
|
6e7bfecbbc | ||
|
|
fa080ea689 | ||
|
|
9fb06fc5af | ||
|
|
5debbb23ca | ||
|
|
7dcc760fd8 | ||
|
|
fdb9b9b8e0 | ||
|
|
2f51a15064 | ||
|
|
d2acacfc8f | ||
|
|
622a15b89e | ||
|
|
bf50938de5 | ||
|
|
d67a500cd9 | ||
|
|
c6f5e561ec | ||
|
|
288fe12933 | ||
|
|
5b58efc8d7 | ||
|
|
2e1e582991 | ||
|
|
72260e64d5 | ||
|
|
3193f5d063 | ||
|
|
1b9093459c | ||
|
|
392805938e | ||
|
|
4fb2aeacc7 | ||
|
|
8252668bcc | ||
|
|
27e4b0fcfe | ||
|
|
ba388e25b3 | ||
|
|
947a233fc5 | ||
|
|
b76dc8ae0a | ||
|
|
cbbfaba1fd | ||
|
|
e9aec77893 | ||
|
|
44691b69a3 | ||
|
|
067c4dd246 | ||
|
|
ccc21974e0 | ||
|
|
a6e66746f7 | ||
|
|
3a17df6106 | ||
|
|
52c2837e42 | ||
|
|
cee6a450dc | ||
|
|
0b3bf37a55 | ||
|
|
305a676675 | ||
|
|
5b73e38c38 | ||
|
|
41fb5ec77c | ||
|
|
cff349e22e | ||
|
|
8f32fc4819 | ||
|
|
5690555394 | ||
|
|
933d6aa8f5 | ||
|
|
7d5621a84f | ||
|
|
5f1213a1c7 | ||
|
|
a98f9542fa | ||
|
|
d2e854b04f | ||
|
|
c5b118a672 | ||
|
|
aee39605e0 | ||
|
|
72bec9161a | ||
|
|
ae17e77522 | ||
|
|
a96fc75c55 | ||
|
|
d541ed90d5 | ||
|
|
7d7708fe42 | ||
|
|
c757247858 | ||
|
|
5a0228cb13 | ||
|
|
4abfc7702d | ||
|
|
a5495e84c8 | ||
|
|
4f353dac31 | ||
|
|
4c89bb39d4 | ||
|
|
7ebd6f5e1f | ||
|
|
0a1dd7a01a | ||
|
|
7e08339335 | ||
|
|
e1d4b5552d |
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -71,4 +71,3 @@ jobs:
|
||||
working-directory: libs/cli/js-examples
|
||||
run: |
|
||||
langgraph build -t langgraph-test-e
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
description: "From which folder this pipeline executes"
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
# This env var allows us to get inline annotations when ruff has complaints.
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
@@ -50,12 +50,6 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: poetry check
|
||||
|
||||
- name: Check lock file
|
||||
if: steps.changed-files.outputs.all
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: poetry check --lock
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changed-files.outputs.all
|
||||
# Also installs dev/lint/test/typing dependencies, to ensure we have
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
description: "From which folder this pipeline executes"
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -39,12 +39,6 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_RO_TOKEN }}
|
||||
|
||||
- name: Check Lock
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
poetry check --lock
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
description: "From which folder this pipeline executes"
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
PYTHON_VERSION: "3.10"
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
- "libs/**"
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- "libs/**"
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
markdown-link-check:
|
||||
@@ -42,8 +42,8 @@ jobs:
|
||||
|
||||
- name: Check README.md is in sync
|
||||
run: |
|
||||
if ! diff -q README.md libs/langgraph/README.md >/dev/null; then
|
||||
echo "README.md is out of sync with libs/langgraph/README.md"
|
||||
diff -C 3 README.md libs/langgraph/README.md
|
||||
exit 1
|
||||
fi
|
||||
if ! diff -q README.md libs/langgraph/README.md >/dev/null; then
|
||||
echo "README.md is out of sync with libs/langgraph/README.md"
|
||||
diff -C 3 README.md libs/langgraph/README.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
POETRY_VERSION: "1.7.1"
|
||||
POETRY_VERSION: "2.1.2"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -9,7 +9,7 @@ on:
|
||||
type: string
|
||||
description: "JSON string of changed files"
|
||||
schedule:
|
||||
- cron: '0 13 * * *'
|
||||
- cron: "0 13 * * *"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
uses: "./.github/actions/poetry_setup"
|
||||
with:
|
||||
python-version: 3.11
|
||||
poetry-version: 1.7.1
|
||||
poetry-version: 2.1.2
|
||||
cache-key: test-langgraph-notebooks
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
poetry install --with test
|
||||
poetry install --with test --no-root
|
||||
poetry run pip install jupyter
|
||||
|
||||
- name: Start services
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# How to integrate LangGraph into your React application
|
||||
|
||||
!!! info "Prerequisites" - [LangGraph Platform](../../concepts/langgraph_platform.md) - [LangGraph Server](../../concepts/langgraph_server.md)
|
||||
!!! info "Prerequisites"
|
||||
|
||||
- [LangGraph Platform](../../concepts/langgraph_platform.md)
|
||||
- [LangGraph Server](../../concepts/langgraph_server.md)
|
||||
|
||||
The `useStream()` React hook provides a seamless way to integrate LangGraph into your React applications. It handles all the complexities of streaming, state management, and branching logic, letting you focus on building great chat experiences.
|
||||
|
||||
@@ -155,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).
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
LangGraph Platform provides a flexible authentication and authorization system that can integrate with most authentication schemes.
|
||||
|
||||
!!! note "Python only"
|
||||
|
||||
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Authentication vs Authorization
|
||||
@@ -146,7 +142,7 @@ The returned user information is available:
|
||||
|
||||
After authentication, LangGraph calls your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers to control access to specific resources (e.g., threads, assistants, crons). These handlers can:
|
||||
|
||||
1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. See the [supported actions table](##supported-actions) for the list of types the value can take for each action.
|
||||
1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. See the [supported actions table](#supported-actions) for the list of types the value can take for each action.
|
||||
2. Filter resources by metadata during search/list or read operations by returning a [filter dictionary](#filter-operations).
|
||||
3. Raise an HTTP exception if access is denied.
|
||||
|
||||
@@ -289,7 +285,7 @@ async def on_assistant_create(
|
||||
)
|
||||
```
|
||||
|
||||
Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action. Requests to create, update,
|
||||
Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action.
|
||||
|
||||
### Filter Operations {#filter-operations}
|
||||
|
||||
@@ -423,6 +419,7 @@ Here are all the supported action handlers:
|
||||
| | `@auth.on.crons.search` | Listing cron jobs | [`CronsSearch`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.CronsSearch) |
|
||||
|
||||
???+ note "About Runs"
|
||||
|
||||
Runs are scoped to their parent thread for access control. This means permissions are typically inherited from the thread, reflecting the conversational nature of the data model. All run operations (reading, listing) except creation are controlled by the thread's handlers.
|
||||
There is a specific `create_run` handler for creating new runs because it had more arguments that you can view in the handler.
|
||||
|
||||
|
||||
@@ -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,6 +4,10 @@ LangGraph has a built-in persistence layer, implemented through checkpointers. W
|
||||
|
||||

|
||||
|
||||
!!! info "LangGraph API handles checkpointing automatically"
|
||||
|
||||
When using the LangGraph API, you don't need to implement or configure checkpointers manually. The API handles all persistence infrastructure for you behind the scenes.
|
||||
|
||||
## Threads
|
||||
|
||||
A thread is a unique ID or [thread identifier](#threads) assigned to each checkpoint saved by a checkpointer. When invoking graph with a checkpointer, you **must** specify a `thread_id` as part of the `configurable` portion of the config:
|
||||
@@ -26,7 +30,7 @@ Let's see what checkpoints are saved when a simple graph is invoked as follows:
|
||||
|
||||
```python
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from typing import Annotated
|
||||
from typing_extensions import TypedDict
|
||||
from operator import add
|
||||
@@ -49,7 +53,7 @@ workflow.add_edge(START, "node_a")
|
||||
workflow.add_edge("node_a", "node_b")
|
||||
workflow.add_edge("node_b", END)
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
checkpointer = InMemorySaver()
|
||||
graph = workflow.compile(checkpointer=checkpointer)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
@@ -223,6 +227,10 @@ But, what if we want to retain some information *across threads*? Consider the c
|
||||
|
||||
With checkpointers alone, we cannot share information across threads. This motivates the need for the [`Store`](../reference/store.md#langgraph.store.base.BaseStore) interface. As an illustration, we can define an `InMemoryStore` to store information about a user across threads. We simply compile our graph with a checkpointer, as before, and with our new `in_memory_store` variable.
|
||||
|
||||
!!! info "LangGraph API handles stores automatically"
|
||||
|
||||
When using the LangGraph API, you don't need to implement or configure stores manually. The API handles all storage infrastructure for you behind the scenes.
|
||||
|
||||
### Basic Usage
|
||||
|
||||
First, let's showcase this in isolation without using LangGraph.
|
||||
@@ -324,10 +332,10 @@ store.put(
|
||||
With this all in place, we use the `in_memory_store` in LangGraph. The `in_memory_store` works hand-in-hand with the checkpointer: the checkpointer saves state to threads, as discussed above, and the `in_memory_store` allows us to store arbitrary information for access *across* threads. We compile the graph with both the checkpointer and the `in_memory_store` as follows.
|
||||
|
||||
```python
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
# We need this because we want to enable threads (conversations)
|
||||
checkpointer = MemorySaver()
|
||||
checkpointer = InMemorySaver()
|
||||
|
||||
# ... Define the graph ...
|
||||
|
||||
@@ -440,6 +448,7 @@ Under the hood, checkpointing is powered by checkpointer objects that conform to
|
||||
* `langgraph-checkpoint-sqlite`: An implementation of LangGraph checkpointer that uses SQLite database ([SqliteSaver][langgraph.checkpoint.sqlite.SqliteSaver] / [AsyncSqliteSaver][langgraph.checkpoint.sqlite.aio.AsyncSqliteSaver]). Ideal for experimentation and local workflows. Needs to be installed separately.
|
||||
* `langgraph-checkpoint-postgres`: An advanced checkpointer that uses Postgres database ([PostgresSaver][langgraph.checkpoint.postgres.PostgresSaver] / [AsyncPostgresSaver][langgraph.checkpoint.postgres.aio.AsyncPostgresSaver]), used in LangGraph Cloud. Ideal for using in production. Needs to be installed separately.
|
||||
|
||||
|
||||
### Checkpointer interface
|
||||
|
||||
Each checkpointer conforms to [BaseCheckpointSaver][langgraph.checkpoint.base.BaseCheckpointSaver] interface and implements the following methods:
|
||||
@@ -452,7 +461,7 @@ Each checkpointer conforms to [BaseCheckpointSaver][langgraph.checkpoint.base.Ba
|
||||
If the checkpointer is used with asynchronous graph execution (i.e. executing the graph via `.ainvoke`, `.astream`, `.abatch`), asynchronous versions of the above methods will be used (`.aput`, `.aput_writes`, `.aget_tuple`, `.alist`).
|
||||
|
||||
!!! note Note
|
||||
For running your graph asynchronously, you can use `MemorySaver`, or async versions of Sqlite/Postgres checkpointers -- `AsyncSqliteSaver` / `AsyncPostgresSaver` checkpointers.
|
||||
For running your graph asynchronously, you can use `InMemorySaver`, or async versions of Sqlite/Postgres checkpointers -- `AsyncSqliteSaver` / `AsyncPostgresSaver` checkpointers.
|
||||
|
||||
### Serializer
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,10 +9,6 @@
|
||||
|
||||
For a more guided walkthrough, see [**setting up custom authentication**](../../tutorials/auth/getting_started.md) tutorial.
|
||||
|
||||
???+ note "Python only"
|
||||
|
||||
We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon.
|
||||
|
||||
???+ note "Support by deployment type"
|
||||
|
||||
Custom auth is supported for all deployments in the **managed LangGraph Cloud**, as well as **Enterprise** self-hosted plans. It is not supported for **Lite** self-hosted plans.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
" - [Memory](../../concepts/memory/)\n",
|
||||
" - [Chat Models](https://python.langchain.com/docs/concepts/chat_models/)\n",
|
||||
"\n",
|
||||
"!!! info \"Not needed for LangGraph API users\"\n",
|
||||
"\n",
|
||||
" If you're using the LangGraph API, you needn't manually implement a checkpointer. The API automatically handles checkpointing for you. This guide is relevant when implementing LangGraph in your own custom server.\n",
|
||||
"\n",
|
||||
"Many AI applications need memory to share context across multiple interactions on the same [thread](../../concepts/persistence#threads) (e.g., multiple turns of a conversation). In LangGraph functional API, this kind of memory can be added to any [entrypoint()][langgraph.func.entrypoint] workflow using [thread-level persistence](https://langchain-ai.github.io/langgraph/concepts/persistence).\n",
|
||||
"\n",
|
||||
"When creating a LangGraph workflow, you can set it up to persist its results by using a [checkpointer](https://langchain-ai.github.io/langgraph/reference/checkpoints/#basecheckpointsaver):\n",
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
" </p>\n",
|
||||
"</div> \n",
|
||||
"\n",
|
||||
"!!! info \"Not needed for LangGraph API users\"\n",
|
||||
"\n",
|
||||
" If you're using the LangGraph API, you needn't manually implement a checkpointer. The API automatically handles checkpointing for you. This guide is relevant when implementing LangGraph in your own custom server.\n",
|
||||
"\n",
|
||||
"Many AI applications need memory to share context across multiple interactions. In LangGraph, this kind of memory can be added to any [StateGraph](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.StateGraph) using [thread-level persistence](https://langchain-ai.github.io/langgraph/concepts/persistence) .\n",
|
||||
"\n",
|
||||
"When creating any LangGraph graph, you can set it up to persist its state by adding a [checkpointer](https://langchain-ai.github.io/langgraph/reference/checkpoints/#basecheckpointsaver) when compiling the graph:\n",
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
" </p>\n",
|
||||
"</div> \n",
|
||||
"\n",
|
||||
"!!! info \"Not needed for LangGraph API users\"\n",
|
||||
"\n",
|
||||
" If you're using the LangGraph API, you needn't manually implement a checkpointer. The API automatically handles checkpointing for you. This guide is relevant when implementing LangGraph in your own custom server.\n",
|
||||
"\n",
|
||||
"When creating LangGraph agents, you can also set them up so that they persist their state. This allows you to do things like interact with an agent multiple times and have it remember previous interactions.\n",
|
||||
"\n",
|
||||
"This how-to guide shows how to use `Postgres` as the backend for persisting checkpoint state using the [`langgraph-checkpoint-postgres`](https://github.com/langchain-ai/langgraph/tree/main/libs/checkpoint-postgres) library.\n",
|
||||
@@ -44,7 +48,7 @@
|
||||
"...\n",
|
||||
"```\n",
|
||||
"\n",
|
||||
"!!! info \"Setup\"",
|
||||
"!!! info \"Setup\"\n",
|
||||
"\n",
|
||||
" You need to run `.setup()` once on your checkpointer to initialize the database before you can use it."
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
!!! tip "Prerequisites"
|
||||
|
||||
This guide assumes familiarity with the [LangGraph Platform](../../concepts/index.md#langgraph-platform), [Persistence](../../concepts/persistence.md), and [Cross-thread persistence](../../concepts/store.md) concepts.
|
||||
This guide assumes familiarity with the [LangGraph Platform](../../concepts/index.md#langgraph-platform), [Persistence](../../concepts/persistence.md), and [Cross-thread persistence](../../concepts/persistence.md#memory-store) concepts.
|
||||
|
||||
???+ note "LangGraph platform only"
|
||||
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
---
|
||||
hide_comments: true
|
||||
title: Home
|
||||
title: LangGraph
|
||||
---
|
||||
|
||||
<script>
|
||||
@@ -23,6 +23,9 @@ title: Home
|
||||
.md-content h1 {
|
||||
display: none;
|
||||
}
|
||||
.md-header__topic {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
{!../README.md!}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import warnings
|
||||
from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import Any, Optional
|
||||
@@ -150,7 +151,7 @@ def _dump_blobs(
|
||||
checkpoint_ns: str,
|
||||
values: dict[str, Any],
|
||||
versions: ChannelVersions,
|
||||
) -> list[tuple[str, str, str, str, str, Optional[bytes]]]:
|
||||
) -> list[tuple[str, str, str, str, Optional[bytes]]]:
|
||||
if not versions:
|
||||
return []
|
||||
|
||||
@@ -188,6 +189,12 @@ class ShallowPostgresSaver(BasePostgresSaver):
|
||||
pipe: Optional[Pipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
warnings.warn(
|
||||
"ShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
|
||||
"Use PostgresSaver instead, and invoke the graph with `graph.invoke(..., checkpoint_during=False)`.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
super().__init__(serde=serde)
|
||||
if isinstance(conn, ConnectionPool) and pipe is not None:
|
||||
raise ValueError(
|
||||
@@ -528,6 +535,12 @@ class AsyncShallowPostgresSaver(BasePostgresSaver):
|
||||
pipe: Optional[AsyncPipeline] = None,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
warnings.warn(
|
||||
"AsyncShallowPostgresSaver is deprecated as of version 2.0.20 and will be removed in 3.0.0. "
|
||||
"Use AsyncPostgresSaver instead, and invoke the graph with `await graph.ainvoke(..., checkpoint_during=False)`.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
super().__init__(serde=serde)
|
||||
if isinstance(conn, AsyncConnectionPool) and pipe is not None:
|
||||
raise ValueError(
|
||||
|
||||
@@ -38,6 +38,8 @@ class InMemorySaver(
|
||||
Only use `InMemorySaver` for debugging or testing purposes.
|
||||
For production use cases we recommend installing [langgraph-checkpoint-postgres](https://pypi.org/project/langgraph-checkpoint-postgres/) and using `PostgresSaver` / `AsyncPostgresSaver`.
|
||||
|
||||
If you are using the LangGraph Platform, no checkpointer needs to be specified. The correct managed checkpointer will be used automatically.
|
||||
|
||||
Args:
|
||||
serde (Optional[SerializerProtocol]): The serializer to use for serializing and deserializing checkpoints. Defaults to None.
|
||||
|
||||
|
||||
@@ -274,23 +274,13 @@ def _build(
|
||||
tag: str,
|
||||
passthrough: Sequence[str] = (),
|
||||
):
|
||||
base_image = base_image or (
|
||||
"langchain/langgraphjs-api"
|
||||
if config_json.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
)
|
||||
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
(
|
||||
f"{base_image}:{config_json['node_version']}"
|
||||
if config_json.get("node_version")
|
||||
else f"{base_image}:{config_json['python_version']}"
|
||||
),
|
||||
langgraph_cli.config.docker_tag(config_json, base_image),
|
||||
verbose=True,
|
||||
)
|
||||
)
|
||||
@@ -450,11 +440,7 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
|
||||
dockerfile, additional_contexts = langgraph_cli.config.config_to_docker(
|
||||
config,
|
||||
config_json,
|
||||
(
|
||||
"langchain/langgraphjs-api"
|
||||
if config_json.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
),
|
||||
None,
|
||||
)
|
||||
with open(str(save_path), "w", encoding="utf-8") as f:
|
||||
f.write(dockerfile)
|
||||
@@ -719,11 +705,7 @@ def prepare_args_and_stdin(
|
||||
config_path,
|
||||
config,
|
||||
watch=watch,
|
||||
base_image=(
|
||||
"langchain/langgraphjs-api"
|
||||
if config.get("node_version")
|
||||
else "langchain/langgraph-api"
|
||||
),
|
||||
base_image=langgraph_cli.config.default_base_image(config),
|
||||
)
|
||||
return args, stdin
|
||||
|
||||
@@ -750,11 +732,7 @@ def prepare(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
(
|
||||
f"langchain/langgraphjs-api:{config_json['node_version']}"
|
||||
if config_json.get("node_version")
|
||||
else f"langchain/langgraph-api:{config_json['python_version']}"
|
||||
),
|
||||
langgraph_cli.config.docker_tag(config_json),
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,7 +8,10 @@ from typing import Any, Literal, NamedTuple, Optional, TypedDict, Union
|
||||
import click
|
||||
|
||||
MIN_NODE_VERSION = "20"
|
||||
DEFAULT_NODE_VERSION = "20"
|
||||
|
||||
MIN_PYTHON_VERSION = "3.11"
|
||||
DEFAULT_PYTHON_VERSION = "3.11"
|
||||
|
||||
|
||||
class TTLConfig(TypedDict, total=False):
|
||||
@@ -406,6 +409,18 @@ class Config(TypedDict, total=False):
|
||||
"""
|
||||
|
||||
|
||||
PIP_CLEANUP_LINES = """# -- Ensure user deps didn't inadvertently overwrite langgraph-api
|
||||
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
|
||||
touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir --no-deps -e /api
|
||||
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
|
||||
# -- Removing pip from the final image ~<:===~~~ --
|
||||
RUN pip uninstall -y pip setuptools wheel && \
|
||||
rm -rf /usr/local/lib/python*/site-packages/pip* /usr/local/lib/python*/site-packages/setuptools* /usr/local/lib/python*/site-packages/wheel* && \
|
||||
find /usr/local/bin -name "pip*" -delete
|
||||
# -- End of pip removal --"""
|
||||
|
||||
|
||||
def _parse_version(version_str: str) -> tuple[int, int]:
|
||||
"""Parse a version string into a tuple of (major, minor)."""
|
||||
try:
|
||||
@@ -428,38 +443,54 @@ def _parse_node_version(version_str: str) -> int:
|
||||
) from None
|
||||
|
||||
|
||||
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 [
|
||||
".ts",
|
||||
".mts",
|
||||
".cts",
|
||||
".js",
|
||||
".mjs",
|
||||
".cjs",
|
||||
]
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
"""Validate a configuration dictionary."""
|
||||
config = (
|
||||
{
|
||||
"node_version": config.get("node_version"),
|
||||
"dockerfile_lines": config.get("dockerfile_lines", []),
|
||||
"dependencies": config.get("dependencies", []),
|
||||
"graphs": config.get("graphs", {}),
|
||||
"env": config.get("env", {}),
|
||||
"store": config.get("store"),
|
||||
"auth": config.get("auth"),
|
||||
"http": config.get("http"),
|
||||
"checkpointer": config.get("checkpointer"),
|
||||
"ui": config.get("ui"),
|
||||
"ui_config": config.get("ui_config"),
|
||||
}
|
||||
if config.get("node_version")
|
||||
else {
|
||||
"python_version": config.get("python_version", "3.11"),
|
||||
"pip_config_file": config.get("pip_config_file"),
|
||||
"dockerfile_lines": config.get("dockerfile_lines", []),
|
||||
"dependencies": config.get("dependencies", []),
|
||||
"graphs": config.get("graphs", {}),
|
||||
"env": config.get("env", {}),
|
||||
"store": config.get("store"),
|
||||
"auth": config.get("auth"),
|
||||
"http": config.get("http"),
|
||||
"checkpointer": config.get("checkpointer"),
|
||||
"ui": config.get("ui"),
|
||||
"ui_config": config.get("ui_config"),
|
||||
}
|
||||
|
||||
graphs = config.get("graphs", {})
|
||||
|
||||
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
|
||||
)
|
||||
python_version = config.get(
|
||||
"python_version", DEFAULT_PYTHON_VERSION if some_python else None
|
||||
)
|
||||
|
||||
config = {
|
||||
"node_version": node_version,
|
||||
"python_version": python_version,
|
||||
"pip_config_file": config.get("pip_config_file"),
|
||||
"dependencies": config.get("dependencies", []),
|
||||
"dockerfile_lines": config.get("dockerfile_lines", []),
|
||||
"graphs": config.get("graphs", {}),
|
||||
"env": config.get("env", {}),
|
||||
"store": config.get("store"),
|
||||
"auth": config.get("auth"),
|
||||
"http": config.get("http"),
|
||||
"checkpointer": config.get("checkpointer"),
|
||||
"ui": config.get("ui"),
|
||||
"ui_config": config.get("ui_config"),
|
||||
}
|
||||
|
||||
if config.get("node_version"):
|
||||
node_version = config["node_version"]
|
||||
@@ -1073,26 +1104,11 @@ ADD {relpath} /deps/{name}
|
||||
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
|
||||
)
|
||||
|
||||
ui_inst_str: str = ""
|
||||
install_node_str: str = ""
|
||||
|
||||
if config.get("ui") and local_deps.working_dir:
|
||||
install_node_str = "RUN /storage/install-node.sh"
|
||||
|
||||
ui_inst: list[str] = []
|
||||
ui_inst.append(f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'")
|
||||
if config.get("ui_config"):
|
||||
ui_inst.append(
|
||||
f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'"
|
||||
)
|
||||
|
||||
ui_inst.append(
|
||||
f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
|
||||
)
|
||||
|
||||
ui_inst_str = f"""# -- Installing UI dependencies --
|
||||
{os.linesep.join(ui_inst)}
|
||||
# -- End of UI dependencies install --"""
|
||||
install_node_str: str = (
|
||||
"RUN /storage/install-node.sh"
|
||||
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir
|
||||
else ""
|
||||
)
|
||||
|
||||
installs = f"{os.linesep}{os.linesep}".join(
|
||||
filter(
|
||||
@@ -1124,8 +1140,24 @@ ADD {relpath} /deps/{name}
|
||||
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
|
||||
)
|
||||
|
||||
graphs = config["graphs"]
|
||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(graphs)}'")
|
||||
if (ui := config.get("ui")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
|
||||
|
||||
if (ui_config := config.get("ui_config")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
|
||||
|
||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
|
||||
|
||||
js_inst_str: str = ""
|
||||
if (config.get("ui") or config.get("node_version")) and local_deps.working_dir:
|
||||
js_inst_str = os.linesep.join(
|
||||
[
|
||||
"# -- Installing JS dependencies --",
|
||||
f"ENV NODE_VERSION={config.get('node_version') or DEFAULT_NODE_VERSION}",
|
||||
f"RUN cd {local_deps.working_dir} && {_get_node_pm_install_cmd(config_path, config)} && tsx /api/langgraph_api/js/build.mts",
|
||||
"# -- End of JS dependencies install --",
|
||||
]
|
||||
)
|
||||
|
||||
docker_file_contents = [
|
||||
f"FROM {base_image}:{config['python_version']}",
|
||||
@@ -1139,7 +1171,9 @@ ADD {relpath} /deps/{name}
|
||||
"# -- End of local dependencies install --",
|
||||
os.linesep.join(env_vars),
|
||||
"",
|
||||
ui_inst_str,
|
||||
js_inst_str,
|
||||
"",
|
||||
PIP_CLEANUP_LINES, # Add pip cleanup after all installations are complete
|
||||
"",
|
||||
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
|
||||
]
|
||||
@@ -1162,51 +1196,70 @@ def node_config_to_docker(
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
faux_path = f"/deps/{config_path.parent.name}"
|
||||
install_cmd = _get_node_pm_install_cmd(config_path, config)
|
||||
store_config = config.get("store")
|
||||
env_additional_config = (
|
||||
""
|
||||
if not store_config
|
||||
else f"""
|
||||
ENV LANGGRAPH_STORE='{json.dumps(store_config)}'
|
||||
"""
|
||||
)
|
||||
|
||||
env_vars: list[str] = []
|
||||
|
||||
if (store_config := config.get("store")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_STORE='{json.dumps(store_config)}'")
|
||||
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_additional_config += f"""
|
||||
ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'
|
||||
"""
|
||||
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
|
||||
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_additional_config += f"""
|
||||
ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'
|
||||
"""
|
||||
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
|
||||
|
||||
if (checkpointer_config := config.get("checkpointer")) is not None:
|
||||
env_additional_config += f"""
|
||||
ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'
|
||||
"""
|
||||
env_vars.append(
|
||||
f"ENV LANGGRAPH_CHECKPOINTER='{json.dumps(checkpointer_config)}'"
|
||||
)
|
||||
|
||||
return (
|
||||
f"""FROM {base_image}:{config['node_version']}
|
||||
if ui := config.get("ui"):
|
||||
env_vars.append(f"ENV LANGGRAPH_UI='{json.dumps(ui)}'")
|
||||
|
||||
{os.linesep.join(config["dockerfile_lines"])}
|
||||
if ui_config := config.get("ui_config"):
|
||||
env_vars.append(f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(ui_config)}'")
|
||||
|
||||
ADD . {faux_path}
|
||||
env_vars.append(f"ENV LANGSERVE_GRAPHS='{json.dumps(config['graphs'])}'")
|
||||
|
||||
RUN cd {faux_path} && {install_cmd}
|
||||
{env_additional_config}
|
||||
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
|
||||
{f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'" if config.get("ui") else ""}
|
||||
{f"ENV LANGGRAPH_UI_CONFIG='{json.dumps(config['ui_config'])}'" if config.get("ui_config") else ""}
|
||||
docker_file_contents = [
|
||||
f"FROM {base_image}:{config['node_version']}",
|
||||
"",
|
||||
os.linesep.join(config["dockerfile_lines"]),
|
||||
"",
|
||||
f"ADD . {faux_path}",
|
||||
"",
|
||||
f"RUN cd {faux_path} && {install_cmd}",
|
||||
"",
|
||||
os.linesep.join(env_vars),
|
||||
"",
|
||||
f"WORKDIR {faux_path}",
|
||||
"",
|
||||
'RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts',
|
||||
]
|
||||
|
||||
WORKDIR {faux_path}
|
||||
return os.linesep.join(docker_file_contents), {}
|
||||
|
||||
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts""",
|
||||
{},
|
||||
)
|
||||
|
||||
def default_base_image(config: Config) -> str:
|
||||
if config.get("node_version") and not config.get("python_version"):
|
||||
return "langchain/langgraphjs-api"
|
||||
return "langchain/langgraph-api"
|
||||
|
||||
|
||||
def docker_tag(config: Config, base_image: Optional[str] = None) -> str:
|
||||
base_image = base_image or default_base_image(config)
|
||||
|
||||
if config.get("node_version") and not config.get("python_version"):
|
||||
return f"{base_image}:{config['node_version']}"
|
||||
return f"{base_image}:{config['python_version']}"
|
||||
|
||||
|
||||
def config_to_docker(
|
||||
config_path: pathlib.Path, config: Config, base_image: str
|
||||
config_path: pathlib.Path, config: Config, base_image: Optional[str] = None
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
if config.get("node_version"):
|
||||
base_image = base_image or default_base_image(config)
|
||||
|
||||
if config.get("node_version") and not config.get("python_version"):
|
||||
return node_config_to_docker(config_path, config, base_image)
|
||||
|
||||
return python_config_to_docker(config_path, config, base_image)
|
||||
@@ -1215,9 +1268,11 @@ def config_to_docker(
|
||||
def config_to_compose(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
base_image: str,
|
||||
base_image: Optional[str] = None,
|
||||
watch: bool = False,
|
||||
) -> str:
|
||||
base_image = base_image or default_base_image(config)
|
||||
|
||||
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
|
||||
env_vars_str = "\n".join(f' {k}: "{v}"' for k, v in env_vars)
|
||||
env_file_str = (
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+33
-20
@@ -20,7 +20,7 @@ description = "High level compatibility layer for multiple asynchronous event lo
|
||||
optional = true
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a"},
|
||||
{file = "anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a"},
|
||||
@@ -59,7 +59,7 @@ description = "Python package for providing Mozilla's CA Bundle."
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"},
|
||||
{file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"},
|
||||
@@ -405,7 +405,7 @@ description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
|
||||
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
|
||||
@@ -418,7 +418,7 @@ description = "A minimal low-level HTTP client."
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"},
|
||||
{file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"},
|
||||
@@ -441,7 +441,7 @@ description = "The next generation HTTP client."
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
|
||||
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
|
||||
@@ -467,7 +467,7 @@ description = "Internationalized Domain Names in Applications (IDNA)"
|
||||
optional = true
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
@@ -614,27 +614,27 @@ langgraph-sdk = ">=0.1.42,<0.2.0"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-api"
|
||||
version = "0.0.42"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
optional = true
|
||||
python-versions = "<4.0,>=3.11.0"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
files = [
|
||||
{file = "langgraph_api-0.0.42-py3-none-any.whl", hash = "sha256:19f69d9d39efde60a9bd3eeae6dc7dbe8d04b1b6fccf4ddf51d7e6b7187cc6ea"},
|
||||
{file = "langgraph_api-0.0.42.tar.gz", hash = "sha256:a0a18545c73f9703d5d5907fc030e4a0acb79d1e6b79d4e38b3cac2bfb470e97"},
|
||||
{file = "langgraph_api-0.1.0-py3-none-any.whl", hash = "sha256:93eb369849d5ea0dd6076f0a36fe0a9669415c46e95fcb28ccf30c73e13e5e01"},
|
||||
{file = "langgraph_api-0.1.0.tar.gz", hash = "sha256:d02201d34172f3020af8f21c4560142b302474cee4a27f4335fbd950ab22f22f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
blockbuster = ">=1.5.24,<2.0.0"
|
||||
cloudpickle = ">=3.0.0,<4.0.0"
|
||||
cryptography = ">=43.0.3,<44.0.0"
|
||||
cryptography = ">=42.0.0,<45.0"
|
||||
httpx = ">=0.25.0"
|
||||
jsonschema-rs = ">=0.20.0,<0.30"
|
||||
langchain-core = ">=0.2.38,<0.4.0"
|
||||
langgraph = ">=0.2.56,<0.4.0"
|
||||
langgraph-checkpoint = ">=2.0.23,<3.0"
|
||||
langgraph-sdk = ">=0.1.59,<0.2.0"
|
||||
langgraph-sdk = ">=0.1.61,<0.2.0"
|
||||
langsmith = ">=0.1.63,<0.4.0"
|
||||
orjson = ">=3.9.7"
|
||||
pyjwt = ">=2.9.0,<3.0.0"
|
||||
@@ -679,17 +679,30 @@ files = [
|
||||
langchain-core = ">=0.2.43,<0.3.0 || >0.3.0,<0.3.1 || >0.3.1,<0.3.2 || >0.3.2,<0.3.3 || >0.3.3,<0.3.4 || >0.3.4,<0.3.5 || >0.3.5,<0.3.6 || >0.3.6,<0.3.7 || >0.3.7,<0.3.8 || >0.3.8,<0.3.9 || >0.3.9,<0.3.10 || >0.3.10,<0.3.11 || >0.3.11,<0.3.12 || >0.3.12,<0.3.13 || >0.3.13,<0.3.14 || >0.3.14,<0.3.15 || >0.3.15,<0.3.16 || >0.3.16,<0.3.17 || >0.3.17,<0.3.18 || >0.3.18,<0.3.19 || >0.3.19,<0.3.20 || >0.3.20,<0.3.21 || >0.3.21,<0.3.22 || >0.3.22,<0.4.0"
|
||||
langgraph-checkpoint = ">=2.0.10,<3.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-runtime-inmem"
|
||||
version = "0.0.1"
|
||||
description = "Inmem implementation for the LangGraph API server."
|
||||
optional = true
|
||||
python-versions = ">=3.11"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
files = [
|
||||
{file = "langgraph_runtime_inmem-0.0.1-py3-none-any.whl", hash = "sha256:a25ec8e3219f2fd60450de38412d24c83fbf0b2521c13871cc26ce9a68ead496"},
|
||||
{file = "langgraph_runtime_inmem-0.0.1.tar.gz", hash = "sha256:144bf5217efec4969f7f9c5e8279d6914cc5133d4b1a19e466b4966fa05f00c5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-sdk"
|
||||
version = "0.1.60"
|
||||
version = "0.1.61"
|
||||
description = "SDK for interacting with LangGraph API"
|
||||
optional = true
|
||||
python-versions = "<4.0.0,>=3.9.0"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "langgraph_sdk-0.1.60-py3-none-any.whl", hash = "sha256:953df85b0a6cc3a106f0496ce8f950a65d88b3ba8198c3b4bb58a54469b256a9"},
|
||||
{file = "langgraph_sdk-0.1.60.tar.gz", hash = "sha256:7857a4a2a20a6a4c9934d1e7b5145eda92e3bc7286121813de2464d071050f88"},
|
||||
{file = "langgraph_sdk-0.1.61-py3-none-any.whl", hash = "sha256:f2d774b12497c428862993090622d51e0dbc3f53e0cee3d74a13c7495d835cc6"},
|
||||
{file = "langgraph_sdk-0.1.61.tar.gz", hash = "sha256:87dd1f07ab82da8875ac343268ece8bf5414632017ebc9d1cef4b523962fd601"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -851,7 +864,7 @@ description = "Fast, correct Python JSON library supporting dataclasses, datetim
|
||||
optional = true
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04"},
|
||||
{file = "orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8"},
|
||||
@@ -1414,7 +1427,7 @@ description = "Sniff out which async library your code is running under"
|
||||
optional = true
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
markers = "python_version >= \"3.11\" and extra == \"inmem\""
|
||||
markers = "python_version >= \"3.11\""
|
||||
files = [
|
||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
@@ -1550,7 +1563,7 @@ files = [
|
||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
||||
]
|
||||
markers = {main = "python_version >= \"3.11\" and extra == \"inmem\""}
|
||||
markers = {main = "python_version >= \"3.11\" and (python_version < \"3.13\" or extra == \"inmem\")"}
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
@@ -1834,9 +1847,9 @@ cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\
|
||||
cffi = ["cffi (>=1.11)"]
|
||||
|
||||
[extras]
|
||||
inmem = ["langgraph-api", "python-dotenv"]
|
||||
inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "4a45d739795019ae00e18ba8b0d366209deca9c5a5e65e9f387e5cf1d5aef187"
|
||||
content-hash = "afc2f8776b4b6144bd1197df49ba34089889e2a1110b8470d8f1b212e0b08380"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.89"
|
||||
version = "0.2.4"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
@@ -14,7 +14,9 @@ langgraph = "langgraph_cli.cli:cli"
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
click = "^8.1.7"
|
||||
langgraph-api = { version = ">=0.0.42,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
langgraph-api = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
|
||||
langgraph-runtime-inmem = { version = ">=0.0.1,<0.1.0", optional = true, python = ">=3.11,<4.0" }
|
||||
langgraph-sdk = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
|
||||
python-dotenv = { version = ">=0.8.0", optional = true }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
@@ -28,7 +30,7 @@ mypy = "^1.10.0"
|
||||
msgspec = "^0.19.0"
|
||||
|
||||
[tool.poetry.extras]
|
||||
inmem = ["langgraph-api", "python-dotenv"]
|
||||
inmem = ["langgraph-api", "langgraph-runtime-inmem", "python-dotenv"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# --strict-markers will raise errors on unknown marks.
|
||||
|
||||
@@ -2,13 +2,14 @@ import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import textwrap
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from langgraph_cli.cli import cli, prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, validate_config
|
||||
from langgraph_cli.config import PIP_CLEANUP_LINES, Config, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
from langgraph_cli.util import clean_empty_lines
|
||||
|
||||
@@ -143,6 +144,7 @@ services:
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/cli
|
||||
|
||||
develop:
|
||||
@@ -176,8 +178,9 @@ def test_dockerfile_command_basic() -> None:
|
||||
"""Test the 'dockerfile' command with basic configuration."""
|
||||
runner = CliRunner()
|
||||
config_content = {
|
||||
"node_version": "20", # Add any other necessary configuration fields
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "agent.py:graph"},
|
||||
"dependencies": ["."],
|
||||
}
|
||||
|
||||
with temporary_config_folder(config_content) as temp_dir:
|
||||
|
||||
@@ -2,11 +2,13 @@ import json
|
||||
import os
|
||||
import pathlib
|
||||
import tempfile
|
||||
import textwrap
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.config import (
|
||||
PIP_CLEANUP_LINES,
|
||||
config_to_compose,
|
||||
config_to_docker,
|
||||
validate_config,
|
||||
@@ -25,8 +27,10 @@ def test_validate_config():
|
||||
"agent": "./agent.py:graph",
|
||||
},
|
||||
}
|
||||
actual_config = validate_config(expected_config)
|
||||
expected_config = {
|
||||
"python_version": "3.11",
|
||||
"node_version": None,
|
||||
"pip_config_file": None,
|
||||
"dockerfile_lines": [],
|
||||
"env": {},
|
||||
@@ -38,13 +42,13 @@ def test_validate_config():
|
||||
"ui_config": None,
|
||||
**expected_config,
|
||||
}
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
|
||||
# full config
|
||||
env = ".env"
|
||||
expected_config = {
|
||||
"python_version": "3.12",
|
||||
"node_version": None,
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
"dockerfile_lines": ["ARG meow"],
|
||||
"dependencies": [".", "langchain"],
|
||||
@@ -67,16 +71,12 @@ def test_validate_config():
|
||||
|
||||
# check wrong python version raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.9",
|
||||
}
|
||||
)
|
||||
validate_config({"python_version": "3.9"})
|
||||
|
||||
# check missing dependencies key raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config(
|
||||
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}},
|
||||
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}}
|
||||
)
|
||||
|
||||
# check missing graphs key raises
|
||||
@@ -194,6 +194,58 @@ def test_validate_config_file():
|
||||
validate_config_file(config_path)
|
||||
|
||||
|
||||
def test_validate_config_multiplatform():
|
||||
# default node
|
||||
config = validate_config(
|
||||
{"dependencies": ["."], "graphs": {"js": "./js.mts:graph"}}
|
||||
)
|
||||
assert config["node_version"] == "20"
|
||||
assert config["python_version"] is None
|
||||
|
||||
# default multiplatform
|
||||
config = validate_config(
|
||||
{
|
||||
"node_version": "22",
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"python": "./python.py:graph", "js": "./js.mts:graph"},
|
||||
}
|
||||
)
|
||||
assert config["node_version"] == "22"
|
||||
assert config["python_version"] == "3.12"
|
||||
|
||||
# default multiplatform (full infer)
|
||||
graphs = {"python": "./python.py:graph", "js": "./js.mts:graph"}
|
||||
config = validate_config({"dependencies": ["."], "graphs": graphs})
|
||||
assert config["node_version"] == "20"
|
||||
assert config["python_version"] == "3.11"
|
||||
|
||||
# default multiplatform (partial node)
|
||||
config = validate_config(
|
||||
{"node_version": "22", "dependencies": ["."], "graphs": graphs}
|
||||
)
|
||||
assert config["node_version"] == "22"
|
||||
assert config["python_version"] == "3.11"
|
||||
|
||||
# default multiplatform (partial python)
|
||||
config = validate_config(
|
||||
{"python_version": "3.12", "dependencies": ["."], "graphs": graphs}
|
||||
)
|
||||
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():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
@@ -208,7 +260,7 @@ def test_config_to_docker_simple():
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
expected_docker_stdin = f"""\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Installing local requirements --
|
||||
COPY --from=__outer_requirements.txt requirements.txt /deps/__outer_graphs_reqs_a/graphs_reqs_a/requirements.txt
|
||||
@@ -242,8 +294,9 @@ RUN set -ex && \\
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGGRAPH_HTTP='{"app": "/deps/examples/my_app.py:app"}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGGRAPH_HTTP='{{"app": "/deps/examples/my_app.py:app"}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{PIP_CLEANUP_LINES}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
@@ -263,7 +316,8 @@ def test_config_to_docker_outside_path():
|
||||
validate_config({"dependencies": [".", ".."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
expected_docker_stdin = (
|
||||
"""\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
@@ -291,8 +345,12 @@ RUN set -ex && \\
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
"""
|
||||
+ PIP_CLEANUP_LINES
|
||||
+ """
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
)
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {
|
||||
"__outer_tests": str(pathlib.Path(__file__).parent.parent.absolute()),
|
||||
@@ -312,7 +370,8 @@ def test_config_to_docker_pipconfig():
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
expected_docker_stdin = (
|
||||
"""\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
@@ -330,8 +389,12 @@ RUN set -ex && \\
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
"""
|
||||
+ PIP_CLEANUP_LINES
|
||||
+ """
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
)
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
@@ -368,7 +431,7 @@ def test_config_to_docker_local_deps():
|
||||
),
|
||||
"langchain/langgraph-api-custom",
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
expected_docker_stdin = f"""\
|
||||
FROM langchain/langgraph-api-custom:3.11
|
||||
# -- Adding non-package dependency graphs --
|
||||
ADD ./graphs /deps/__outer_graphs/src
|
||||
@@ -384,7 +447,8 @@ RUN set -ex && \\
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'\
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
|
||||
{PIP_CLEANUP_LINES}\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
@@ -411,7 +475,8 @@ dependencies = ["langchain"]"""
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
os.remove(pyproject_path)
|
||||
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
|
||||
expected_docker_stdin = (
|
||||
"""FROM langchain/langgraph-api:3.11
|
||||
# -- Adding local package . --
|
||||
ADD . /deps/unit_tests
|
||||
# -- End of local package . --
|
||||
@@ -419,7 +484,12 @@ ADD . /deps/unit_tests
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
|
||||
WORKDIR /deps/unit_tests"""
|
||||
"""
|
||||
+ PIP_CLEANUP_LINES
|
||||
+ "\n"
|
||||
+ "WORKDIR /deps/unit_tests"
|
||||
""
|
||||
)
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
@@ -439,7 +509,7 @@ def test_config_to_docker_end_to_end():
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
expected_docker_stdin = """FROM langchain/langgraph-api:3.12
|
||||
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.12
|
||||
ARG meow
|
||||
ARG foo
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
@@ -458,7 +528,8 @@ RUN set -ex && \\
|
||||
# -- Installing all local dependencies --
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'"""
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_graphs/src/agent.py:graph"}}'
|
||||
{PIP_CLEANUP_LINES}"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
@@ -473,6 +544,7 @@ def test_config_to_docker_nodejs():
|
||||
"node_version": "20",
|
||||
"graphs": graphs,
|
||||
"dockerfile_lines": ["ARG meow", "ARG foo"],
|
||||
"auth": {"path": "./graphs/auth.mts:auth"},
|
||||
"ui": {"agent": "./graphs/agent.ui.jsx"},
|
||||
"ui_config": {"shared": ["nuqs"]},
|
||||
}
|
||||
@@ -484,9 +556,10 @@ ARG meow
|
||||
ARG foo
|
||||
ADD . /deps/unit_tests
|
||||
RUN cd /deps/unit_tests && npm i
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
|
||||
ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
|
||||
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
|
||||
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
|
||||
WORKDIR /deps/unit_tests
|
||||
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
|
||||
|
||||
@@ -509,7 +582,7 @@ def test_config_to_docker_gen_ui_python():
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
|
||||
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
|
||||
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
|
||||
RUN /storage/install-node.sh
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
@@ -525,12 +598,55 @@ RUN set -ex && \\
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
# -- Installing UI dependencies --
|
||||
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
|
||||
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
|
||||
ENV LANGGRAPH_UI='{{"agent": "./graphs/agent.ui.jsx"}}'
|
||||
ENV LANGGRAPH_UI_CONFIG='{{"shared": ["nuqs"]}}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
# -- Installing JS dependencies --
|
||||
ENV NODE_VERSION=20
|
||||
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
|
||||
# -- End of UI dependencies install --
|
||||
# -- End of JS dependencies install --
|
||||
{PIP_CLEANUP_LINES}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
def test_config_to_docker_multiplatform():
|
||||
graphs = {
|
||||
"python": "./multiplatform/python.py:graph",
|
||||
"js": "./multiplatform/js.mts:graph",
|
||||
}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{"node_version": "22", "dependencies": ["."], "graphs": graphs}
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
|
||||
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
|
||||
RUN /storage/install-node.sh
|
||||
# -- Adding non-package dependency unit_tests --
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
RUN set -ex && \\
|
||||
for line in '[project]' \\
|
||||
'name = "unit_tests"' \\
|
||||
'version = "0.1"' \\
|
||||
'[tool.setuptools.package-data]' \\
|
||||
'"*" = ["**/*"]'; do \\
|
||||
echo "$line" >> /deps/__outer_unit_tests/pyproject.toml; \\
|
||||
done
|
||||
# -- End of non-package dependency unit_tests --
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{{"python": "/deps/__outer_unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/__outer_unit_tests/unit_tests/multiplatform/js.mts:graph"}}'
|
||||
# -- Installing JS dependencies --
|
||||
ENV NODE_VERSION=22
|
||||
RUN cd /deps/__outer_unit_tests/unit_tests && npm i && tsx /api/langgraph_api/js/build.mts
|
||||
# -- End of JS dependencies install --
|
||||
{PIP_CLEANUP_LINES}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
@@ -540,8 +656,8 @@ WORKDIR /deps/__outer_unit_tests/unit_tests"""
|
||||
# config_to_compose
|
||||
def test_config_to_compose_simple_config():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
|
||||
# Create a properly indented version of PIP_CLEANUP_LINES for compose files
|
||||
expected_compose_stdin = f"""
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
@@ -561,7 +677,8 @@ def test_config_to_compose_simple_config():
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
@@ -569,12 +686,15 @@ def test_config_to_compose_simple_config():
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
assert (
|
||||
clean_empty_lines(actual_compose_stdin).strip()
|
||||
== expected_compose_stdin.strip()
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_compose_env_vars():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """ OPENAI_API_KEY: "key"
|
||||
expected_compose_stdin = f""" OPENAI_API_KEY: "key"
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
@@ -595,7 +715,8 @@ def test_config_to_compose_env_vars():
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
openai_api_key = "key"
|
||||
@@ -615,7 +736,7 @@ def test_config_to_compose_env_vars():
|
||||
|
||||
def test_config_to_compose_env_file():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
expected_compose_stdin = f"""\
|
||||
env_file: .env
|
||||
pull_policy: build
|
||||
build:
|
||||
@@ -636,7 +757,8 @@ def test_config_to_compose_env_file():
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
@@ -649,7 +771,7 @@ def test_config_to_compose_env_file():
|
||||
|
||||
def test_config_to_compose_watch():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
expected_compose_stdin = f"""\
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
@@ -670,7 +792,8 @@ def test_config_to_compose_watch():
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
@@ -692,7 +815,7 @@ def test_config_to_compose_watch():
|
||||
def test_config_to_compose_end_to_end():
|
||||
# test all of the above + langgraph API path
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
expected_compose_stdin = f"""\
|
||||
env_file: .env
|
||||
pull_policy: build
|
||||
build:
|
||||
@@ -713,7 +836,8 @@ def test_config_to_compose_end_to_end():
|
||||
# -- Installing all local dependencies --
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 pip install --no-cache-dir -c /api/constraints.txt -e /deps/*
|
||||
# -- End of local dependencies install --
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}}'
|
||||
{textwrap.indent(textwrap.dedent(PIP_CLEANUP_LINES), " ")}
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,6 +9,7 @@ from bench.fanout_to_subgraph import fanout_to_subgraph, fanout_to_subgraph_sync
|
||||
from bench.pydantic_state import pydantic_state
|
||||
from bench.react_agent import react_agent
|
||||
from bench.sequential import create_sequential
|
||||
from bench.wide_dict import wide_dict
|
||||
from bench.wide_state import wide_state
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import StateGraph
|
||||
@@ -25,6 +26,7 @@ async def arun(graph: Pregel, input: dict):
|
||||
"configurable": {"thread_id": str(uuid4())},
|
||||
"recursion_limit": 1000000000,
|
||||
},
|
||||
checkpoint_during=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -41,6 +43,7 @@ async def arun_first_event_latency(graph: Pregel, input: dict) -> None:
|
||||
"configurable": {"thread_id": str(uuid4())},
|
||||
"recursion_limit": 1000000000,
|
||||
},
|
||||
checkpoint_during=False,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -60,6 +63,7 @@ def run(graph: Pregel, input: dict):
|
||||
"configurable": {"thread_id": str(uuid4())},
|
||||
"recursion_limit": 1000000000,
|
||||
},
|
||||
checkpoint_during=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -76,6 +80,7 @@ def run_first_event_latency(graph: Pregel, input: dict) -> None:
|
||||
"configurable": {"thread_id": str(uuid4())},
|
||||
"recursion_limit": 1000000000,
|
||||
},
|
||||
checkpoint_during=False,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -251,6 +256,102 @@ benchmarks = (
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"wide_dict_25x300",
|
||||
wide_dict(300).compile(checkpointer=None),
|
||||
wide_dict(300).compile(checkpointer=None),
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
str(i) * 10: {
|
||||
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
|
||||
for j in range(5)
|
||||
}
|
||||
for i in range(5)
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"wide_dict_25x300_checkpoint",
|
||||
wide_dict(300).compile(checkpointer=MemorySaver()),
|
||||
wide_dict(300).compile(checkpointer=MemorySaver()),
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
str(i) * 10: {
|
||||
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
|
||||
for j in range(5)
|
||||
}
|
||||
for i in range(5)
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"wide_dict_15x600",
|
||||
wide_dict(600).compile(checkpointer=None),
|
||||
wide_dict(600).compile(checkpointer=None),
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
str(i) * 10: {
|
||||
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
|
||||
for j in range(5)
|
||||
}
|
||||
for i in range(3)
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"wide_dict_15x600_checkpoint",
|
||||
wide_dict(600).compile(checkpointer=MemorySaver()),
|
||||
wide_dict(600).compile(checkpointer=MemorySaver()),
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
str(i) * 10: {
|
||||
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
|
||||
for j in range(5)
|
||||
}
|
||||
for i in range(3)
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"wide_dict_9x1200",
|
||||
wide_dict(1200).compile(checkpointer=None),
|
||||
wide_dict(1200).compile(checkpointer=None),
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
str(i) * 10: {
|
||||
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
|
||||
for j in range(3)
|
||||
}
|
||||
for i in range(3)
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"wide_dict_9x1200_checkpoint",
|
||||
wide_dict(1200).compile(checkpointer=MemorySaver()),
|
||||
wide_dict(1200).compile(checkpointer=MemorySaver()),
|
||||
{
|
||||
"messages": [
|
||||
{
|
||||
str(i) * 10: {
|
||||
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
|
||||
for j in range(3)
|
||||
}
|
||||
for i in range(3)
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
(
|
||||
"sequential_10",
|
||||
create_sequential(10).compile(),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import operator
|
||||
from functools import partial
|
||||
from random import choice
|
||||
from typing import Annotated, Optional, Sequence
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph.state import StateGraph
|
||||
|
||||
|
||||
def wide_dict(n: int) -> StateGraph:
|
||||
class State(TypedDict):
|
||||
messages: Annotated[list, operator.add]
|
||||
trigger_events: Annotated[list, operator.add]
|
||||
"""The external events that are converted by the graph."""
|
||||
primary_issue_medium: Annotated[str, lambda x, y: y or x]
|
||||
autoresponse: Annotated[Optional[dict], lambda _, y: y] # Always overwrite
|
||||
issue: Annotated[dict | None, lambda x, y: y if y else x]
|
||||
relevant_rules: Optional[list[dict]]
|
||||
"""SOPs fetched from the rulebook that are relevant to the current conversation."""
|
||||
memory_docs: Optional[list[dict]]
|
||||
"""Memory docs fetched from the memory service that are relevant to the current conversation."""
|
||||
categorizations: Annotated[list[dict], operator.add]
|
||||
"""The issue categorizations auto-generated by the AI."""
|
||||
responses: Annotated[list[dict], operator.add]
|
||||
"""The draft responses recommended by the AI."""
|
||||
|
||||
user_info: Annotated[Optional[dict], lambda x, y: y if y is not None else x]
|
||||
"""The current user state (by email)."""
|
||||
crm_info: Annotated[Optional[dict], lambda x, y: y if y is not None else x]
|
||||
"""The CRM information for organization the current user is from."""
|
||||
email_thread_id: Annotated[
|
||||
Optional[str], lambda x, y: y if y is not None else x
|
||||
]
|
||||
"""The current email thread ID."""
|
||||
slack_participants: Annotated[dict, operator.or_]
|
||||
"""The growing list of current slack participants."""
|
||||
bot_id: Optional[str]
|
||||
"""The ID of the bot user in the slack channel."""
|
||||
notified_assignees: Annotated[dict, operator.or_]
|
||||
|
||||
list_fields = {
|
||||
"messages",
|
||||
"trigger_events",
|
||||
"categorizations",
|
||||
"responses",
|
||||
"memory_docs",
|
||||
"relevant_rules",
|
||||
}
|
||||
dict_fields = {
|
||||
"user_info",
|
||||
"crm_info",
|
||||
"slack_participants",
|
||||
"notified_assignees",
|
||||
"autoresponse",
|
||||
"issue",
|
||||
}
|
||||
|
||||
def read_write(read: str, write: Sequence[str], input: State) -> dict:
|
||||
val = input.get(read)
|
||||
val = {val: val} if isinstance(val, str) else val
|
||||
val_single = val[-1] if isinstance(val, list) else val
|
||||
val_list = val if isinstance(val, list) else [val]
|
||||
return {
|
||||
k: val_list
|
||||
if k in list_fields
|
||||
else val_single
|
||||
if k in dict_fields
|
||||
else "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
|
||||
for k in write
|
||||
}
|
||||
|
||||
builder = StateGraph(State)
|
||||
builder.add_edge(START, "one")
|
||||
builder.add_node(
|
||||
"one",
|
||||
partial(read_write, "messages", ["trigger_events", "primary_issue_medium"]),
|
||||
)
|
||||
builder.add_edge("one", "two")
|
||||
builder.add_node(
|
||||
"two",
|
||||
partial(read_write, "trigger_events", ["autoresponse", "issue"]),
|
||||
)
|
||||
builder.add_edge("two", "three")
|
||||
builder.add_edge("two", "four")
|
||||
builder.add_node(
|
||||
"three",
|
||||
partial(read_write, "autoresponse", ["relevant_rules"]),
|
||||
)
|
||||
builder.add_node(
|
||||
"four",
|
||||
partial(
|
||||
read_write,
|
||||
"trigger_events",
|
||||
["categorizations", "responses", "memory_docs"],
|
||||
),
|
||||
)
|
||||
builder.add_node(
|
||||
"five",
|
||||
partial(
|
||||
read_write,
|
||||
"categorizations",
|
||||
[
|
||||
"user_info",
|
||||
"crm_info",
|
||||
"email_thread_id",
|
||||
"slack_participants",
|
||||
"bot_id",
|
||||
"notified_assignees",
|
||||
],
|
||||
),
|
||||
)
|
||||
builder.add_edge(["three", "four"], "five")
|
||||
builder.add_edge("five", "six")
|
||||
builder.add_node(
|
||||
"six",
|
||||
partial(read_write, "responses", ["messages"]),
|
||||
)
|
||||
builder.add_conditional_edges(
|
||||
"six", lambda state: END if len(state["messages"]) > n else "one"
|
||||
)
|
||||
|
||||
return builder
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
import uvloop
|
||||
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
graph = wide_dict(1000).compile(checkpointer=MemorySaver())
|
||||
input = {
|
||||
"messages": [
|
||||
{
|
||||
str(i) * 10: {
|
||||
str(j) * 10: ["hi?" * 10, True, 1, 6327816386138, None] * 5
|
||||
for j in range(50)
|
||||
}
|
||||
for i in range(50)
|
||||
}
|
||||
]
|
||||
}
|
||||
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 20000000000}
|
||||
|
||||
async def run():
|
||||
async for c in graph.astream(input, config=config):
|
||||
print(c.keys())
|
||||
|
||||
uvloop.install()
|
||||
asyncio.run(run())
|
||||
@@ -1,6 +1,7 @@
|
||||
import operator
|
||||
from dataclasses import dataclass, field
|
||||
from functools import partial
|
||||
from random import choice
|
||||
from typing import Annotated, Optional, Sequence
|
||||
|
||||
from langgraph.constants import END, START
|
||||
@@ -49,12 +50,34 @@ def wide_state(n: int) -> StateGraph:
|
||||
"""The ID of the bot user in the slack channel."""
|
||||
notified_assignees: Annotated[dict, operator.or_] = field(default_factory=dict)
|
||||
|
||||
list_fields = {
|
||||
"messages",
|
||||
"trigger_events",
|
||||
"categorizations",
|
||||
"responses",
|
||||
"memory_docs",
|
||||
"relevant_rules",
|
||||
}
|
||||
dict_fields = {
|
||||
"user_info",
|
||||
"crm_info",
|
||||
"slack_participants",
|
||||
"notified_assignees",
|
||||
"autoresponse",
|
||||
"issue",
|
||||
}
|
||||
|
||||
def read_write(read: str, write: Sequence[str], input: State) -> dict:
|
||||
val = getattr(input, read)
|
||||
val = {val: val} if isinstance(val, str) else val
|
||||
val_single = val[-1] if isinstance(val, list) else val
|
||||
val_list = val if isinstance(val, list) else [val]
|
||||
return {
|
||||
k: val_list if isinstance(getattr(input, k), list) else val_single
|
||||
k: val_list
|
||||
if k in list_fields
|
||||
else val_single
|
||||
if k in dict_fields
|
||||
else "".join(choice("abcdefghijklmnopqrstuvwxyz") for _ in range(n))
|
||||
for k in write
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -83,6 +85,8 @@ CONFIG_KEY_PREVIOUS = sys.intern("__pregel_previous")
|
||||
# holds the previous return value from a stateful Pregel graph.
|
||||
CONFIG_KEY_RUNNER_SUBMIT = sys.intern("__pregel_runner_submit")
|
||||
# holds a function that receives tasks from runner, executes them and returns results
|
||||
CONFIG_KEY_CHECKPOINT_DURING = sys.intern("__pregel_checkpoint_during")
|
||||
# holds a boolean indicating whether to checkpoint during the run (or only at the end)
|
||||
|
||||
# --- Other constants ---
|
||||
PUSH = sys.intern("__pregel_push")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import functools
|
||||
import logging
|
||||
import weakref
|
||||
from inspect import isclass
|
||||
@@ -16,6 +17,8 @@ from pydantic import BaseModel
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
from typing_extensions import Annotated
|
||||
|
||||
__all__ = ["SchemaCoercionMapper"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -25,54 +28,60 @@ _cache: weakref.WeakKeyDictionary[Type[Any], dict[int, "SchemaCoercionMapper"]]
|
||||
|
||||
|
||||
class SchemaCoercionMapper:
|
||||
"""Lightweight coercion of *dict* → *BaseModel* instances."""
|
||||
|
||||
def __new__(
|
||||
cls,
|
||||
schema: Type[Any],
|
||||
type_hints: Optional[dict[str, Any]] = None,
|
||||
*,
|
||||
max_depth: int = 12,
|
||||
) -> "SchemaCoercionMapper":
|
||||
if schema not in _cache:
|
||||
_cache[schema] = {}
|
||||
if max_depth in _cache[schema]:
|
||||
return _cache[schema][max_depth]
|
||||
|
||||
by_depth = _cache.setdefault(schema, {})
|
||||
if max_depth in by_depth:
|
||||
return by_depth[max_depth]
|
||||
inst = super().__new__(cls)
|
||||
_cache[schema][max_depth] = inst
|
||||
by_depth[max_depth] = inst
|
||||
return inst
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema: Type[Any],
|
||||
type_hints: Optional[dict[str, Any]] = None,
|
||||
*,
|
||||
max_depth: int = 12,
|
||||
):
|
||||
if hasattr(self, "_inited"):
|
||||
) -> None:
|
||||
if hasattr(self, "_initialised"):
|
||||
return
|
||||
self._inited = True
|
||||
self._initialised = True
|
||||
|
||||
self.schema = schema
|
||||
self.max_depth = max_depth
|
||||
|
||||
self.type_hints = (
|
||||
type_hints
|
||||
if type_hints is not None
|
||||
else get_type_hints(schema, localns={schema.__name__: schema})
|
||||
)
|
||||
self.max_depth = max_depth
|
||||
|
||||
if issubclass(schema, BaseModel):
|
||||
self._fields = {
|
||||
n: self.type_hints.get(n, f.annotation)
|
||||
for n, f in schema.model_fields.items()
|
||||
}
|
||||
self._construct: Callable[..., Any] = schema.model_construct
|
||||
|
||||
elif issubclass(schema, BaseModelV1):
|
||||
if issubclass(schema, BaseModelV1):
|
||||
self._fields = {
|
||||
n: self.type_hints.get(n, f.annotation)
|
||||
for n, f in schema.__fields__.items()
|
||||
}
|
||||
self._construct = schema.construct
|
||||
|
||||
elif issubclass(schema, BaseModel):
|
||||
self._fields = {
|
||||
n: self.type_hints.get(n, f.annotation)
|
||||
for n, f in schema.model_fields.items()
|
||||
}
|
||||
self._construct: Callable[..., Any] = schema.model_construct # type: ignore
|
||||
|
||||
else:
|
||||
raise TypeError("Schema is neither valid Pydantic v1 nor v2 model.")
|
||||
self._field_coercers: Optional[dict[str, Callable[[Any, Any], Any]]] = None
|
||||
raise TypeError("Schema is neither a Pydantic v1 nor v2 model.")
|
||||
|
||||
self._field_coercers: Optional[dict[str, Callable[[Any, int], Any]]] = None
|
||||
|
||||
def __call__(self, input_data: Any, depth: Optional[int] = None) -> Any:
|
||||
return self.coerce(input_data, depth)
|
||||
@@ -82,45 +91,51 @@ class SchemaCoercionMapper:
|
||||
depth = self.max_depth
|
||||
if not isinstance(input_data, dict) or depth <= 0:
|
||||
return input_data
|
||||
processed = {}
|
||||
|
||||
if self._field_coercers is None:
|
||||
self._field_coercers = {
|
||||
n: self._build_coercer(t, depth - 1) for n, t in self._fields.items()
|
||||
}
|
||||
|
||||
processed: dict[str, Any] = {}
|
||||
for k, v in input_data.items():
|
||||
fn = self._field_coercers.get(k)
|
||||
processed[k] = fn(v, depth - 1) if fn else v
|
||||
return self._construct(**processed)
|
||||
|
||||
def _build_coercer(
|
||||
self, field_type: Any, depth: int, throw: bool = False
|
||||
self, field_type: Any, depth: int, *, throw: bool = False
|
||||
) -> Callable[[Any, Any], Any]:
|
||||
if depth == 0:
|
||||
return self._passthrough
|
||||
|
||||
origin = get_origin(field_type)
|
||||
|
||||
if (field_type in _IDENTITY_TYPES) or (origin in _IDENTITY_TYPES):
|
||||
return self._passthrough
|
||||
|
||||
if origin is Annotated:
|
||||
real_type, *_ = get_args(field_type)
|
||||
sub = self._build_coercer(real_type, depth - 1)
|
||||
return lambda v, d: sub(v, d)
|
||||
|
||||
if isclass(field_type):
|
||||
# This is needed bcs. of issubclass issues on older versions of python
|
||||
is_class_ = True
|
||||
try:
|
||||
is_base_model = issubclass(field_type, BaseModel)
|
||||
is_bm_v2 = issubclass(field_type, BaseModel)
|
||||
except TypeError:
|
||||
# python < 3.11 issue.
|
||||
is_class_ = False
|
||||
is_base_model = False
|
||||
is_bm_v2 = False
|
||||
if is_bm_v2 or (is_class_ and issubclass(field_type, BaseModelV1)):
|
||||
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
|
||||
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
|
||||
|
||||
if is_base_model:
|
||||
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
|
||||
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
|
||||
if is_class_ and issubclass(field_type, BaseModelV1):
|
||||
mapper = SchemaCoercionMapper(field_type, max_depth=depth - 1)
|
||||
return lambda v, d: mapper.coerce(v, d) if isinstance(v, dict) else v
|
||||
if origin is list or field_type is list:
|
||||
if origin is list:
|
||||
args = get_args(field_type)
|
||||
if len(args) != 1:
|
||||
return lambda v, d: v
|
||||
return self._passthrough
|
||||
sub = self._build_coercer(args[0], depth - 1)
|
||||
|
||||
def list_coercer(v: Any, d: Any) -> Any:
|
||||
@@ -129,15 +144,21 @@ class SchemaCoercionMapper:
|
||||
return [sub(x, d - 1) for x in v]
|
||||
|
||||
return list_coercer
|
||||
|
||||
if origin is set or field_type is set:
|
||||
args = get_args(field_type)
|
||||
if len(args) != 1:
|
||||
return lambda v, d: v
|
||||
sub = self._build_coercer(args[0], depth - 1)
|
||||
if len(args) > 1:
|
||||
return self._passthrough
|
||||
elif len(args) == 1:
|
||||
sub = self._build_coercer(args[0], depth - 1)
|
||||
else:
|
||||
sub = None # type: ignore
|
||||
|
||||
def set_coercer(v: Any, d: Any) -> Any:
|
||||
if not isinstance(v, (list, tuple, set)):
|
||||
return v
|
||||
if sub is None:
|
||||
return set(v)
|
||||
return {sub(x, d - 1) for x in v}
|
||||
|
||||
return set_coercer
|
||||
@@ -165,20 +186,19 @@ class SchemaCoercionMapper:
|
||||
return dict_coercer
|
||||
|
||||
if origin is tuple:
|
||||
targs = get_args(field_type)
|
||||
if not targs:
|
||||
return lambda v, d: v
|
||||
subs = [self._build_coercer(a, depth - 1) for a in targs]
|
||||
elem_types = get_args(field_type)
|
||||
if not elem_types:
|
||||
return self._passthrough
|
||||
subs = [self._build_coercer(t, depth - 1) for t in elem_types]
|
||||
return lambda v, d: (
|
||||
tuple(
|
||||
subs[i](v[i] if i < len(v) else None, d - 1)
|
||||
for i in range(len(subs))
|
||||
)
|
||||
if isinstance(v, (list, tuple))
|
||||
else v
|
||||
)
|
||||
|
||||
def tuple_coercer(v: Any, d: Any) -> Any:
|
||||
if not isinstance(v, (list, tuple)):
|
||||
return v
|
||||
out = []
|
||||
for i, sp in enumerate(subs):
|
||||
out.append(sp(v[i] if i < len(v) else None, d - 1))
|
||||
return tuple(out)
|
||||
|
||||
return tuple_coercer
|
||||
if origin is Union:
|
||||
uargs = get_args(field_type)
|
||||
subs, none_in_union = [], False
|
||||
@@ -204,7 +224,97 @@ class SchemaCoercionMapper:
|
||||
return v
|
||||
|
||||
return union_coercer
|
||||
return self._passthrough
|
||||
|
||||
def _passthrough(self, v: Any, d: Any) -> Any:
|
||||
adapter_fn = _get_adapter(field_type)
|
||||
return lambda v, _d: adapter_fn(v)
|
||||
|
||||
@staticmethod
|
||||
def _passthrough(v: Any, _d: Any) -> Any: # noqa: D401
|
||||
return v
|
||||
|
||||
|
||||
_adapter_cache: dict[Any, Callable[[Any], Any]] = {}
|
||||
|
||||
|
||||
_IDENTITY_TYPES: tuple[type[Any], ...] = (
|
||||
int,
|
||||
float,
|
||||
str,
|
||||
bool,
|
||||
bytes,
|
||||
bytearray,
|
||||
complex,
|
||||
memoryview,
|
||||
type(None),
|
||||
)
|
||||
|
||||
try:
|
||||
# Pydantic v2.
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
try:
|
||||
import pydantic.v1.types as v1_types_
|
||||
from pydantic.v1 import parse_obj_as
|
||||
|
||||
v1_types = tuple(
|
||||
v for k, v in vars(v1_types_).items() if k in v1_types_.__all__
|
||||
)
|
||||
except ImportError:
|
||||
v1_types = ()
|
||||
|
||||
def parse_obj_as(tp: Any, v: Any) -> Any: # type: ignore
|
||||
return v
|
||||
|
||||
try:
|
||||
from pydantic.v1 import parse_obj_as
|
||||
from pydantic.v1.main import create_model
|
||||
except ImportError:
|
||||
create_model = None # type: ignore
|
||||
|
||||
def _get_v1_parser(tp: Any) -> Any:
|
||||
if create_model is not None:
|
||||
try:
|
||||
parser = create_model(
|
||||
f"ParsingModel[{tp}]",
|
||||
__root__=(tp, ...),
|
||||
)
|
||||
return lambda v: parser(__root__=v).__root__ # type: ignore
|
||||
except RuntimeError:
|
||||
return lambda v: v
|
||||
return lambda v: parse_obj_as(tp, v)
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def _adapter_for(tp: Any) -> Callable[[Any], Any]: # noqa: D401
|
||||
if tp in v1_types:
|
||||
return _get_v1_parser(tp)
|
||||
try:
|
||||
return TypeAdapter(
|
||||
tp, config={"arbitrary_types_allowed": True}
|
||||
).validate_python
|
||||
except TypeError:
|
||||
# Delayed classes like ConstrainedList
|
||||
return _get_v1_parser(tp)
|
||||
|
||||
except ImportError:
|
||||
# Pydantic V1
|
||||
from pydantic.v1.main import create_model
|
||||
|
||||
@functools.lru_cache(maxsize=2048)
|
||||
def _adapter_for(tp: Any) -> Callable[[Any], Any]: # noqa: D401
|
||||
try:
|
||||
parser = create_model(
|
||||
f"ParsingModel[{tp}]",
|
||||
__root__=(tp, ...),
|
||||
)
|
||||
return lambda v: parser(__root__=v).__root__ # type: ignore
|
||||
except RuntimeError:
|
||||
return lambda v: v
|
||||
|
||||
|
||||
def _get_adapter(tp: Any) -> Callable[[Any], Any]:
|
||||
try:
|
||||
return _adapter_cache[tp]
|
||||
except KeyError:
|
||||
fn = _adapter_for(tp)
|
||||
_adapter_cache[tp] = fn
|
||||
return fn
|
||||
|
||||
@@ -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, BaseModel):
|
||||
keep: Optional[set[str]] = input.model_fields_set
|
||||
defaults = {k: v.default for k, v in input.model_fields.items()}
|
||||
# Pydantic v1
|
||||
elif isinstance(input, BaseModelV1):
|
||||
keep = input.__fields_set__
|
||||
defaults = {k: v.default for k, v in t.__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 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]]:
|
||||
@@ -1060,7 +1023,7 @@ def _pick_mapper(
|
||||
if issubclass(schema, dict):
|
||||
return None
|
||||
if issubclass(schema, (BaseModel, BaseModelV1)):
|
||||
return SchemaCoercionMapper(schema, type_hints)
|
||||
return SchemaCoercionMapper(schema, type_hints=type_hints)
|
||||
return partial(_coerce_state, schema)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -39,7 +39,6 @@ from langchain_core.runnables.utils import (
|
||||
ConfigurableFieldSpec,
|
||||
get_unique_config_specs,
|
||||
)
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -54,6 +53,7 @@ from langgraph.checkpoint.base import (
|
||||
)
|
||||
from langgraph.constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_CHECKPOINT_DURING,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_CHECKPOINTER,
|
||||
@@ -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,
|
||||
@@ -125,6 +126,11 @@ from langgraph.utils.fields import get_enhanced_type_hints
|
||||
from langgraph.utils.pydantic import create_model, is_supported_by_pydantic
|
||||
from langgraph.utils.queue import AsyncQueue, SyncQueue # type: ignore[attr-defined]
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = None # type: ignore
|
||||
|
||||
WriteValue = Union[Callable[[Input], Output], Any]
|
||||
|
||||
|
||||
@@ -493,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
|
||||
|
||||
@@ -523,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,
|
||||
@@ -543,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
|
||||
@@ -1036,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(
|
||||
@@ -1075,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(
|
||||
@@ -1120,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(
|
||||
@@ -1167,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 [
|
||||
@@ -1587,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
|
||||
@@ -1997,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
|
||||
@@ -2094,6 +2123,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
checkpoint_during: Optional[bool] = None,
|
||||
debug: Optional[bool] = None,
|
||||
subgraphs: bool = False,
|
||||
) -> Iterator[Union[dict[str, Any], Any]]:
|
||||
@@ -2115,6 +2145,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys: The keys to stream, defaults to all non-context channels.
|
||||
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
|
||||
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
|
||||
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
|
||||
debug: Whether to print debug information during execution, defaults to False.
|
||||
subgraphs: Whether to stream subgraphs, defaults to False.
|
||||
|
||||
@@ -2276,6 +2307,9 @@ class Pregel(PregelProtocol):
|
||||
config[CONF][CONFIG_KEY_STREAM_WRITER] = lambda c: stream.put(
|
||||
((), "custom", c)
|
||||
)
|
||||
# set checkpointing mode for subgraphs
|
||||
if checkpoint_during is not None:
|
||||
config[CONF][CONFIG_KEY_CHECKPOINT_DURING] = checkpoint_during
|
||||
with SyncPregelLoop(
|
||||
input,
|
||||
input_model=self.input_model,
|
||||
@@ -2291,6 +2325,9 @@ class Pregel(PregelProtocol):
|
||||
interrupt_after=interrupt_after_,
|
||||
manager=run_manager,
|
||||
debug=debug,
|
||||
checkpoint_during=checkpoint_during
|
||||
if checkpoint_during is not None
|
||||
else config[CONF].get(CONFIG_KEY_CHECKPOINT_DURING, True),
|
||||
trigger_to_nodes=self.trigger_to_nodes,
|
||||
migrate_checkpoint=self._migrate_checkpoint,
|
||||
) as loop:
|
||||
@@ -2373,6 +2410,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
checkpoint_during: Optional[bool] = None,
|
||||
debug: Optional[bool] = None,
|
||||
subgraphs: bool = False,
|
||||
) -> AsyncIterator[Union[dict[str, Any], Any]]:
|
||||
@@ -2394,6 +2432,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys: The keys to stream, defaults to all non-context channels.
|
||||
interrupt_before: Nodes to interrupt before, defaults to all nodes in the graph.
|
||||
interrupt_after: Nodes to interrupt after, defaults to all nodes in the graph.
|
||||
checkpoint_during: Whether to checkpoint intermediate steps, defaults to True. If False, only the final checkpoint is saved.
|
||||
debug: Whether to print debug information during execution, defaults to False.
|
||||
subgraphs: Whether to stream subgraphs, defaults to False.
|
||||
|
||||
@@ -2529,13 +2568,18 @@ class Pregel(PregelProtocol):
|
||||
run_id=config.get("run_id"),
|
||||
)
|
||||
# if running from astream_log() run each proc with streaming
|
||||
do_stream = next(
|
||||
(
|
||||
cast(_StreamingCallbackHandler, h)
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
),
|
||||
None,
|
||||
do_stream = (
|
||||
next(
|
||||
(
|
||||
True
|
||||
for h in run_manager.handlers
|
||||
if isinstance(h, _StreamingCallbackHandler)
|
||||
and not isinstance(h, StreamMessagesHandler)
|
||||
),
|
||||
False,
|
||||
)
|
||||
if _StreamingCallbackHandler is not None
|
||||
else False
|
||||
)
|
||||
try:
|
||||
# assign defaults
|
||||
@@ -2571,6 +2615,9 @@ class Pregel(PregelProtocol):
|
||||
stream.put_nowait, ((), "custom", c)
|
||||
)
|
||||
)
|
||||
# set checkpointing mode for subgraphs
|
||||
if checkpoint_during is not None:
|
||||
config[CONF][CONFIG_KEY_CHECKPOINT_DURING] = checkpoint_during
|
||||
async with AsyncPregelLoop(
|
||||
input,
|
||||
input_model=self.input_model,
|
||||
@@ -2586,6 +2633,9 @@ class Pregel(PregelProtocol):
|
||||
interrupt_after=interrupt_after_,
|
||||
manager=run_manager,
|
||||
debug=debug,
|
||||
checkpoint_during=checkpoint_during
|
||||
if checkpoint_during is not None
|
||||
else config[CONF].get(CONFIG_KEY_CHECKPOINT_DURING, True),
|
||||
trigger_to_nodes=self.trigger_to_nodes,
|
||||
migrate_checkpoint=self._migrate_checkpoint,
|
||||
) as loop:
|
||||
@@ -2596,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
|
||||
@@ -2661,6 +2711,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
checkpoint_during: Optional[bool] = None,
|
||||
debug: Optional[bool] = None,
|
||||
**kwargs: Any,
|
||||
) -> Union[dict[str, Any], Any]:
|
||||
@@ -2692,6 +2743,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys=output_keys,
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
checkpoint_during=checkpoint_during,
|
||||
debug=debug,
|
||||
**kwargs,
|
||||
):
|
||||
@@ -2713,6 +2765,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys: Optional[Union[str, Sequence[str]]] = None,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
checkpoint_during: Optional[bool] = None,
|
||||
debug: Optional[bool] = None,
|
||||
**kwargs: Any,
|
||||
) -> Union[dict[str, Any], Any]:
|
||||
@@ -2745,6 +2798,7 @@ class Pregel(PregelProtocol):
|
||||
output_keys=output_keys,
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
checkpoint_during=checkpoint_during,
|
||||
debug=debug,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
@@ -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,
|
||||
@@ -63,6 +64,7 @@ from langgraph.constants import (
|
||||
RESUME,
|
||||
SCHEDULED,
|
||||
TAG_HIDDEN,
|
||||
TASKS,
|
||||
)
|
||||
from langgraph.errors import (
|
||||
CheckpointNotLatest,
|
||||
@@ -155,7 +157,7 @@ class PregelLoop(LoopProtocol):
|
||||
manager: Union[None, AsyncParentRunManager, ParentRunManager]
|
||||
interrupt_after: Union[All, Sequence[str]]
|
||||
interrupt_before: Union[All, Sequence[str]]
|
||||
checkpoint_every_step: bool
|
||||
checkpoint_during: bool
|
||||
debug: bool
|
||||
|
||||
checkpointer_get_next_version: GetNextVersion
|
||||
@@ -180,6 +182,7 @@ class PregelLoop(LoopProtocol):
|
||||
channels: Mapping[str, BaseChannel]
|
||||
managed: ManagedValueMapping
|
||||
checkpoint: Checkpoint
|
||||
checkpoint_id_saved: str
|
||||
checkpoint_ns: tuple[str, ...]
|
||||
checkpoint_config: RunnableConfig
|
||||
checkpoint_metadata: CheckpointMetadata
|
||||
@@ -215,7 +218,7 @@ class PregelLoop(LoopProtocol):
|
||||
debug: bool = False,
|
||||
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
|
||||
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
|
||||
checkpoint_every_step: bool = True,
|
||||
checkpoint_during: bool = True,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
step=0,
|
||||
@@ -241,7 +244,7 @@ class PregelLoop(LoopProtocol):
|
||||
)
|
||||
self._migrate_checkpoint = migrate_checkpoint
|
||||
self.trigger_to_nodes = trigger_to_nodes
|
||||
self.checkpoint_every_step = checkpoint_every_step
|
||||
self.checkpoint_during = checkpoint_during
|
||||
self.debug = debug
|
||||
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
|
||||
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
|
||||
@@ -283,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)
|
||||
@@ -294,29 +303,19 @@ class PregelLoop(LoopProtocol):
|
||||
"""Put writes for a task, to be read by the next tick."""
|
||||
if not writes:
|
||||
return
|
||||
# always checkpoint writes containing Send, as they are fetched from the
|
||||
# parent checkpoint, not the current one
|
||||
checkpoint_during = self.checkpoint_during or any(w[0] == TASKS for w in writes)
|
||||
# deduplicate writes to special channels, last write wins
|
||||
if all(w[0] in WRITES_IDX_MAP for w in writes):
|
||||
writes = list({w[0]: w for w in writes}.values())
|
||||
# remove existing writes for this task
|
||||
self.checkpoint_pending_writes = [
|
||||
w for w in self.checkpoint_pending_writes if w[0] != task_id
|
||||
]
|
||||
# save writes
|
||||
for c, v in writes:
|
||||
if (
|
||||
c in WRITES_IDX_MAP
|
||||
and (
|
||||
idx := next(
|
||||
(
|
||||
i
|
||||
for i, w in enumerate(self.checkpoint_pending_writes)
|
||||
if w[0] == task_id and w[1] == c
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
):
|
||||
self.checkpoint_pending_writes[idx] = (task_id, c, v)
|
||||
else:
|
||||
self.checkpoint_pending_writes.append((task_id, c, v))
|
||||
if self.checkpointer_put_writes is not None:
|
||||
self.checkpoint_pending_writes.extend((task_id, c, v) for c, v in writes)
|
||||
if checkpoint_during and self.checkpointer_put_writes is not None:
|
||||
config = patch_configurable(
|
||||
self.checkpoint_config,
|
||||
{
|
||||
@@ -349,6 +348,46 @@ class PregelLoop(LoopProtocol):
|
||||
if hasattr(self, "tasks"):
|
||||
self._output_writes(task_id, writes)
|
||||
|
||||
def _put_pending_writes(self) -> None:
|
||||
if self.checkpointer_put_writes is None:
|
||||
return
|
||||
if not self.checkpoint_pending_writes:
|
||||
return
|
||||
# patch config
|
||||
config = patch_configurable(
|
||||
self.checkpoint_config,
|
||||
{
|
||||
CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINT_NS, ""
|
||||
),
|
||||
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
|
||||
},
|
||||
)
|
||||
# group by task id
|
||||
by_task = defaultdict(list)
|
||||
for task_id, channel, value in self.checkpoint_pending_writes:
|
||||
by_task[task_id].append((channel, value))
|
||||
# submit writes to checkpointer
|
||||
for task_id, writes in by_task.items():
|
||||
if self.checkpointer_put_writes_accepts_task_path and hasattr(
|
||||
self, "tasks"
|
||||
):
|
||||
task = self.tasks.get(task_id)
|
||||
self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
config,
|
||||
writes,
|
||||
task_id,
|
||||
task_path_str(task.path) if task else "",
|
||||
)
|
||||
else:
|
||||
self.submit(
|
||||
self.checkpointer_put_writes,
|
||||
config,
|
||||
writes,
|
||||
task_id,
|
||||
)
|
||||
|
||||
def accept_push(
|
||||
self, task: PregelExecutableTask, write_idx: int, call: Optional[Call] = None
|
||||
) -> Optional[PregelExecutableTask]:
|
||||
@@ -711,32 +750,44 @@ class PregelLoop(LoopProtocol):
|
||||
|
||||
def _put_checkpoint(self, metadata: CheckpointMetadata) -> None:
|
||||
# assign step and parents
|
||||
metadata["step"] = self.step
|
||||
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
|
||||
# debug flag
|
||||
if self.debug:
|
||||
print_step_checkpoint(
|
||||
metadata,
|
||||
self.channels,
|
||||
(
|
||||
[self.stream_keys]
|
||||
if isinstance(self.stream_keys, str)
|
||||
else self.stream_keys
|
||||
),
|
||||
)
|
||||
exiting = metadata is self.checkpoint_metadata
|
||||
if exiting and self.checkpoint["id"] == self.checkpoint_id_saved:
|
||||
# checkpoint already saved
|
||||
return
|
||||
if not exiting:
|
||||
metadata["step"] = self.step
|
||||
metadata["parents"] = self.config[CONF].get(CONFIG_KEY_CHECKPOINT_MAP, {})
|
||||
self.checkpoint_metadata = metadata
|
||||
# debug flag
|
||||
if self.debug:
|
||||
print_step_checkpoint(
|
||||
metadata,
|
||||
self.channels,
|
||||
(
|
||||
[self.stream_keys]
|
||||
if isinstance(self.stream_keys, str)
|
||||
else self.stream_keys
|
||||
),
|
||||
)
|
||||
self.checkpoint_id_prev = self.checkpoint["id"] if self.step > -1 else None
|
||||
# do checkpoint?
|
||||
do_checkpoint = self._checkpointer_put_after_previous is not None and (
|
||||
exiting or self.checkpoint_during
|
||||
)
|
||||
# create new checkpoint
|
||||
self.checkpoint = create_checkpoint(
|
||||
self.checkpoint,
|
||||
self.channels if do_checkpoint else None,
|
||||
self.step,
|
||||
id=self.checkpoint["id"] if exiting else None,
|
||||
)
|
||||
# bail if no checkpointer
|
||||
if self._checkpointer_put_after_previous is not None:
|
||||
if do_checkpoint and self._checkpointer_put_after_previous is not None:
|
||||
for k, v in self.config["metadata"].items():
|
||||
if k in EXCLUDED_METADATA_KEYS:
|
||||
continue
|
||||
metadata.setdefault(k, v) # type: ignore
|
||||
|
||||
# create new checkpoint
|
||||
self.checkpoint = create_checkpoint(
|
||||
self.checkpoint, self.channels, self.step
|
||||
)
|
||||
self.checkpoint_metadata = metadata
|
||||
|
||||
self.prev_checkpoint_config = (
|
||||
self.checkpoint_config
|
||||
if CONFIG_KEY_CHECKPOINT_ID in self.checkpoint_config[CONF]
|
||||
@@ -747,6 +798,8 @@ class PregelLoop(LoopProtocol):
|
||||
**self.checkpoint_config,
|
||||
CONF: {
|
||||
**self.checkpoint_config[CONF],
|
||||
# this is guaranteed to be set by code above
|
||||
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint_id_prev,
|
||||
CONFIG_KEY_CHECKPOINT_NS: self.config[CONF].get(
|
||||
CONFIG_KEY_CHECKPOINT_NS, ""
|
||||
),
|
||||
@@ -777,8 +830,9 @@ class PregelLoop(LoopProtocol):
|
||||
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
|
||||
},
|
||||
}
|
||||
# increment step
|
||||
self.step += 1
|
||||
if not exiting:
|
||||
# increment step
|
||||
self.step += 1
|
||||
|
||||
def _update_mv(self, key: str, values: Sequence[Any]) -> None:
|
||||
raise NotImplementedError
|
||||
@@ -789,6 +843,10 @@ class PregelLoop(LoopProtocol):
|
||||
exc_value: Optional[BaseException],
|
||||
traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
# persist current checkpoint and writes
|
||||
if not self.checkpoint_during:
|
||||
self._put_checkpoint(self.checkpoint_metadata)
|
||||
self._put_pending_writes()
|
||||
# suppress interrupt
|
||||
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
|
||||
if suppress:
|
||||
@@ -907,6 +965,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
|
||||
debug: bool = False,
|
||||
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
|
||||
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
|
||||
checkpoint_during: bool = True,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
input,
|
||||
@@ -925,6 +984,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
|
||||
debug=debug,
|
||||
migrate_checkpoint=migrate_checkpoint,
|
||||
trigger_to_nodes=trigger_to_nodes,
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
self.stack = ExitStack()
|
||||
if checkpointer:
|
||||
@@ -990,20 +1050,21 @@ 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, {}),
|
||||
},
|
||||
}
|
||||
self.prev_checkpoint_config = saved.parent_config
|
||||
self.checkpoint_id_saved = saved.checkpoint["id"]
|
||||
self.checkpoint = saved.checkpoint
|
||||
self.checkpoint_metadata = saved.metadata
|
||||
self.checkpoint_pending_writes = (
|
||||
@@ -1054,6 +1115,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
|
||||
debug: bool = False,
|
||||
migrate_checkpoint: Optional[Callable[[Checkpoint], None]] = None,
|
||||
trigger_to_nodes: Optional[Mapping[str, Sequence[str]]] = None,
|
||||
checkpoint_during: bool = True,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
input,
|
||||
@@ -1072,6 +1134,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
|
||||
debug=debug,
|
||||
migrate_checkpoint=migrate_checkpoint,
|
||||
trigger_to_nodes=trigger_to_nodes,
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
self.stack = AsyncExitStack()
|
||||
if checkpointer:
|
||||
@@ -1137,20 +1200,21 @@ 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, {}),
|
||||
},
|
||||
}
|
||||
self.prev_checkpoint_config = saved.parent_config
|
||||
self.checkpoint_id_saved = saved.checkpoint["id"]
|
||||
self.checkpoint = saved.checkpoint
|
||||
self.checkpoint_metadata = saved.metadata
|
||||
self.checkpoint_pending_writes = (
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import (
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
@@ -15,11 +16,16 @@ from uuid import UUID, uuid4
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import ChatGenerationChunk, LLMResult
|
||||
from langchain_core.tracers._streaming import T, _StreamingCallbackHandler
|
||||
|
||||
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
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = object # type: ignore
|
||||
|
||||
T = TypeVar("T")
|
||||
Meta = tuple[tuple[str, ...], dict[str, Any]]
|
||||
|
||||
|
||||
@@ -147,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
|
||||
|
||||
@@ -10,7 +10,6 @@ from typing import (
|
||||
cast,
|
||||
)
|
||||
|
||||
import orjson
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.runnables.graph import (
|
||||
Edge as DrawableEdge,
|
||||
@@ -35,6 +34,8 @@ from typing_extensions import Self
|
||||
from langgraph.checkpoint.base import CheckpointMetadata
|
||||
from langgraph.constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_STREAM,
|
||||
INTERRUPT,
|
||||
@@ -46,6 +47,14 @@ from langgraph.pregel.types import All, PregelTask, StateSnapshot, StreamMode
|
||||
from langgraph.types import Command, Interrupt, StreamProtocol
|
||||
from langgraph.utils.config import merge_configs
|
||||
|
||||
CONF_DROPLIST = frozenset(
|
||||
(
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class RemoteException(Exception):
|
||||
"""Exception raised when an error occurs in the remote graph."""
|
||||
@@ -290,47 +299,26 @@ class RemoteGraph(PregelProtocol):
|
||||
}
|
||||
|
||||
def _sanitize_config(self, config: RunnableConfig) -> RunnableConfig:
|
||||
reserved_configurable_keys = frozenset(
|
||||
[
|
||||
"callbacks",
|
||||
"checkpoint_map",
|
||||
"checkpoint_id",
|
||||
"checkpoint_ns",
|
||||
]
|
||||
)
|
||||
|
||||
def _sanitize_obj(obj: Any) -> Any:
|
||||
"""Remove non-JSON serializable fields from the given object."""
|
||||
if isinstance(obj, dict):
|
||||
return {k: _sanitize_obj(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [_sanitize_obj(v) for v in obj]
|
||||
else:
|
||||
try:
|
||||
orjson.dumps(obj)
|
||||
return obj
|
||||
except orjson.JSONEncodeError:
|
||||
return None
|
||||
|
||||
# Remove non-JSON serializable fields from the config.
|
||||
config = _sanitize_obj(config)
|
||||
|
||||
# Only include configurable keys that are not reserved and
|
||||
# not starting with "__pregel_" prefix.
|
||||
new_configurable = {
|
||||
k: v
|
||||
for k, v in config["configurable"].items()
|
||||
if k not in reserved_configurable_keys and not k.startswith("__pregel_")
|
||||
}
|
||||
|
||||
sanitized: RunnableConfig = {
|
||||
"tags": config.get("tags") or [],
|
||||
"metadata": config.get("metadata") or {},
|
||||
"configurable": new_configurable,
|
||||
}
|
||||
"""Sanitize the config to remove non-serializable fields."""
|
||||
sanitized: RunnableConfig = {}
|
||||
if "recursion_limit" in config:
|
||||
sanitized["recursion_limit"] = config["recursion_limit"]
|
||||
|
||||
if "tags" in config:
|
||||
sanitized["tags"] = [tag for tag in config["tags"] if isinstance(tag, str)]
|
||||
if "metadata" in config:
|
||||
sanitized["metadata"] = {}
|
||||
for k, v in config["metadata"].items():
|
||||
if isinstance(k, str) and isinstance(v, (str, int, float, bool)):
|
||||
sanitized["metadata"][k] = v
|
||||
if "configurable" in config:
|
||||
sanitized["configurable"] = {}
|
||||
for k, v in config["configurable"].items():
|
||||
if (
|
||||
isinstance(k, str)
|
||||
and k not in CONF_DROPLIST
|
||||
and isinstance(v, (str, int, float, bool))
|
||||
):
|
||||
sanitized["configurable"][k] = v
|
||||
return sanitized
|
||||
|
||||
def get_state(
|
||||
@@ -654,9 +642,10 @@ class RemoteGraph(PregelProtocol):
|
||||
# raise interrupt or errors
|
||||
if chunk.event.startswith("updates"):
|
||||
if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
|
||||
raise GraphInterrupt(
|
||||
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
|
||||
)
|
||||
if caller_ns:
|
||||
raise GraphInterrupt(
|
||||
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
|
||||
)
|
||||
elif chunk.event.startswith("error"):
|
||||
raise RemoteException(chunk.data)
|
||||
# filter for what was actually requested
|
||||
@@ -748,9 +737,10 @@ class RemoteGraph(PregelProtocol):
|
||||
# raise interrupt or errors
|
||||
if chunk.event.startswith("updates"):
|
||||
if isinstance(chunk.data, dict) and INTERRUPT in chunk.data:
|
||||
raise GraphInterrupt(
|
||||
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
|
||||
)
|
||||
if caller_ns:
|
||||
raise GraphInterrupt(
|
||||
[Interrupt(**i) for i in chunk.data[INTERRUPT]]
|
||||
)
|
||||
elif chunk.event.startswith("error"):
|
||||
raise RemoteException(chunk.data)
|
||||
# filter for what was actually requested
|
||||
|
||||
@@ -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,7 +36,7 @@ from langchain_core.runnables.config import (
|
||||
var_child_runnable_config,
|
||||
)
|
||||
from langchain_core.runnables.utils import Input, Output
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
from langchain_core.tracers.langchain import LangChainTracer
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
from langgraph.constants import (
|
||||
@@ -54,60 +54,41 @@ from langgraph.utils.config import (
|
||||
patch_config,
|
||||
)
|
||||
|
||||
try:
|
||||
from langchain_core.tracers._streaming import _StreamingCallbackHandler
|
||||
except ImportError:
|
||||
_StreamingCallbackHandler = None # type: ignore
|
||||
|
||||
|
||||
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,
|
||||
@@ -115,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."""
|
||||
@@ -359,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)
|
||||
@@ -367,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
|
||||
|
||||
@@ -413,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
|
||||
|
||||
@@ -594,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):
|
||||
@@ -602,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
|
||||
@@ -639,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
|
||||
@@ -668,51 +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 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,
|
||||
@@ -731,51 +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 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.25"
|
||||
version = "0.3.30"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from tests.conftest import (
|
||||
ALL_CHECKPOINTERS_ASYNC,
|
||||
ALL_CHECKPOINTERS_SYNC,
|
||||
REGULAR_CHECKPOINTERS_ASYNC,
|
||||
REGULAR_CHECKPOINTERS_SYNC,
|
||||
awith_checkpointer,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC)
|
||||
def test_interruption_without_state_updates(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
"""Test interruption without state updates. This test confirms that
|
||||
interrupting doesn't require a state key having been updated in the prev step"""
|
||||
@@ -40,20 +40,27 @@ def test_interruption_without_state_updates(
|
||||
initial_input = {"input": "hello world"}
|
||||
thread = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
graph.invoke(initial_input, thread, debug=True)
|
||||
graph.invoke(initial_input, thread, checkpoint_during=checkpoint_during)
|
||||
assert graph.get_state(thread).next == ("step_2",)
|
||||
n_checkpoints = len([c for c in graph.get_state_history(thread)])
|
||||
assert n_checkpoints == (3 if checkpoint_during else 1)
|
||||
|
||||
graph.invoke(None, thread, debug=True)
|
||||
graph.invoke(None, thread, checkpoint_during=checkpoint_during)
|
||||
assert graph.get_state(thread).next == ("step_3",)
|
||||
n_checkpoints = len([c for c in graph.get_state_history(thread)])
|
||||
assert n_checkpoints == (4 if checkpoint_during else 2)
|
||||
|
||||
graph.invoke(None, thread, debug=True)
|
||||
graph.invoke(None, thread, checkpoint_during=checkpoint_during)
|
||||
assert graph.get_state(thread).next == ()
|
||||
n_checkpoints = len([c for c in graph.get_state_history(thread)])
|
||||
assert n_checkpoints == (5 if checkpoint_during else 3)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
|
||||
async def test_interruption_without_state_updates_async(
|
||||
checkpointer_name: str, mocker: MockerFixture
|
||||
):
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
"""Test interruption without state updates. This test confirms that
|
||||
interrupting doesn't require a state key having been updated in the prev step"""
|
||||
|
||||
@@ -78,11 +85,17 @@ async def test_interruption_without_state_updates_async(
|
||||
initial_input = {"input": "hello world"}
|
||||
thread = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
await graph.ainvoke(initial_input, thread, debug=True)
|
||||
await graph.ainvoke(initial_input, thread, checkpoint_during=checkpoint_during)
|
||||
assert (await graph.aget_state(thread)).next == ("step_2",)
|
||||
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
|
||||
assert n_checkpoints == (3 if checkpoint_during else 1)
|
||||
|
||||
await graph.ainvoke(None, thread, debug=True)
|
||||
await graph.ainvoke(None, thread, checkpoint_during=checkpoint_during)
|
||||
assert (await graph.aget_state(thread)).next == ("step_3",)
|
||||
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
|
||||
assert n_checkpoints == (4 if checkpoint_during else 2)
|
||||
|
||||
await graph.ainvoke(None, thread, debug=True)
|
||||
await graph.ainvoke(None, thread, checkpoint_during=checkpoint_during)
|
||||
assert (await graph.aget_state(thread)).next == ()
|
||||
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
|
||||
assert n_checkpoints == (5 if checkpoint_during else 3)
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
@@ -7258,9 +7258,10 @@ def test_branch_then(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_SYNC)
|
||||
def test_send_dedupe_on_resume(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
|
||||
|
||||
@@ -7316,7 +7317,7 @@ def test_send_dedupe_on_resume(
|
||||
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert graph.invoke(["0"], thread1, debug=1) == [
|
||||
assert graph.invoke(["0"], thread1, checkpoint_during=checkpoint_during) == [
|
||||
"0",
|
||||
"1",
|
||||
"3.1",
|
||||
@@ -7333,12 +7334,11 @@ def test_send_dedupe_on_resume(
|
||||
pytest.xfail("TODO: shallow checkpointer reports wrong next set")
|
||||
assert state.next == ("flaky",)
|
||||
# check history
|
||||
if "shallow" not in checkpointer_name:
|
||||
history = [c for c in graph.get_state_history(thread1)]
|
||||
assert len(history) == 4
|
||||
history = [c for c in graph.get_state_history(thread1)]
|
||||
assert len(history) == (4 if checkpoint_during else 1)
|
||||
|
||||
# resume execution
|
||||
assert graph.invoke(None, thread1, debug=1) == [
|
||||
assert graph.invoke(None, thread1, checkpoint_during=checkpoint_during) == [
|
||||
"0",
|
||||
"1",
|
||||
"3.1",
|
||||
@@ -7358,6 +7358,7 @@ def test_send_dedupe_on_resume(
|
||||
assert state.next == ()
|
||||
# check history
|
||||
history = [c for c in graph.get_state_history(thread1)]
|
||||
assert len(history) == (6 if checkpoint_during else 2)
|
||||
expected_history = [
|
||||
StateSnapshot(
|
||||
values=[
|
||||
@@ -7494,13 +7495,9 @@ def test_send_dedupe_on_resume(
|
||||
name="flaky",
|
||||
path=("__pregel_push", 1),
|
||||
error=None,
|
||||
interrupts=(
|
||||
Interrupt(
|
||||
value="Bahh", resumable=False, ns=None, when="during"
|
||||
),
|
||||
),
|
||||
interrupts=(Interrupt(value="Bahh", resumable=False, ns=None),),
|
||||
state=None,
|
||||
result=["flaky|4"],
|
||||
result=["flaky|4"] if checkpoint_during else None,
|
||||
),
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -7637,10 +7634,11 @@ def test_send_dedupe_on_resume(
|
||||
),
|
||||
),
|
||||
]
|
||||
if "shallow" in checkpointer_name:
|
||||
expected_history = expected_history[:1]
|
||||
|
||||
assert history == expected_history
|
||||
if checkpoint_during:
|
||||
assert history == expected_history
|
||||
else:
|
||||
assert history[0] == expected_history[0]
|
||||
assert history[1] == expected_history[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import datetime
|
||||
import decimal
|
||||
import enum
|
||||
import functools
|
||||
import gc
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import operator
|
||||
import pathlib
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
@@ -12,6 +17,7 @@ from collections import Counter, deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from random import randrange
|
||||
from typing import (
|
||||
Annotated,
|
||||
@@ -1115,10 +1121,14 @@ def test_invoke_checkpoint_two(
|
||||
assert checkpoint["channel_values"].get("total") == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_pending_writes_resume(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
checkpointer: BaseCheckpointSaver = request.getfixturevalue(
|
||||
f"checkpointer_{checkpointer_name}"
|
||||
)
|
||||
@@ -1144,17 +1154,19 @@ def test_pending_writes_resume(
|
||||
self.calls = 0
|
||||
|
||||
one = AwhileMaker(0.1, {"value": 2})
|
||||
two = AwhileMaker(0.3, ConnectionError("I'm not good"))
|
||||
two = AwhileMaker(0.2, ConnectionError("I'm not good"))
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("one", one)
|
||||
builder.add_node("two", two, retry=RetryPolicy(max_attempts=2))
|
||||
builder.add_node(
|
||||
"two", two, retry=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False)
|
||||
)
|
||||
builder.add_edge(START, "one")
|
||||
builder.add_edge(START, "two")
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
|
||||
thread1: RunnableConfig = {"configurable": {"thread_id": "1"}}
|
||||
with pytest.raises(ConnectionError, match="I'm not good"):
|
||||
graph.invoke({"value": 1}, thread1)
|
||||
graph.invoke({"value": 1}, thread1, checkpoint_during=checkpoint_during)
|
||||
|
||||
# both nodes should have been called once
|
||||
assert one.calls == 1
|
||||
@@ -1200,7 +1212,7 @@ def test_pending_writes_resume(
|
||||
|
||||
# resume execution
|
||||
with pytest.raises(ConnectionError, match="I'm not good"):
|
||||
graph.invoke(None, thread1)
|
||||
graph.invoke(None, thread1, checkpoint_during=checkpoint_during)
|
||||
|
||||
# node "one" succeeded previously, so shouldn't be called again
|
||||
assert one.calls == 1
|
||||
@@ -1214,7 +1226,9 @@ def test_pending_writes_resume(
|
||||
# resume execution, without exception
|
||||
two.rtn = {"value": 3}
|
||||
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
|
||||
assert graph.invoke(None, thread1) == {"value": 6}
|
||||
assert graph.invoke(None, thread1, checkpoint_during=checkpoint_during) == {
|
||||
"value": 6
|
||||
}
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
assert len(list(checkpointer.list(thread1))) == 1
|
||||
@@ -1223,7 +1237,7 @@ def test_pending_writes_resume(
|
||||
# check all final checkpoints
|
||||
checkpoints = [c for c in checkpointer.list(thread1)]
|
||||
# we should have 3
|
||||
assert len(checkpoints) == 3
|
||||
assert len(checkpoints) == (3 if checkpoint_during else 2)
|
||||
# the last one not too interesting for this test
|
||||
assert checkpoints[0] == CheckpointTuple(
|
||||
config={
|
||||
@@ -1325,15 +1339,26 @@ def test_pending_writes_resume(
|
||||
"configurable": {
|
||||
"thread_id": "1",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"],
|
||||
"checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"]
|
||||
if checkpoint_during
|
||||
else AnyStr(),
|
||||
}
|
||||
},
|
||||
pending_writes=UnsortedSequence(
|
||||
(AnyStr(), "value", 2),
|
||||
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
|
||||
(AnyStr(), "value", 3),
|
||||
)
|
||||
if checkpoint_during
|
||||
else UnsortedSequence(
|
||||
(AnyStr(), "value", 2),
|
||||
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
|
||||
# the write against the previous checkpoint is not saved, as it is
|
||||
# produced in a run where only the next checkpoint (the last) is saved
|
||||
),
|
||||
)
|
||||
if not checkpoint_during:
|
||||
return
|
||||
assert checkpoints[2] == CheckpointTuple(
|
||||
config={
|
||||
"configurable": {
|
||||
@@ -1491,8 +1516,14 @@ def test_send_sequences() -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> None:
|
||||
def test_imp_task(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
|
||||
mapper_calls = 0
|
||||
|
||||
@@ -1558,7 +1589,7 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non
|
||||
}
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert [*graph.stream([0, 1], thread1)] == [
|
||||
assert [*graph.stream([0, 1], thread1, checkpoint_during=checkpoint_during)] == [
|
||||
{"mapper": "00"},
|
||||
{"mapper": "11"},
|
||||
{
|
||||
@@ -1574,17 +1605,23 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non
|
||||
]
|
||||
assert mapper_calls == 2
|
||||
|
||||
assert graph.invoke(Command(resume="answer"), thread1) == [
|
||||
assert graph.invoke(
|
||||
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
|
||||
) == [
|
||||
"00answer",
|
||||
"11answer",
|
||||
]
|
||||
assert mapper_calls == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_imp_nested(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
|
||||
|
||||
def mynode(input: list[str]) -> list[str]:
|
||||
@@ -1626,7 +1663,7 @@ def test_imp_nested(
|
||||
}
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert [*graph.stream([0, 1], thread1)] == [
|
||||
assert [*graph.stream([0, 1], thread1, checkpoint_during=checkpoint_during)] == [
|
||||
{"submapper": "0"},
|
||||
{"mapper": "00"},
|
||||
{"submapper": "1"},
|
||||
@@ -1643,16 +1680,22 @@ def test_imp_nested(
|
||||
},
|
||||
]
|
||||
|
||||
assert graph.invoke(Command(resume="answer"), thread1) == [
|
||||
assert graph.invoke(
|
||||
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
|
||||
) == [
|
||||
"00answera",
|
||||
"11answera",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_imp_stream_order(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
|
||||
|
||||
@task()
|
||||
@@ -1675,7 +1718,10 @@ def test_imp_stream_order(
|
||||
return fut_baz.result()
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert [c for c in graph.stream({"a": "0"}, thread1)] == [
|
||||
assert [
|
||||
c
|
||||
for c in graph.stream({"a": "0"}, thread1, checkpoint_during=checkpoint_during)
|
||||
] == [
|
||||
{
|
||||
"foo": (
|
||||
"0foo",
|
||||
@@ -2735,6 +2781,9 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
|
||||
checkpointer_name: str,
|
||||
) -> None:
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
|
||||
IS_V1 = BaseModel is BaseModelV1
|
||||
|
||||
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
|
||||
setup = mocker.Mock()
|
||||
@@ -2773,14 +2822,28 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
|
||||
class InnerObject(BaseModel):
|
||||
yo: int
|
||||
|
||||
class State(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
if IS_V1:
|
||||
|
||||
query: str
|
||||
inner: Annotated[InnerObject, lambda x, y: y]
|
||||
answer: Optional[str] = None
|
||||
docs: Annotated[list[str], sorted_add]
|
||||
client: Annotated[httpx.Client, Context(make_httpx_client)]
|
||||
class State(BaseModel):
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
query: str
|
||||
inner: Annotated[InnerObject, lambda x, y: y]
|
||||
answer: Optional[str] = None
|
||||
docs: Annotated[list[str], sorted_add]
|
||||
client: Annotated[httpx.Client, Context(make_httpx_client)]
|
||||
|
||||
else:
|
||||
|
||||
class State(BaseModel):
|
||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
query: str
|
||||
inner: Annotated[InnerObject, lambda x, y: y]
|
||||
answer: Optional[str] = None
|
||||
docs: Annotated[list[str], sorted_add]
|
||||
client: Annotated[httpx.Client, Context(make_httpx_client)]
|
||||
|
||||
class StateUpdate(BaseModel):
|
||||
query: Optional[str] = None
|
||||
@@ -3039,15 +3102,49 @@ def test_nested_pydantic_models(version: str) -> None:
|
||||
"""Test that nested Pydantic models are properly constructed from leaf nodes up."""
|
||||
|
||||
# Define nested Pydantic models
|
||||
# Import necessary modules
|
||||
|
||||
if version == "v1":
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
from pydantic.v1 import ( # type: ignore
|
||||
BaseModel,
|
||||
ByteSize,
|
||||
Field,
|
||||
SecretStr,
|
||||
confloat,
|
||||
conint,
|
||||
conlist,
|
||||
constr,
|
||||
)
|
||||
else:
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import ( # type: ignore
|
||||
BaseModel,
|
||||
ByteSize,
|
||||
Field,
|
||||
SecretStr,
|
||||
confloat,
|
||||
conint,
|
||||
conlist,
|
||||
constr,
|
||||
)
|
||||
from pydantic.v1 import BaseModel as BaseModelV1
|
||||
|
||||
if BaseModel is BaseModelV1:
|
||||
pytest.skip("Cannot test pydantic v2 using installed version < 2")
|
||||
|
||||
class NestedModel(BaseModel):
|
||||
value: int
|
||||
name: str
|
||||
|
||||
# For constrained types
|
||||
PositiveInt = Annotated[int, Field(gt=0)]
|
||||
NonNegativeFloat = Annotated[float, Field(ge=0)]
|
||||
|
||||
# Enum type
|
||||
class UserRole(Enum):
|
||||
ADMIN = "admin"
|
||||
USER = "user"
|
||||
GUEST = "guest"
|
||||
|
||||
# Forward reference model
|
||||
class RecursiveModel(BaseModel):
|
||||
value: str
|
||||
@@ -3068,12 +3165,19 @@ def test_nested_pydantic_models(version: str) -> None:
|
||||
name: str
|
||||
friends: list[str] = Field(default_factory=list) # IDs of friends
|
||||
|
||||
if version == "v2":
|
||||
conlist_type = conlist(item_type=int, min_length=2, max_length=5)
|
||||
else:
|
||||
conlist_type = conlist(item_type=int, min_items=2, max_items=5)
|
||||
|
||||
class State(BaseModel):
|
||||
# Basic nested model tests
|
||||
top_level: str
|
||||
auuid: uuid.UUID
|
||||
nested: NestedModel
|
||||
optional_nested: Annotated[Optional[NestedModel], lambda x, y: y, "Foo"]
|
||||
dict_nested: dict[str, NestedModel]
|
||||
simple_str_list: list[str]
|
||||
list_nested: Annotated[
|
||||
Union[dict, list[dict[str, NestedModel]]], lambda x, y: (x or []) + [y]
|
||||
]
|
||||
@@ -3090,15 +3194,51 @@ def test_nested_pydantic_models(version: str) -> None:
|
||||
# Cyclic reference test
|
||||
people: dict[str, Person] # Map of ID -> Person
|
||||
|
||||
# Rich type adapters
|
||||
ip_address: ipaddress.IPv4Address
|
||||
ip_address_v6: ipaddress.IPv6Address
|
||||
amount: decimal.Decimal
|
||||
file_path: pathlib.Path
|
||||
timestamp: datetime.datetime
|
||||
date_only: datetime.date
|
||||
time_only: datetime.time
|
||||
duration: datetime.timedelta
|
||||
immutable_set: frozenset[int]
|
||||
binary_data: bytes
|
||||
pattern: re.Pattern
|
||||
secret: SecretStr
|
||||
file_size: ByteSize
|
||||
|
||||
# Constrained types
|
||||
positive_value: PositiveInt
|
||||
non_negative: NonNegativeFloat
|
||||
limited_string: constr(min_length=3, max_length=10)
|
||||
bounded_int: conint(ge=10, le=100)
|
||||
restricted_float: confloat(gt=0, lt=1)
|
||||
required_list: conlist_type
|
||||
|
||||
# Enum & Literal
|
||||
role: UserRole
|
||||
status: Literal["active", "inactive", "pending"]
|
||||
|
||||
# Annotated & NewType
|
||||
validated_age: Annotated[int, Field(gt=0, lt=120)]
|
||||
|
||||
# Generic containers with validators
|
||||
decimal_list: List[decimal.Decimal]
|
||||
id_tuple: tuple[uuid.UUID, uuid.UUID]
|
||||
|
||||
inputs = {
|
||||
# Basic nested models
|
||||
"top_level": "initial",
|
||||
"auuid": str(uuid.uuid4()),
|
||||
"nested": {"value": 42, "name": "test"},
|
||||
"optional_nested": {"value": 10, "name": "optional"},
|
||||
"dict_nested": {"a": {"value": 5, "name": "a"}},
|
||||
"list_nested": [{"a": {"value": 6, "name": "b"}}],
|
||||
"tuple_nested": ["tuple-key", {"value": 7, "name": "tuple-value"}],
|
||||
"tuple_list_nested": [[1, {"value": 8, "name": "tuple-in-list"}]],
|
||||
"simple_str_list": ["siss", "boom", "bah"],
|
||||
"complex_tuple": [
|
||||
"complex",
|
||||
{"nested": [9, {"value": 10, "name": "deep"}]},
|
||||
@@ -3125,6 +3265,35 @@ def test_nested_pydantic_models(version: str) -> None:
|
||||
"friends": ["1", "2"], # Charlie is friends with Alice and Bob
|
||||
},
|
||||
},
|
||||
# Rich type adapters
|
||||
"ip_address": "192.168.1.1",
|
||||
"ip_address_v6": "2001:db8::1",
|
||||
"amount": "123.45",
|
||||
"file_path": "/tmp/test.txt",
|
||||
"timestamp": "2025-04-07T10:58:04",
|
||||
"date_only": "2025-04-07",
|
||||
"time_only": "10:58:04",
|
||||
"duration": 3600, # seconds
|
||||
"immutable_set": [1, 2, 3, 4],
|
||||
"binary_data": b"hello world",
|
||||
"pattern": "^test$",
|
||||
"secret": "password123",
|
||||
"file_size": 1024,
|
||||
# Constrained types
|
||||
"positive_value": 42,
|
||||
"non_negative": 0.0,
|
||||
"limited_string": "test",
|
||||
"bounded_int": 50,
|
||||
"restricted_float": 0.5,
|
||||
"required_list": [10, 20, 30],
|
||||
# Enum & Literal
|
||||
"role": "admin",
|
||||
"status": "active",
|
||||
# Annotated & NewType
|
||||
"validated_age": 30,
|
||||
# Generic containers with validators
|
||||
"decimal_list": ["10.5", "20.75", "30.25"],
|
||||
"id_tuple": [str(uuid.uuid4()), str(uuid.uuid4())],
|
||||
}
|
||||
|
||||
update = {"top_level": "updated", "nested": {"value": 100, "name": "updated"}}
|
||||
@@ -3132,7 +3301,42 @@ def test_nested_pydantic_models(version: str) -> None:
|
||||
expected = State(**inputs)
|
||||
|
||||
def node_fn(state: State) -> dict:
|
||||
# Basic assertions
|
||||
assert isinstance(state.auuid, uuid.UUID)
|
||||
assert state == expected
|
||||
|
||||
# Rich type assertions
|
||||
assert isinstance(state.ip_address, ipaddress.IPv4Address)
|
||||
assert isinstance(state.ip_address_v6, ipaddress.IPv6Address)
|
||||
assert isinstance(state.amount, decimal.Decimal)
|
||||
assert isinstance(state.file_path, pathlib.Path)
|
||||
assert isinstance(state.timestamp, datetime.datetime)
|
||||
assert isinstance(state.date_only, datetime.date)
|
||||
assert isinstance(state.time_only, datetime.time)
|
||||
assert isinstance(state.duration, datetime.timedelta)
|
||||
assert isinstance(state.immutable_set, frozenset)
|
||||
assert isinstance(state.binary_data, bytes)
|
||||
assert isinstance(state.pattern, re.Pattern)
|
||||
|
||||
# Constrained types
|
||||
assert state.positive_value > 0
|
||||
assert state.non_negative >= 0
|
||||
assert 3 <= len(state.limited_string) <= 10
|
||||
assert 10 <= state.bounded_int <= 100
|
||||
assert 0 < state.restricted_float < 1
|
||||
assert 2 <= len(state.required_list) <= 5
|
||||
|
||||
# Enum & Literal
|
||||
assert state.role == UserRole.ADMIN
|
||||
assert state.status == "active"
|
||||
|
||||
# Annotated
|
||||
assert 0 < state.validated_age < 120
|
||||
|
||||
# Generic containers
|
||||
assert len(state.decimal_list) == 3
|
||||
assert len(state.id_tuple) == 2
|
||||
|
||||
return update
|
||||
|
||||
builder = StateGraph(State)
|
||||
@@ -3643,10 +3847,14 @@ def test_nested_graph(snapshot: SnapshotAssertion) -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_subgraph_checkpoint_true(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Unsupported combo")
|
||||
|
||||
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
|
||||
|
||||
class InnerState(TypedDict):
|
||||
@@ -3678,7 +3886,12 @@ def test_subgraph_checkpoint_true(
|
||||
app = graph.compile(checkpointer=checkpointer)
|
||||
|
||||
config = {"configurable": {"thread_id": "2"}}
|
||||
assert [c for c in app.stream({"my_key": ""}, config, subgraphs=True)] == [
|
||||
assert [
|
||||
c
|
||||
for c in app.stream(
|
||||
{"my_key": ""}, config, subgraphs=True, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
(("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}),
|
||||
(("inner",), {"inner_2": {"my_key": " and there"}}),
|
||||
((), {"inner": {"my_key": " got here and there"}}),
|
||||
@@ -3703,10 +3916,14 @@ def test_subgraph_checkpoint_true(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_subgraph_checkpoint_true_interrupt(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Unsupported combo")
|
||||
|
||||
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
|
||||
|
||||
# Define subgraph
|
||||
@@ -3745,15 +3962,18 @@ def test_subgraph_checkpoint_true_interrupt(
|
||||
builder.add_edge(START, "node_1")
|
||||
builder.add_edge("node_1", "node_2")
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
assert graph.invoke({"foo": "foo"}, config) == {"foo": "hi! foo"}
|
||||
assert graph.invoke(
|
||||
{"foo": "foo"}, config, checkpoint_during=checkpoint_during
|
||||
) == {"foo": "hi! foo"}
|
||||
assert graph.get_state(config, subgraphs=True).tasks[0].state.values == {
|
||||
"bar": "hi! foo"
|
||||
}
|
||||
assert graph.invoke(Command(resume="baz"), config) == {"foo": "hi! foobaz"}
|
||||
assert graph.invoke(
|
||||
Command(resume="baz"), config, checkpoint_during=checkpoint_during
|
||||
) == {"foo": "hi! foobaz"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
@@ -3869,10 +4089,14 @@ def test_stream_buffering_single_node(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_nested_graph_interrupts_parallel(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Unsupported combo")
|
||||
|
||||
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
|
||||
|
||||
class InnerState(TypedDict):
|
||||
@@ -3919,11 +4143,11 @@ def test_nested_graph_interrupts_parallel(
|
||||
|
||||
# test invoke w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
assert app.invoke({"my_key": ""}, config, debug=True) == {
|
||||
assert app.invoke({"my_key": ""}, config, checkpoint_during=checkpoint_during) == {
|
||||
"my_key": " and parallel",
|
||||
}
|
||||
|
||||
assert app.invoke(None, config, debug=True) == {
|
||||
assert app.invoke(None, config, checkpoint_during=checkpoint_during) == {
|
||||
"my_key": "got here and there and parallel and back again",
|
||||
}
|
||||
|
||||
@@ -3932,13 +4156,17 @@ def test_nested_graph_interrupts_parallel(
|
||||
# - the writes of outer are persisted in 1st call and used in 2nd call, ie outer isn't called again (because we dont see outer_1 output again in 2nd stream)
|
||||
# test stream updates w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "2"}}
|
||||
assert [*app.stream({"my_key": ""}, config, subgraphs=True)] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
{"my_key": ""}, config, subgraphs=True, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
# we got to parallel node first
|
||||
((), {"outer_1": {"my_key": " and parallel"}}),
|
||||
((AnyStr("inner:"),), {"inner_1": {"my_key": "got here", "my_other_key": ""}}),
|
||||
((), {"__interrupt__": ()}),
|
||||
]
|
||||
assert [*app.stream(None, config)] == [
|
||||
assert [*app.stream(None, config, checkpoint_during=checkpoint_during)] == [
|
||||
{"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}},
|
||||
{"inner": {"my_key": "got here and there"}},
|
||||
{"outer_2": {"my_key": " and back again"}},
|
||||
@@ -3946,11 +4174,22 @@ def test_nested_graph_interrupts_parallel(
|
||||
|
||||
# test stream values w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "3"}}
|
||||
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": " and parallel"},
|
||||
]
|
||||
assert [*app.stream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": "got here and there and parallel"},
|
||||
{"my_key": "got here and there and parallel and back again"},
|
||||
@@ -3959,15 +4198,28 @@ def test_nested_graph_interrupts_parallel(
|
||||
# test interrupts BEFORE the parallel node
|
||||
app = graph.compile(checkpointer=checkpointer, interrupt_before=["outer_1"])
|
||||
config = {"configurable": {"thread_id": "4"}}
|
||||
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
|
||||
{"my_key": ""}
|
||||
]
|
||||
assert [
|
||||
*app.stream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [{"my_key": ""}]
|
||||
# while we're waiting for the node w/ interrupt inside to finish
|
||||
assert [*app.stream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": " and parallel"},
|
||||
]
|
||||
assert [*app.stream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": "got here and there and parallel"},
|
||||
{"my_key": "got here and there and parallel and back again"},
|
||||
@@ -3976,24 +4228,43 @@ def test_nested_graph_interrupts_parallel(
|
||||
# test interrupts AFTER the parallel node
|
||||
app = graph.compile(checkpointer=checkpointer, interrupt_after=["outer_1"])
|
||||
config = {"configurable": {"thread_id": "5"}}
|
||||
assert [*app.stream({"my_key": ""}, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": " and parallel"},
|
||||
]
|
||||
assert [*app.stream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": "got here and there and parallel"},
|
||||
]
|
||||
assert [*app.stream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": "got here and there and parallel"},
|
||||
{"my_key": "got here and there and parallel and back again"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
|
||||
def test_doubly_nested_graph_interrupts(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Unsupported combo")
|
||||
|
||||
checkpointer = request.getfixturevalue("checkpointer_" + checkpointer_name)
|
||||
|
||||
class State(TypedDict):
|
||||
@@ -4047,11 +4318,13 @@ def test_doubly_nested_graph_interrupts(
|
||||
|
||||
# test invoke w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
assert app.invoke({"my_key": "my value"}, config, debug=True) == {
|
||||
assert app.invoke(
|
||||
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
|
||||
) == {
|
||||
"my_key": "hi my value",
|
||||
}
|
||||
|
||||
assert app.invoke(None, config, debug=True) == {
|
||||
assert app.invoke(None, config, checkpoint_during=checkpoint_during) == {
|
||||
"my_key": "hi my value here and there and back again",
|
||||
}
|
||||
|
||||
@@ -4060,12 +4333,14 @@ def test_doubly_nested_graph_interrupts(
|
||||
config = {
|
||||
"configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append}
|
||||
}
|
||||
assert [*app.stream({"my_key": "my value"}, config)] == [
|
||||
assert [
|
||||
*app.stream({"my_key": "my value"}, config, checkpoint_during=checkpoint_during)
|
||||
] == [
|
||||
{"parent_1": {"my_key": "hi my value"}},
|
||||
{"__interrupt__": ()},
|
||||
]
|
||||
assert nodes == ["parent_1", "grandchild_1"]
|
||||
assert [*app.stream(None, config)] == [
|
||||
assert [*app.stream(None, config, checkpoint_during=checkpoint_during)] == [
|
||||
{"child": {"my_key": "hi my value here and there"}},
|
||||
{"parent_2": {"my_key": "hi my value here and there and back again"}},
|
||||
]
|
||||
@@ -4080,11 +4355,22 @@ def test_doubly_nested_graph_interrupts(
|
||||
|
||||
# test stream values w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "3"}}
|
||||
assert [*app.stream({"my_key": "my value"}, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
{"my_key": "my value"},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
{"my_key": "my value"},
|
||||
{"my_key": "hi my value"},
|
||||
]
|
||||
assert [*app.stream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
*app.stream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": "hi my value"},
|
||||
{"my_key": "hi my value here and there"},
|
||||
{"my_key": "hi my value here and there and back again"},
|
||||
@@ -5870,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")
|
||||
] == [
|
||||
@@ -5883,7 +6169,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -5891,7 +6177,6 @@ def test_falsy_return_from_task(
|
||||
"parents": {},
|
||||
"source": "input",
|
||||
"step": -1,
|
||||
"thread_id": AnyStr(),
|
||||
"writes": {
|
||||
"__start__": {
|
||||
"a": 5,
|
||||
@@ -5982,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")
|
||||
@@ -5995,7 +6281,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -6090,7 +6376,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -6098,7 +6384,6 @@ def test_falsy_return_from_task(
|
||||
"parents": {},
|
||||
"source": "loop",
|
||||
"step": 0,
|
||||
"thread_id": AnyStr(),
|
||||
"writes": {
|
||||
"falsy_task": False,
|
||||
"graph": None,
|
||||
@@ -6112,7 +6397,7 @@ def test_falsy_return_from_task(
|
||||
"checkpoint_ns": "",
|
||||
"thread_id": AnyStr(),
|
||||
},
|
||||
"metadata": configurable["configurable"],
|
||||
"metadata": {},
|
||||
"recursion_limit": 25,
|
||||
"tags": [],
|
||||
},
|
||||
@@ -6882,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]
|
||||
@@ -6960,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
|
||||
|
||||
@@ -6994,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
|
||||
|
||||
@@ -1947,10 +1947,14 @@ async def test_invoke_checkpoint(mocker: MockerFixture, checkpointer_name: str)
|
||||
assert checkpoint["channel_values"].get("total") == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_pending_writes_resume(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
class State(TypedDict):
|
||||
value: Annotated[int, operator.add]
|
||||
|
||||
@@ -1972,10 +1976,12 @@ async def test_pending_writes_resume(
|
||||
self.calls = 0
|
||||
|
||||
one = AwhileMaker(0.1, {"value": 2})
|
||||
two = AwhileMaker(0.3, ConnectionError("I'm not good"))
|
||||
two = AwhileMaker(0.2, ConnectionError("I'm not good"))
|
||||
builder = StateGraph(State)
|
||||
builder.add_node("one", one)
|
||||
builder.add_node("two", two, retry=RetryPolicy(max_attempts=2))
|
||||
builder.add_node(
|
||||
"two", two, retry=RetryPolicy(max_attempts=2, initial_interval=0, jitter=False)
|
||||
)
|
||||
builder.add_edge(START, "one")
|
||||
builder.add_edge(START, "two")
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
@@ -1983,7 +1989,9 @@ async def test_pending_writes_resume(
|
||||
|
||||
thread1: RunnableConfig = {"configurable": {"thread_id": "1"}}
|
||||
with pytest.raises(ConnectionError, match="I'm not good"):
|
||||
await graph.ainvoke({"value": 1}, thread1)
|
||||
await graph.ainvoke(
|
||||
{"value": 1}, thread1, checkpoint_during=checkpoint_during
|
||||
)
|
||||
|
||||
# both nodes should have been called once
|
||||
assert one.calls == 1
|
||||
@@ -2034,7 +2042,7 @@ async def test_pending_writes_resume(
|
||||
|
||||
# resume execution
|
||||
with pytest.raises(ConnectionError, match="I'm not good"):
|
||||
await graph.ainvoke(None, thread1)
|
||||
await graph.ainvoke(None, thread1, checkpoint_during=checkpoint_during)
|
||||
|
||||
# node "one" succeeded previously, so shouldn't be called again
|
||||
assert one.calls == 1
|
||||
@@ -2048,7 +2056,9 @@ async def test_pending_writes_resume(
|
||||
# resume execution, without exception
|
||||
two.rtn = {"value": 3}
|
||||
# both the pending write and the new write were applied, 1 + 2 + 3 = 6
|
||||
assert await graph.ainvoke(None, thread1) == {"value": 6}
|
||||
assert await graph.ainvoke(
|
||||
None, thread1, checkpoint_during=checkpoint_during
|
||||
) == {"value": 6}
|
||||
|
||||
if "shallow" in checkpointer_name:
|
||||
assert len([c async for c in checkpointer.alist(thread1)]) == 1
|
||||
@@ -2057,7 +2067,7 @@ async def test_pending_writes_resume(
|
||||
# check all final checkpoints
|
||||
checkpoints = [c async for c in checkpointer.alist(thread1)]
|
||||
# we should have 3
|
||||
assert len(checkpoints) == 3
|
||||
assert len(checkpoints) == (3 if checkpoint_during else 2)
|
||||
# the last one not too interesting for this test
|
||||
assert checkpoints[0] == CheckpointTuple(
|
||||
config={
|
||||
@@ -2163,15 +2173,26 @@ async def test_pending_writes_resume(
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": checkpoints[2].config["configurable"][
|
||||
"checkpoint_id"
|
||||
],
|
||||
]
|
||||
if checkpoint_during
|
||||
else AnyStr(),
|
||||
}
|
||||
},
|
||||
pending_writes=UnsortedSequence(
|
||||
(AnyStr(), "value", 2),
|
||||
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
|
||||
(AnyStr(), "value", 3),
|
||||
)
|
||||
if checkpoint_during
|
||||
else UnsortedSequence(
|
||||
(AnyStr(), "value", 2),
|
||||
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
|
||||
# the write against the previous checkpoint is not saved, as it is
|
||||
# produced in a run where only the next checkpoint (the last) is saved
|
||||
),
|
||||
)
|
||||
if not checkpoint_during:
|
||||
return
|
||||
assert checkpoints[2] == CheckpointTuple(
|
||||
config={
|
||||
"configurable": {
|
||||
@@ -2209,7 +2230,7 @@ async def test_pending_writes_resume(
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
|
||||
async def test_run_from_checkpoint_id_retains_previous_writes(
|
||||
request: pytest.FixtureRequest, checkpointer_name: str, mocker: MockerFixture
|
||||
checkpointer_name: str,
|
||||
) -> None:
|
||||
class MyState(TypedDict):
|
||||
myval: Annotated[int, operator.add]
|
||||
@@ -2254,8 +2275,8 @@ async def test_run_from_checkpoint_id_retains_previous_writes(
|
||||
history = [c async for c in graph.aget_state_history(thread1)]
|
||||
|
||||
assert len(history) == 4
|
||||
assert history[-1].values == {"myval": 0}
|
||||
assert history[0].values == {"myval": 4, "otherval": False}
|
||||
assert history[-1].values == {"myval": 0}
|
||||
|
||||
second_run_config = {
|
||||
**thread1,
|
||||
@@ -2432,8 +2453,12 @@ async def test_send_sequences(checkpointer_name: str) -> None:
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_imp_task(checkpointer_name: str) -> None:
|
||||
async def test_imp_task(checkpointer_name: str, checkpoint_during: bool) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
mapper_calls = 0
|
||||
|
||||
@@ -2453,7 +2478,12 @@ async def test_imp_task(checkpointer_name: str) -> None:
|
||||
|
||||
tracer = FakeTracer()
|
||||
thread1 = {"configurable": {"thread_id": "1"}, "callbacks": [tracer]}
|
||||
assert [c async for c in graph.astream([0, 1], thread1)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in graph.astream(
|
||||
[0, 1], thread1, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"mapper": "00"},
|
||||
{"mapper": "11"},
|
||||
{
|
||||
@@ -2477,7 +2507,9 @@ async def test_imp_task(checkpointer_name: str) -> None:
|
||||
assert any(r.inputs == {"input": 0} for r in mapper_runs)
|
||||
assert any(r.inputs == {"input": 1} for r in mapper_runs)
|
||||
|
||||
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
|
||||
assert await graph.ainvoke(
|
||||
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
|
||||
) == [
|
||||
"00answer",
|
||||
"11answer",
|
||||
]
|
||||
@@ -2485,8 +2517,12 @@ async def test_imp_task(checkpointer_name: str) -> None:
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_imp_nested(checkpointer_name: str) -> None:
|
||||
async def test_imp_nested(checkpointer_name: str, checkpoint_during: bool) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
async def mynode(input: list[str]) -> list[str]:
|
||||
return [it + "a" for it in input]
|
||||
|
||||
@@ -2526,7 +2562,12 @@ async def test_imp_nested(checkpointer_name: str) -> None:
|
||||
}
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert [c async for c in graph.astream([0, 1], thread1)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in graph.astream(
|
||||
[0, 1], thread1, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"submapper": "0"},
|
||||
{"mapper": "00"},
|
||||
{"submapper": "1"},
|
||||
@@ -2543,15 +2584,21 @@ async def test_imp_nested(checkpointer_name: str) -> None:
|
||||
},
|
||||
]
|
||||
|
||||
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
|
||||
assert await graph.ainvoke(
|
||||
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
|
||||
) == [
|
||||
"00answera",
|
||||
"11answera",
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_imp_task_cancel(checkpointer_name: str) -> None:
|
||||
async def test_imp_task_cancel(checkpointer_name: str, checkpoint_during: bool) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
mapper_calls = 0
|
||||
mapper_cancels = 0
|
||||
@@ -2577,7 +2624,12 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None:
|
||||
return [m + answer for m in mapped]
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert [c async for c in graph.astream([0, 1], thread1)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in graph.astream(
|
||||
[0, 1], thread1, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"mapper": "00"},
|
||||
{
|
||||
"__interrupt__": (
|
||||
@@ -2593,7 +2645,9 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None:
|
||||
assert mapper_calls == 2
|
||||
assert mapper_cancels == 1
|
||||
|
||||
assert await graph.ainvoke(Command(resume="answer"), thread1) == [
|
||||
assert await graph.ainvoke(
|
||||
Command(resume="answer"), thread1, checkpoint_during=checkpoint_during
|
||||
) == [
|
||||
"00answer",
|
||||
]
|
||||
assert mapper_calls == 3
|
||||
@@ -2601,8 +2655,14 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None:
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_imp_sync_from_async(checkpointer_name: str) -> None:
|
||||
async def test_imp_sync_from_async(
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
|
||||
@task()
|
||||
@@ -2625,7 +2685,12 @@ async def test_imp_sync_from_async(checkpointer_name: str) -> None:
|
||||
return fut_baz.result()
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert [c async for c in graph.astream({"a": "0"}, thread1)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in graph.astream(
|
||||
{"a": "0"}, thread1, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"foo": {"a": "0foo", "b": "bar"}},
|
||||
{"bar": {"a": "0foobar", "c": "bark"}},
|
||||
{"baz": {"a": "0foobarbaz", "c": "something else"}},
|
||||
@@ -2634,8 +2699,14 @@ async def test_imp_sync_from_async(checkpointer_name: str) -> None:
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_imp_stream_order(checkpointer_name: str) -> None:
|
||||
async def test_imp_stream_order(
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
if not checkpoint_during and "shallow" in checkpointer_name:
|
||||
pytest.skip("Checkpointing during execution not supported")
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
|
||||
@task()
|
||||
@@ -2659,7 +2730,12 @@ async def test_imp_stream_order(checkpointer_name: str) -> None:
|
||||
return await fut_baz
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert [c async for c in graph.astream({"a": "0"}, thread1)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in graph.astream(
|
||||
{"a": "0"}, thread1, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"foo": {"a": "0foo", "b": "bar"}},
|
||||
{"bar": {"a": "0foobar", "c": "bark"}},
|
||||
{"baz": {"a": "0foobarbaz", "c": "something else"}},
|
||||
@@ -2667,8 +2743,11 @@ async def test_imp_stream_order(checkpointer_name: str) -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
|
||||
async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
async def test_send_dedupe_on_resume(
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
class InterruptOnce:
|
||||
ticks: int = 0
|
||||
|
||||
@@ -2719,7 +2798,9 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
assert await graph.ainvoke(["0"], thread1, debug=1) == [
|
||||
assert await graph.ainvoke(
|
||||
["0"], thread1, checkpoint_during=checkpoint_during
|
||||
) == [
|
||||
"0",
|
||||
"1",
|
||||
"3.1",
|
||||
@@ -2731,7 +2812,9 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
assert builder.nodes["2"].runnable.func.ticks == 3
|
||||
assert builder.nodes["flaky"].runnable.func.ticks == 1
|
||||
# resume execution
|
||||
assert await graph.ainvoke(None, thread1, debug=1) == [
|
||||
assert await graph.ainvoke(
|
||||
None, thread1, checkpoint_during=checkpoint_during
|
||||
) == [
|
||||
"0",
|
||||
"1",
|
||||
"3.1",
|
||||
@@ -2748,7 +2831,8 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
assert builder.nodes["flaky"].runnable.func.ticks == 2
|
||||
# check history
|
||||
history = [c async for c in graph.aget_state_history(thread1)]
|
||||
assert history == [
|
||||
assert len(history) == (6 if checkpoint_during else 2)
|
||||
expected_history = [
|
||||
StateSnapshot(
|
||||
values=[
|
||||
"0",
|
||||
@@ -2884,13 +2968,9 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
name="flaky",
|
||||
path=("__pregel_push", 1),
|
||||
error=None,
|
||||
interrupts=(
|
||||
Interrupt(
|
||||
value="Bahh", resumable=False, ns=None, when="during"
|
||||
),
|
||||
),
|
||||
interrupts=(Interrupt(value="Bahh", resumable=False, ns=None),),
|
||||
state=None,
|
||||
result=["flaky|4"],
|
||||
result=["flaky|4"] if checkpoint_during else None,
|
||||
),
|
||||
PregelTask(
|
||||
id=AnyStr(),
|
||||
@@ -3027,6 +3107,11 @@ async def test_send_dedupe_on_resume(checkpointer_name: str) -> None:
|
||||
),
|
||||
),
|
||||
]
|
||||
if checkpoint_during:
|
||||
assert history == expected_history
|
||||
else:
|
||||
assert history[0] == expected_history[0]
|
||||
assert history[1] == expected_history[2]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
@@ -4553,6 +4638,7 @@ async def test_nested_pydantic_models(version: str) -> None:
|
||||
optional_nested: Optional[NestedModel] = None
|
||||
dict_nested: dict[str, NestedModel]
|
||||
my_set: set[int]
|
||||
another_set: set
|
||||
my_enum: MyEnum
|
||||
list_nested: Annotated[
|
||||
Union[dict, list[dict[str, NestedModel]]], lambda x, y: (x or []) + [y]
|
||||
@@ -4581,6 +4667,7 @@ async def test_nested_pydantic_models(version: str) -> None:
|
||||
"nested": {"value": 42, "name": "test"},
|
||||
"optional_nested": {"value": 10, "name": "optional"},
|
||||
"my_set": [1, 2, 7],
|
||||
"another_set": ["foo", 3],
|
||||
"my_enum": MyEnum.B,
|
||||
"my_typed_dict": {"x": 1, "my_enum": MyEnum.A},
|
||||
"dict_nested": {"a": {"value": 5, "name": "a"}},
|
||||
@@ -5348,6 +5435,132 @@ async def test_nested_graph(snapshot: SnapshotAssertion) -> None:
|
||||
assert times_called == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
|
||||
async def test_subgraph_checkpoint_true(
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
class InnerState(TypedDict):
|
||||
my_key: Annotated[str, operator.add]
|
||||
my_other_key: str
|
||||
|
||||
def inner_1(state: InnerState):
|
||||
return {"my_key": " got here", "my_other_key": state["my_key"]}
|
||||
|
||||
def inner_2(state: InnerState):
|
||||
return {"my_key": " and there"}
|
||||
|
||||
inner = StateGraph(InnerState)
|
||||
inner.add_node("inner_1", inner_1)
|
||||
inner.add_node("inner_2", inner_2)
|
||||
inner.add_edge("inner_1", "inner_2")
|
||||
inner.set_entry_point("inner_1")
|
||||
inner.set_finish_point("inner_2")
|
||||
|
||||
class State(TypedDict):
|
||||
my_key: str
|
||||
|
||||
graph = StateGraph(State)
|
||||
graph.add_node("inner", inner.compile(checkpointer=True))
|
||||
graph.add_edge(START, "inner")
|
||||
graph.add_conditional_edges(
|
||||
"inner", lambda s: "inner" if s["my_key"].count("there") < 2 else END
|
||||
)
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
app = graph.compile(checkpointer=checkpointer)
|
||||
|
||||
config = {"configurable": {"thread_id": "2"}}
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
subgraphs=True,
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
(("inner",), {"inner_1": {"my_key": " got here", "my_other_key": ""}}),
|
||||
(("inner",), {"inner_2": {"my_key": " and there"}}),
|
||||
((), {"inner": {"my_key": " got here and there"}}),
|
||||
(
|
||||
("inner",),
|
||||
{
|
||||
"inner_1": {
|
||||
"my_key": " got here",
|
||||
"my_other_key": " got here and there got here and there",
|
||||
}
|
||||
},
|
||||
),
|
||||
(("inner",), {"inner_2": {"my_key": " and there"}}),
|
||||
(
|
||||
(),
|
||||
{
|
||||
"inner": {
|
||||
"my_key": " got here and there got here and there got here and there"
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", REGULAR_CHECKPOINTERS_ASYNC)
|
||||
async def test_subgraph_checkpoint_true_interrupt(
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
# Define subgraph
|
||||
class SubgraphState(TypedDict):
|
||||
# note that none of these keys are shared with the parent graph state
|
||||
bar: str
|
||||
baz: str
|
||||
|
||||
def subgraph_node_1(state: SubgraphState):
|
||||
baz_value = interrupt("Provide baz value")
|
||||
return {"baz": baz_value}
|
||||
|
||||
def subgraph_node_2(state: SubgraphState):
|
||||
return {"bar": state["bar"] + state["baz"]}
|
||||
|
||||
subgraph_builder = StateGraph(SubgraphState)
|
||||
subgraph_builder.add_node(subgraph_node_1)
|
||||
subgraph_builder.add_node(subgraph_node_2)
|
||||
subgraph_builder.add_edge(START, "subgraph_node_1")
|
||||
subgraph_builder.add_edge("subgraph_node_1", "subgraph_node_2")
|
||||
subgraph = subgraph_builder.compile(checkpointer=True)
|
||||
|
||||
class ParentState(TypedDict):
|
||||
foo: str
|
||||
|
||||
def node_1(state: ParentState):
|
||||
return {"foo": "hi! " + state["foo"]}
|
||||
|
||||
async def node_2(state: ParentState, config: RunnableConfig):
|
||||
response = await subgraph.ainvoke({"bar": state["foo"]})
|
||||
return {"foo": response["bar"]}
|
||||
|
||||
builder = StateGraph(ParentState)
|
||||
builder.add_node("node_1", node_1)
|
||||
builder.add_node("node_2", node_2)
|
||||
builder.add_edge(START, "node_1")
|
||||
builder.add_edge("node_1", "node_2")
|
||||
|
||||
async with awith_checkpointer(checkpointer_name) as checkpointer:
|
||||
graph = builder.compile(checkpointer=checkpointer)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
assert await graph.ainvoke(
|
||||
{"foo": "foo"}, config, checkpoint_during=checkpoint_during
|
||||
) == {"foo": "hi! foo"}
|
||||
assert (await graph.aget_state(config, subgraphs=True)).tasks[
|
||||
0
|
||||
].state.values == {"bar": "hi! foo"}
|
||||
assert await graph.ainvoke(
|
||||
Command(resume="baz"), config, checkpoint_during=checkpoint_during
|
||||
) == {"foo": "hi! foobaz"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_stream_subgraphs_during_execution(checkpointer_name: str) -> None:
|
||||
class InnerState(TypedDict):
|
||||
@@ -5456,8 +5669,11 @@ async def test_stream_buffering_single_node(checkpointer_name: str) -> None:
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
|
||||
async def test_nested_graph_interrupts_parallel(
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
class InnerState(TypedDict):
|
||||
my_key: Annotated[str, operator.add]
|
||||
my_other_key: str
|
||||
@@ -5506,11 +5722,13 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
|
||||
|
||||
# test invoke w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
assert await app.ainvoke({"my_key": ""}, config, debug=True) == {
|
||||
assert await app.ainvoke(
|
||||
{"my_key": ""}, config, checkpoint_during=checkpoint_during
|
||||
) == {
|
||||
"my_key": " and parallel",
|
||||
}
|
||||
|
||||
assert await app.ainvoke(None, config, debug=True) == {
|
||||
assert await app.ainvoke(None, config, checkpoint_during=checkpoint_during) == {
|
||||
"my_key": "got here and there and parallel and back again",
|
||||
}
|
||||
|
||||
@@ -5520,7 +5738,13 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
|
||||
# test stream updates w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "2"}}
|
||||
assert [
|
||||
c async for c in app.astream({"my_key": ""}, config, subgraphs=True)
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
subgraphs=True,
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
# we got to parallel node first
|
||||
((), {"outer_1": {"my_key": " and parallel"}}),
|
||||
@@ -5530,7 +5754,12 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
|
||||
),
|
||||
((), {"__interrupt__": ()}),
|
||||
]
|
||||
assert [c async for c in app.astream(None, config)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"outer_1": {"my_key": " and parallel"}, "__metadata__": {"cached": True}},
|
||||
{"inner": {"my_key": "got here and there"}},
|
||||
{"outer_2": {"my_key": " and back again"}},
|
||||
@@ -5539,12 +5768,23 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
|
||||
# test stream values w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "3"}}
|
||||
assert [
|
||||
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": " and parallel"},
|
||||
]
|
||||
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": "got here and there and parallel"},
|
||||
{"my_key": "got here and there and parallel and back again"},
|
||||
@@ -5554,16 +5794,32 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
|
||||
app = graph.compile(checkpointer=checkpointer, interrupt_before=["outer_1"])
|
||||
config = {"configurable": {"thread_id": "4"}}
|
||||
assert [
|
||||
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
]
|
||||
# while we're waiting for the node w/ interrupt inside to finish
|
||||
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": " and parallel"},
|
||||
]
|
||||
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": "got here and there and parallel"},
|
||||
{"my_key": "got here and there and parallel and back again"},
|
||||
@@ -5573,23 +5829,42 @@ async def test_nested_graph_interrupts_parallel(checkpointer_name: str) -> None:
|
||||
app = graph.compile(checkpointer=checkpointer, interrupt_after=["outer_1"])
|
||||
config = {"configurable": {"thread_id": "5"}}
|
||||
assert [
|
||||
c async for c in app.astream({"my_key": ""}, config, stream_mode="values")
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"my_key": ""},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": " and parallel"},
|
||||
]
|
||||
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": ""},
|
||||
{"my_key": "got here and there and parallel"},
|
||||
]
|
||||
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": "got here and there and parallel"},
|
||||
{"my_key": "got here and there and parallel and back again"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("checkpoint_during", [True, False])
|
||||
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
|
||||
async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
|
||||
async def test_doubly_nested_graph_interrupts(
|
||||
checkpointer_name: str, checkpoint_during: bool
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
my_key: str
|
||||
|
||||
@@ -5642,11 +5917,13 @@ async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
|
||||
|
||||
# test invoke w/ nested interrupt
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
assert await app.ainvoke({"my_key": "my value"}, config, debug=True) == {
|
||||
assert await app.ainvoke(
|
||||
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
|
||||
) == {
|
||||
"my_key": "hi my value",
|
||||
}
|
||||
|
||||
assert await app.ainvoke(None, config, debug=True) == {
|
||||
assert await app.ainvoke(None, config, checkpoint_during=checkpoint_during) == {
|
||||
"my_key": "hi my value here and there and back again",
|
||||
}
|
||||
|
||||
@@ -5655,12 +5932,22 @@ async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
|
||||
config = {
|
||||
"configurable": {"thread_id": "2", CONFIG_KEY_NODE_FINISHED: nodes.append}
|
||||
}
|
||||
assert [c async for c in app.astream({"my_key": "my value"}, config)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"my_key": "my value"}, config, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"parent_1": {"my_key": "hi my value"}},
|
||||
{"__interrupt__": ()},
|
||||
]
|
||||
assert nodes == ["parent_1", "grandchild_1"]
|
||||
assert [c async for c in app.astream(None, config)] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"child": {"my_key": "hi my value here and there"}},
|
||||
{"parent_2": {"my_key": "hi my value here and there and back again"}},
|
||||
]
|
||||
@@ -5678,13 +5965,21 @@ async def test_doubly_nested_graph_interrupts(checkpointer_name: str) -> None:
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
{"my_key": "my value"}, config, stream_mode="values"
|
||||
{"my_key": "my value"},
|
||||
config,
|
||||
stream_mode="values",
|
||||
checkpoint_during=checkpoint_during,
|
||||
)
|
||||
] == [
|
||||
{"my_key": "my value"},
|
||||
{"my_key": "hi my value"},
|
||||
]
|
||||
assert [c async for c in app.astream(None, config, stream_mode="values")] == [
|
||||
assert [
|
||||
c
|
||||
async for c in app.astream(
|
||||
None, config, stream_mode="values", checkpoint_during=checkpoint_during
|
||||
)
|
||||
] == [
|
||||
{"my_key": "hi my value"},
|
||||
{"my_key": "hi my value here and there"},
|
||||
{"my_key": "hi my value here and there and back again"},
|
||||
@@ -7599,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
|
||||
|
||||
|
||||
@@ -437,15 +437,17 @@ def test_stream():
|
||||
sync_client=mock_sync_client,
|
||||
)
|
||||
|
||||
# stream modes doesn't include 'updates'
|
||||
stream_parts = []
|
||||
# test raising graph interrupt if invoked as a subgraph
|
||||
with pytest.raises(GraphInterrupt) as exc:
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
# pretend we invoked this as a subgraph
|
||||
config={
|
||||
"configurable": {"thread_id": "thread_1", "checkpoint_ns": "some_ns"}
|
||||
},
|
||||
stream_mode="values",
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
pass
|
||||
|
||||
assert exc.value.args[0] == [
|
||||
Interrupt(
|
||||
@@ -456,6 +458,15 @@ def test_stream():
|
||||
)
|
||||
]
|
||||
|
||||
# stream modes doesn't include 'updates'
|
||||
stream_parts = []
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode="values",
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
{"chunk": "data1"},
|
||||
{"chunk": "data2"},
|
||||
@@ -470,62 +481,62 @@ def test_stream():
|
||||
|
||||
# default stream_mode is updates
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
{"chunk": "data3"},
|
||||
{"chunk": "data4"},
|
||||
{"__interrupt__": ()},
|
||||
]
|
||||
|
||||
# list stream_mode includes mode names
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
("updates", {"chunk": "data3"}),
|
||||
("updates", {"chunk": "data4"}),
|
||||
("updates", {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
# subgraphs + list modes
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
((), "updates", {"chunk": "data3"}),
|
||||
((), "updates", {"chunk": "data4"}),
|
||||
((), "updates", {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
# subgraphs + single mode
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
for stream_part in remote_pregel.stream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
((), {"chunk": "data3"}),
|
||||
((), {"chunk": "data4"}),
|
||||
((), {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
|
||||
@@ -561,15 +572,17 @@ async def test_astream():
|
||||
client=mock_async_client,
|
||||
)
|
||||
|
||||
# stream modes doesn't include 'updates'
|
||||
stream_parts = []
|
||||
# test raising graph interrupt if invoked as a subgraph
|
||||
with pytest.raises(GraphInterrupt) as exc:
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
# pretend we invoked this as a subgraph
|
||||
config={
|
||||
"configurable": {"thread_id": "thread_1", "checkpoint_ns": "some_ns"}
|
||||
},
|
||||
stream_mode="values",
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
pass
|
||||
|
||||
assert exc.value.args[0] == [
|
||||
Interrupt(
|
||||
@@ -580,6 +593,15 @@ async def test_astream():
|
||||
)
|
||||
]
|
||||
|
||||
# stream modes doesn't include 'updates'
|
||||
stream_parts = []
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode="values",
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
{"chunk": "data1"},
|
||||
{"chunk": "data2"},
|
||||
@@ -596,62 +618,62 @@ async def test_astream():
|
||||
|
||||
# default stream_mode is updates
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
{"chunk": "data3"},
|
||||
{"chunk": "data4"},
|
||||
{"__interrupt__": ()},
|
||||
]
|
||||
|
||||
# list stream_mode includes mode names
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
("updates", {"chunk": "data3"}),
|
||||
("updates", {"chunk": "data4"}),
|
||||
("updates", {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
# subgraphs + list modes
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
((), "updates", {"chunk": "data3"}),
|
||||
((), "updates", {"chunk": "data4"}),
|
||||
((), "updates", {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
# subgraphs + single mode
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
((), {"chunk": "data3"}),
|
||||
((), {"chunk": "data4"}),
|
||||
((), {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
async_iter = MagicMock()
|
||||
@@ -664,33 +686,33 @@ async def test_astream():
|
||||
|
||||
# subgraphs + list modes
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
stream_mode=["updates"],
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
(("my", "subgraph"), "updates", {"chunk": "data3"}),
|
||||
(("hello", "subgraph"), "updates", {"chunk": "data4"}),
|
||||
(("bye", "subgraph"), "updates", {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
# subgraphs + single mode
|
||||
stream_parts = []
|
||||
with pytest.raises(GraphInterrupt):
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
async for stream_part in remote_pregel.astream(
|
||||
{"input": "data"},
|
||||
config={"configurable": {"thread_id": "thread_1"}},
|
||||
subgraphs=True,
|
||||
):
|
||||
stream_parts.append(stream_part)
|
||||
|
||||
assert stream_parts == [
|
||||
(("my", "subgraph"), {"chunk": "data3"}),
|
||||
(("hello", "subgraph"), {"chunk": "data4"}),
|
||||
(("bye", "subgraph"), {"__interrupt__": ()}),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -6,6 +6,10 @@ client.cjs
|
||||
client.js
|
||||
client.d.ts
|
||||
client.d.cts
|
||||
auth.cjs
|
||||
auth.js
|
||||
auth.d.ts
|
||||
auth.d.cts
|
||||
react.cjs
|
||||
react.js
|
||||
react.d.ts
|
||||
|
||||
@@ -14,6 +14,7 @@ export const config = {
|
||||
entrypoints: {
|
||||
index: "index",
|
||||
client: "client",
|
||||
auth: "auth/index",
|
||||
react: "react/index",
|
||||
"react-ui": "react-ui/index",
|
||||
"react-ui/server": "react-ui/server/index",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@langchain/langgraph-sdk",
|
||||
"version": "0.0.62",
|
||||
"version": "0.0.67",
|
||||
"description": "Client library for interacting with the LangGraph API",
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.19",
|
||||
@@ -11,7 +11,7 @@
|
||||
"format": "prettier --write src",
|
||||
"lint": "prettier --check src && tsc --noEmit",
|
||||
"test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts",
|
||||
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json"
|
||||
"typedoc": "typedoc && typedoc src/react/index.ts --out docs/react --options typedoc.react.json && typedoc src/auth/index.ts --out docs/auth --options typedoc.auth.json"
|
||||
},
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
@@ -72,6 +72,15 @@
|
||||
"import": "./client.js",
|
||||
"require": "./client.cjs"
|
||||
},
|
||||
"./auth": {
|
||||
"types": {
|
||||
"import": "./auth.d.ts",
|
||||
"require": "./auth.d.cts",
|
||||
"default": "./auth.d.ts"
|
||||
},
|
||||
"import": "./auth.js",
|
||||
"require": "./auth.cjs"
|
||||
},
|
||||
"./react": {
|
||||
"types": {
|
||||
"import": "./react.d.ts",
|
||||
@@ -111,6 +120,10 @@
|
||||
"client.js",
|
||||
"client.d.ts",
|
||||
"client.d.cts",
|
||||
"auth.cjs",
|
||||
"auth.js",
|
||||
"auth.d.ts",
|
||||
"auth.d.cts",
|
||||
"react.cjs",
|
||||
"react.js",
|
||||
"react.d.ts",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const HTTP_STATUS_MAPPING: { [key: number]: string } = {
|
||||
100: "Continue",
|
||||
101: "Switching Protocols",
|
||||
102: "Processing",
|
||||
103: "Early Hints",
|
||||
200: "OK",
|
||||
201: "Created",
|
||||
202: "Accepted",
|
||||
203: "Non-Authoritative Information",
|
||||
204: "No Content",
|
||||
205: "Reset Content",
|
||||
206: "Partial Content",
|
||||
207: "Multi-Status",
|
||||
208: "Already Reported",
|
||||
226: "IM Used",
|
||||
300: "Multiple Choices",
|
||||
301: "Moved Permanently",
|
||||
302: "Found",
|
||||
303: "See Other",
|
||||
304: "Not Modified",
|
||||
305: "Use Proxy",
|
||||
307: "Temporary Redirect",
|
||||
308: "Permanent Redirect",
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
402: "Payment Required",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
406: "Not Acceptable",
|
||||
407: "Proxy Authentication Required",
|
||||
408: "Request Timeout",
|
||||
409: "Conflict",
|
||||
410: "Gone",
|
||||
411: "Length Required",
|
||||
412: "Precondition Failed",
|
||||
413: "Request Entity Too Large",
|
||||
414: "Request-URI Too Long",
|
||||
415: "Unsupported Media Type",
|
||||
416: "Requested Range Not Satisfiable",
|
||||
417: "Expectation Failed",
|
||||
418: "I'm a Teapot",
|
||||
421: "Misdirected Request",
|
||||
422: "Unprocessable Entity",
|
||||
423: "Locked",
|
||||
424: "Failed Dependency",
|
||||
425: "Too Early",
|
||||
426: "Upgrade Required",
|
||||
428: "Precondition Required",
|
||||
429: "Too Many Requests",
|
||||
431: "Request Header Fields Too Large",
|
||||
451: "Unavailable For Legal Reasons",
|
||||
500: "Internal Server Error",
|
||||
501: "Not Implemented",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
504: "Gateway Timeout",
|
||||
505: "HTTP Version Not Supported",
|
||||
506: "Variant Also Negotiates",
|
||||
507: "Insufficient Storage",
|
||||
508: "Loop Detected",
|
||||
510: "Not Extended",
|
||||
511: "Network Authentication Required",
|
||||
};
|
||||
|
||||
export class HTTPException extends Error {
|
||||
status: number;
|
||||
headers: HeadersInit;
|
||||
|
||||
constructor(
|
||||
status: number,
|
||||
options?: { message?: string; headers?: HeadersInit; cause?: unknown },
|
||||
) {
|
||||
super(options?.message ?? HTTP_STATUS_MAPPING[status] ?? "Unknown error", {
|
||||
cause: options?.cause,
|
||||
});
|
||||
this.status = status;
|
||||
this.headers = options?.headers ?? {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type {
|
||||
AuthenticateCallback,
|
||||
AnyCallback,
|
||||
CallbackEvent,
|
||||
OnCallback,
|
||||
BaseAuthReturn,
|
||||
ToUserLike,
|
||||
BaseUser,
|
||||
} from "./types.js";
|
||||
|
||||
export class Auth<
|
||||
TExtra = {},
|
||||
TAuthReturn extends BaseAuthReturn = BaseAuthReturn,
|
||||
TUser extends BaseUser = ToUserLike<TAuthReturn>,
|
||||
> {
|
||||
/**
|
||||
* @internal
|
||||
* @ignore
|
||||
*/
|
||||
"~handlerCache": {
|
||||
authenticate?: AuthenticateCallback<BaseAuthReturn>;
|
||||
callbacks?: Record<string, AnyCallback>;
|
||||
} = {};
|
||||
|
||||
authenticate<T extends BaseAuthReturn>(
|
||||
cb: AuthenticateCallback<T>,
|
||||
): Auth<TExtra, T> {
|
||||
this["~handlerCache"].authenticate = cb;
|
||||
return this as unknown as Auth<TExtra, T>;
|
||||
}
|
||||
|
||||
on<T extends CallbackEvent>(event: T, callback: OnCallback<T, TUser>): this {
|
||||
this["~handlerCache"].callbacks ??= {};
|
||||
const events: string[] = Array.isArray(event) ? event : [event];
|
||||
for (const event of events) {
|
||||
this["~handlerCache"].callbacks[event] = callback as AnyCallback;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export type {
|
||||
Filters as AuthFilters,
|
||||
EventValueMap as AuthEventValueMap,
|
||||
} from "./types.js";
|
||||
export { HTTPException } from "./error.js";
|
||||
@@ -0,0 +1,411 @@
|
||||
type Maybe<T> = T | null | undefined;
|
||||
type PromiseMaybe<T> = Promise<T> | T;
|
||||
|
||||
interface AssistantConfig {
|
||||
tags?: Maybe<string[]>;
|
||||
recursion_limit?: Maybe<number>;
|
||||
configurable?: Maybe<{
|
||||
thread_id?: Maybe<string>;
|
||||
thread_ts?: Maybe<string>;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface AssistantCreate {
|
||||
assistant_id?: Maybe<string>;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
config?: Maybe<AssistantConfig>;
|
||||
if_exists?: Maybe<"raise" | "do_nothing">;
|
||||
name?: Maybe<string>;
|
||||
graph_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface AssistantRead {
|
||||
assistant_id: string;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface AssistantUpdate {
|
||||
assistant_id: string;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
config?: Maybe<AssistantConfig>;
|
||||
graph_id?: Maybe<string>;
|
||||
name?: Maybe<string>;
|
||||
version?: Maybe<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface AssistantDelete {
|
||||
assistant_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface AssistantSearch {
|
||||
graph_id?: Maybe<string>;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
limit?: Maybe<number>;
|
||||
offset?: Maybe<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface ThreadCreate {
|
||||
thread_id?: Maybe<string>;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
if_exists?: Maybe<"raise" | "do_nothing">;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface ThreadRead {
|
||||
thread_id?: Maybe<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface ThreadUpdate {
|
||||
thread_id?: Maybe<string>;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
action?: Maybe<"interrupt" | "rollback">;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface ThreadDelete {
|
||||
thread_id?: Maybe<string>;
|
||||
run_id?: Maybe<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface ThreadSearch {
|
||||
thread_id?: Maybe<string>;
|
||||
status?: Maybe<"idle" | "busy" | "interrupted" | "error" | (string & {})>;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
values?: Maybe<Record<string, unknown>>;
|
||||
limit?: Maybe<number>;
|
||||
offset?: Maybe<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface CronCreate {
|
||||
payload?: Maybe<Record<string, unknown>>;
|
||||
schedule: string;
|
||||
cron_id?: Maybe<string>;
|
||||
thread_id?: Maybe<string>;
|
||||
user_id?: Maybe<string>;
|
||||
end_time?: Maybe<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface CronRead {
|
||||
cron_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface CronUpdate {
|
||||
cron_id: string;
|
||||
payload?: Maybe<Record<string, unknown>>;
|
||||
schedule?: Maybe<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface CronDelete {
|
||||
cron_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface CronSearch {
|
||||
assistant_id?: Maybe<string>;
|
||||
thread_id?: Maybe<string>;
|
||||
limit?: Maybe<number>;
|
||||
offset?: Maybe<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface StorePut {
|
||||
namespace: string[];
|
||||
key: string;
|
||||
value: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface StoreGet {
|
||||
namespace: Maybe<string[]>;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface StoreSearch {
|
||||
namespace?: Maybe<string[]>;
|
||||
filter?: Maybe<Record<string, unknown>>;
|
||||
limit?: Maybe<number>;
|
||||
offset?: Maybe<number>;
|
||||
query?: Maybe<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface StoreListNamespaces {
|
||||
namespace?: Maybe<string[]>;
|
||||
suffix?: Maybe<string[]>;
|
||||
max_depth?: Maybe<number>;
|
||||
limit?: Maybe<number>;
|
||||
offset?: Maybe<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface StoreDelete {
|
||||
namespace?: Maybe<string[]>;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inline
|
||||
*/
|
||||
interface RunsCreate {
|
||||
thread_id?: Maybe<string>;
|
||||
assistant_id: string;
|
||||
run_id: string;
|
||||
status: Maybe<
|
||||
"pending" | "running" | "error" | "success" | "timeout" | "interrupted"
|
||||
>;
|
||||
metadata?: Maybe<Record<string, unknown>>;
|
||||
prevent_insert_if_inflight?: Maybe<boolean>;
|
||||
multitask_strategy?: Maybe<"interrupt" | "rollback" | "reject" | "enqueue">;
|
||||
if_not_exists?: Maybe<"reject" | "create">;
|
||||
after_seconds?: Maybe<number>;
|
||||
kwargs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EventValueMap {
|
||||
["threads:create"]: ThreadCreate;
|
||||
["threads:read"]: ThreadRead;
|
||||
["threads:update"]: ThreadUpdate;
|
||||
["threads:delete"]: ThreadDelete;
|
||||
["threads:search"]: ThreadSearch;
|
||||
["threads:create_run"]: RunsCreate;
|
||||
|
||||
["assistants:create"]: AssistantCreate;
|
||||
["assistants:read"]: AssistantRead;
|
||||
["assistants:update"]: AssistantUpdate;
|
||||
["assistants:delete"]: AssistantDelete;
|
||||
["assistants:search"]: AssistantSearch;
|
||||
|
||||
["crons:create"]: CronCreate;
|
||||
["crons:read"]: CronRead;
|
||||
["crons:update"]: CronUpdate;
|
||||
["crons:delete"]: CronDelete;
|
||||
["crons:search"]: CronSearch;
|
||||
|
||||
["store:put"]: StorePut;
|
||||
["store:get"]: StoreGet;
|
||||
["store:search"]: StoreSearch;
|
||||
["store:list_namespaces"]: StoreListNamespaces;
|
||||
["store:delete"]: StoreDelete;
|
||||
}
|
||||
interface ResourceType {
|
||||
threads:
|
||||
| "threads:create"
|
||||
| "threads:read"
|
||||
| "threads:update"
|
||||
| "threads:delete"
|
||||
| "threads:search"
|
||||
| "threads:create_run";
|
||||
|
||||
assistants:
|
||||
| "assistants:create"
|
||||
| "assistants:read"
|
||||
| "assistants:update"
|
||||
| "assistants:delete"
|
||||
| "assistants:search";
|
||||
crons:
|
||||
| "crons:create"
|
||||
| "crons:read"
|
||||
| "crons:update"
|
||||
| "crons:delete"
|
||||
| "crons:search";
|
||||
|
||||
store:
|
||||
| "store:put"
|
||||
| "store:get"
|
||||
| "store:search"
|
||||
| "store:list_namespaces"
|
||||
| "store:delete";
|
||||
}
|
||||
interface ActionType {
|
||||
"*:create": "threads:create" | "assistants:create" | "crons:create";
|
||||
|
||||
"*:read": "threads:read" | "assistants:read" | "crons:read";
|
||||
|
||||
"*:update": "threads:update" | "assistants:update" | "crons:update";
|
||||
|
||||
"*:delete":
|
||||
| "threads:delete"
|
||||
| "assistants:delete"
|
||||
| "crons:delete"
|
||||
| "store:delete";
|
||||
|
||||
"*:search":
|
||||
| "threads:search"
|
||||
| "assistants:search"
|
||||
| "crons:search"
|
||||
| "store:search";
|
||||
|
||||
"*:create_run": "threads:create_run";
|
||||
|
||||
"*:put": "store:put";
|
||||
|
||||
"*:get": "store:get";
|
||||
|
||||
"*:list_namespaces": "store:list_namespaces";
|
||||
}
|
||||
|
||||
export type BaseAuthReturn =
|
||||
| {
|
||||
is_authenticated?: boolean;
|
||||
display_name?: string;
|
||||
identity: string;
|
||||
permissions: string[];
|
||||
}
|
||||
| string;
|
||||
|
||||
export interface BaseUser {
|
||||
is_authenticated: boolean;
|
||||
display_name: string;
|
||||
identity: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export type ToUserLike<T extends BaseAuthReturn> = T extends string
|
||||
? {
|
||||
is_authenticated: boolean;
|
||||
display_name: string;
|
||||
identity: string;
|
||||
permissions: string[];
|
||||
}
|
||||
: Omit<T, "is_authenticated" | "display_name"> & {
|
||||
is_authenticated: boolean;
|
||||
display_name: string;
|
||||
};
|
||||
|
||||
type CallbackParameter<
|
||||
Event extends string = string,
|
||||
Resource extends string = string,
|
||||
Action extends string = string,
|
||||
Value extends unknown = unknown,
|
||||
TUser extends BaseUser = BaseUser,
|
||||
> = {
|
||||
event: Event;
|
||||
resource: Resource;
|
||||
action: Action;
|
||||
value: Value;
|
||||
user: TUser;
|
||||
permissions: string[];
|
||||
};
|
||||
|
||||
type ContextMap = {
|
||||
[EventType in keyof EventValueMap]: CallbackParameter<
|
||||
EventType,
|
||||
EventType extends `${infer Resource}:${string}` ? Resource : never,
|
||||
EventType extends `${string}:${infer Action}` ? Action : never,
|
||||
EventValueMap[EventType],
|
||||
BaseUser
|
||||
>;
|
||||
};
|
||||
|
||||
type ActionCallbackParameter<
|
||||
T extends keyof ActionType,
|
||||
TUser extends BaseUser = BaseUser,
|
||||
> = ContextMap[ActionType[T]] & { user: TUser };
|
||||
type AuthCallbackParameter<
|
||||
T extends keyof EventValueMap,
|
||||
TUser extends BaseUser = BaseUser,
|
||||
> = ContextMap[T] & { user: TUser };
|
||||
type ResourceCallbackParameter<
|
||||
T extends keyof ResourceType,
|
||||
TUser extends BaseUser = BaseUser,
|
||||
> = ContextMap[ResourceType[T]] & { user: TUser };
|
||||
|
||||
export type Filters<TKey extends string | number | symbol> = {
|
||||
[key in TKey]: string | { [op in "$contains" | "$eq"]?: string };
|
||||
};
|
||||
|
||||
export interface AuthenticateCallback<T extends BaseAuthReturn> {
|
||||
(request: Request): PromiseMaybe<T>;
|
||||
}
|
||||
|
||||
type OnKey = keyof ResourceType | keyof ActionType | keyof EventValueMap;
|
||||
|
||||
type OnSingleParameter<
|
||||
T extends OnKey,
|
||||
TUser extends BaseUser = BaseUser,
|
||||
> = T extends keyof ResourceType
|
||||
? ResourceCallbackParameter<T, TUser>
|
||||
: T extends keyof ActionType
|
||||
? ActionCallbackParameter<T, TUser>
|
||||
: T extends keyof EventValueMap
|
||||
? AuthCallbackParameter<T, TUser>
|
||||
: never;
|
||||
|
||||
type OnParameter<
|
||||
T extends "*" | OnKey | OnKey[],
|
||||
TUser extends BaseUser = BaseUser,
|
||||
> = T extends OnKey[]
|
||||
? OnSingleParameter<T[number], TUser>
|
||||
: T extends "*"
|
||||
? AuthCallbackParameter<keyof EventValueMap, TUser>
|
||||
: T extends OnKey
|
||||
? OnSingleParameter<T, TUser>
|
||||
: never;
|
||||
|
||||
export type AnyCallback = (
|
||||
request: CallbackParameter,
|
||||
) => void | boolean | Filters<string>;
|
||||
|
||||
export type CallbackEvent = "*" | OnKey | OnKey[];
|
||||
|
||||
export type OnCallback<
|
||||
T extends CallbackEvent,
|
||||
TUser extends BaseUser = BaseUser,
|
||||
TMetadata extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = (
|
||||
request: OnParameter<T, TUser>,
|
||||
) => void | boolean | Filters<keyof TMetadata>;
|
||||
@@ -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) {
|
||||
@@ -340,6 +359,7 @@ export class AssistantsClient extends BaseClient {
|
||||
assistantId?: string;
|
||||
ifExists?: OnConflictBehavior;
|
||||
name?: string;
|
||||
description?: string;
|
||||
}): Promise<Assistant> {
|
||||
return this.fetch<Assistant>("/assistants", {
|
||||
method: "POST",
|
||||
@@ -350,6 +370,7 @@ export class AssistantsClient extends BaseClient {
|
||||
assistant_id: payload.assistantId,
|
||||
if_exists: payload.ifExists,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -367,6 +388,7 @@ export class AssistantsClient extends BaseClient {
|
||||
config?: Config;
|
||||
metadata?: Metadata;
|
||||
name?: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<Assistant> {
|
||||
return this.fetch<Assistant>(`/assistants/${assistantId}`, {
|
||||
@@ -376,6 +398,7 @@ export class AssistantsClient extends BaseClient {
|
||||
config: payload.config,
|
||||
metadata: payload.metadata,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
> {
|
||||
|
||||
@@ -113,6 +113,9 @@ export interface AssistantBase {
|
||||
|
||||
/** The name of the assistant */
|
||||
name: string;
|
||||
|
||||
/** The description of the assistant */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AssistantVersion extends AssistantBase {}
|
||||
|
||||
@@ -12,10 +12,8 @@ const STATUS_NO_RETRY = [
|
||||
406, // Not Acceptable
|
||||
407, // Proxy Authentication Required
|
||||
408, // Request Timeout
|
||||
422, // Unprocessable Entity
|
||||
];
|
||||
const STATUS_IGNORE = [
|
||||
409, // Conflict
|
||||
422, // Unprocessable Entity
|
||||
];
|
||||
|
||||
type ResponseCallback = (response?: Response) => Promise<boolean>;
|
||||
@@ -171,8 +169,6 @@ export class AsyncCaller {
|
||||
if (error instanceof HTTPError) {
|
||||
if (STATUS_NO_RETRY.includes(error.status)) {
|
||||
throw error;
|
||||
} else if (STATUS_IGNORE.includes(error.status)) {
|
||||
return;
|
||||
}
|
||||
if (onFailedResponseHook && error.response) {
|
||||
await onFailedResponseHook(error.response);
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
"extends": "@tsconfig/recommended",
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": [
|
||||
"ES2021",
|
||||
"ES2022.Object",
|
||||
"DOM"
|
||||
],
|
||||
"lib": ["ES2021", "ES2022.Object", "ES2022.Error", "DOM"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"esModuleInterop": true,
|
||||
@@ -22,24 +18,14 @@
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"coverage"
|
||||
],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "coverage"],
|
||||
"includeVersion": true,
|
||||
"typedocOptions": {
|
||||
"entryPoints": [
|
||||
"src/client.ts"
|
||||
],
|
||||
"entryPoints": ["src/client.ts"],
|
||||
"readme": "none",
|
||||
"out": "docs",
|
||||
"plugin": [
|
||||
"typedoc-plugin-markdown"
|
||||
],
|
||||
"plugin": ["typedoc-plugin-markdown"],
|
||||
"excludePrivate": true,
|
||||
"excludeProtected": true,
|
||||
"excludeExternals": false
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"pageTitleTemplates": {
|
||||
"index": "{projectName}/auth"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user