mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-18 07:37:55 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d6e7f2161 | ||
|
|
c81c13533e | ||
|
|
448a763779 | ||
|
|
230927fb3a |
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/deploy-redirects.yml'
|
||||
# llms.txt is fetched from docs.langchain.com at build time, so redeploy on a
|
||||
# schedule to pick up docs changes that never touch this repo.
|
||||
schedule:
|
||||
- cron: '17 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -12,13 +12,26 @@ 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 http.client
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
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"
|
||||
|
||||
# The docs site regenerates this index on every deploy, so fetching it here
|
||||
# keeps the published llms.txt from drifting. The URL is a hardcoded constant,
|
||||
# never built from input, and both it and the post-redirect URL are checked
|
||||
# against ALLOWED_LLMS_HOST before anything is read.
|
||||
CANONICAL_LLMS_URL = "https://docs.langchain.com/oss/python/langgraph/llms.txt"
|
||||
ALLOWED_LLMS_HOST = "docs.langchain.com"
|
||||
LLMS_FETCH_TIMEOUT = 30
|
||||
LLMS_MAX_BYTES = 1_000_000
|
||||
|
||||
HTML_TEMPLATE = """<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -75,6 +88,64 @@ CATCHALL_404_TEMPLATE = """<!doctype html>
|
||||
"""
|
||||
|
||||
|
||||
def is_allowed_llms_url(url):
|
||||
"""Return True if url is HTTPS on the one host we accept content from."""
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
return parsed.scheme == "https" and parsed.hostname == ALLOWED_LLMS_HOST
|
||||
|
||||
|
||||
def fetch_canonical_llms_txt():
|
||||
"""Return the published LangGraph index, or None if it cannot be used.
|
||||
|
||||
Returning None leaves the caller on the committed docs/llms.txt, so a
|
||||
docs.langchain.com outage degrades to a stale file rather than a broken
|
||||
deploy or a published error page.
|
||||
"""
|
||||
if not is_allowed_llms_url(CANONICAL_LLMS_URL):
|
||||
print(f"Refusing to fetch {CANONICAL_LLMS_URL}: host not allowed")
|
||||
return None
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen( # noqa: S310 - constant, allowlisted URL
|
||||
CANONICAL_LLMS_URL, timeout=LLMS_FETCH_TIMEOUT
|
||||
) as response:
|
||||
# urlopen follows redirects, so re-check where it actually landed.
|
||||
if not is_allowed_llms_url(response.url):
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: redirected to {response.url}")
|
||||
return None
|
||||
body = response.read(LLMS_MAX_BYTES + 1)
|
||||
# A connection dropped mid-body raises http.client.IncompleteRead, which
|
||||
# descends from HTTPException rather than OSError, so catching only the
|
||||
# urllib and OS errors would let it escape and fail the whole deploy.
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
http.client.HTTPException,
|
||||
TimeoutError,
|
||||
OSError,
|
||||
) as exc:
|
||||
print(f"Could not fetch {CANONICAL_LLMS_URL}: {type(exc).__name__}: {exc}")
|
||||
return None
|
||||
|
||||
if len(body) > LLMS_MAX_BYTES:
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: larger than {LLMS_MAX_BYTES} bytes")
|
||||
return None
|
||||
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: not valid UTF-8: {exc}")
|
||||
return None
|
||||
|
||||
# An index opens with a markdown heading and links to the docs site. A
|
||||
# body that does not is an error page or a truncated response, not content
|
||||
# worth publishing.
|
||||
if not text.startswith("# ") or f"https://{ALLOWED_LLMS_HOST}/" not in text:
|
||||
print(f"Refusing {CANONICAL_LLMS_URL}: does not look like an llms.txt index")
|
||||
return None
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def generate_redirects():
|
||||
script_dir = Path(__file__).parent
|
||||
output_dir = script_dir / "_site"
|
||||
@@ -126,14 +197,19 @@ def generate_redirects():
|
||||
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
|
||||
# llms.txt can't be redirected via HTML, so publish the docs site's own
|
||||
# generated index. The committed copy is only a fallback.
|
||||
llms_txt = fetch_canonical_llms_txt()
|
||||
if llms_txt is not None:
|
||||
(output_dir / "llms.txt").write_text(llms_txt)
|
||||
print(f"Fetched: {output_dir / 'llms.txt'} (from {CANONICAL_LLMS_URL})")
|
||||
else:
|
||||
src = script_dir / "llms.txt"
|
||||
if src.exists():
|
||||
dst = output_dir / static_file
|
||||
dst.write_text(src.read_text())
|
||||
print(f"Copied: {dst}")
|
||||
(output_dir / "llms.txt").write_text(src.read_text())
|
||||
print(f"Copied: {output_dir / 'llms.txt'} (fallback, may be stale)")
|
||||
else:
|
||||
print("No llms.txt fetched and no committed fallback; skipping")
|
||||
|
||||
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
||||
|
||||
|
||||
+46
-32
@@ -1,35 +1,49 @@
|
||||
# LangGraph
|
||||
# Docs by LangChain: LangGraph (Python)
|
||||
|
||||
LangGraph documentation has moved to docs.langchain.com.
|
||||
> Markdown index of the LangGraph (Python) documentation.
|
||||
|
||||
## Overview
|
||||
## LangGraph (Python)
|
||||
|
||||
- [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).
|
||||
- [Memory](https://docs.langchain.com/oss/python/langgraph/add-memory.md)
|
||||
- [Build a custom RAG agent with LangGraph](https://docs.langchain.com/oss/python/langgraph/agentic-rag.md)
|
||||
- [Application structure](https://docs.langchain.com/oss/python/langgraph/application-structure.md)
|
||||
- [Backward compatibility](https://docs.langchain.com/oss/python/langgraph/backward-compatibility.md)
|
||||
- [Case studies](https://docs.langchain.com/oss/python/langgraph/case-studies.md)
|
||||
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-js.md)
|
||||
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-py.md)
|
||||
- [Checkpointers](https://docs.langchain.com/oss/python/langgraph/checkpointers.md)
|
||||
- [Choosing between Graph and Functional APIs](https://docs.langchain.com/oss/python/langgraph/choosing-apis.md)
|
||||
- [Deployment](https://docs.langchain.com/oss/python/langgraph/deploy.md)
|
||||
- [GRAPH_RECURSION_LIMIT](https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT.md)
|
||||
- [INVALID_CHAT_HISTORY](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CHAT_HISTORY.md)
|
||||
- [INVALID_CONCURRENT_GRAPH_UPDATE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md)
|
||||
- [INVALID_GRAPH_NODE_RETURN_VALUE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
|
||||
- [MISSING_CHECKPOINTER](https://docs.langchain.com/oss/python/langgraph/errors/MISSING_CHECKPOINTER.md)
|
||||
- [MULTIPLE_SUBGRAPHS](https://docs.langchain.com/oss/python/langgraph/errors/MULTIPLE_SUBGRAPHS.md)
|
||||
- [Event streaming](https://docs.langchain.com/oss/python/langgraph/event-streaming.md)
|
||||
- [Fault tolerance](https://docs.langchain.com/oss/python/langgraph/fault-tolerance.md)
|
||||
- [Custom stream channels](https://docs.langchain.com/oss/python/langgraph/frontend/custom-stream-channels.md)
|
||||
- [Graph execution](https://docs.langchain.com/oss/python/langgraph/frontend/graph-execution.md)
|
||||
- [Overview](https://docs.langchain.com/oss/python/langgraph/frontend/overview.md)
|
||||
- [Functional API overview](https://docs.langchain.com/oss/python/langgraph/functional-api.md)
|
||||
- [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api.md)
|
||||
- [Install LangGraph](https://docs.langchain.com/oss/python/langgraph/install.md)
|
||||
- [Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts.md)
|
||||
- [Run a local server](https://docs.langchain.com/oss/python/langgraph/local-server.md)
|
||||
- [LangSmith Observability](https://docs.langchain.com/oss/python/langgraph/observability.md)
|
||||
- [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview.md)
|
||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence.md)
|
||||
- [LangGraph runtime](https://docs.langchain.com/oss/python/langgraph/pregel.md)
|
||||
- [Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart.md)
|
||||
- [Build a custom SQL agent](https://docs.langchain.com/oss/python/langgraph/sql-agent.md)
|
||||
- [Stores](https://docs.langchain.com/oss/python/langgraph/stores.md)
|
||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming.md)
|
||||
- [LangSmith Studio](https://docs.langchain.com/oss/python/langgraph/studio.md)
|
||||
- [Test](https://docs.langchain.com/oss/python/langgraph/test.md)
|
||||
- [Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph.md)
|
||||
- [Agent Chat UI](https://docs.langchain.com/oss/python/langgraph/ui.md)
|
||||
- [Use the functional API](https://docs.langchain.com/oss/python/langgraph/use-functional-api.md)
|
||||
- [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api.md)
|
||||
- [Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs.md)
|
||||
- [Use time-travel](https://docs.langchain.com/oss/python/langgraph/use-time-travel.md)
|
||||
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents.md)
|
||||
|
||||
@@ -12,15 +12,23 @@ from typing import (
|
||||
Generic,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
TypeVar,
|
||||
final,
|
||||
overload,
|
||||
)
|
||||
from warnings import warn
|
||||
|
||||
from langchain_core.messages import AnyMessage
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
|
||||
from typing_extensions import NotRequired, TypeAliasType, TypedDict, Unpack, deprecated
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import (
|
||||
NotRequired,
|
||||
TypeAliasType,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Unpack,
|
||||
deprecated,
|
||||
)
|
||||
from xxhash import xxh3_128_hexdigest
|
||||
|
||||
from langgraph._internal._cache import default_cache_key
|
||||
@@ -36,6 +44,7 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10, LangGraphDeprecatedS
|
||||
# when used in standalone type aliases.
|
||||
StateT = TypeVar("StateT")
|
||||
OutputT = TypeVar("OutputT")
|
||||
ResponseT = TypeVar("ResponseT", default=Any)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.pregel.protocol import PregelProtocol
|
||||
@@ -572,7 +581,7 @@ _DEFAULT_INTERRUPT_ID = "placeholder-id"
|
||||
|
||||
@final
|
||||
@dataclass(init=False, slots=True)
|
||||
class Interrupt:
|
||||
class Interrupt(Generic[ResponseT]):
|
||||
"""Information about an interrupt that occurred in a node.
|
||||
|
||||
!!! version-added "Added in version 0.2.24"
|
||||
@@ -596,13 +605,22 @@ class Interrupt:
|
||||
id: str
|
||||
"""The ID of the interrupt. Can be used to resume the interrupt directly."""
|
||||
|
||||
response_schema: type[ResponseT] | dict[str, Any] | None = None
|
||||
"""Schema for the value expected when resuming this interrupt, if the graph provided one.
|
||||
|
||||
A surfaced interrupt carries JSON Schema (a `dict`); `type[ResponseT]` records the
|
||||
Python type at construction so `Interrupt[Decision]` is meaningful to type checkers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: Any,
|
||||
id: str = _DEFAULT_INTERRUPT_ID,
|
||||
*,
|
||||
response_schema: type[ResponseT] | dict[str, Any] | None = None,
|
||||
**deprecated_kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> None:
|
||||
self.value = value
|
||||
self.response_schema = response_schema
|
||||
|
||||
if (
|
||||
(ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING
|
||||
@@ -614,8 +632,18 @@ class Interrupt:
|
||||
self.id = id
|
||||
|
||||
@classmethod
|
||||
def from_ns(cls, value: Any, ns: str) -> Interrupt:
|
||||
return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))
|
||||
def from_ns(
|
||||
cls,
|
||||
value: Any,
|
||||
ns: str,
|
||||
*,
|
||||
response_schema: type[ResponseT] | dict[str, Any] | None = None,
|
||||
) -> Interrupt[ResponseT]:
|
||||
return cls(
|
||||
value=value,
|
||||
id=xxh3_128_hexdigest(ns.encode()),
|
||||
response_schema=response_schema,
|
||||
)
|
||||
|
||||
@property
|
||||
@deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None)
|
||||
@@ -848,7 +876,17 @@ class Command(Generic[N], ToolOutputMixin):
|
||||
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
|
||||
|
||||
|
||||
def interrupt(value: Any) -> Any:
|
||||
@overload
|
||||
def interrupt(value: Any, *, response_schema: type[ResponseT]) -> ResponseT: ...
|
||||
|
||||
|
||||
@overload
|
||||
def interrupt(value: Any, *, response_schema: dict[str, Any] | None = None) -> Any: ...
|
||||
|
||||
|
||||
def interrupt(
|
||||
value: Any, *, response_schema: dict[str, Any] | type | None = None
|
||||
) -> Any:
|
||||
"""Interrupt the graph with a resumable exception from within a node.
|
||||
|
||||
The `interrupt` function enables human-in-the-loop workflows by pausing graph
|
||||
@@ -918,7 +956,7 @@ def interrupt(value: Any) -> Any:
|
||||
for chunk in graph.stream({\"foo\": \"abc\"}, config):
|
||||
print(chunk)
|
||||
|
||||
# > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06'),)}
|
||||
# > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06', response_schema=None),)}
|
||||
|
||||
command = Command(resume=\"some input from a human!!!\")
|
||||
|
||||
@@ -931,12 +969,20 @@ def interrupt(value: Any) -> Any:
|
||||
|
||||
Args:
|
||||
value: The value to surface to the client when the graph is interrupted.
|
||||
response_schema: Optional schema for the value expected on resume, surfaced
|
||||
to clients so they can render a typed input form. Accepts a JSON Schema
|
||||
`dict` (used as-is, resume values are not validated), or a Pydantic model
|
||||
class, `TypedDict`, or dataclass, which are converted to JSON Schema for
|
||||
clients and used to validate the resume value; the validated object is
|
||||
what `interrupt` returns.
|
||||
|
||||
Returns:
|
||||
Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation
|
||||
Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation,
|
||||
validated against `response_schema` when one that supports validation was given.
|
||||
|
||||
Raises:
|
||||
GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client.
|
||||
pydantic.ValidationError: When a resume value does not match a Pydantic model, `TypedDict`, or dataclass `response_schema`.
|
||||
"""
|
||||
from langgraph._internal._constants import (
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
@@ -948,27 +994,36 @@ def interrupt(value: Any) -> Any:
|
||||
from langgraph.errors import GraphInterrupt
|
||||
|
||||
conf = get_config()["configurable"]
|
||||
adapter = (
|
||||
None
|
||||
if response_schema is None or isinstance(response_schema, dict)
|
||||
else TypeAdapter(response_schema)
|
||||
)
|
||||
# track interrupt index
|
||||
scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
|
||||
idx = scratchpad.interrupt_counter()
|
||||
# find previous resume values
|
||||
if scratchpad.resume:
|
||||
if idx < len(scratchpad.resume):
|
||||
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
|
||||
return scratchpad.resume[idx]
|
||||
v = scratchpad.resume[idx]
|
||||
validated = adapter.validate_python(v) if adapter else v
|
||||
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume[: idx + 1])])
|
||||
return validated
|
||||
# find current resume value
|
||||
v = scratchpad.get_null_resume(True)
|
||||
if v is not None:
|
||||
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
|
||||
validated = adapter.validate_python(v) if adapter else v
|
||||
scratchpad.resume.append(v)
|
||||
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
|
||||
return v
|
||||
return validated
|
||||
# no resume value found
|
||||
raise GraphInterrupt(
|
||||
(
|
||||
Interrupt.from_ns(
|
||||
value=value,
|
||||
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
|
||||
response_schema=adapter.json_schema() if adapter else response_schema,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
from langgraph.types import Durability
|
||||
from langgraph.types import Command, Durability, Interrupt, interrupt
|
||||
from tests.any_str import AnyStr
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
@@ -90,3 +95,150 @@ async def test_interruption_without_state_updates_async(
|
||||
assert (await graph.aget_state(thread)).next == ()
|
||||
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
|
||||
assert n_checkpoints == (5 if durability != "exit" else 3)
|
||||
|
||||
|
||||
class Decision(BaseModel):
|
||||
approved: bool
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class DecisionDict(TypedDict):
|
||||
approved: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecisionData:
|
||||
approved: bool
|
||||
|
||||
|
||||
RAW_SCHEMA = {"type": "object", "properties": {"approved": {"type": "boolean"}}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("response_schema", "expected_schema", "expected_answer"),
|
||||
[
|
||||
(None, None, {"approved": True, "extra": 1}),
|
||||
(RAW_SCHEMA, RAW_SCHEMA, {"approved": True, "extra": 1}),
|
||||
(Decision, Decision.model_json_schema(), Decision(approved=True)),
|
||||
(
|
||||
DecisionDict,
|
||||
{
|
||||
"properties": {"approved": {"title": "Approved", "type": "boolean"}},
|
||||
"required": ["approved"],
|
||||
"title": "DecisionDict",
|
||||
"type": "object",
|
||||
},
|
||||
{"approved": True},
|
||||
),
|
||||
(
|
||||
DecisionData,
|
||||
{
|
||||
"properties": {"approved": {"title": "Approved", "type": "boolean"}},
|
||||
"required": ["approved"],
|
||||
"title": "DecisionData",
|
||||
"type": "object",
|
||||
},
|
||||
DecisionData(approved=True),
|
||||
),
|
||||
],
|
||||
ids=["none", "raw_dict", "pydantic", "typeddict", "dataclass"],
|
||||
)
|
||||
def test_interrupt_response_schema(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
response_schema: Any,
|
||||
expected_schema: dict[str, Any] | None,
|
||||
expected_answer: Any,
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
answer: Any
|
||||
|
||||
def node(state: State) -> State:
|
||||
return {
|
||||
"answer": interrupt(
|
||||
{"question": "approve?"}, response_schema=response_schema
|
||||
)
|
||||
}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("node", node)
|
||||
.add_edge(START, "node")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
expected = Interrupt(
|
||||
value={"question": "approve?"}, id=AnyStr(), response_schema=expected_schema
|
||||
)
|
||||
|
||||
assert list(graph.stream({"answer": None}, config)) == [
|
||||
{"__interrupt__": (expected,)}
|
||||
]
|
||||
assert graph.get_state(config).tasks[0].interrupts == (expected,)
|
||||
assert graph.invoke(Command(resume={"approved": True, "extra": 1}), config) == {
|
||||
"answer": expected_answer
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resume_style", ["null", "map"])
|
||||
def test_interrupt_response_schema_rejects_invalid_resume(
|
||||
sync_checkpointer: BaseCheckpointSaver, resume_style: str
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
answer: Any
|
||||
|
||||
def node(state: State) -> State:
|
||||
return {"answer": interrupt("approve?", response_schema=Decision)}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("node", node)
|
||||
.add_edge(START, "node")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"answer": None}, config)
|
||||
[pending] = graph.get_state(config).tasks[0].interrupts
|
||||
|
||||
def resume(value: dict[str, Any]) -> Command:
|
||||
return Command(resume=value if resume_style == "null" else {pending.id: value})
|
||||
|
||||
with pytest.raises(ValidationError, match="approved"):
|
||||
graph.invoke(resume({"approved": "nope"}), config)
|
||||
|
||||
assert graph.invoke(resume({"approved": False}), config) == {
|
||||
"answer": Decision(approved=False)
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resume_style", ["null", "id_map"])
|
||||
def test_interrupt_response_schema_invalid_resume_after_earlier_interrupt(
|
||||
sync_checkpointer: BaseCheckpointSaver, resume_style: str
|
||||
) -> None:
|
||||
class State(TypedDict):
|
||||
answer: Any
|
||||
|
||||
def node(state: State) -> State:
|
||||
first = interrupt("first")
|
||||
second = interrupt("approve?", response_schema=Decision)
|
||||
return {"answer": [first, second]}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("node", node)
|
||||
.add_edge(START, "node")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = {"configurable": {"thread_id": "1"}}
|
||||
graph.invoke({"answer": None}, config)
|
||||
graph.invoke(Command(resume="ok"), config)
|
||||
[pending] = graph.get_state(config).tasks[0].interrupts
|
||||
|
||||
def resume(value: dict[str, Any]) -> Command:
|
||||
return Command(resume=value if resume_style == "null" else {pending.id: value})
|
||||
|
||||
with pytest.raises(ValidationError, match="approved"):
|
||||
graph.invoke(resume({"approved": "nope"}), config)
|
||||
|
||||
assert graph.invoke(resume({"approved": True}), config) == {
|
||||
"answer": ["ok", Decision(approved=True)]
|
||||
}
|
||||
|
||||
@@ -5583,6 +5583,7 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
|
||||
"interrupts": [
|
||||
{
|
||||
"id": AnyStr(),
|
||||
"response_schema": None,
|
||||
"value": "test",
|
||||
},
|
||||
],
|
||||
@@ -5627,6 +5628,7 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
|
||||
"interrupts": (
|
||||
{
|
||||
"id": AnyStr(),
|
||||
"response_schema": None,
|
||||
"value": "test",
|
||||
},
|
||||
),
|
||||
|
||||
Generated
+3
-3
@@ -3404,11 +3404,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.8.4"
|
||||
version = "2.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -173,8 +173,18 @@ class RunModule:
|
||||
config: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
langsmith_tracing: LangSmithTracing | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`).
|
||||
|
||||
Args:
|
||||
input: the run input; omitted from the wire payload when None.
|
||||
config: the run config; omitted when None.
|
||||
metadata: run metadata; omitted when None.
|
||||
langsmith_tracing: tracing options; omitted when None.
|
||||
context: per-run static context; omitted from the wire payload
|
||||
when None (server applies its default context behavior).
|
||||
"""
|
||||
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
||||
if input is not None:
|
||||
params["input"] = input
|
||||
@@ -184,6 +194,8 @@ class RunModule:
|
||||
params["metadata"] = metadata
|
||||
if langsmith_tracing is not None:
|
||||
params["langsmith_tracer"] = langsmith_tracing
|
||||
if context is not None:
|
||||
params["context"] = context
|
||||
loop = asyncio.get_running_loop()
|
||||
gate: asyncio.Future[None] = loop.create_future()
|
||||
self._owner._run_start_ready = gate
|
||||
@@ -216,6 +228,7 @@ class RunModule:
|
||||
response: Any,
|
||||
*,
|
||||
interrupt_id: str | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reply to a server-side interrupt and resume the run.
|
||||
|
||||
@@ -224,6 +237,8 @@ class RunModule:
|
||||
wire (protocol field name).
|
||||
interrupt_id: optional explicit id. When omitted, requires exactly
|
||||
one outstanding interrupt and uses its id.
|
||||
context: optional per-run static context for the resumed run;
|
||||
forwarded with the `input.respond` command when non-None.
|
||||
|
||||
Raises:
|
||||
RuntimeError: no outstanding interrupts; `interrupt_id` is None but
|
||||
@@ -266,6 +281,8 @@ class RunModule:
|
||||
"namespace": match["namespace"],
|
||||
"response": response,
|
||||
}
|
||||
if context is not None:
|
||||
params["context"] = context
|
||||
return await self._owner._send_command("input.respond", params)
|
||||
|
||||
|
||||
|
||||
@@ -216,8 +216,18 @@ class SyncRunModule:
|
||||
config: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
langsmith_tracing: LangSmithTracing | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`)."""
|
||||
"""Send `run.start` to the server. Returns the result (`{"run_id": ...}`).
|
||||
|
||||
Args:
|
||||
input: the run input; omitted from the wire payload when None.
|
||||
config: the run config; omitted when None.
|
||||
metadata: run metadata; omitted when None.
|
||||
langsmith_tracing: tracing options; omitted when None.
|
||||
context: per-run static context; omitted from the wire payload
|
||||
when None (server applies its default context behavior).
|
||||
"""
|
||||
params: dict[str, Any] = {"assistant_id": self._owner.assistant_id}
|
||||
if input is not None:
|
||||
params["input"] = input
|
||||
@@ -227,6 +237,8 @@ class SyncRunModule:
|
||||
params["metadata"] = metadata
|
||||
if langsmith_tracing is not None:
|
||||
params["langsmith_tracer"] = langsmith_tracing
|
||||
if context is not None:
|
||||
params["context"] = context
|
||||
result = self._owner._send_command("run.start", params)
|
||||
self._owner._run_seen = True
|
||||
controller = self._owner._controller
|
||||
@@ -239,6 +251,7 @@ class SyncRunModule:
|
||||
response: Any,
|
||||
*,
|
||||
interrupt_id: str | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Reply to a server-side interrupt and resume the run.
|
||||
|
||||
@@ -246,6 +259,8 @@ class SyncRunModule:
|
||||
response: the response value forwarded as `params.response` on the wire.
|
||||
interrupt_id: optional explicit id. When omitted, requires exactly one
|
||||
outstanding interrupt.
|
||||
context: optional per-run static context for the resumed run;
|
||||
forwarded with the `input.respond` command when non-None.
|
||||
|
||||
Raises:
|
||||
RuntimeError: no outstanding interrupts; `interrupt_id` is None but
|
||||
@@ -282,6 +297,8 @@ class SyncRunModule:
|
||||
"namespace": match["namespace"],
|
||||
"response": response,
|
||||
}
|
||||
if context is not None:
|
||||
params["context"] = context
|
||||
return self._owner._send_command("input.respond", params)
|
||||
|
||||
|
||||
|
||||
@@ -295,6 +295,8 @@ class Interrupt(TypedDict):
|
||||
"""The value associated with the interrupt."""
|
||||
id: str
|
||||
"""The ID of the interrupt. Can be used to resume the interrupt."""
|
||||
response_schema: NotRequired[dict[str, Any]]
|
||||
"""JSON Schema for the value expected when resuming this interrupt, if the graph provided one."""
|
||||
|
||||
|
||||
class Thread(TypedDict):
|
||||
|
||||
@@ -439,6 +439,64 @@ def test_sync_run_start_sends_command():
|
||||
}
|
||||
|
||||
|
||||
def test_sync_run_start_forwards_context():
|
||||
fake = SyncFakeServer()
|
||||
fake.script([lifecycle_completed_event(seq=1)])
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
thread.run.start(input={"x": 1}, context={"user_id": "u-1"})
|
||||
|
||||
assert fake.received_commands[0]["params"]["context"] == {"user_id": "u-1"}
|
||||
|
||||
|
||||
def test_sync_run_start_omits_context_when_not_provided():
|
||||
fake = SyncFakeServer()
|
||||
fake.script([lifecycle_completed_event(seq=1)])
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
thread.run.start(input={"x": 1})
|
||||
|
||||
assert "context" not in fake.received_commands[0]["params"]
|
||||
|
||||
|
||||
def test_sync_run_respond_forwards_context():
|
||||
fake = SyncFakeServer()
|
||||
fake.script([lifecycle_completed_event(seq=1)])
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
thread.run.start(input={})
|
||||
thread.interrupts.append(
|
||||
{"interrupt_id": "i-1", "value": None, "namespace": []}
|
||||
)
|
||||
thread.interrupted = True
|
||||
thread.run.respond("yes", context={"user_id": "u-1"})
|
||||
|
||||
command = fake.received_commands[-1]
|
||||
assert command["method"] == "input.respond"
|
||||
assert command["params"]["context"] == {"user_id": "u-1"}
|
||||
|
||||
|
||||
def test_sync_run_respond_omits_context_when_not_provided():
|
||||
fake = SyncFakeServer()
|
||||
fake.script([lifecycle_completed_event(seq=1)])
|
||||
with httpx.Client(transport=fake.transport, base_url="http://test") as raw:
|
||||
threads = SyncThreadsClient(SyncHttpClient(raw))
|
||||
with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
thread.run.start(input={})
|
||||
thread.interrupts.append(
|
||||
{"interrupt_id": "i-1", "value": None, "namespace": []}
|
||||
)
|
||||
thread.interrupted = True
|
||||
thread.run.respond("yes")
|
||||
|
||||
command = fake.received_commands[-1]
|
||||
assert command["method"] == "input.respond"
|
||||
assert "context" not in command["params"]
|
||||
|
||||
|
||||
def test_sync_events_iterates_raw_events():
|
||||
|
||||
fake = SyncFakeServer()
|
||||
|
||||
@@ -311,6 +311,28 @@ async def test_run_start_forwards_config_metadata_and_langsmith_tracing():
|
||||
}
|
||||
|
||||
|
||||
async def test_run_start_forwards_context():
|
||||
fake = FakeServer()
|
||||
transport = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
await thread.run.start(input={"x": 1}, context={"user_id": "u-1"})
|
||||
params = fake.received_commands[0]["params"]
|
||||
assert params["context"] == {"user_id": "u-1"}
|
||||
|
||||
|
||||
async def test_run_start_omits_context_when_not_provided():
|
||||
fake = FakeServer()
|
||||
transport = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
await thread.run.start(input={"x": 1})
|
||||
params = fake.received_commands[0]["params"]
|
||||
assert "context" not in params
|
||||
|
||||
|
||||
async def test_run_start_raises_outside_context_manager():
|
||||
|
||||
async with httpx.AsyncClient(base_url="http://test") as raw:
|
||||
@@ -616,6 +638,38 @@ async def test_run_respond_dispatches_input_respond_command():
|
||||
assert command["params"]["namespace"] == []
|
||||
|
||||
|
||||
async def test_run_respond_forwards_context():
|
||||
fake = FakeServer()
|
||||
asgi = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
await thread.run.start(input={})
|
||||
thread.interrupts.append(
|
||||
{"interrupt_id": "i-1", "value": None, "namespace": []}
|
||||
)
|
||||
thread.interrupted = True
|
||||
await thread.run.respond("yes", context={"user_id": "u-1"})
|
||||
params = fake.received_commands[-1]["params"]
|
||||
assert params["context"] == {"user_id": "u-1"}
|
||||
|
||||
|
||||
async def test_run_respond_omits_context_when_not_provided():
|
||||
fake = FakeServer()
|
||||
asgi = httpx.ASGITransport(app=fake.app)
|
||||
async with httpx.AsyncClient(transport=asgi, base_url="http://test") as raw:
|
||||
threads = ThreadsClient(HttpClient(raw))
|
||||
async with threads.stream(thread_id="t-1", assistant_id="agent") as thread:
|
||||
await thread.run.start(input={})
|
||||
thread.interrupts.append(
|
||||
{"interrupt_id": "i-1", "value": None, "namespace": []}
|
||||
)
|
||||
thread.interrupted = True
|
||||
await thread.run.respond("yes")
|
||||
params = fake.received_commands[-1]["params"]
|
||||
assert "context" not in params
|
||||
|
||||
|
||||
async def test_run_respond_with_explicit_interrupt_id():
|
||||
fake = FakeServer()
|
||||
asgi = httpx.ASGITransport(app=fake.app)
|
||||
|
||||
Reference in New Issue
Block a user