mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-28 10:49:56 +02:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73cebea3c2 | ||
|
|
b73b2d19eb | ||
|
|
ca26805b5f | ||
|
|
5ac837d7cd | ||
|
|
c4f5861166 |
@@ -0,0 +1,49 @@
|
||||
name: Deploy Redirects to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-redirects.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Generate redirect files
|
||||
run: python docs/generate_redirects.py
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: 'docs/_site'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -0,0 +1 @@
|
||||
_site/
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate HTML redirect files from redirects.json.
|
||||
|
||||
Usage:
|
||||
python generate_redirects.py
|
||||
|
||||
This script reads redirects.json and generates individual HTML files
|
||||
for each redirect path. Each HTML file uses meta refresh (0 delay)
|
||||
which is SEO-friendly and treated similarly to 301 redirects by Google.
|
||||
|
||||
To add new redirects, simply edit redirects.json and re-run this script.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Default fallback URL for any path not in the redirect map
|
||||
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
|
||||
HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting...</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
Redirecting...
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
ROOT_HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>var anchor=window.location.hash.substr(1);location.href="{url}"+(anchor?"#"+anchor:"")</script>
|
||||
<meta http-equiv="refresh" content="0; url={url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
CATCHALL_404_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Redirecting to LangGraph Documentation</title>
|
||||
<link rel="canonical" href="{default_url}">
|
||||
<meta name="robots" content="noindex">
|
||||
<script>
|
||||
// Catchall redirect for any unmapped paths
|
||||
window.location.replace("{default_url}");
|
||||
</script>
|
||||
<meta http-equiv="refresh" content="0; url={default_url}">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Documentation has moved</h1>
|
||||
<p>The LangGraph documentation has moved to <a href="{default_url}">docs.langchain.com</a>.</p>
|
||||
<p>Redirecting you now...</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def generate_redirects():
|
||||
script_dir = Path(__file__).parent
|
||||
output_dir = script_dir / "_site"
|
||||
|
||||
# Load redirects
|
||||
with open(script_dir / "redirects.json") as f:
|
||||
redirects = json.load(f)
|
||||
|
||||
# Clean output directory
|
||||
if output_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(output_dir)
|
||||
output_dir.mkdir(parents=True)
|
||||
|
||||
# Generate individual HTML files for each redirect
|
||||
for old_path, new_url in redirects.items():
|
||||
# Remove leading slash and create directory structure
|
||||
path = old_path.lstrip("/")
|
||||
|
||||
# Check if path has a file extension (e.g., .txt, .xml)
|
||||
# If so, create the file directly instead of a directory with index.html
|
||||
path_obj = Path(path)
|
||||
has_extension = path_obj.suffix and len(path_obj.suffix) <= 5
|
||||
|
||||
if not path:
|
||||
html_path = output_dir / "index.html"
|
||||
elif has_extension:
|
||||
# For files with extensions, create the file directly
|
||||
html_path = output_dir / path
|
||||
else:
|
||||
# For directory-style URLs, create index.html inside
|
||||
html_path = output_dir / path / "index.html"
|
||||
|
||||
# Create parent directories
|
||||
html_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write the redirect HTML
|
||||
html_path.write_text(HTML_TEMPLATE.format(url=new_url))
|
||||
print(f"Created: {html_path}")
|
||||
|
||||
# Create root index.html
|
||||
root_index = output_dir / "index.html"
|
||||
if not root_index.exists():
|
||||
root_index.write_text(ROOT_HTML_TEMPLATE.format(url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {root_index}")
|
||||
|
||||
# Create 404.html for catchall
|
||||
catchall_404 = output_dir / "404.html"
|
||||
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
|
||||
print(f"Created: {catchall_404}")
|
||||
|
||||
# Copy static files (like llms.txt) that can't be redirected via HTML
|
||||
static_files = ["llms.txt"]
|
||||
for static_file in static_files:
|
||||
src = script_dir / static_file
|
||||
if src.exists():
|
||||
dst = output_dir / static_file
|
||||
dst.write_text(src.read_text())
|
||||
print(f"Copied: {dst}")
|
||||
|
||||
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_redirects()
|
||||
@@ -0,0 +1,35 @@
|
||||
# LangGraph
|
||||
|
||||
LangGraph documentation has moved to docs.langchain.com.
|
||||
|
||||
## Overview
|
||||
|
||||
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs.
|
||||
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges.
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX.
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs.
|
||||
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory.
|
||||
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph.
|
||||
|
||||
## How-To Guides
|
||||
|
||||
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs.
|
||||
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs.
|
||||
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors.
|
||||
|
||||
## Tutorials
|
||||
|
||||
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph.
|
||||
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph.
|
||||
|
||||
## Reference
|
||||
|
||||
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph.
|
||||
|
||||
## LangGraph Platform
|
||||
|
||||
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server).
|
||||
@@ -0,0 +1,296 @@
|
||||
{
|
||||
"/how-tos/stream-values": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-updates": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-content": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/stream-multiple": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-tokens-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-from-final-node": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming-events-from-within-tools-without-langchain": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/state-reducers": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-and-update-state",
|
||||
"/how-tos/sequence": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-a-sequence-of-steps",
|
||||
"/how-tos/branching": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-branches",
|
||||
"/how-tos/recursion-limit": "https://docs.langchain.com/oss/python/langgraph/graph-api#create-and-control-loops",
|
||||
"/how-tos/visualization": "https://docs.langchain.com/oss/python/langgraph/graph-api#visualize-your-graph",
|
||||
"/how-tos/input_output_schema": "https://docs.langchain.com/oss/python/langgraph/graph-api#define-input-and-output-schemas",
|
||||
"/how-tos/pass_private_state": "https://docs.langchain.com/oss/python/langgraph/graph-api#pass-private-state-between-nodes",
|
||||
"/how-tos/state-model": "https://docs.langchain.com/oss/python/langgraph/graph-api#use-pydantic-models-for-graph-state",
|
||||
"/how-tos/map-reduce": "https://docs.langchain.com/oss/python/langgraph/graph-api#map-reduce-and-the-send-api",
|
||||
"/how-tos/command": "https://docs.langchain.com/oss/python/langgraph/graph-api#combine-control-flow-and-state-updates-with-command",
|
||||
"/how-tos/configuration": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-runtime-configuration",
|
||||
"/how-tos/node-retries": "https://docs.langchain.com/oss/python/langgraph/graph-api#add-retry-policies",
|
||||
"/how-tos/return-when-recursion-limit-hits": "https://docs.langchain.com/oss/python/langgraph/graph-api#impose-a-recursion-limit",
|
||||
"/how-tos/async": "https://docs.langchain.com/oss/python/langgraph/graph-api#async",
|
||||
"/how-tos/memory/manage-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/memory/delete-messages": "https://docs.langchain.com/oss/python/langgraph/add-memory#delete-messages",
|
||||
"/how-tos/memory/add-summary-conversation-history": "https://docs.langchain.com/oss/python/langgraph/add-memory#summarize-messages",
|
||||
"/how-tos/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/subgraph-transform-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#different-state-schemas",
|
||||
"/how-tos/subgraphs-manage-state": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs#add-persistence",
|
||||
"/how-tos/persistence_postgres": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_mongodb": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/persistence_redis": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-in-production",
|
||||
"/how-tos/subgraph-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#use-with-subgraphs",
|
||||
"/how-tos/cross-thread-persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/cloud/how-tos/copy_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/check-thread-status": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/concepts/threads": "https://docs.langchain.com/oss/python/langgraph/persistence#threads",
|
||||
"/how-tos/persistence": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/tool-calling-errors": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-config-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/pass-run-time-values-to-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/update-state-from-tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/agent-handoffs": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-multi-turn-convo": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/cloud/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/cloud/how-tos/index": "https://docs.langchain.com/langsmith/home",
|
||||
"/cloud/concepts/api": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/cloud/concepts/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/faq/studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/human_in_the_loop_edit_state": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/how-tos/human_in_the_loop_user_input": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/concepts/platform_architecture": "https://docs.langchain.com/langsmith/cloud#architecture",
|
||||
"/cloud/how-tos/stream_values": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_updates": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_messages": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_events": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_debug": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/how-tos/stream_multiple": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/agents/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/create-react-agent": "https://docs.langchain.com/oss/python/langchain/agents#basic-configuration",
|
||||
"/how-tos/create-react-agent-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-system-prompt": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/create-react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/prebuilt": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/concepts/high_level": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/v0-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/introduction": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/deployment": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/how-tos/deploy-self-hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/self_hosted": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/tutorials/deployment": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/how-tos/assistant_versioning": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/concepts/runs": "https://docs.langchain.com/langsmith/assistants#execution",
|
||||
"/how-tos/wait-user-input-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/review-tool-calls-functional": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/create-react-agent-hitl": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/agents/human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/dynamic_breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/breakpoints": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/human_in_the_loop_breakpoint": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/how-tos/human_in_the_loop/edit-graph-state": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/examples/index": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/guides/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/tutorials/index": "https://docs.langchain.com/oss/python/learn",
|
||||
"/llms-txt-overview": "https://docs.langchain.com/llms.txt",
|
||||
"/tutorials/rag/langgraph_adaptive_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/multi-agent-collaboration": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/create-react-agent-manage-message-history": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/many-tools": "https://docs.langchain.com/oss/python/langchain/tools",
|
||||
"/tutorials/customer-support/customer-support": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/how-tos/react-agent-structured-output": "https://docs.langchain.com/oss/python/langchain/agents#structured-output",
|
||||
"/tutorials/code_assistant/langgraph_code_assistant": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/hierarchical_agent_teams": "https://docs.langchain.com/oss/python/langchain/supervisor",
|
||||
"/tutorials/auth/getting_started": "https://docs.langchain.com/langsmith/auth",
|
||||
"/tutorials/auth/resource_auth": "https://docs.langchain.com/langsmith/resource-auth",
|
||||
"/tutorials/auth/add_auth_server": "https://docs.langchain.com/langsmith/add-auth-server",
|
||||
"/how-tos/use-remote-graph": "https://docs.langchain.com/langsmith/use-remote-graph",
|
||||
"/how-tos/autogen-integration": "https://docs.langchain.com/langsmith/autogen-integration",
|
||||
"/how-tos/human_in_the_loop/wait-user-input": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/cloud/how-tos/use_stream_react": "https://docs.langchain.com/langsmith/use-stream-react",
|
||||
"/cloud/how-tos/generative_ui_react": "https://docs.langchain.com/langsmith/generative-ui-react",
|
||||
"/concepts/langgraph_platform": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/langgraph_components": "https://docs.langchain.com/langsmith/components",
|
||||
"/concepts/langgraph_server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/concepts/langgraph_data_plane": "https://docs.langchain.com/langsmith/data-plane",
|
||||
"/concepts/langgraph_control_plane": "https://docs.langchain.com/langsmith/control-plane",
|
||||
"/concepts/langgraph_cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/concepts/langgraph_studio": "https://docs.langchain.com/langsmith/studio",
|
||||
"/cloud/how-tos/studio/quick_start": "https://docs.langchain.com/langsmith/quick-start-studio",
|
||||
"/cloud/how-tos/invoke_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/manage_assistants": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/threads_studio": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/iterate_graph_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/cloud/how-tos/studio/run_evals": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/clone_traces_studio": "https://docs.langchain.com/langsmith/observability",
|
||||
"/cloud/how-tos/datasets_studio": "https://docs.langchain.com/langsmith/use-studio",
|
||||
"/concepts/sdk": "https://docs.langchain.com/langsmith/sdk",
|
||||
"/concepts/plans": "https://docs.langchain.com/langsmith/home",
|
||||
"/concepts/application_structure": "https://docs.langchain.com/langsmith/application-structure",
|
||||
"/concepts/scalability_and_resilience": "https://docs.langchain.com/langsmith/scalability-and-resilience",
|
||||
"/concepts/auth": "https://docs.langchain.com/langsmith/auth",
|
||||
"/how-tos/auth/custom_auth": "https://docs.langchain.com/langsmith/custom-auth",
|
||||
"/how-tos/auth/openapi_security": "https://docs.langchain.com/langsmith/openapi-security",
|
||||
"/concepts/assistants": "https://docs.langchain.com/langsmith/assistants",
|
||||
"/cloud/how-tos/configuration_cloud": "https://docs.langchain.com/langsmith/configuration-cloud",
|
||||
"/cloud/how-tos/use_threads": "https://docs.langchain.com/langsmith/use-threads",
|
||||
"/cloud/how-tos/background_run": "https://docs.langchain.com/langsmith/background-run",
|
||||
"/cloud/how-tos/same-thread": "https://docs.langchain.com/langsmith/same-thread",
|
||||
"/cloud/how-tos/stateless_runs": "https://docs.langchain.com/langsmith/stateless-runs",
|
||||
"/cloud/how-tos/configurable_headers": "https://docs.langchain.com/langsmith/configurable-headers",
|
||||
"/concepts/double_texting": "https://docs.langchain.com/langsmith/double-texting",
|
||||
"/cloud/how-tos/interrupt_concurrent": "https://docs.langchain.com/langsmith/interrupt-concurrent",
|
||||
"/cloud/how-tos/rollback_concurrent": "https://docs.langchain.com/langsmith/rollback-concurrent",
|
||||
"/cloud/how-tos/reject_concurrent": "https://docs.langchain.com/langsmith/reject-concurrent",
|
||||
"/cloud/how-tos/enqueue_concurrent": "https://docs.langchain.com/langsmith/enqueue-concurrent",
|
||||
"/cloud/concepts/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/how-tos/webhooks": "https://docs.langchain.com/langsmith/use-webhooks",
|
||||
"/cloud/concepts/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/cloud/how-tos/cron_jobs": "https://docs.langchain.com/langsmith/cron-jobs",
|
||||
"/how-tos/http/custom_lifespan": "https://docs.langchain.com/langsmith/custom-lifespan",
|
||||
"/how-tos/http/custom_middleware": "https://docs.langchain.com/langsmith/custom-middleware",
|
||||
"/how-tos/http/custom_routes": "https://docs.langchain.com/langsmith/custom-routes",
|
||||
"/cloud/concepts/data_storage_and_privacy": "https://docs.langchain.com/langsmith/data-storage-and-privacy",
|
||||
"/cloud/deployment/semantic_search": "https://docs.langchain.com/langsmith/semantic-search",
|
||||
"/how-tos/ttl/configure_ttl": "https://docs.langchain.com/langsmith/configure-ttl",
|
||||
"/concepts/deployment_options": "https://docs.langchain.com/langsmith/deployments",
|
||||
"/cloud/quick_start": "https://docs.langchain.com/langsmith/deployment-quickstart",
|
||||
"/cloud/deployment/setup": "https://docs.langchain.com/langsmith/setup-app-requirements-txt",
|
||||
"/cloud/deployment/setup_pyproject": "https://docs.langchain.com/langsmith/setup-pyproject",
|
||||
"/cloud/deployment/setup_javascript": "https://docs.langchain.com/langsmith/setup-javascript",
|
||||
"/cloud/deployment/custom_docker": "https://docs.langchain.com/langsmith/custom-docker",
|
||||
"/cloud/deployment/graph_rebuild": "https://docs.langchain.com/langsmith/graph-rebuild",
|
||||
"/concepts/langgraph_cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/concepts/langgraph_self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/concepts/langgraph_standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/cloud/deployment/cloud": "https://docs.langchain.com/langsmith/cloud",
|
||||
"/cloud/deployment/self_hosted_data_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/self_hosted_control_plane": "https://docs.langchain.com/langsmith/platform-setup",
|
||||
"/cloud/deployment/standalone_container": "https://docs.langchain.com/langsmith/docker",
|
||||
"/concepts/server-mcp": "https://docs.langchain.com/langsmith/server-mcp",
|
||||
"/cloud/how-tos/human_in_the_loop_time_travel": "https://docs.langchain.com/langsmith/human-in-the-loop-time-travel",
|
||||
"/cloud/how-tos/add-human-in-the-loop": "https://docs.langchain.com/langsmith/add-human-in-the-loop",
|
||||
"/cloud/deployment/egress": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/cloud/how-tos/streaming": "https://docs.langchain.com/langsmith/streaming",
|
||||
"/cloud/reference/api/api_ref": "https://docs.langchain.com/langsmith/server-api-ref",
|
||||
"/cloud/reference/langgraph_server_changelog": "https://docs.langchain.com/langsmith/agent-server-changelog",
|
||||
"/cloud/reference/api/api_ref_control_plane": "https://docs.langchain.com/langsmith/api-ref-control-plane",
|
||||
"/cloud/reference/cli": "https://docs.langchain.com/langsmith/cli",
|
||||
"/cloud/reference/env_var": "https://docs.langchain.com/langsmith/env-var",
|
||||
"/troubleshooting/studio": "https://docs.langchain.com/langsmith/troubleshooting-studio",
|
||||
"/index": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/agents": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/concepts/why-langgraph": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/get-started/1-build-basic-chatbot": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/2-add-tools": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/3-add-memory": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/4-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/5-customize-state": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/get-started/6-time-travel": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/tutorials/langsmith/local-server": "https://docs.langchain.com/oss/python/langgraph/local-server",
|
||||
"/tutorials/workflows": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/plan-and-execute/plan-and-execute": "https://docs.langchain.com/oss/python/langchain/middleware/built-in#to-do-list",
|
||||
"/tutorials/langgraph-platform/local-server/local-server": "https://docs.langchain.com/langsmith/local-server",
|
||||
"/concepts/agentic_concepts": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/agents/overview": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/agents/run_agents": "https://docs.langchain.com/oss/python/langgraph/quickstart",
|
||||
"/concepts/low_level": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/graph-api": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/react-agent-from-scratch": "https://docs.langchain.com/oss/python/langchain/quickstart",
|
||||
"/concepts/functional_api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/how-tos/use-functional-api": "https://docs.langchain.com/oss/python/langgraph/functional-api",
|
||||
"/concepts/pregel": "https://docs.langchain.com/oss/python/langgraph/pregel",
|
||||
"/concepts/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/concepts/persistence": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/concepts/durable_execution": "https://docs.langchain.com/oss/python/langgraph/durable-execution",
|
||||
"/concepts/memory": "https://docs.langchain.com/oss/python/langgraph/memory",
|
||||
"/how-tos/memory/add-memory": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/context": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/agents/models": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tools": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/how-tos/tool-calling": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/concepts/human_in_the_loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/how-tos/human_in_the_loop/add-human-in-the-loop": "https://docs.langchain.com/oss/python/langgraph/interrupts",
|
||||
"/concepts/time-travel": "https://docs.langchain.com/oss/python/langgraph/persistence",
|
||||
"/how-tos/human_in_the_loop/time-travel": "https://docs.langchain.com/oss/python/langgraph/use-time-travel",
|
||||
"/concepts/subgraphs": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/how-tos/subgraph": "https://docs.langchain.com/oss/python/langgraph/use-subgraphs",
|
||||
"/concepts/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/agents/multi-agent": "https://docs.langchain.com/oss/python/langchain/multi-agent",
|
||||
"/how-tos/multi_agent": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/concepts/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/mcp": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/how-tos/enable-tracing": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/agents/evals": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/concepts/template_applications": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_agentic_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/multi_agent/agent_supervisor": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/tutorials/sql/sql-agent": "https://docs.langchain.com/oss/python/langgraph/sql-agent",
|
||||
"/agents/ui": "https://docs.langchain.com/oss/python/langgraph/ui",
|
||||
"/how-tos/run-id-langsmith": "https://docs.langchain.com/oss/python/langgraph/observability",
|
||||
"/troubleshooting/errors/index": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/troubleshooting/errors/INVALID_CHAT_HISTORY": "https://docs.langchain.com/oss/python/langgraph/INVALID_CHAT_HISTORY",
|
||||
"/troubleshooting/errors/INVALID_LICENSE": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/adopters": "https://docs.langchain.com/oss/python/langgraph/case-studies",
|
||||
"/concepts/faq": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/agents/prebuilt": "https://docs.langchain.com/oss/python/langchain/agents",
|
||||
"/reference/index": "https://reference.langchain.com/python/langgraph/",
|
||||
"/reference/graphs": "https://reference.langchain.com/python/langgraph/graphs/",
|
||||
"/reference/func": "https://reference.langchain.com/python/langgraph/func/",
|
||||
"/reference/pregel": "https://reference.langchain.com/python/langgraph/pregel/",
|
||||
"/reference/checkpoints": "https://reference.langchain.com/python/langgraph/checkpoints/",
|
||||
"/reference/store": "https://reference.langchain.com/python/langgraph/store/",
|
||||
"/reference/cache": "https://reference.langchain.com/python/langgraph/cache/",
|
||||
"/reference/types": "https://reference.langchain.com/python/langgraph/types/",
|
||||
"/reference/runtime": "https://reference.langchain.com/python/langgraph/runtime/",
|
||||
"/reference/config": "https://reference.langchain.com/python/langgraph/config/",
|
||||
"/reference/errors": "https://reference.langchain.com/python/langgraph/errors/",
|
||||
"/reference/constants": "https://reference.langchain.com/python/langgraph/constants/",
|
||||
"/reference/channels": "https://reference.langchain.com/python/langgraph/channels/",
|
||||
"/reference/agents": "https://reference.langchain.com/python/langgraph/agents/",
|
||||
"/reference/supervisor": "https://reference.langchain.com/python/langgraph/supervisor/",
|
||||
"/reference/swarm": "https://reference.langchain.com/python/langgraph/swarm/",
|
||||
"/reference/mcp": "https://reference.langchain.com/python/langgraph/mcp/",
|
||||
"/cloud/reference/sdk/python_sdk_ref": "https://reference.langchain.com/python/langsmith/deployment/sdk/",
|
||||
"/reference/remote_graph": "https://reference.langchain.com/python/langsmith/deployment/remote_graph/",
|
||||
"/additional-resources/index": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/cloud/reference/sdk/js_ts_sdk_ref": "https://reference.langchain.com/javascript/modules/langsmith.html",
|
||||
"/snippets/chat_model_tabs": "https://docs.langchain.com/oss/python/langchain/overview",
|
||||
"/troubleshooting/errors/GRAPH_RECURSION_LIMIT": "https://docs.langchain.com/oss/python/langgraph/GRAPH_RECURSION_LIMIT",
|
||||
"/troubleshooting/errors/INVALID_CONCURRENT_GRAPH_UPDATE": "https://docs.langchain.com/oss/python/langgraph/INVALID_CONCURRENT_GRAPH_UPDATE",
|
||||
"/troubleshooting/errors/INVALID_GRAPH_NODE_RETURN_VALUE": "https://docs.langchain.com/oss/python/langgraph/INVALID_GRAPH_NODE_RETURN_VALUE",
|
||||
"/troubleshooting/errors/MULTIPLE_SUBGRAPHS": "https://docs.langchain.com/oss/python/langgraph/MULTIPLE_SUBGRAPHS",
|
||||
"/tutorials/rag/langgraph_self_rag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/additional-resources": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/examples": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/guides": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/autogen-integration-functional": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/how-tos/cross-thread-persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory#add-long-term-memory",
|
||||
"/how-tos/disable-streaming": "https://docs.langchain.com/oss/python/langgraph/streaming",
|
||||
"/how-tos/memory/semantic-search": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/multi-agent-multi-turn-convo-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/multi-agent-network-functional": "https://docs.langchain.com/oss/python/langgraph/graph-api",
|
||||
"/how-tos/persistence-functional": "https://docs.langchain.com/oss/python/langgraph/add-memory",
|
||||
"/how-tos/react-agent-from-scratch-functional": "https://docs.langchain.com/oss/python/langgraph/workflows-agents",
|
||||
"/reference": "https://reference.langchain.com/python/langgraph/",
|
||||
"/troubleshooting/errors": "https://docs.langchain.com/oss/python/langgraph/common-errors",
|
||||
"/tutorials/chatbot-simulation-evaluation/agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbot-simulation-evaluation/langsmith-agent-simulation-evaluation": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/chatbots/information-gather-prompting": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/extraction/retries": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/langgraph-platform/local-server": "https://docs.langchain.com/langsmith/agent-server",
|
||||
"/tutorials/lats/lats": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/llm-compiler/LLMCompiler": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rag/langgraph_adaptive_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_crag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/rag/langgraph_self_rag_local": "https://docs.langchain.com/oss/python/langgraph/agentic-rag",
|
||||
"/tutorials/reflection/reflection": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/reflexion/reflexion": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/rewoo/rewoo": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/self-discover/self-discover": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tnt-llm/tnt-llm": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/tot/tot": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/usaco/usaco": "https://docs.langchain.com/oss/python/langgraph/overview",
|
||||
"/tutorials/web-navigation/web_voyager": "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||
}
|
||||
@@ -728,7 +728,6 @@ async def _acall_impl(
|
||||
)
|
||||
else:
|
||||
fut.set_result(None)
|
||||
futures()[fut] = next_task # type: ignore[index]
|
||||
else:
|
||||
# schedule the next task
|
||||
fut = cast(
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -27,7 +27,7 @@ dependencies = [
|
||||
"langchain-core>=0.1",
|
||||
"langgraph-checkpoint>=2.1.0,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-prebuilt>=1.0.7,<1.1.0",
|
||||
"langgraph-prebuilt>=1.0.8,<1.1.0",
|
||||
"xxhash>=3.5.0",
|
||||
"pydantic>=2.7.4",
|
||||
]
|
||||
|
||||
@@ -5800,6 +5800,284 @@ def test_multiple_interrupts_functional_cache(
|
||||
assert counter == 6
|
||||
|
||||
|
||||
def test_task_before_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when a @task runs
|
||||
before interrupt-producing tasks in an @entrypoint.
|
||||
|
||||
The @task wrapper on both setup and ask is essential to reproduce the bug:
|
||||
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
|
||||
- @task on ask means interrupt() runs in a child scratchpad that must
|
||||
delegate to the parent for null resume consumption tracking
|
||||
"""
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(number_of_topics: int) -> dict:
|
||||
@task
|
||||
def setup() -> int:
|
||||
return number_of_topics
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
n = setup().result()
|
||||
|
||||
answers = []
|
||||
for i in range(n):
|
||||
q = f"Whats the answer for topic {i + 1}?"
|
||||
answers.append(ask(q).result())
|
||||
|
||||
return {"answers": answers}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get first interrupt
|
||||
result = workflow.invoke(2, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = workflow.invoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should get final result
|
||||
result = workflow.invoke(Command(resume="answer2"), config=config)
|
||||
assert result == {"answers": ["answer1", "answer2"]}
|
||||
|
||||
|
||||
def test_multiple_tasks_before_interrupt_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when multiple @tasks
|
||||
run before an interrupt-producing task in an @entrypoint."""
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(inputs: dict) -> dict:
|
||||
@task
|
||||
def step_a(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
@task
|
||||
def step_b(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
a = step_a(inputs["x"]).result()
|
||||
b = step_b(a).result()
|
||||
|
||||
answer = ask(f"Result so far is {b}. What next?").result()
|
||||
|
||||
return {"computed": b, "answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get interrupt
|
||||
result = workflow.invoke({"x": 5}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume
|
||||
result = workflow.invoke(Command(resume="continue"), config=config)
|
||||
assert result == {"computed": 12, "answer": "continue"}
|
||||
|
||||
|
||||
def test_no_redundant_put_writes_for_cached_task(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Cached @tasks on resume must not trigger redundant put_writes."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from langgraph.pregel._loop import PregelLoop
|
||||
|
||||
@task
|
||||
def setup(x: int) -> int:
|
||||
return x
|
||||
|
||||
@task
|
||||
def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
@entrypoint(checkpointer=sync_checkpointer)
|
||||
def workflow(x: int) -> dict:
|
||||
n = setup(x).result()
|
||||
answer = ask(f"q{n}").result()
|
||||
return {"answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
result = workflow.invoke(1, config=config)
|
||||
assert "__interrupt__" in result
|
||||
|
||||
put_writes_task_ids: list[str] = []
|
||||
orig = PregelLoop.put_writes
|
||||
|
||||
def spy(self, task_id, writes):
|
||||
put_writes_task_ids.append(task_id)
|
||||
return orig(self, task_id, writes)
|
||||
|
||||
with patch.object(PregelLoop, "put_writes", spy):
|
||||
result = workflow.invoke(Command(resume="ans"), config=config)
|
||||
|
||||
assert result == {"answer": "ans"}
|
||||
# Count unique non-null task IDs that got put_writes.
|
||||
# Should be exactly 2: the ask task and the entrypoint task.
|
||||
# If 3, the cached setup task is being redundantly re-committed.
|
||||
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
|
||||
assert len(non_null) == 2, (
|
||||
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
|
||||
)
|
||||
|
||||
|
||||
def test_node_before_interrupt_resume_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when a
|
||||
node runs before a node that calls interrupt(). This is the graph-API
|
||||
analog of test_task_before_interrupt_resume (entrypoint API)."""
|
||||
|
||||
class State(TypedDict):
|
||||
topics: list[str]
|
||||
answers: Annotated[list[str], operator.add]
|
||||
|
||||
def setup(state: State) -> dict:
|
||||
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answers = []
|
||||
for topic in state["topics"]:
|
||||
answer = interrupt(f"Whats the answer for {topic}?")
|
||||
answers.append(answer)
|
||||
return {"answers": answers}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("setup", setup)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "setup")
|
||||
.add_edge("setup", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - setup runs, then ask interrupts on the first topic
|
||||
result = graph.invoke({"topics": ["a", "b"], "answers": []}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = graph.invoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should complete
|
||||
result = graph.invoke(Command(resume="answer2"), config=config)
|
||||
assert result == {
|
||||
"topics": ["topic 1", "topic 2"],
|
||||
"answers": ["answer1", "answer2"],
|
||||
}
|
||||
|
||||
|
||||
def test_multiple_nodes_before_interrupt_resume_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when
|
||||
multiple nodes run before a node that calls interrupt(). This is the
|
||||
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
|
||||
|
||||
class State(TypedDict):
|
||||
value: int
|
||||
answer: str
|
||||
|
||||
def step_a(state: State) -> dict:
|
||||
return {"value": state["value"] + 1}
|
||||
|
||||
def step_b(state: State) -> dict:
|
||||
return {"value": state["value"] * 2}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answer = interrupt(f"Result so far is {state['value']}. What next?")
|
||||
return {"answer": answer}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("step_a", step_a)
|
||||
.add_node("step_b", step_b)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "step_a")
|
||||
.add_edge("step_a", "step_b")
|
||||
.add_edge("step_b", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - step_a and step_b run, then ask interrupts
|
||||
result = graph.invoke({"value": 5, "answer": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume - should complete
|
||||
result = graph.invoke(Command(resume="continue"), config=config)
|
||||
assert result == {"value": 12, "answer": "continue"}
|
||||
|
||||
|
||||
def test_node_before_multiple_interrupt_cycles_graph_api(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that a node running before an interrupt node does not interfere
|
||||
with multiple interrupt/resume cycles in a StateGraph."""
|
||||
|
||||
class State(TypedDict):
|
||||
count: int
|
||||
data: str
|
||||
|
||||
def prepare(state: State) -> dict:
|
||||
return {"count": state["count"] + 10}
|
||||
|
||||
def multi_interrupt(state: State) -> dict:
|
||||
first = interrupt("First question?")
|
||||
second = interrupt("Second question?")
|
||||
return {"data": f"{first},{second}"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("prepare", prepare)
|
||||
.add_node("multi_interrupt", multi_interrupt)
|
||||
.add_edge(START, "prepare")
|
||||
.add_edge("prepare", "multi_interrupt")
|
||||
.add_edge("multi_interrupt", END)
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - prepare runs, multi_interrupt hits first interrupt
|
||||
result = graph.invoke({"count": 0, "data": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "First question?"
|
||||
|
||||
# Resume first interrupt - hits second interrupt
|
||||
result = graph.invoke(Command(resume="first_answer"), config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Second question?"
|
||||
|
||||
# Resume second interrupt - completes
|
||||
result = graph.invoke(Command(resume="second_answer"), config=config)
|
||||
assert result == {"count": 10, "data": "first_answer,second_answer"}
|
||||
|
||||
|
||||
def test_double_interrupt_subgraph(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
class AgentState(TypedDict):
|
||||
input: str
|
||||
|
||||
@@ -7920,6 +7920,290 @@ async def test_interrupts_in_tasks_surfaced_once(
|
||||
assert result[1] == "Added Will!"
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_task_before_interrupt_resume(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when a @task runs
|
||||
before interrupt-producing tasks in an @entrypoint.
|
||||
|
||||
The @task wrapper on both setup and ask is essential to reproduce the bug:
|
||||
- @task on setup triggers a mid-step put_writes (creating a new pending_writes list)
|
||||
- @task on ask means interrupt() runs in a child scratchpad that must
|
||||
delegate to the parent for null resume consumption tracking
|
||||
"""
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(number_of_topics: int) -> dict:
|
||||
@task
|
||||
async def setup() -> int:
|
||||
return number_of_topics
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
n = await setup()
|
||||
|
||||
answers = []
|
||||
for i in range(n):
|
||||
q = f"Whats the answer for topic {i + 1}?"
|
||||
answers.append(await ask(q))
|
||||
|
||||
return {"answers": answers}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get first interrupt
|
||||
result = await workflow.ainvoke(2, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = await workflow.ainvoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should get final result
|
||||
result = await workflow.ainvoke(Command(resume="answer2"), config=config)
|
||||
assert result == {"answers": ["answer1", "answer2"]}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_multiple_tasks_before_interrupt_resume(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly when multiple @tasks
|
||||
run before an interrupt-producing task in an @entrypoint."""
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(inputs: dict) -> dict:
|
||||
@task
|
||||
async def step_a(x: int) -> int:
|
||||
return x + 1
|
||||
|
||||
@task
|
||||
async def step_b(x: int) -> int:
|
||||
return x * 2
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
a = await step_a(inputs["x"])
|
||||
b = await step_b(a)
|
||||
|
||||
answer = await ask(f"Result so far is {b}. What next?")
|
||||
|
||||
return {"computed": b, "answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - should get interrupt
|
||||
result = await workflow.ainvoke({"x": 5}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume
|
||||
result = await workflow.ainvoke(Command(resume="continue"), config=config)
|
||||
assert result == {"computed": 12, "answer": "continue"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_no_redundant_put_writes_for_cached_task(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Cached @tasks on resume must not trigger redundant put_writes."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from langgraph.pregel._loop import PregelLoop
|
||||
|
||||
@task
|
||||
async def setup(x: int) -> int:
|
||||
return x
|
||||
|
||||
@task
|
||||
async def ask(question: str) -> str:
|
||||
return interrupt(question)
|
||||
|
||||
@entrypoint(checkpointer=async_checkpointer)
|
||||
async def workflow(x: int) -> dict:
|
||||
n = await setup(x)
|
||||
answer = await ask(f"q{n}")
|
||||
return {"answer": answer}
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
result = await workflow.ainvoke(1, config=config)
|
||||
assert "__interrupt__" in result
|
||||
|
||||
put_writes_task_ids: list[str] = []
|
||||
orig = PregelLoop.put_writes
|
||||
|
||||
def spy(self, task_id, writes):
|
||||
put_writes_task_ids.append(task_id)
|
||||
return orig(self, task_id, writes)
|
||||
|
||||
with patch.object(PregelLoop, "put_writes", spy):
|
||||
result = await workflow.ainvoke(Command(resume="ans"), config=config)
|
||||
|
||||
assert result == {"answer": "ans"}
|
||||
# Count unique non-null task IDs that got put_writes.
|
||||
# Should be exactly 2: the ask task and the entrypoint task.
|
||||
# If 3, the cached setup task is being redundantly re-committed.
|
||||
non_null = set(tid for tid in put_writes_task_ids if not tid.startswith("00000000"))
|
||||
assert len(non_null) == 2, (
|
||||
f"Expected 2 task IDs in put_writes (ask + entrypoint), got {len(non_null)}"
|
||||
)
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_node_before_interrupt_resume_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when a
|
||||
node runs before a node that calls interrupt(). This is the graph-API
|
||||
analog of test_task_before_interrupt_resume (entrypoint API)."""
|
||||
|
||||
class State(TypedDict):
|
||||
topics: list[str]
|
||||
answers: Annotated[list[str], operator.add]
|
||||
|
||||
def setup(state: State) -> dict:
|
||||
return {"topics": [f"topic {i + 1}" for i in range(len(state["topics"]))]}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answers = []
|
||||
for topic in state["topics"]:
|
||||
answer = interrupt(f"Whats the answer for {topic}?")
|
||||
answers.append(answer)
|
||||
return {"answers": answers}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("setup", setup)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "setup")
|
||||
.add_edge("setup", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - setup runs, then ask interrupts on the first topic
|
||||
result = await graph.ainvoke({"topics": ["a", "b"], "answers": []}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 1?"
|
||||
|
||||
# Resume with answer for topic 1 - should get second interrupt
|
||||
result = await graph.ainvoke(Command(resume="answer1"), config=config)
|
||||
assert "__interrupt__" in result, f"Expected interrupt for topic 2, got: {result}"
|
||||
assert len(result["__interrupt__"]) == 1
|
||||
assert result["__interrupt__"][0].value == "Whats the answer for topic 2?"
|
||||
|
||||
# Resume with answer for topic 2 - should complete
|
||||
result = await graph.ainvoke(Command(resume="answer2"), config=config)
|
||||
assert result == {
|
||||
"topics": ["topic 1", "topic 2"],
|
||||
"answers": ["answer1", "answer2"],
|
||||
}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_multiple_nodes_before_interrupt_resume_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that Command(resume=value) works correctly in a StateGraph when
|
||||
multiple nodes run before a node that calls interrupt(). This is the
|
||||
graph-API analog of test_multiple_tasks_before_interrupt_resume."""
|
||||
|
||||
class State(TypedDict):
|
||||
value: int
|
||||
answer: str
|
||||
|
||||
def step_a(state: State) -> dict:
|
||||
return {"value": state["value"] + 1}
|
||||
|
||||
def step_b(state: State) -> dict:
|
||||
return {"value": state["value"] * 2}
|
||||
|
||||
def ask(state: State) -> dict:
|
||||
answer = interrupt(f"Result so far is {state['value']}. What next?")
|
||||
return {"answer": answer}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("step_a", step_a)
|
||||
.add_node("step_b", step_b)
|
||||
.add_node("ask", ask)
|
||||
.add_edge(START, "step_a")
|
||||
.add_edge("step_a", "step_b")
|
||||
.add_edge("step_b", "ask")
|
||||
.add_edge("ask", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - step_a and step_b run, then ask interrupts
|
||||
result = await graph.ainvoke({"value": 5, "answer": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Result so far is 12. What next?"
|
||||
|
||||
# Resume - should complete
|
||||
result = await graph.ainvoke(Command(resume="continue"), config=config)
|
||||
assert result == {"value": 12, "answer": "continue"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_node_before_multiple_interrupt_cycles_graph_api(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Test that a node running before an interrupt node does not interfere
|
||||
with multiple interrupt/resume cycles in a StateGraph."""
|
||||
|
||||
class State(TypedDict):
|
||||
count: int
|
||||
data: str
|
||||
|
||||
def prepare(state: State) -> dict:
|
||||
return {"count": state["count"] + 10}
|
||||
|
||||
def multi_interrupt(state: State) -> dict:
|
||||
first = interrupt("First question?")
|
||||
second = interrupt("Second question?")
|
||||
return {"data": f"{first},{second}"}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("prepare", prepare)
|
||||
.add_node("multi_interrupt", multi_interrupt)
|
||||
.add_edge(START, "prepare")
|
||||
.add_edge("prepare", "multi_interrupt")
|
||||
.add_edge("multi_interrupt", END)
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First invocation - prepare runs, multi_interrupt hits first interrupt
|
||||
result = await graph.ainvoke({"count": 0, "data": ""}, config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "First question?"
|
||||
|
||||
# Resume first interrupt - hits second interrupt
|
||||
result = await graph.ainvoke(Command(resume="first_answer"), config=config)
|
||||
assert "__interrupt__" in result
|
||||
assert result["__interrupt__"][0].value == "Second question?"
|
||||
|
||||
# Resume second interrupt - completes
|
||||
result = await graph.ainvoke(Command(resume="second_answer"), config=config)
|
||||
assert result == {"count": 10, "data": "first_answer,second_answer"}
|
||||
|
||||
|
||||
async def test_pregel_loop_refcount():
|
||||
gc.collect()
|
||||
try:
|
||||
|
||||
Generated
+2
-2
@@ -1367,7 +1367,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1735,7 +1735,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -922,7 +922,7 @@ class ToolNode(RunnableCallable):
|
||||
raise TypeError(msg)
|
||||
|
||||
# Inject state, store, and runtime right before invocation
|
||||
injected_call = self._inject_tool_args(call, request.runtime)
|
||||
injected_call = self._inject_tool_args(call, request.runtime, tool)
|
||||
call_args = {**injected_call, "type": "tool_call"}
|
||||
|
||||
try:
|
||||
@@ -1075,7 +1075,7 @@ class ToolNode(RunnableCallable):
|
||||
raise TypeError(msg)
|
||||
|
||||
# Inject state, store, and runtime right before invocation
|
||||
injected_call = self._inject_tool_args(call, request.runtime)
|
||||
injected_call = self._inject_tool_args(call, request.runtime, tool)
|
||||
call_args = {**injected_call, "type": "tool_call"}
|
||||
|
||||
try:
|
||||
@@ -1281,6 +1281,7 @@ class ToolNode(RunnableCallable):
|
||||
self,
|
||||
tool_call: ToolCall,
|
||||
tool_runtime: ToolRuntime,
|
||||
tool: BaseTool | None = None,
|
||||
) -> ToolCall:
|
||||
"""Inject graph state, store, and runtime into tool call arguments.
|
||||
|
||||
@@ -1299,6 +1300,9 @@ class ToolNode(RunnableCallable):
|
||||
Must contain 'name', 'args', 'id', and 'type' fields.
|
||||
tool_runtime: The ToolRuntime instance containing all runtime context
|
||||
(state, config, store, context, stream_writer) to inject into tools.
|
||||
tool: Optional tool instance. When provided, allows injection for
|
||||
dynamically registered tools that are not in self.tools_by_name
|
||||
(e.g., tools added via middleware's wrap_tool_call).
|
||||
|
||||
Returns:
|
||||
A new ToolCall dictionary with the same structure as the input but with
|
||||
@@ -1312,10 +1316,12 @@ class ToolNode(RunnableCallable):
|
||||
This method is called automatically during tool execution. It should not
|
||||
be called from outside the `ToolNode`.
|
||||
"""
|
||||
if tool_call["name"] not in self.tools_by_name:
|
||||
return tool_call
|
||||
|
||||
injected = self._injected_args.get(tool_call["name"])
|
||||
if not injected and tool is not None:
|
||||
# For dynamically registered tools (e.g., added via middleware's
|
||||
# wrap_tool_call), compute injected args on-the-fly since they
|
||||
# were not present during ToolNode initialization.
|
||||
injected = _get_all_injected_args(tool)
|
||||
if not injected:
|
||||
return tool_call
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1902,3 +1902,109 @@ async def test_tool_node_tool_runtime_generic() -> None:
|
||||
assert tool_message.type == "tool"
|
||||
assert tool_message.content == "test_info"
|
||||
assert tool_message.tool_call_id == "call_1"
|
||||
|
||||
|
||||
def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call() -> None:
|
||||
"""Test that ToolRuntime is injected for dynamically registered tools.
|
||||
|
||||
Regression test for https://github.com/langchain-ai/langchain/issues/35305.
|
||||
When a tool is dynamically provided via wrap_tool_call (not registered at
|
||||
ToolNode init time), ToolRuntime should still be injected into the tool.
|
||||
"""
|
||||
|
||||
@dec_tool
|
||||
def static_tool(x: int) -> str:
|
||||
"""A static tool registered at init."""
|
||||
return f"static: {x}"
|
||||
|
||||
@dec_tool
|
||||
def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
|
||||
"""A dynamic tool that needs ToolRuntime injection."""
|
||||
return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"
|
||||
|
||||
def wrap_tool_call(request, execute):
|
||||
"""Middleware that swaps in a dynamic tool."""
|
||||
if request.tool_call["name"] == "dynamic_tool_with_runtime":
|
||||
# Override tool to the dynamic one (not registered at init)
|
||||
new_request = request.override(tool=dynamic_tool_with_runtime)
|
||||
return execute(new_request)
|
||||
return execute(request)
|
||||
|
||||
# ToolNode only knows about static_tool at init time
|
||||
tool_node = ToolNode(
|
||||
[static_tool],
|
||||
wrap_tool_call=wrap_tool_call,
|
||||
)
|
||||
|
||||
# Verify the dynamic tool is NOT in the tool node's registered tools
|
||||
assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name
|
||||
|
||||
# Call the dynamic tool
|
||||
tool_call = {
|
||||
"name": "dynamic_tool_with_runtime",
|
||||
"args": {"x": 42},
|
||||
"id": "call_dynamic_1",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("", tool_calls=[tool_call])
|
||||
result = tool_node.invoke(
|
||||
{"messages": [msg]},
|
||||
config=_create_config_with_runtime(),
|
||||
)
|
||||
|
||||
# ToolRuntime should be injected and the tool should execute successfully
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_1"
|
||||
assert tool_message.tool_call_id == "call_dynamic_1"
|
||||
|
||||
|
||||
async def test_tool_node_inject_runtime_dynamic_tool_via_wrap_tool_call_async() -> None:
|
||||
"""Test that ToolRuntime is injected for dynamically registered tools (async).
|
||||
|
||||
Async version of the regression test for
|
||||
https://github.com/langchain-ai/langchain/issues/35305.
|
||||
"""
|
||||
|
||||
@dec_tool
|
||||
def static_tool(x: int) -> str:
|
||||
"""A static tool registered at init."""
|
||||
return f"static: {x}"
|
||||
|
||||
@dec_tool
|
||||
async def dynamic_tool_with_runtime(x: int, runtime: ToolRuntime) -> str:
|
||||
"""A dynamic async tool that needs ToolRuntime injection."""
|
||||
return f"dynamic: x={x}, tool_call_id={runtime.tool_call_id}"
|
||||
|
||||
async def awrap_tool_call(request, execute):
|
||||
"""Async middleware that swaps in a dynamic tool."""
|
||||
if request.tool_call["name"] == "dynamic_tool_with_runtime":
|
||||
new_request = request.override(tool=dynamic_tool_with_runtime)
|
||||
return await execute(new_request)
|
||||
return await execute(request)
|
||||
|
||||
# ToolNode only knows about static_tool at init time
|
||||
tool_node = ToolNode(
|
||||
[static_tool],
|
||||
awrap_tool_call=awrap_tool_call,
|
||||
)
|
||||
|
||||
# Verify the dynamic tool is NOT in the tool node's registered tools
|
||||
assert "dynamic_tool_with_runtime" not in tool_node.tools_by_name
|
||||
|
||||
# Call the dynamic tool
|
||||
tool_call = {
|
||||
"name": "dynamic_tool_with_runtime",
|
||||
"args": {"x": 42},
|
||||
"id": "call_dynamic_2",
|
||||
"type": "tool_call",
|
||||
}
|
||||
msg = AIMessage("", tool_calls=[tool_call])
|
||||
result = await tool_node.ainvoke(
|
||||
{"messages": [msg]},
|
||||
config=_create_config_with_runtime(),
|
||||
)
|
||||
|
||||
# ToolRuntime should be injected and the tool should execute successfully
|
||||
tool_message = result["messages"][-1]
|
||||
assert tool_message.content == "dynamic: x=42, tool_call_id=call_dynamic_2"
|
||||
assert tool_message.tool_call_id == "call_dynamic_2"
|
||||
|
||||
Generated
+2
-2
@@ -268,7 +268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -489,7 +489,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -72,8 +72,13 @@ class Auth:
|
||||
assert params.get("metadata", {}).get("owner") == "allowed_user"
|
||||
|
||||
@auth.on.store
|
||||
async def authorize_store(ctx: Auth.types.AuthContext, value: Auth.types.on):
|
||||
assert ctx.user.identity in value["namespace"], "Not authorized"
|
||||
async def authorize_store(ctx: Auth.types.AuthContext, value: Auth.types.on.store.value):
|
||||
# Automatically scope all store operations to the user's namespace.
|
||||
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
|
||||
assert isinstance(namespace, tuple)
|
||||
if not namespace or namespace[0] != ctx.user.identity:
|
||||
namespace = (ctx.user.identity, *namespace)
|
||||
value["namespace"] = namespace
|
||||
```
|
||||
|
||||
???+ note "Request Processing Flow"
|
||||
@@ -170,13 +175,32 @@ class Auth:
|
||||
```
|
||||
|
||||
Auth for the `store` resource is a bit different since its structure is developer defined.
|
||||
You typically want to enforce user creds in the namespace.
|
||||
You typically want to scope store operations by rewriting the namespace to include the user's identity.
|
||||
The `value` dict is mutable — changes to `value["namespace"]` are used by the server for the actual operation.
|
||||
|
||||
```python
|
||||
@auth.on.store
|
||||
async def check_store_access(ctx: AuthContext, value: Auth.types.on) -> bool:
|
||||
# Assuming you structure your store like (store.aput((user_id, application_context), key, value))
|
||||
assert value["namespace"][0] == ctx.user.identity
|
||||
async def authorize_store(ctx: AuthContext, value: Auth.types.on.store.value):
|
||||
# Automatically scope all store operations to the user's namespace.
|
||||
namespace = tuple(value["namespace"]) if value.get("namespace") else ()
|
||||
assert isinstance(namespace, tuple)
|
||||
if not namespace or namespace[0] != ctx.user.identity:
|
||||
namespace = (ctx.user.identity, *namespace)
|
||||
value["namespace"] = namespace
|
||||
```
|
||||
|
||||
You can also register handlers for specific store actions:
|
||||
|
||||
```python
|
||||
@auth.on.store.put
|
||||
async def on_put(ctx: AuthContext, value: Auth.types.on.store.put.value):
|
||||
# value has typed fields: namespace, key, value, index
|
||||
...
|
||||
|
||||
@auth.on.store.get
|
||||
async def on_get(ctx: AuthContext, value: Auth.types.on.store.get.value):
|
||||
# value has typed fields: namespace, key
|
||||
...
|
||||
```
|
||||
"""
|
||||
# These are accessed by the API. Changes to their names or types is
|
||||
@@ -483,9 +507,85 @@ class _CronsOn(
|
||||
Search = types.CronsSearch
|
||||
|
||||
|
||||
class _StoreActionOn(typing.Generic[T]):
|
||||
"""Decorator for registering a handler for a specific store action."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
auth: Auth,
|
||||
action: typing.Literal["put", "get", "search", "delete", "list_namespaces"],
|
||||
value: type[T],
|
||||
) -> None:
|
||||
self.auth = auth
|
||||
self.action = action
|
||||
self.value = value
|
||||
|
||||
def __call__(self, fn: _ActionHandler[T]) -> _ActionHandler[T]:
|
||||
_validate_handler(fn)
|
||||
_register_handler(self.auth, "store", self.action, fn)
|
||||
return fn
|
||||
|
||||
|
||||
class _StoreOn:
|
||||
def __init__(self, auth: Auth) -> None:
|
||||
self._auth = auth
|
||||
self.put = _StoreActionOn(auth, "put", types.StorePut)
|
||||
"""Register a handler for store put operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.put
|
||||
async def on_store_put(ctx: Auth.types.AuthContext, value: Auth.types.on.store.put.value):
|
||||
# Scope puts to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.get = _StoreActionOn(auth, "get", types.StoreGet)
|
||||
"""Register a handler for store get operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.get
|
||||
async def on_store_get(ctx: Auth.types.AuthContext, value: Auth.types.on.store.get.value):
|
||||
# Scope gets to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.search = _StoreActionOn(auth, "search", types.StoreSearch)
|
||||
"""Register a handler for store search operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.search
|
||||
async def on_store_search(ctx: Auth.types.AuthContext, value: Auth.types.on.store.search.value):
|
||||
# Scope searches to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.delete = _StoreActionOn(auth, "delete", types.StoreDelete)
|
||||
"""Register a handler for store delete operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.delete
|
||||
async def on_store_delete(ctx: Auth.types.AuthContext, value: Auth.types.on.store.delete.value):
|
||||
# Scope deletes to user's namespace
|
||||
...
|
||||
```
|
||||
"""
|
||||
self.list_namespaces = _StoreActionOn(
|
||||
auth, "list_namespaces", types.StoreListNamespaces
|
||||
)
|
||||
"""Register a handler for store list_namespaces operations.
|
||||
|
||||
???+ example "Example"
|
||||
```python
|
||||
@auth.on.store.list_namespaces
|
||||
async def on_list_ns(ctx: Auth.types.AuthContext, value: Auth.types.on.store.list_namespaces.value):
|
||||
# Scope namespace listing to user's prefix
|
||||
...
|
||||
```
|
||||
"""
|
||||
|
||||
@typing.overload
|
||||
def __call__(
|
||||
|
||||
@@ -402,7 +402,7 @@ class AuthContext(BaseAuthContext):
|
||||
"list_namespaces",
|
||||
]
|
||||
"""The action being performed on the resource.
|
||||
|
||||
|
||||
Most resources support the following actions:
|
||||
- create: Create a new resource
|
||||
- read: Read information about a resource
|
||||
@@ -411,8 +411,10 @@ class AuthContext(BaseAuthContext):
|
||||
- search: Search for resources
|
||||
|
||||
The store supports the following actions:
|
||||
- put: Add or update a document in the store
|
||||
- get: Get a document from the store
|
||||
- put: Add or update an item in the store
|
||||
- get: Get an item from the store
|
||||
- search: Search for items within a namespace prefix
|
||||
- delete: Delete an item from the store
|
||||
- list_namespaces: List the namespaces in the store
|
||||
"""
|
||||
|
||||
@@ -851,20 +853,34 @@ class CronsSearch(typing.TypedDict, total=False):
|
||||
|
||||
|
||||
class StoreGet(typing.TypedDict):
|
||||
"""Operation to retrieve a specific item by its namespace and key."""
|
||||
"""Operation to retrieve a specific item by its namespace and key.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that uniquely identifies the item's location."""
|
||||
"""Hierarchical path that uniquely identifies the item's location.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the item within its specific namespace."""
|
||||
|
||||
|
||||
class StoreSearch(typing.TypedDict):
|
||||
"""Operation to search for items within a specified namespace hierarchy."""
|
||||
"""Operation to search for items within a specified namespace hierarchy.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Prefix filter for defining the search scope."""
|
||||
"""Prefix filter for defining the search scope.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
filter: dict[str, typing.Any] | None
|
||||
"""Key-value pairs for filtering results based on exact matches or comparison operators."""
|
||||
@@ -876,14 +892,22 @@ class StoreSearch(typing.TypedDict):
|
||||
"""Number of matching items to skip for pagination."""
|
||||
|
||||
query: str | None
|
||||
"""Naturalj language search query for semantic search capabilities."""
|
||||
"""Natural language search query for semantic search capabilities."""
|
||||
|
||||
|
||||
class StoreListNamespaces(typing.TypedDict):
|
||||
"""Operation to list and filter namespaces in the store."""
|
||||
"""Operation to list and filter namespaces in the store.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` (the prefix)
|
||||
to enforce access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...] | None
|
||||
"""Prefix filter namespaces."""
|
||||
"""Prefix filter for namespaces. Can be `None` if no prefix was provided.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping. When `None`,
|
||||
handlers should set it to `(user_id,)` to scope listing to the user's namespaces.
|
||||
"""
|
||||
|
||||
suffix: tuple[str, ...] | None
|
||||
"""Optional conditions for filtering namespaces."""
|
||||
@@ -903,10 +927,17 @@ class StoreListNamespaces(typing.TypedDict):
|
||||
|
||||
|
||||
class StorePut(typing.TypedDict):
|
||||
"""Operation to store, update, or delete an item in the store."""
|
||||
"""Operation to store, update, or delete an item in the store.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that identifies the location of the item."""
|
||||
"""Hierarchical path that identifies the location of the item.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the item within its namespace."""
|
||||
@@ -919,10 +950,17 @@ class StorePut(typing.TypedDict):
|
||||
|
||||
|
||||
class StoreDelete(typing.TypedDict):
|
||||
"""Operation to delete an item from the store."""
|
||||
"""Operation to delete an item from the store.
|
||||
|
||||
This dict is mutable — auth handlers can modify `namespace` to enforce
|
||||
access scoping (e.g., prepending the user's identity).
|
||||
"""
|
||||
|
||||
namespace: tuple[str, ...]
|
||||
"""Hierarchical path that uniquely identifies the item's location."""
|
||||
"""Hierarchical path that uniquely identifies the item's location.
|
||||
|
||||
Auth handlers can modify this to enforce per-user scoping.
|
||||
"""
|
||||
|
||||
key: str
|
||||
"""Unique identifier for the item within its specific namespace."""
|
||||
|
||||
Generated
+2
-2
@@ -265,7 +265,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -396,7 +396,7 @@ test = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { editable = "../prebuilt" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Reference in New Issue
Block a user