mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-20 22:52:29 +02:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5720a21c45 | ||
|
|
c0aa6ab612 | ||
|
|
669cf817e8 | ||
|
|
000f5c3043 | ||
|
|
b3708bd7f6 | ||
|
|
8271e39e00 | ||
|
|
60560ea755 | ||
|
|
e2acfb24cc | ||
|
|
191192b142 | ||
|
|
df368bdd30 | ||
|
|
4ec897033f | ||
|
|
c16e42e6d5 | ||
|
|
376469ea90 | ||
|
|
7e2af0ce8d | ||
|
|
1b205a99cb | ||
|
|
0a8ba20f5f | ||
|
|
048cb3584c | ||
|
|
22c35b7bc8 | ||
|
|
f3ed32e611 | ||
|
|
276675b618 | ||
|
|
882de42996 | ||
|
|
70be50f37b | ||
|
|
1c7234e9c5 | ||
|
|
3d88f75254 | ||
|
|
407abbe9ff | ||
|
|
c7bbb26ac0 | ||
|
|
ac9b6c416e | ||
|
|
d4b4eebe4a | ||
|
|
f8e1e803e1 | ||
|
|
141a6af4f7 | ||
|
|
8a763ad358 |
@@ -10,6 +10,6 @@ contact_links:
|
||||
- name: Show and tell
|
||||
about: Show what you built with LangChain
|
||||
url: https://github.com/langchain-ai/langgraph/discussions/categories/show-and-tell
|
||||
- name: Slack
|
||||
url: https://www.langchain.com/join-community
|
||||
about: General community discussions
|
||||
- name: LangChain Forum
|
||||
url: https://forum.langchain.com/
|
||||
about: General community discussions and support
|
||||
|
||||
+12
-5
@@ -1,11 +1,18 @@
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
# and
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
directories:
|
||||
- "libs/checkpoint"
|
||||
- "libs/checkpoint-postgres"
|
||||
- "libs/checkpoint-sqlite"
|
||||
- "libs/cli"
|
||||
- "libs/langgraph"
|
||||
- "libs/prebuilt"
|
||||
- "libs/sdk-py"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
@@ -58,3 +58,13 @@ jobs:
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
|
||||
- name: Install min version of deps
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --no-default-groups --resolution lowest-direct --no-sources --force-reinstall
|
||||
|
||||
- name: Run tests with min version of deps
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: make test
|
||||
|
||||
@@ -53,3 +53,13 @@ jobs:
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
|
||||
- name: Install min version of deps
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --no-default-groups --resolution lowest-direct --no-sources --force-reinstall
|
||||
|
||||
- name: Run tests with min version of deps
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: make test
|
||||
|
||||
@@ -22,6 +22,7 @@ jobs:
|
||||
outputs:
|
||||
python: ${{ steps.filter.outputs.python }}
|
||||
sdk-js: ${{ steps.filter.outputs.sdk-js }}
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
@@ -38,6 +39,9 @@ jobs:
|
||||
- 'libs/prebuilt/**'
|
||||
sdk-js:
|
||||
- 'libs/sdk-js/**'
|
||||
deps:
|
||||
- '**/pyproject.toml'
|
||||
- '**/uv.lock'
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
@@ -55,7 +59,7 @@ jobs:
|
||||
|
||||
"libs/prebuilt",
|
||||
]
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
|
||||
uses: ./.github/workflows/_lint.yml
|
||||
with:
|
||||
working-directory: ${{ matrix.working-directory }}
|
||||
@@ -74,7 +78,7 @@ jobs:
|
||||
"libs/checkpoint-postgres",
|
||||
"libs/prebuilt",
|
||||
]
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
|
||||
uses: ./.github/workflows/_test.yml
|
||||
with:
|
||||
working-directory: ${{ matrix.working-directory }}
|
||||
@@ -83,7 +87,7 @@ jobs:
|
||||
# NOTE: we're testing langgraph separately because it requires a different matrix
|
||||
test-langgraph:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
|
||||
name: "cd libs/langgraph"
|
||||
uses: ./.github/workflows/_test_langgraph.yml
|
||||
secrets: inherit
|
||||
@@ -140,7 +144,7 @@ jobs:
|
||||
|
||||
integration-test:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
if: needs.changes.outputs.python == 'true' || needs.changes.outputs.deps == 'true'
|
||||
name: CLI integration test
|
||||
uses: ./.github/workflows/_integration_test.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: UV Lock Upgrade
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# run at midnight every Sunday
|
||||
- cron: '0 0 * * 0'
|
||||
# allow manual triggering
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
upgrade-dependencies:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
with:
|
||||
# use minimum supported Python version
|
||||
python-version: "3.9"
|
||||
enable-cache: true
|
||||
cache-suffix: "uv-lock-upgrade"
|
||||
|
||||
- name: Run uv lock --upgrade in all Python packages
|
||||
run: make lock-upgrade
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "chore: upgrade dependencies with `uv lock --upgrade`"
|
||||
title: "chore: upgrade dependencies with `uv lock --upgrade`"
|
||||
body: |
|
||||
This PR updates the dependencies in all Python packages using `uv lock --upgrade`.
|
||||
|
||||
This is an automated PR created by the UV Lock Upgrade workflow.
|
||||
branch: deps/uv-lock-upgrade
|
||||
delete-branch: true
|
||||
labels: |
|
||||
dependencies
|
||||
@@ -47,6 +47,16 @@ lock:
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Lock all projects and upgrade dependencies
|
||||
.PHONY: lock-upgrade
|
||||
lock-upgrade:
|
||||
@for dir in $(LIBS_DIRS); do \
|
||||
if [ -f $$dir/Makefile ]; then \
|
||||
echo "Running lock-upgrade in $$dir"; \
|
||||
(cd $$dir && uv lock --upgrade); \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Test all projects
|
||||
.PHONY: test
|
||||
test:
|
||||
|
||||
@@ -63,7 +63,7 @@ LangGraph provides low-level supporting infrastructure for *any* long-running, s
|
||||
While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with:
|
||||
|
||||
- [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
|
||||
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
|
||||
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
|
||||
- [LangChain](https://python.langchain.com/docs/introduction/) – Provides integrations and composable components to streamline LLM application development.
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Before deploying, review the [conceptual guide for the Self-Hosted Control Plane](../../concepts/langgraph_self_hosted_control_plane.md) deployment option.
|
||||
|
||||
!!! info "Important"
|
||||
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
|
||||
The Self-Hosted Control Plane deployment option requires an [Enterprise](../../concepts/plans.md) plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Before deploying, review the [conceptual guide for the Self-Hosted Data Plane](../../concepts/langgraph_self_hosted_data_plane.md) deployment option.
|
||||
|
||||
!!! info "Important"
|
||||
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](../../concepts/plans.md) plan.
|
||||
The Self-Hosted Data Plane deployment option requires an [Enterprise](../../concepts/plans.md) plan.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
How to integrate LangGraph into your React application# How to integrate LangGraph into your React application
|
||||
# How to integrate LangGraph into your React application
|
||||
|
||||
!!! info "Prerequisites"
|
||||
|
||||
@@ -503,6 +503,74 @@ const handleSubmit = (text: string) => {
|
||||
};
|
||||
```
|
||||
|
||||
### Cached Thread Display
|
||||
|
||||
Use the `initialValues` option to display cached thread data immediately while the history is being loaded from the server. This improves user experience by showing cached data instantly when navigating to existing threads.
|
||||
|
||||
```tsx
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
const CachedThreadExample = ({ threadId, cachedThreadData }) => {
|
||||
const stream = useStream({
|
||||
apiUrl: "http://localhost:2024",
|
||||
assistantId: "agent",
|
||||
threadId,
|
||||
// Show cached data immediately while history loads
|
||||
initialValues: cachedThreadData?.values,
|
||||
messagesKey: "messages",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{stream.messages.map((message) => (
|
||||
<div key={message.id}>{message.content as string}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Optimistic Thread Creation
|
||||
|
||||
Use the `threadId` option in `submit` function to enable optimistic UI patterns where you need to know the thread ID before the thread is actually created.
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
const OptimisticThreadExample = () => {
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
const [optimisticThreadId] = useState(() => crypto.randomUUID());
|
||||
|
||||
const stream = useStream({
|
||||
apiUrl: "http://localhost:2024",
|
||||
assistantId: "agent",
|
||||
threadId,
|
||||
onThreadId: setThreadId, // (3) Updated after thread has been created.
|
||||
messagesKey: "messages",
|
||||
});
|
||||
|
||||
const handleSubmit = (text: string) => {
|
||||
// (1) Perform a soft navigation to /threads/${optimisticThreadId}
|
||||
// without waiting for thread creation.
|
||||
window.history.pushState({}, "", `/threads/${optimisticThreadId}`);
|
||||
|
||||
// (2) Submit message to create thread with the predetermined ID.
|
||||
stream.submit(
|
||||
{ messages: [{ type: "human", content: text }] },
|
||||
{ threadId: optimisticThreadId }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Thread ID: {threadId ?? optimisticThreadId}</p>
|
||||
{/* Rest of component */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
|
||||
The `useStream()` hook is friendly for apps written in TypeScript and you can specify types for the state to get better type safety and IDE support.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -395,7 +395,7 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
|
||||
|
||||
=== "Python"
|
||||
|
||||
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform closed beta. Requires a license key for production use.
|
||||
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform. Requires a license key for production use.
|
||||
|
||||
**Usage**
|
||||
|
||||
@@ -422,7 +422,7 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
|
||||
|
||||
=== "JS"
|
||||
|
||||
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform closed beta. Requires a license key for production use.
|
||||
Start LangGraph API server. For local testing, requires a LangSmith API key with access to LangGraph Platform. Requires a license key for production use.
|
||||
|
||||
**Usage**
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Below are examples of directory structures for Python and JavaScript application
|
||||
│ ├── utils # utilities for your graph
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── tools.py # tools for your graph
|
||||
│ │ ├── nodes.py # node functions for you graph
|
||||
│ │ ├── nodes.py # node functions for your graph
|
||||
│ │ └── state.py # state definition of your graph
|
||||
│ ├── __init__.py
|
||||
│ └── agent.py # code for constructing your graph
|
||||
@@ -64,7 +64,7 @@ Below are examples of directory structures for Python and JavaScript application
|
||||
├── src # all project code lies within here
|
||||
│ ├── utils # optional utilities for your graph
|
||||
│ │ ├── tools.ts # tools for your graph
|
||||
│ │ ├── nodes.ts # node functions for you graph
|
||||
│ │ ├── nodes.ts # node functions for your graph
|
||||
│ │ └── state.ts # state definition of your graph
|
||||
│ └── agent.ts # code for constructing your graph
|
||||
├── package.json # package dependencies
|
||||
|
||||
@@ -18,9 +18,9 @@ There are 4 main options for deploying with the [LangGraph Platform](langgraph_p
|
||||
|
||||
1. [Cloud SaaS](#cloud-saas)
|
||||
|
||||
1. [Self-Hosted Data Plane<sup>(Beta)</sup>](#self-hosted-data-plane)
|
||||
1. [Self-Hosted Data Plane](#self-hosted-data-plane)
|
||||
|
||||
1. [Self-Hosted Control Plane<sup>(Beta)</sup>](#self-hosted-control-plane)
|
||||
1. [Self-Hosted Control Plane](#self-hosted-control-plane)
|
||||
|
||||
1. [Standalone Container](#standalone-container)
|
||||
|
||||
@@ -50,7 +50,7 @@ For more information, please see:
|
||||
## Self-Hosted Data Plane
|
||||
|
||||
!!! info "Important"
|
||||
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](../concepts/plans.md) plan.
|
||||
The Self-Hosted Data Plane deployment option requires an [Enterprise](../concepts/plans.md) plan.
|
||||
|
||||
The [Self-Hosted Data Plane](./langgraph_self_hosted_data_plane.md) deployment option is a "hybrid" model for deployment where we manage the [control plane](./langgraph_control_plane.md) in our cloud and you manage the [data plane](./langgraph_data_plane.md) in your cloud. This option provides a way to securely manage your data plane infrastructure, while offloading control plane management to us.
|
||||
|
||||
@@ -66,7 +66,7 @@ For more information, please see:
|
||||
## Self-Hosted Control Plane
|
||||
|
||||
!!! info "Important"
|
||||
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](../concepts/plans.md) plan.
|
||||
The Self-Hosted Control Plane deployment option requires an [Enterprise](../concepts/plans.md) plan.
|
||||
|
||||
The [Self-Hosted Control Plane](./langgraph_self_hosted_control_plane.md) deployment option is a fully self-hosted model for deployment where you manage the [control plane](./langgraph_control_plane.md) and [data plane](./langgraph_data_plane.md) in your cloud. This option gives you full control and responsibility of the control plane and data plane infrastructure.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ LangGraph is a stateful, orchestration framework that brings added control to ag
|
||||
|
||||
No. LangGraph Platform is proprietary software.
|
||||
|
||||
There is a free, self-hosted version of LangGraph Platform with access to basic features. The Cloud SaaS deployment option is free while in beta, but will eventually be a paid service. We will always give ample notice before charging for a service and reward our early adopters with preferential pricing. The Self-Hosted deployment options are paid services. [Contact our sales team](https://www.langchain.com/contact-sales) to learn more.
|
||||
There is a free, self-hosted version of LangGraph Platform with access to basic features. The Cloud SaaS deployment option and the Self-Hosted deployment options are paid services. [Contact our sales team](https://www.langchain.com/contact-sales) to learn more.
|
||||
|
||||
For more information, see our [LangGraph Platform pricing page](https://www.langchain.com/pricing-langgraph-platform).
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
There are two versions of the self-hosted deployment: [Self-Hosted Data Plane](./deployment_options.md#self-hosted-data-plane) and [Self-Hosted Control Plane](./deployment_options.md#self-hosted-control-plane).
|
||||
|
||||
!!! info "Important"
|
||||
The Self-Hosted Control Plane deployment option is currently in beta stage and requires an [Enterprise](plans.md) plan.
|
||||
The Self-Hosted Control Plane deployment option requires an [Enterprise](plans.md) plan.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ search:
|
||||
There are two versions of the self-hosted deployment: [Self-Hosted Data Plane](./deployment_options.md#self-hosted-data-plane) and [Self-Hosted Control Plane](./deployment_options.md#self-hosted-control-plane).
|
||||
|
||||
!!! info "Important"
|
||||
The Self-Hosted Data Plane deployment option is currently in beta stage and requires an [Enterprise](plans.md) plan.
|
||||
The Self-Hosted Data Plane deployment option requires an [Enterprise](plans.md) plan.
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
@@ -289,6 +289,7 @@ nav:
|
||||
- Case studies: adopters.md
|
||||
- concepts/faq.md
|
||||
- llms.txt: llms-txt-overview.md
|
||||
- LangChain Forum: https://forum.langchain.com/
|
||||
- Troubleshooting:
|
||||
- Errors:
|
||||
- troubleshooting/errors/index.md
|
||||
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.0.21",
|
||||
"langgraph-checkpoint>=2.0.21,<3.0.0",
|
||||
"orjson>=3.10.1",
|
||||
"psycopg>=3.2.0",
|
||||
"psycopg-pool>=3.2.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.0.21",
|
||||
"langgraph-checkpoint>=2.0.21,<3.0.0",
|
||||
"aiosqlite>=0.20",
|
||||
"sqlite-vec>=0.1.6",
|
||||
]
|
||||
|
||||
@@ -208,7 +208,7 @@ def up(
|
||||
):
|
||||
click.secho("Starting LangGraph API server...", fg="green")
|
||||
click.secho(
|
||||
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Platform closed beta.
|
||||
"""For local dev, requires env var LANGSMITH_API_KEY with access to LangGraph Platform.
|
||||
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""",
|
||||
)
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
|
||||
@@ -18,8 +18,8 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
inmem = [
|
||||
"langgraph-api>=0.2.67 ; python_version >= '3.11'",
|
||||
"langgraph-runtime-inmem>=0.3.0 ; python_version >= '3.11'",
|
||||
"langgraph-api>=0.2.67,<0.3.0 ; python_version >= '3.11'",
|
||||
"langgraph-runtime-inmem>=0.3.0,<0.4.0 ; python_version >= '3.11'",
|
||||
"python-dotenv>=0.8.0",
|
||||
]
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -530,8 +530,8 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.1.7" },
|
||||
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.2.67" },
|
||||
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.3.0" },
|
||||
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.2.67,<0.3.0" },
|
||||
{ name = "langgraph-runtime-inmem", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.3.0,<0.4.0" },
|
||||
{ name = "langgraph-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.1.0" },
|
||||
{ name = "python-dotenv", marker = "extra == 'inmem'", specifier = ">=0.8.0" },
|
||||
]
|
||||
|
||||
@@ -63,7 +63,7 @@ LangGraph provides low-level supporting infrastructure for *any* long-running, s
|
||||
While LangGraph can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools for building agents. To improve your LLM application development, pair LangGraph with:
|
||||
|
||||
- [LangSmith](http://www.langchain.com/langsmith) — Helpful for agent evals and observability. Debug poor-performing LLM app runs, evaluate agent trajectories, gain visibility in production, and improve performance over time.
|
||||
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/#langgraph-platform) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
|
||||
- [LangGraph Platform](https://langchain-ai.github.io/langgraph/concepts/langgraph_platform/) — Deploy and scale agents effortlessly with a purpose-built deployment platform for long running, stateful workflows. Discover, reuse, configure, and share agents across teams — and iterate quickly with visual prototyping in [LangGraph Studio](https://langchain-ai.github.io/langgraph/concepts/langgraph_studio/).
|
||||
- [LangChain](https://python.langchain.com/docs/introduction/) – Provides integrations and composable components to streamline LLM application development.
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -13,11 +13,12 @@ license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langchain-core>=0.1",
|
||||
"langgraph-checkpoint>=2.1.0",
|
||||
"langgraph-sdk>=0.1.42",
|
||||
"langgraph-prebuilt>=0.5.0",
|
||||
"langgraph-checkpoint>=2.1.0,<3.0.0",
|
||||
"langgraph-sdk>=0.1.42,<0.2.0",
|
||||
"langgraph-prebuilt>=0.5.0,<0.6.0",
|
||||
"xxhash>=3.5.0",
|
||||
"pydantic>=2.7.4",
|
||||
"pydantic>=2.7.4; python_version < '3.13'",
|
||||
"pydantic>=2.0.0; python_version >= '3.13'",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Generated
+2
-1
@@ -1246,7 +1246,8 @@ requires-dist = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "../prebuilt" },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
{ name = "pydantic", specifier = ">=2.7.4" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.13'", specifier = ">=2.7.4" },
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.13'", specifier = ">=2.0.0" },
|
||||
{ name = "xxhash", specifier = ">=3.5.0" },
|
||||
]
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"langgraph-checkpoint>=2.1.0",
|
||||
"langgraph-checkpoint>=2.1.0,<3.0.0",
|
||||
"langchain-core>=0.3.67",
|
||||
]
|
||||
|
||||
|
||||
Generated
+4
-2
@@ -2,7 +2,8 @@ version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.9"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12.4'",
|
||||
"python_full_version >= '3.13'",
|
||||
"python_full_version >= '3.12.4' and python_full_version < '3.13'",
|
||||
"python_full_version < '3.12.4'",
|
||||
]
|
||||
|
||||
@@ -337,7 +338,8 @@ requires-dist = [
|
||||
{ name = "langgraph-checkpoint", editable = "../checkpoint" },
|
||||
{ name = "langgraph-prebuilt", editable = "." },
|
||||
{ name = "langgraph-sdk", editable = "../sdk-py" },
|
||||
{ name = "pydantic", specifier = ">=2.7.4" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.13'", specifier = ">=2.7.4" },
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.13'", specifier = ">=2.0.0" },
|
||||
{ name = "xxhash", specifier = ">=3.5.0" },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@langchain/langgraph-sdk",
|
||||
"version": "0.0.87",
|
||||
"version": "0.0.89",
|
||||
"description": "Client library for interacting with the LangGraph API",
|
||||
"type": "module",
|
||||
"packageManager": "yarn@1.22.19",
|
||||
|
||||
@@ -1014,8 +1014,8 @@ export class RunsClient<
|
||||
const stream: ReadableStream<{ event: any; data: any }> = (
|
||||
response.body || new ReadableStream({ start: (ctrl) => ctrl.close() })
|
||||
)
|
||||
.pipeThrough(new BytesLineDecoder())
|
||||
.pipeThrough(new SSEDecoder());
|
||||
.pipeThrough(BytesLineDecoder())
|
||||
.pipeThrough(SSEDecoder());
|
||||
|
||||
yield* IterableReadableStream.fromReadableStream(stream);
|
||||
}
|
||||
@@ -1318,8 +1318,8 @@ export class RunsClient<
|
||||
const stream: ReadableStream<{ event: string; data: any }> = (
|
||||
response.body || new ReadableStream({ start: (ctrl) => ctrl.close() })
|
||||
)
|
||||
.pipeThrough(new BytesLineDecoder())
|
||||
.pipeThrough(new SSEDecoder());
|
||||
.pipeThrough(BytesLineDecoder())
|
||||
.pipeThrough(SSEDecoder());
|
||||
|
||||
yield* IterableReadableStream.fromReadableStream(stream);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import type {
|
||||
} from "../types.stream.js";
|
||||
|
||||
import {
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -316,8 +316,8 @@ function fetchHistory<StateType extends Record<string, unknown>>(
|
||||
function useThreadHistory<StateType extends Record<string, unknown>>(
|
||||
threadId: string | undefined | null,
|
||||
client: Client,
|
||||
clearCallbackRef: MutableRefObject<(() => void) | undefined>,
|
||||
submittingRef: MutableRefObject<boolean>,
|
||||
clearCallbackRef: RefObject<(() => void) | undefined>,
|
||||
submittingRef: RefObject<boolean>,
|
||||
) {
|
||||
const [history, setHistory] = useState<ThreadState<StateType>[]>([]);
|
||||
|
||||
@@ -511,6 +511,31 @@ export interface UseStreamOptions<
|
||||
*/
|
||||
onDebugEvent?: (data: DebugStreamEvent["data"]) => void;
|
||||
|
||||
/**
|
||||
* Callback that is called when the stream is stopped by the user.
|
||||
* Provides a mutate function to update the stream state immediately
|
||||
* without requiring a server roundtrip.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* onStop: ({ mutate }) => {
|
||||
* mutate((prev) => ({
|
||||
* ...prev,
|
||||
* ui: prev.ui?.map(component =>
|
||||
* component.props.isLoading
|
||||
* ? { ...component, props: { ...component.props, stopped: true, isLoading: false }}
|
||||
* : component
|
||||
* )
|
||||
* }));
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
onStop?: (options: {
|
||||
mutate: (
|
||||
update: Partial<StateType> | ((prev: StateType) => Partial<StateType>),
|
||||
) => void;
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* The ID of the thread to fetch history and current values from.
|
||||
*/
|
||||
@@ -523,6 +548,17 @@ export interface UseStreamOptions<
|
||||
|
||||
/** Will reconnect the stream on mount */
|
||||
reconnectOnMount?: boolean | (() => RunMetadataStorage);
|
||||
|
||||
/**
|
||||
* Initial values to display immediately when loading a thread.
|
||||
* Useful for displaying cached thread data while official history loads.
|
||||
* These values will be replaced when official thread data is fetched.
|
||||
*
|
||||
* Note: UI components from initialValues will render immediately if they're
|
||||
* predefined in LoadExternalComponent's components prop, providing instant
|
||||
* cached UI display without server fetches.
|
||||
*/
|
||||
initialValues?: StateType | null;
|
||||
}
|
||||
|
||||
interface RunMetadataStorage {
|
||||
@@ -656,6 +692,61 @@ interface SubmitOptions<
|
||||
*/
|
||||
streamSubgraphs?: boolean;
|
||||
streamResumable?: boolean;
|
||||
/**
|
||||
* The ID to use when creating a new thread. When provided, this ID will be used
|
||||
* for thread creation when threadId is `null` or `undefined`.
|
||||
* This enables optimistic UI updates where you know the thread ID
|
||||
* before the thread is actually created.
|
||||
*/
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
function useStreamValuesState<StateType extends Record<string, unknown>>() {
|
||||
type Kind = "stream" | "stop";
|
||||
type Values = StateType | null;
|
||||
type Update = Values | ((prev: Values, kind?: Kind) => Values);
|
||||
type Mutate = Partial<StateType> | ((prev: StateType) => Partial<StateType>);
|
||||
|
||||
const [values, setValues] = useState<[values: StateType, kind: Kind] | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const setStreamValues = useCallback(
|
||||
(values: Update, kind: Kind = "stream") => {
|
||||
if (typeof values === "function") {
|
||||
setValues((prevTuple) => {
|
||||
const [prevValues, prevKind] = prevTuple ?? [null, "stream"];
|
||||
const next = values(prevValues, prevKind);
|
||||
|
||||
if (next == null) return null;
|
||||
return [next, kind] as [StateType, Kind];
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (values == null) setValues(null);
|
||||
setValues([values, kind] as [StateType, Kind]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const mutate = useCallback(
|
||||
(kind: Kind, serverValues: StateType) => (update: Mutate) => {
|
||||
setStreamValues((clientValues) => {
|
||||
const prev = { ...serverValues, ...clientValues };
|
||||
const next = typeof update === "function" ? update(prev) : update;
|
||||
return { ...prev, ...next };
|
||||
}, kind);
|
||||
},
|
||||
[setStreamValues],
|
||||
);
|
||||
|
||||
return [values?.[0] ?? null, setStreamValues, mutate] as [
|
||||
Values,
|
||||
(update: Update, kind?: Kind) => void,
|
||||
(kind: Kind, serverValues: StateType) => (update: Mutate) => void,
|
||||
];
|
||||
}
|
||||
|
||||
export function useStream<
|
||||
@@ -721,7 +812,8 @@ export function useStream<
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [streamError, setStreamError] = useState<unknown>(undefined);
|
||||
const [streamValues, setStreamValues] = useState<StateType | null>(null);
|
||||
const [streamValues, setStreamValues, getMutateFn] =
|
||||
useStreamValuesState<StateType>();
|
||||
|
||||
const messageManagerRef = useRef(new MessageTupleManager());
|
||||
const submittingRef = useRef(false);
|
||||
@@ -792,7 +884,9 @@ export function useStream<
|
||||
);
|
||||
|
||||
const threadHead: ThreadState<StateType> | undefined = flatHistory.at(-1);
|
||||
const historyValues = threadHead?.values ?? ({} as StateType);
|
||||
const historyValues =
|
||||
threadHead?.values ?? options.initialValues ?? ({} as StateType);
|
||||
|
||||
const historyError = (() => {
|
||||
const error = threadHead?.tasks?.at(-1)?.error;
|
||||
if (error == null) return undefined;
|
||||
@@ -857,6 +951,8 @@ export function useStream<
|
||||
if (runId) client.runs.cancel(threadId, runId);
|
||||
runMetadataStorage.removeItem(`lg:stream:${threadId}`);
|
||||
}
|
||||
|
||||
options?.onStop?.({ mutate: getMutateFn("stop", historyValues) });
|
||||
};
|
||||
|
||||
async function consumeStream(
|
||||
@@ -889,15 +985,7 @@ export function useStream<
|
||||
if (event === "updates") options.onUpdateEvent?.(data);
|
||||
if (event === "custom")
|
||||
options.onCustomEvent?.(data, {
|
||||
mutate: (update) =>
|
||||
setStreamValues((prev) => {
|
||||
// should not happen
|
||||
if (prev == null) return prev;
|
||||
return {
|
||||
...prev,
|
||||
...(typeof update === "function" ? update(prev) : update),
|
||||
};
|
||||
}),
|
||||
mutate: getMutateFn("stream", historyValues),
|
||||
});
|
||||
if (event === "metadata") options.onMetadataEvent?.(data);
|
||||
if (event === "events") options.onLangChainEvent?.(data);
|
||||
@@ -939,8 +1027,11 @@ export function useStream<
|
||||
|
||||
// TODO: stream created checkpoints to avoid an unnecessary network request
|
||||
const result = await run.onSuccess();
|
||||
|
||||
setStreamValues(null);
|
||||
setStreamValues((values, kind) => {
|
||||
// Do not clear out the user values set on `stop`.
|
||||
if (kind === "stop") return values;
|
||||
return null;
|
||||
});
|
||||
if (streamError != null) throw streamError;
|
||||
|
||||
const lastHead = result.at(0);
|
||||
@@ -1003,26 +1094,24 @@ export function useStream<
|
||||
|
||||
if (newPath != null) setBranch(newPath ?? "");
|
||||
|
||||
// Assumption: we're setting the initial value
|
||||
// Used for instant feedback
|
||||
setStreamValues(() => {
|
||||
const values = { ...historyValues };
|
||||
|
||||
if (submitOptions?.optimisticValues != null) {
|
||||
return {
|
||||
...values,
|
||||
...historyValues,
|
||||
...(typeof submitOptions.optimisticValues === "function"
|
||||
? submitOptions.optimisticValues(values)
|
||||
? submitOptions.optimisticValues(historyValues)
|
||||
: submitOptions.optimisticValues),
|
||||
};
|
||||
}
|
||||
|
||||
return values;
|
||||
return { ...historyValues };
|
||||
});
|
||||
|
||||
let usableThreadId = threadId;
|
||||
if (!usableThreadId) {
|
||||
const thread = await client.threads.create();
|
||||
const thread = await client.threads.create({
|
||||
threadId: submitOptions?.threadId,
|
||||
});
|
||||
onThreadId(thread.thread_id);
|
||||
usableThreadId = thread.thread_id;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("BytesLineDecoder", () => {
|
||||
|
||||
test("handles single line with newline", async () => {
|
||||
const input = createStream([textEncoder.encode("hello\n")]);
|
||||
const decoded = input.pipeThrough(new BytesLineDecoder());
|
||||
const decoded = input.pipeThrough(BytesLineDecoder());
|
||||
const results = await gather(decoded);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
@@ -29,7 +29,7 @@ describe("BytesLineDecoder", () => {
|
||||
|
||||
test("handles multiple lines", async () => {
|
||||
const input = createStream([textEncoder.encode("line1\nline2\nline3\n")]);
|
||||
const decoded = input.pipeThrough(new BytesLineDecoder());
|
||||
const decoded = input.pipeThrough(BytesLineDecoder());
|
||||
const results = await gather(decoded);
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
@@ -44,7 +44,7 @@ describe("BytesLineDecoder", () => {
|
||||
textEncoder.encode("ne1\nli"),
|
||||
textEncoder.encode("ne2\n"),
|
||||
]);
|
||||
const decoded = input.pipeThrough(new BytesLineDecoder());
|
||||
const decoded = input.pipeThrough(BytesLineDecoder());
|
||||
const results = await gather(decoded);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
@@ -54,7 +54,7 @@ describe("BytesLineDecoder", () => {
|
||||
|
||||
test("handles CR LF line endings", async () => {
|
||||
const input = createStream([textEncoder.encode("line1\r\nline2\r\n")]);
|
||||
const decoded = input.pipeThrough(new BytesLineDecoder());
|
||||
const decoded = input.pipeThrough(BytesLineDecoder());
|
||||
const results = await gather(decoded);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
@@ -67,7 +67,7 @@ describe("BytesLineDecoder", () => {
|
||||
textEncoder.encode("line1\r"),
|
||||
textEncoder.encode("\nline2\r\n"),
|
||||
]);
|
||||
const decoded = input.pipeThrough(new BytesLineDecoder());
|
||||
const decoded = input.pipeThrough(BytesLineDecoder());
|
||||
const results = await gather(decoded);
|
||||
|
||||
expect(results.length).toBe(2);
|
||||
@@ -77,7 +77,7 @@ describe("BytesLineDecoder", () => {
|
||||
|
||||
test("handles stale line", async () => {
|
||||
const input = createStream([textEncoder.encode("hello")]);
|
||||
const decoded = input.pipeThrough(new BytesLineDecoder());
|
||||
const decoded = input.pipeThrough(BytesLineDecoder());
|
||||
const results = await gather(decoded);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
@@ -99,8 +99,8 @@ describe("SSEDecoder", () => {
|
||||
"\n",
|
||||
]);
|
||||
const decoded = input
|
||||
.pipeThrough(new BytesLineDecoder())
|
||||
.pipeThrough(new SSEDecoder());
|
||||
.pipeThrough(BytesLineDecoder())
|
||||
.pipeThrough(SSEDecoder());
|
||||
|
||||
const results = await gather(decoded);
|
||||
expect(results.length).toBe(1);
|
||||
@@ -117,8 +117,8 @@ describe("SSEDecoder", () => {
|
||||
'data: {"message": "hello"}\n',
|
||||
]);
|
||||
const decoded = input
|
||||
.pipeThrough(new BytesLineDecoder())
|
||||
.pipeThrough(new SSEDecoder());
|
||||
.pipeThrough(BytesLineDecoder())
|
||||
.pipeThrough(SSEDecoder());
|
||||
|
||||
const results = await gather(decoded);
|
||||
expect(results.length).toBe(1);
|
||||
@@ -138,8 +138,8 @@ describe("SSEDecoder", () => {
|
||||
"\n",
|
||||
]);
|
||||
const decoded = input
|
||||
.pipeThrough(new BytesLineDecoder())
|
||||
.pipeThrough(new SSEDecoder());
|
||||
.pipeThrough(BytesLineDecoder())
|
||||
.pipeThrough(SSEDecoder());
|
||||
|
||||
const results = await gather(decoded);
|
||||
expect(results.length).toBe(2);
|
||||
@@ -156,8 +156,8 @@ describe("SSEDecoder", () => {
|
||||
test("end event without data", async () => {
|
||||
const input = createStream(["event: test\n"]);
|
||||
const decoded = input
|
||||
.pipeThrough(new BytesLineDecoder())
|
||||
.pipeThrough(new SSEDecoder());
|
||||
.pipeThrough(BytesLineDecoder())
|
||||
.pipeThrough(SSEDecoder());
|
||||
|
||||
const results = await gather(decoded);
|
||||
expect(results.length).toBe(1);
|
||||
@@ -170,8 +170,8 @@ describe("SSEDecoder", () => {
|
||||
test("end event without newline", async () => {
|
||||
const input = createStream(["event: end"]);
|
||||
const decoded = input
|
||||
.pipeThrough(new BytesLineDecoder())
|
||||
.pipeThrough(new SSEDecoder());
|
||||
.pipeThrough(BytesLineDecoder())
|
||||
.pipeThrough(SSEDecoder());
|
||||
|
||||
const results = await gather(decoded);
|
||||
expect(results.length).toBe(1);
|
||||
|
||||
@@ -6,15 +6,15 @@ import { userEvent } from "@testing-library/user-event";
|
||||
import { setupServer } from "msw/node";
|
||||
import { http } from "msw";
|
||||
import { useStream } from "../react/stream.js";
|
||||
import type { Message } from "../types.messages.js";
|
||||
|
||||
import { StateGraph, MessagesAnnotation, START } from "@langchain/langgraph";
|
||||
import { MemorySaver } from "@langchain/langgraph-checkpoint";
|
||||
import { FakeStreamingChatModel } from "@langchain/core/utils/testing";
|
||||
import { AIMessage, BaseMessageLike } from "@langchain/core/messages";
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { logger } from "hono/logger";
|
||||
import { AIMessage } from "@langchain/core/messages";
|
||||
import { createEmbedServer } from "@langchain/langgraph-api/experimental/embed";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { useState } from "react";
|
||||
|
||||
const threads = (() => {
|
||||
const THREADS: Record<
|
||||
@@ -40,17 +40,14 @@ const checkpointer = new MemorySaver();
|
||||
|
||||
const model = new FakeStreamingChatModel({ responses: [new AIMessage("Hey")] });
|
||||
const agent = new StateGraph(MessagesAnnotation)
|
||||
.addNode("agent", async (state: { messages: BaseMessageLike[] }) => {
|
||||
.addNode("agent", async (state: { messages: Message[] }) => {
|
||||
const response = await model.invoke(state.messages);
|
||||
return { messages: [response] };
|
||||
})
|
||||
.addEdge(START, "agent")
|
||||
.compile();
|
||||
|
||||
const app = new Hono();
|
||||
app.use(logger());
|
||||
app.route("/", createEmbedServer({ graph: { agent }, checkpointer, threads }));
|
||||
|
||||
const app = createEmbedServer({ graph: { agent }, checkpointer, threads });
|
||||
const server = setupServer(http.all("*", (ctx) => app.fetch(ctx.request)));
|
||||
|
||||
function TestChatComponent() {
|
||||
@@ -138,4 +135,316 @@ describe("useStream", () => {
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
|
||||
});
|
||||
});
|
||||
|
||||
it("displays initial values immediately and clears them when submitting", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestCachedComponent() {
|
||||
const { messages, values, submit } = useStream<{
|
||||
messages: Message[];
|
||||
}>({
|
||||
assistantId: "agent",
|
||||
apiKey: "test-api-key",
|
||||
initialValues: {
|
||||
messages: [
|
||||
{ id: "cached-1", type: "human", content: "Cached user message" },
|
||||
{ id: "cached-2", type: "ai", content: "Cached AI response" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={msg.id ?? i}
|
||||
data-testid={
|
||||
msg.id?.includes("cached")
|
||||
? `message-cached-${i}`
|
||||
: `message-${i}`
|
||||
}
|
||||
>
|
||||
{typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div data-testid="values">{JSON.stringify(values)}</div>
|
||||
<button
|
||||
data-testid="submit"
|
||||
onClick={() =>
|
||||
submit({ messages: [{ content: "Hello", type: "human" }] })
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestCachedComponent />);
|
||||
|
||||
// Should immediately show cached messages
|
||||
expect(screen.getByTestId("message-cached-0")).toHaveTextContent(
|
||||
"Cached user message",
|
||||
);
|
||||
expect(screen.getByTestId("message-cached-1")).toHaveTextContent(
|
||||
"Cached AI response",
|
||||
);
|
||||
|
||||
// Values should include initial values
|
||||
expect(screen.getByTestId("values")).toHaveTextContent(
|
||||
"Cached user message",
|
||||
);
|
||||
|
||||
// Submitting should clear out the cached messages
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
|
||||
// Wait for messages to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("message-0")).toHaveTextContent("Hello");
|
||||
expect(screen.getByTestId("message-1")).toHaveTextContent("Hey");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts newThreadId option without errors", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const spy = vi.fn();
|
||||
const predeterminedThreadId = randomUUID();
|
||||
|
||||
// Test that newThreadId option can be passed without causing errors
|
||||
function TestNewThreadComponent() {
|
||||
const stream = useStream<{ messages: Message[] }>({
|
||||
assistantId: "agent",
|
||||
apiKey: "test-api-key",
|
||||
threadId: null, // Start with no thread
|
||||
onThreadId: spy, // Mock callback
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">
|
||||
{stream.isLoading ? "Loading..." : "Not loading"}
|
||||
</div>
|
||||
<div data-testid="thread-id">
|
||||
{stream.client ? "Client ready" : "No client"}
|
||||
</div>
|
||||
<button
|
||||
data-testid="submit"
|
||||
onClick={() =>
|
||||
stream.submit({}, { threadId: predeterminedThreadId })
|
||||
}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestNewThreadComponent />);
|
||||
|
||||
// Should render without errors
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("Not loading");
|
||||
expect(screen.getByTestId("thread-id")).toHaveTextContent("Client ready");
|
||||
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
expect(spy).toHaveBeenCalledWith(predeterminedThreadId);
|
||||
expect(await threads.get(predeterminedThreadId)).toEqual({
|
||||
thread_id: predeterminedThreadId,
|
||||
metadata: {
|
||||
graph_id: "agent",
|
||||
assistant_id: "agent",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("onStop callback is called when stop is called", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStopCallback = vi.fn();
|
||||
|
||||
function TestComponent() {
|
||||
const { submit, stop } = useStream({
|
||||
assistantId: "agent",
|
||||
apiKey: "test-api-key",
|
||||
onStop: onStopCallback,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="submit" onClick={() => submit({})}>
|
||||
Send
|
||||
</button>
|
||||
<button data-testid="stop" onClick={stop}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// Start a stream and stop it
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
await user.click(screen.getByTestId("stop"));
|
||||
|
||||
// Verify onStop was called with mutate function
|
||||
expect(onStopCallback).toHaveBeenCalledTimes(1);
|
||||
expect(onStopCallback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mutate: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("onStop mutate function updates stream values immediately", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestComponent() {
|
||||
const [stopped, setStopped] = useState(false);
|
||||
const { submit, stop, messages } = useStream<{ messages: Message[] }>({
|
||||
assistantId: "agent",
|
||||
apiKey: "test-api-key",
|
||||
onStop: ({ mutate }) => {
|
||||
setStopped(true);
|
||||
mutate((prev) => ({
|
||||
...prev,
|
||||
messages: [
|
||||
...(prev.messages ?? []),
|
||||
{ type: "ai", content: "Stream stopped" },
|
||||
],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="stopped-status">
|
||||
{stopped ? "Stopped" : "Not stopped"}
|
||||
</div>
|
||||
<div data-testid="messages">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={msg.id ?? i} data-testid={`message-${i}`}>
|
||||
{typeof msg.content === "string"
|
||||
? msg.content
|
||||
: JSON.stringify(msg.content)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button data-testid="submit" onClick={() => submit({})}>
|
||||
Send
|
||||
</button>
|
||||
<button data-testid="stop" onClick={stop}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// Initial state
|
||||
expect(screen.getByTestId("stopped-status")).toHaveTextContent(
|
||||
"Not stopped",
|
||||
);
|
||||
|
||||
// Start and stop stream
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
await user.click(screen.getByTestId("stop"));
|
||||
|
||||
// Verify state was updated immediately
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("stopped-status")).toHaveTextContent("Stopped");
|
||||
expect(screen.getByTestId("message-0")).toHaveTextContent(
|
||||
"Stream stopped",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("onStop handles functional updates correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
function TestComponent() {
|
||||
const { submit, stop, values } = useStream({
|
||||
assistantId: "agent",
|
||||
apiKey: "test-api-key",
|
||||
initialValues: {
|
||||
counter: 5,
|
||||
items: ["item1", "item2"],
|
||||
},
|
||||
onStop: ({ mutate }) => {
|
||||
mutate((prev: any) => ({
|
||||
...prev,
|
||||
counter: (prev.counter || 0) + 10,
|
||||
items: [...(prev.items || []), "stopped"],
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="counter">{(values as any).counter}</div>
|
||||
<div data-testid="items">{(values as any).items?.join(", ")}</div>
|
||||
<button data-testid="submit" onClick={() => submit({})}>
|
||||
Send
|
||||
</button>
|
||||
<button data-testid="stop" onClick={stop}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// Initial state
|
||||
expect(screen.getByTestId("counter")).toHaveTextContent("5");
|
||||
expect(screen.getByTestId("items")).toHaveTextContent("item1, item2");
|
||||
|
||||
// Start and stop stream
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
await user.click(screen.getByTestId("stop"));
|
||||
|
||||
// Verify functional update was applied correctly
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("counter")).toHaveTextContent("15");
|
||||
expect(screen.getByTestId("items")).toHaveTextContent(
|
||||
"item1, item2, stopped",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("onStop is not called when stream completes naturally", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const onStopCallback = vi.fn();
|
||||
|
||||
function TestComponent() {
|
||||
const { submit } = useStream({
|
||||
assistantId: "agent",
|
||||
apiKey: "test-api-key",
|
||||
onStop: onStopCallback,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button data-testid="submit" onClick={() => submit({})}>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
// Start a stream and let it complete naturally
|
||||
await user.click(screen.getByTestId("submit"));
|
||||
|
||||
// Wait for stream to complete naturally
|
||||
await waitFor(() => {
|
||||
expect(onStopCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,16 +13,22 @@ type MessageContent = string | MessageContentComplex[];
|
||||
*/
|
||||
type MessageAdditionalKwargs = Record<string, unknown>;
|
||||
|
||||
export type HumanMessage = {
|
||||
type: "human";
|
||||
id?: string | undefined;
|
||||
type BaseMessage = {
|
||||
additional_kwargs?: MessageAdditionalKwargs | undefined;
|
||||
content: MessageContent;
|
||||
id?: string | undefined;
|
||||
name?: string | undefined;
|
||||
response_metadata?: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
export type AIMessage = {
|
||||
export type HumanMessage = BaseMessage & {
|
||||
type: "human";
|
||||
example?: boolean | undefined;
|
||||
};
|
||||
|
||||
export type AIMessage = BaseMessage & {
|
||||
type: "ai";
|
||||
id?: string | undefined;
|
||||
content: MessageContent;
|
||||
example?: boolean | undefined;
|
||||
tool_calls?:
|
||||
| {
|
||||
name: string;
|
||||
@@ -57,19 +63,12 @@ export type AIMessage = {
|
||||
| undefined;
|
||||
}
|
||||
| undefined;
|
||||
additional_kwargs?: MessageAdditionalKwargs | undefined;
|
||||
response_metadata?: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
export type ToolMessage = {
|
||||
export type ToolMessage = BaseMessage & {
|
||||
type: "tool";
|
||||
name?: string | undefined;
|
||||
id?: string | undefined;
|
||||
content: MessageContent;
|
||||
status?: "error" | "success" | undefined;
|
||||
tool_call_id: string;
|
||||
additional_kwargs?: MessageAdditionalKwargs | undefined;
|
||||
response_metadata?: Record<string, unknown> | undefined;
|
||||
/**
|
||||
* Artifact of the Tool execution which is not meant to be sent to the model.
|
||||
*
|
||||
@@ -81,22 +80,16 @@ export type ToolMessage = {
|
||||
artifact?: any;
|
||||
};
|
||||
|
||||
export type SystemMessage = {
|
||||
export type SystemMessage = BaseMessage & {
|
||||
type: "system";
|
||||
id?: string | undefined;
|
||||
content: MessageContent;
|
||||
};
|
||||
|
||||
export type FunctionMessage = {
|
||||
export type FunctionMessage = BaseMessage & {
|
||||
type: "function";
|
||||
id?: string | undefined;
|
||||
content: MessageContent;
|
||||
};
|
||||
|
||||
export type RemoveMessage = {
|
||||
export type RemoveMessage = BaseMessage & {
|
||||
type: "remove";
|
||||
id: string;
|
||||
content: MessageContent;
|
||||
};
|
||||
|
||||
export type Message =
|
||||
|
||||
+119
-123
@@ -6,90 +6,88 @@ const SPACE = " ".charCodeAt(0);
|
||||
|
||||
const TRAILING_NEWLINE = [CR, LF];
|
||||
|
||||
export class BytesLineDecoder extends TransformStream<Uint8Array, Uint8Array> {
|
||||
constructor() {
|
||||
let buffer: Uint8Array[] = [];
|
||||
let trailingCr = false;
|
||||
export function BytesLineDecoder() {
|
||||
let buffer: Uint8Array[] = [];
|
||||
let trailingCr = false;
|
||||
|
||||
super({
|
||||
start() {
|
||||
buffer = [];
|
||||
return new TransformStream<Uint8Array, Uint8Array>({
|
||||
start() {
|
||||
buffer = [];
|
||||
trailingCr = false;
|
||||
},
|
||||
|
||||
transform(chunk, controller) {
|
||||
// See https://docs.python.org/3/glossary.html#term-universal-newlines
|
||||
let text = chunk;
|
||||
|
||||
// Handle trailing CR from previous chunk
|
||||
if (trailingCr) {
|
||||
text = joinArrays([[CR], text]);
|
||||
trailingCr = false;
|
||||
},
|
||||
}
|
||||
|
||||
transform(chunk, controller) {
|
||||
// See https://docs.python.org/3/glossary.html#term-universal-newlines
|
||||
let text = chunk;
|
||||
// Check for trailing CR in current chunk
|
||||
if (text.length > 0 && text.at(-1) === CR) {
|
||||
trailingCr = true;
|
||||
text = text.subarray(0, -1);
|
||||
}
|
||||
|
||||
// Handle trailing CR from previous chunk
|
||||
if (trailingCr) {
|
||||
text = joinArrays([[CR], text]);
|
||||
trailingCr = false;
|
||||
}
|
||||
if (!text.length) return;
|
||||
const trailingNewline = TRAILING_NEWLINE.includes(text.at(-1)!);
|
||||
|
||||
// Check for trailing CR in current chunk
|
||||
if (text.length > 0 && text.at(-1) === CR) {
|
||||
trailingCr = true;
|
||||
text = text.subarray(0, -1);
|
||||
}
|
||||
const lastIdx = text.length - 1;
|
||||
const { lines } = text.reduce<{ lines: Uint8Array[]; from: number }>(
|
||||
(acc, cur, idx) => {
|
||||
if (acc.from > idx) return acc;
|
||||
|
||||
if (!text.length) return;
|
||||
const trailingNewline = TRAILING_NEWLINE.includes(text.at(-1)!);
|
||||
|
||||
const lastIdx = text.length - 1;
|
||||
const { lines } = text.reduce<{ lines: Uint8Array[]; from: number }>(
|
||||
(acc, cur, idx) => {
|
||||
if (acc.from > idx) return acc;
|
||||
|
||||
if (cur === CR || cur === LF) {
|
||||
acc.lines.push(text.subarray(acc.from, idx));
|
||||
if (cur === CR && text[idx + 1] === LF) {
|
||||
acc.from = idx + 2;
|
||||
} else {
|
||||
acc.from = idx + 1;
|
||||
}
|
||||
if (cur === CR || cur === LF) {
|
||||
acc.lines.push(text.subarray(acc.from, idx));
|
||||
if (cur === CR && text[idx + 1] === LF) {
|
||||
acc.from = idx + 2;
|
||||
} else {
|
||||
acc.from = idx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (idx === lastIdx && acc.from <= lastIdx) {
|
||||
acc.lines.push(text.subarray(acc.from));
|
||||
}
|
||||
if (idx === lastIdx && acc.from <= lastIdx) {
|
||||
acc.lines.push(text.subarray(acc.from));
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ lines: [], from: 0 },
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{ lines: [], from: 0 },
|
||||
);
|
||||
|
||||
if (lines.length === 1 && !trailingNewline) {
|
||||
buffer.push(lines[0]);
|
||||
return;
|
||||
}
|
||||
if (lines.length === 1 && !trailingNewline) {
|
||||
buffer.push(lines[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (buffer.length) {
|
||||
// Include existing buffer in first line
|
||||
buffer.push(lines[0]);
|
||||
lines[0] = joinArrays(buffer);
|
||||
buffer = [];
|
||||
}
|
||||
if (buffer.length) {
|
||||
// Include existing buffer in first line
|
||||
buffer.push(lines[0]);
|
||||
lines[0] = joinArrays(buffer);
|
||||
buffer = [];
|
||||
}
|
||||
|
||||
if (!trailingNewline) {
|
||||
// If the last segment is not newline terminated,
|
||||
// buffer it for the next chunk
|
||||
if (lines.length) buffer = [lines.pop()!];
|
||||
}
|
||||
if (!trailingNewline) {
|
||||
// If the last segment is not newline terminated,
|
||||
// buffer it for the next chunk
|
||||
if (lines.length) buffer = [lines.pop()!];
|
||||
}
|
||||
|
||||
// Enqueue complete lines
|
||||
for (const line of lines) {
|
||||
controller.enqueue(line);
|
||||
}
|
||||
},
|
||||
// Enqueue complete lines
|
||||
for (const line of lines) {
|
||||
controller.enqueue(line);
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
if (buffer.length) {
|
||||
controller.enqueue(joinArrays(buffer));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
flush(controller) {
|
||||
if (buffer.length) {
|
||||
controller.enqueue(joinArrays(buffer));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
interface StreamPart {
|
||||
@@ -98,69 +96,67 @@ interface StreamPart {
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
export class SSEDecoder extends TransformStream<Uint8Array, StreamPart> {
|
||||
constructor() {
|
||||
let event = "";
|
||||
let data: Uint8Array[] = [];
|
||||
let lastEventId = "";
|
||||
let retry: number | null = null;
|
||||
export function SSEDecoder() {
|
||||
let event = "";
|
||||
let data: Uint8Array[] = [];
|
||||
let lastEventId = "";
|
||||
let retry: number | null = null;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
super({
|
||||
transform(chunk, controller) {
|
||||
// Handle empty line case
|
||||
if (!chunk.length) {
|
||||
if (!event && !data.length && !lastEventId && retry == null) return;
|
||||
return new TransformStream<Uint8Array, StreamPart>({
|
||||
transform(chunk, controller) {
|
||||
// Handle empty line case
|
||||
if (!chunk.length) {
|
||||
if (!event && !data.length && !lastEventId && retry == null) return;
|
||||
|
||||
const sse = {
|
||||
id: lastEventId || undefined,
|
||||
event,
|
||||
data: data.length ? decodeArraysToJson(decoder, data) : null,
|
||||
};
|
||||
const sse = {
|
||||
id: lastEventId || undefined,
|
||||
event,
|
||||
data: data.length ? decodeArraysToJson(decoder, data) : null,
|
||||
};
|
||||
|
||||
// NOTE: as per the SSE spec, do not reset lastEventId
|
||||
event = "";
|
||||
data = [];
|
||||
retry = null;
|
||||
// NOTE: as per the SSE spec, do not reset lastEventId
|
||||
event = "";
|
||||
data = [];
|
||||
retry = null;
|
||||
|
||||
controller.enqueue(sse);
|
||||
return;
|
||||
}
|
||||
controller.enqueue(sse);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore comments
|
||||
if (chunk[0] === COLON) return;
|
||||
// Ignore comments
|
||||
if (chunk[0] === COLON) return;
|
||||
|
||||
const sepIdx = chunk.indexOf(COLON);
|
||||
if (sepIdx === -1) return;
|
||||
const sepIdx = chunk.indexOf(COLON);
|
||||
if (sepIdx === -1) return;
|
||||
|
||||
const fieldName = decoder.decode(chunk.subarray(0, sepIdx));
|
||||
let value = chunk.subarray(sepIdx + 1);
|
||||
if (value[0] === SPACE) value = value.subarray(1);
|
||||
const fieldName = decoder.decode(chunk.subarray(0, sepIdx));
|
||||
let value = chunk.subarray(sepIdx + 1);
|
||||
if (value[0] === SPACE) value = value.subarray(1);
|
||||
|
||||
if (fieldName === "event") {
|
||||
event = decoder.decode(value);
|
||||
} else if (fieldName === "data") {
|
||||
data.push(value);
|
||||
} else if (fieldName === "id") {
|
||||
if (value.indexOf(NULL) === -1) lastEventId = decoder.decode(value);
|
||||
} else if (fieldName === "retry") {
|
||||
const retryNum = Number.parseInt(decoder.decode(value));
|
||||
if (!Number.isNaN(retryNum)) retry = retryNum;
|
||||
}
|
||||
},
|
||||
if (fieldName === "event") {
|
||||
event = decoder.decode(value);
|
||||
} else if (fieldName === "data") {
|
||||
data.push(value);
|
||||
} else if (fieldName === "id") {
|
||||
if (value.indexOf(NULL) === -1) lastEventId = decoder.decode(value);
|
||||
} else if (fieldName === "retry") {
|
||||
const retryNum = Number.parseInt(decoder.decode(value));
|
||||
if (!Number.isNaN(retryNum)) retry = retryNum;
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
if (event) {
|
||||
controller.enqueue({
|
||||
id: lastEventId || undefined,
|
||||
event,
|
||||
data: data.length ? decodeArraysToJson(decoder, data) : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
flush(controller) {
|
||||
if (event) {
|
||||
controller.enqueue({
|
||||
id: lastEventId || undefined,
|
||||
event,
|
||||
data: data.length ? decodeArraysToJson(decoder, data) : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function joinArrays(data: ArrayLike<number>[]) {
|
||||
|
||||
Reference in New Issue
Block a user