mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-07 18:27:52 +02:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9accc21f0f | ||
|
|
ad75ffdd8d | ||
|
|
85a311087d | ||
|
|
cf5c64590b | ||
|
|
fa1cf5bc0f | ||
|
|
36f94ba038 | ||
|
|
eaf0d0cf10 | ||
|
|
4b19e2f51c | ||
|
|
a49d999ef9 | ||
|
|
e6f66c5949 | ||
|
|
a4a28e908d | ||
|
|
7ff8141438 | ||
|
|
5ddcb3e27c | ||
|
|
a2f5e8c558 | ||
|
|
0a820b908b | ||
|
|
49299465c7 | ||
|
|
ca9b3c6ff6 | ||
|
|
65648e2019 | ||
|
|
cfc595ac62 |
@@ -53,5 +53,3 @@ sdk-js (standalone)
|
||||
```
|
||||
|
||||
Changes to a library may impact all of its dependents shown above.
|
||||
|
||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
||||
|
||||
@@ -53,5 +53,3 @@ sdk-js (standalone)
|
||||
```
|
||||
|
||||
Changes to a library may impact all of its dependents shown above.
|
||||
|
||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
from langgraph.constants import END, START
|
||||
from langgraph.graph.message import (
|
||||
MessageGraph,
|
||||
MessagesState,
|
||||
add_messages,
|
||||
validate_messages_append_only,
|
||||
)
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.graph.state import StateGraph
|
||||
|
||||
__all__ = (
|
||||
@@ -14,5 +9,4 @@ __all__ = (
|
||||
"add_messages",
|
||||
"MessagesState",
|
||||
"MessageGraph",
|
||||
"validate_messages_append_only",
|
||||
)
|
||||
|
||||
@@ -31,7 +31,6 @@ __all__ = (
|
||||
"MessagesState",
|
||||
"MessageGraph",
|
||||
"REMOVE_ALL_MESSAGES",
|
||||
"validate_messages_append_only",
|
||||
)
|
||||
|
||||
Messages = list[MessageLikeRepresentation] | MessageLikeRepresentation
|
||||
@@ -245,89 +244,6 @@ def add_messages(
|
||||
return merged
|
||||
|
||||
|
||||
def validate_messages_append_only(
|
||||
input: dict[str, Any], current_state: dict[str, Any]
|
||||
) -> None:
|
||||
"""Validates that incoming messages are append-only (no updates or removals).
|
||||
|
||||
This validator can be passed to `compile(validate_input=...)` to enforce that
|
||||
external inputs only append new messages and never update or remove existing ones.
|
||||
|
||||
Internal node updates bypass this validation, allowing nodes to perform operations
|
||||
like message compaction or cleanup without restriction.
|
||||
|
||||
Args:
|
||||
input: The raw input dictionary being provided externally
|
||||
current_state: The current state loaded from the checkpoint
|
||||
|
||||
Raises:
|
||||
ValueError: If the input attempts to update or remove existing messages
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langgraph.graph import StateGraph, MessagesState
|
||||
from langgraph.graph.message import validate_messages_append_only
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("chatbot", my_chatbot_node)
|
||||
builder.set_entry_point("chatbot")
|
||||
builder.set_finish_point("chatbot")
|
||||
|
||||
# Compile with validator to enforce append-only at the boundary
|
||||
graph = builder.compile(validate_input=validate_messages_append_only)
|
||||
|
||||
# This works - adding new messages
|
||||
graph.invoke({"messages": [("user", "Hello")]})
|
||||
|
||||
# This raises ValueError - trying to update existing message
|
||||
graph.invoke({"messages": [HumanMessage(content="Modified", id="existing-id")]})
|
||||
```
|
||||
"""
|
||||
# Only validate if input contains messages
|
||||
if "messages" not in input:
|
||||
return
|
||||
|
||||
# Get current messages from state
|
||||
current_messages = current_state.get("messages", [])
|
||||
|
||||
# Build a set of existing message IDs
|
||||
existing_ids: set[str] = set()
|
||||
for msg in current_messages:
|
||||
if hasattr(msg, "id") and msg.id is not None:
|
||||
existing_ids.add(msg.id)
|
||||
|
||||
# Coerce input messages to list
|
||||
input_messages = input["messages"]
|
||||
if not isinstance(input_messages, list):
|
||||
input_messages = [input_messages]
|
||||
|
||||
# Convert to messages to get proper IDs (let conversion errors propagate)
|
||||
converted_messages = convert_to_messages(input_messages)
|
||||
|
||||
# Check each input message
|
||||
for msg in converted_messages:
|
||||
# Check for REMOVE_ALL_MESSAGES
|
||||
if isinstance(msg, RemoveMessage) and msg.id == REMOVE_ALL_MESSAGES:
|
||||
raise ValueError(
|
||||
"Cannot remove all messages in append_only mode. "
|
||||
"External inputs must only append new messages."
|
||||
)
|
||||
|
||||
# Check for removals of existing messages
|
||||
if isinstance(msg, RemoveMessage) and msg.id in existing_ids:
|
||||
raise ValueError(
|
||||
f"Cannot remove existing message with ID '{msg.id}' in append_only mode. "
|
||||
"External inputs must only append new messages."
|
||||
)
|
||||
|
||||
# Check for updates of existing messages
|
||||
if not isinstance(msg, RemoveMessage) and msg.id and msg.id in existing_ids:
|
||||
raise ValueError(
|
||||
f"Cannot update existing message with ID '{msg.id}' in append_only mode. "
|
||||
"External inputs must only append new messages."
|
||||
)
|
||||
|
||||
|
||||
@deprecated(
|
||||
"MessageGraph is deprecated in langgraph 1.0.0, to be removed in 2.0.0. Please use StateGraph with a `messages` key instead.",
|
||||
category=None,
|
||||
|
||||
@@ -1042,7 +1042,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: All | list[str] | None = None,
|
||||
debug: bool = False,
|
||||
name: str | None = None,
|
||||
validate_input: Callable[[Any, dict[str, Any]], None] | None = None,
|
||||
) -> CompiledStateGraph[StateT, ContextT, InputT, OutputT]:
|
||||
"""Compiles the `StateGraph` into a `CompiledStateGraph` object.
|
||||
|
||||
@@ -1075,34 +1074,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
interrupt_after: An optional list of node names to interrupt after.
|
||||
debug: A flag indicating whether to enable debug mode.
|
||||
name: The name to use for the compiled graph.
|
||||
validate_input: Optional validation function for external inputs.
|
||||
The function receives `(input, current_state)` and should raise an
|
||||
exception if the input is invalid, or return normally if valid.
|
||||
|
||||
This validation only applies to external updates (from `invoke()`, `stream()`,
|
||||
`update_state()`, or `Command.update`) - internal node updates bypass this
|
||||
validation, allowing trusted nodes to perform privileged operations.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import validate_messages_append_only
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
# ... add nodes ...
|
||||
|
||||
# Use built-in validator
|
||||
graph = builder.compile(
|
||||
validate_input=validate_messages_append_only
|
||||
)
|
||||
|
||||
# Or custom validator
|
||||
def my_validator(input: dict, current_state: dict) -> None:
|
||||
if "forbidden_key" in input:
|
||||
raise ValueError("Forbidden key detected")
|
||||
|
||||
graph = builder.compile(validate_input=my_validator)
|
||||
```
|
||||
|
||||
Returns:
|
||||
CompiledStateGraph: The compiled `StateGraph`.
|
||||
@@ -1163,7 +1134,6 @@ class StateGraph(Generic[StateT, ContextT, InputT, OutputT]):
|
||||
store=store,
|
||||
cache=cache,
|
||||
name=name or "LangGraph",
|
||||
validate_input=validate_input,
|
||||
)
|
||||
|
||||
compiled.attach_node(START, None)
|
||||
|
||||
@@ -226,7 +226,6 @@ class PregelLoop:
|
||||
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
cache_policy: CachePolicy | None = None,
|
||||
validate_input: Callable[[Any, dict[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
self.stream = stream
|
||||
self.config = config
|
||||
@@ -251,7 +250,6 @@ class PregelLoop:
|
||||
self.retry_policy = retry_policy
|
||||
self.cache_policy = cache_policy
|
||||
self.durability = durability
|
||||
self.validate_input = validate_input
|
||||
if self.stream is not None and CONFIG_KEY_STREAM in config[CONF]:
|
||||
self.stream = DuplexStream(self.stream, config[CONF][CONFIG_KEY_STREAM])
|
||||
scratchpad: PregelScratchpad | None = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
|
||||
@@ -639,14 +637,6 @@ class PregelLoop:
|
||||
|
||||
# map command to writes
|
||||
if isinstance(self.input, Command):
|
||||
# Validate command update if validator is provided
|
||||
if self.validate_input is not None and self.input.update:
|
||||
current_state = read_channels(self.channels, self.output_keys)
|
||||
try:
|
||||
self.validate_input(self.input.update, current_state)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Input validation failed: {str(e)}") from e
|
||||
|
||||
if (resume := self.input.resume) is not None:
|
||||
if not self.checkpointer:
|
||||
raise RuntimeError(
|
||||
@@ -701,13 +691,6 @@ class PregelLoop:
|
||||
)
|
||||
# map inputs to channel updates
|
||||
elif input_writes := deque(map_input(input_keys, self.input)):
|
||||
# Validate input if validator is provided
|
||||
if self.validate_input is not None:
|
||||
current_state = read_channels(self.channels, self.output_keys)
|
||||
try:
|
||||
self.validate_input(self.input, current_state)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Input validation failed: {str(e)}") from e
|
||||
# discard any unfinished tasks from previous checkpoint
|
||||
discard_tasks = prepare_next_tasks(
|
||||
self.checkpoint,
|
||||
@@ -1002,7 +985,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
cache_policy: CachePolicy | None = None,
|
||||
validate_input: Callable[[Any, dict[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
input,
|
||||
@@ -1024,7 +1006,6 @@ class SyncPregelLoop(PregelLoop, AbstractContextManager):
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
durability=durability,
|
||||
validate_input=validate_input,
|
||||
)
|
||||
self.stack = ExitStack()
|
||||
if checkpointer:
|
||||
@@ -1180,7 +1161,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
migrate_checkpoint: Callable[[Checkpoint], None] | None = None,
|
||||
retry_policy: Sequence[RetryPolicy] = (),
|
||||
cache_policy: CachePolicy | None = None,
|
||||
validate_input: Callable[[Any, dict[str, Any]], None] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
input,
|
||||
@@ -1202,7 +1182,6 @@ class AsyncPregelLoop(PregelLoop, AbstractAsyncContextManager):
|
||||
retry_policy=retry_policy,
|
||||
cache_policy=cache_policy,
|
||||
durability=durability,
|
||||
validate_input=validate_input,
|
||||
)
|
||||
self.stack = AsyncExitStack()
|
||||
if checkpointer:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
||||
from dataclasses import fields, is_dataclass
|
||||
from typing import (
|
||||
Any,
|
||||
TypeVar,
|
||||
@@ -12,7 +11,6 @@ from uuid import UUID, uuid4
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import ChatGeneration, ChatGenerationChunk, LLMResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph.constants import TAG_HIDDEN, TAG_NOSTREAM
|
||||
@@ -28,17 +26,6 @@ T = TypeVar("T")
|
||||
Meta = tuple[tuple[str, ...], dict[str, Any]]
|
||||
|
||||
|
||||
def _state_values(obj: Any) -> Sequence[Any]:
|
||||
"""Extract top-level field values from a state object (dict, BaseModel, or dataclass)."""
|
||||
if isinstance(obj, dict):
|
||||
return list(obj.values())
|
||||
elif isinstance(obj, BaseModel):
|
||||
return [getattr(obj, k) for k in type(obj).model_fields]
|
||||
elif is_dataclass(obj) and not isinstance(obj, type):
|
||||
return [getattr(obj, f.name) for f in fields(obj)]
|
||||
return ()
|
||||
|
||||
|
||||
class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
"""A callback handler that implements stream_mode=messages.
|
||||
|
||||
@@ -103,14 +90,26 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
for value in response:
|
||||
if isinstance(value, BaseMessage):
|
||||
self._emit(meta, value, dedupe=True)
|
||||
else:
|
||||
for value in _state_values(response):
|
||||
elif isinstance(response, dict):
|
||||
for value in response.values():
|
||||
if isinstance(value, BaseMessage):
|
||||
self._emit(meta, value, dedupe=True)
|
||||
elif isinstance(value, Sequence):
|
||||
for item in value:
|
||||
if isinstance(item, BaseMessage):
|
||||
self._emit(meta, item, dedupe=True)
|
||||
elif hasattr(response, "__dir__") and callable(response.__dir__):
|
||||
for key in dir(response):
|
||||
try:
|
||||
value = getattr(response, key)
|
||||
if isinstance(value, BaseMessage):
|
||||
self._emit(meta, value, dedupe=True)
|
||||
elif isinstance(value, Sequence):
|
||||
for item in value:
|
||||
if isinstance(item, BaseMessage):
|
||||
self._emit(meta, item, dedupe=True)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def tap_output_aiter(
|
||||
self, run_id: UUID, output: AsyncIterator[T]
|
||||
@@ -204,15 +203,16 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
if not self.subgraphs and len(ns) > 0:
|
||||
return
|
||||
self.metadata[run_id] = (ns, metadata)
|
||||
for value in _state_values(inputs):
|
||||
if isinstance(value, BaseMessage):
|
||||
if value.id is not None:
|
||||
self.seen.add(value.id)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, str):
|
||||
for item in value:
|
||||
if isinstance(item, BaseMessage):
|
||||
if item.id is not None:
|
||||
self.seen.add(item.id)
|
||||
if isinstance(inputs, dict):
|
||||
for key, value in inputs.items():
|
||||
if isinstance(value, BaseMessage):
|
||||
if value.id is not None:
|
||||
self.seen.add(value.id)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, str):
|
||||
for item in value:
|
||||
if isinstance(item, BaseMessage):
|
||||
if item.id is not None:
|
||||
self.seen.add(item.id)
|
||||
|
||||
def on_chain_end(
|
||||
self,
|
||||
|
||||
@@ -565,7 +565,7 @@ def _call(
|
||||
if fut := next(
|
||||
(
|
||||
f
|
||||
for f, t in list(futures().items()) # type: ignore[union-attr]
|
||||
for f, t in futures().items() # type: ignore[union-attr]
|
||||
if t is not None and t == next_task.id
|
||||
),
|
||||
None,
|
||||
@@ -708,7 +708,7 @@ async def _acall_impl(
|
||||
if fut := next(
|
||||
(
|
||||
f
|
||||
for f, t in list(futures().items()) # type: ignore[union-attr]
|
||||
for f, t in futures().items() # type: ignore[union-attr]
|
||||
if t is not None and t == next_task.id
|
||||
),
|
||||
None,
|
||||
|
||||
@@ -652,7 +652,6 @@ class Pregel(
|
||||
config: RunnableConfig | None = None,
|
||||
trigger_to_nodes: Mapping[str, Sequence[str]] | None = None,
|
||||
name: str = "LangGraph",
|
||||
validate_input: Callable[[Any, dict[str, Any]], None] | None = None,
|
||||
**deprecated_kwargs: Unpack[DeprecatedKwargs],
|
||||
) -> None:
|
||||
if (
|
||||
@@ -699,7 +698,6 @@ class Pregel(
|
||||
self.config = config
|
||||
self.trigger_to_nodes = trigger_to_nodes or {}
|
||||
self.name = name
|
||||
self.validate_input = validate_input
|
||||
if auto_validate:
|
||||
self.validate()
|
||||
|
||||
@@ -2601,7 +2599,6 @@ class Pregel(
|
||||
migrate_checkpoint=self._migrate_checkpoint,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
validate_input=self.validate_input,
|
||||
) as loop:
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
@@ -2911,7 +2908,6 @@ class Pregel(
|
||||
migrate_checkpoint=self._migrate_checkpoint,
|
||||
retry_policy=self.retry_policy,
|
||||
cache_policy=self.cache_policy,
|
||||
validate_input=self.validate_input,
|
||||
) as loop:
|
||||
# create runner
|
||||
runner = PregelRunner(
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.7"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -20,7 +20,7 @@ from langgraph.graph.message import REMOVE_ALL_MESSAGES, MessagesState, push_mes
|
||||
from langgraph.graph.state import StateGraph
|
||||
from tests.messages import _AnyIdHumanMessage
|
||||
|
||||
CORE_MAJOR, CORE_MINOR, CORE_PATCH = (
|
||||
_, CORE_MINOR, CORE_PATCH = (
|
||||
int("".join(c for c in v if c.isdigit()))
|
||||
for v in langchain_core.__version__.split(".")
|
||||
)
|
||||
@@ -205,11 +205,7 @@ def test_messages_state(state_schema):
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
condition=not (
|
||||
(CORE_MAJOR == 0 and CORE_MINOR == 3 and CORE_PATCH >= 11)
|
||||
or (CORE_MAJOR == 0 and CORE_MINOR > 3)
|
||||
or CORE_MAJOR > 0
|
||||
),
|
||||
condition=not ((CORE_MINOR == 3 and CORE_PATCH >= 11) or CORE_MINOR > 3),
|
||||
reason="Requires langchain_core>=0.3.11.",
|
||||
)
|
||||
def test_messages_state_format_openai():
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Annotated, Any, Literal, get_type_hints
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import GenericFakeChatModel
|
||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
|
||||
from langchain_core.messages import AnyMessage
|
||||
from langchain_core.runnables import (
|
||||
RunnableConfig,
|
||||
RunnableLambda,
|
||||
@@ -1264,20 +1264,18 @@ def test_imp_task(
|
||||
}
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
result = [*graph.stream([0, 1], thread1, durability=durability)]
|
||||
# mapper tasks run concurrently so output order is non-deterministic
|
||||
assert sorted(result[:-1], key=lambda d: str(d)) == [
|
||||
assert [*graph.stream([0, 1], thread1, durability=durability)] == [
|
||||
{"mapper": "00"},
|
||||
{"mapper": "11"},
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="question",
|
||||
id=AnyStr(),
|
||||
),
|
||||
)
|
||||
},
|
||||
]
|
||||
assert result[-1] == {
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="question",
|
||||
id=AnyStr(),
|
||||
),
|
||||
)
|
||||
}
|
||||
assert mapper_calls == 2
|
||||
|
||||
assert graph.invoke(Command(resume="answer"), thread1, durability=durability) == [
|
||||
@@ -6980,93 +6978,6 @@ def test_stream_messages_dedupe_state(sync_checkpointer: BaseCheckpointSaver) ->
|
||||
assert chunks[0][1]["langgraph_node"] == "call_model"
|
||||
|
||||
|
||||
def test_stream_messages_dedupe_pydantic_subgraph_interrupt(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Pydantic BaseModel state should not cause duplicate messages when
|
||||
streaming from subgraphs that use interrupts. Regression test for a bug
|
||||
where ``on_chain_start`` only populated the ``seen`` set for dict inputs,
|
||||
skipping Pydantic model inputs entirely."""
|
||||
|
||||
class PydanticState(BaseModel):
|
||||
messages: Annotated[list[AnyMessage], add_messages] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
def subgraph_proposal(state) -> Command[Literal["subgraph_approval"]]:
|
||||
return Command(
|
||||
goto="subgraph_approval",
|
||||
update={"messages": [AIMessage(content="Proposal", id="proposal_msg")]},
|
||||
)
|
||||
|
||||
def subgraph_approval(state) -> Command[Literal["__end__"]]:
|
||||
resume_value = interrupt({"message": "Waiting for approval"})
|
||||
user_msg = resume_value.get("user_message", "")
|
||||
msgs = [HumanMessage(content=user_msg)] if user_msg else []
|
||||
return Command(goto="__end__", update={"messages": msgs})
|
||||
|
||||
subgraph = (
|
||||
StateGraph(PydanticState)
|
||||
.add_node("proposal", subgraph_proposal)
|
||||
.add_node("subgraph_approval", subgraph_approval)
|
||||
.add_edge(START, "proposal")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
def finalize(state) -> Command[Literal["__end__"]]:
|
||||
return Command(
|
||||
goto="__end__",
|
||||
update={"messages": [AIMessage(content="Finalized", id="finalize_msg")]},
|
||||
)
|
||||
|
||||
graph = (
|
||||
StateGraph(PydanticState)
|
||||
.add_node("subgraph", subgraph)
|
||||
.add_node("finalize", finalize)
|
||||
.add_edge(START, "subgraph")
|
||||
.add_edge("subgraph", "finalize")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
|
||||
thread1 = {"configurable": {"thread_id": "1"}}
|
||||
|
||||
# First stream: should hit interrupt after proposal
|
||||
chunks_req0 = [
|
||||
(ns, chunk)
|
||||
for ns, chunk in graph.stream(
|
||||
{"messages": [HumanMessage(content="Create a proposal")]},
|
||||
thread1,
|
||||
stream_mode="messages",
|
||||
subgraphs=True,
|
||||
)
|
||||
]
|
||||
|
||||
msg_ids_req0 = {chunk[0].id for _, chunk in chunks_req0}
|
||||
assert "proposal_msg" in msg_ids_req0
|
||||
|
||||
# Verify interrupted
|
||||
state = graph.get_state(thread1)
|
||||
assert state.next
|
||||
|
||||
# Second stream: resume — should NOT duplicate messages from first stream
|
||||
chunks_req1 = [
|
||||
(ns, chunk)
|
||||
for ns, chunk in graph.stream(
|
||||
Command(resume={"user_message": "Yes"}),
|
||||
thread1,
|
||||
stream_mode="messages",
|
||||
subgraphs=True,
|
||||
)
|
||||
]
|
||||
|
||||
msg_ids_req1 = {chunk[0].id for _, chunk in chunks_req1}
|
||||
assert "finalize_msg" in msg_ids_req1
|
||||
|
||||
# The key assertion: no message IDs from request 0 should appear in request 1
|
||||
duplicates = msg_ids_req0 & msg_ids_req1
|
||||
assert not duplicates, f"Duplicate message IDs across requests: {duplicates}"
|
||||
|
||||
|
||||
def test_interrupt_subgraph_reenter_checkpointer_true(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
|
||||
@@ -2262,20 +2262,18 @@ async def test_imp_task(
|
||||
|
||||
tracer = FakeTracer()
|
||||
thread1 = {"configurable": {"thread_id": "1"}, "callbacks": [tracer]}
|
||||
result = [c async for c in graph.astream([0, 1], thread1, durability=durability)]
|
||||
# mapper tasks run concurrently so output order is non-deterministic
|
||||
assert sorted(result[:-1], key=lambda d: str(d)) == [
|
||||
assert [c async for c in graph.astream([0, 1], thread1, durability=durability)] == [
|
||||
{"mapper": "00"},
|
||||
{"mapper": "11"},
|
||||
{
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="question",
|
||||
id=AnyStr(),
|
||||
),
|
||||
)
|
||||
},
|
||||
]
|
||||
assert result[-1] == {
|
||||
"__interrupt__": (
|
||||
Interrupt(
|
||||
value="question",
|
||||
id=AnyStr(),
|
||||
),
|
||||
)
|
||||
}
|
||||
assert mapper_calls == 2
|
||||
assert len(tracer.runs) == 1
|
||||
assert len(tracer.runs[0].child_runs) == 1
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Tests for validate_input with callable approach."""
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, RemoveMessage
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.graph.message import MessagesState, validate_messages_append_only
|
||||
from langgraph.types import Command
|
||||
|
||||
|
||||
def test_validate_messages_append_only_allows_new():
|
||||
"""Test that validate_messages_append_only allows adding new messages."""
|
||||
|
||||
def chatbot(state: MessagesState) -> MessagesState:
|
||||
return {"messages": [AIMessage(content="Response")]}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("chatbot", chatbot)
|
||||
builder.set_entry_point("chatbot")
|
||||
builder.set_finish_point("chatbot")
|
||||
|
||||
graph = builder.compile(validate_input=validate_messages_append_only)
|
||||
|
||||
result = graph.invoke({"messages": [HumanMessage(content="Hello", id="1")]})
|
||||
assert len(result["messages"]) == 2
|
||||
|
||||
|
||||
def test_validate_messages_append_only_blocks_updates():
|
||||
"""Test that validate_messages_append_only blocks message updates."""
|
||||
|
||||
def chatbot(state: MessagesState) -> MessagesState:
|
||||
return {"messages": [AIMessage(content="Response")]}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("chatbot", chatbot)
|
||||
builder.set_entry_point("chatbot")
|
||||
builder.set_finish_point("chatbot")
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = builder.compile(
|
||||
checkpointer=checkpointer, validate_input=validate_messages_append_only
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "test"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="Hello", id="1")]}, config)
|
||||
|
||||
# Try to update - should fail
|
||||
with pytest.raises(ValueError, match="Cannot update existing message"):
|
||||
graph.invoke({"messages": [HumanMessage(content="Modified", id="1")]}, config)
|
||||
|
||||
|
||||
def test_validate_messages_append_only_blocks_removals():
|
||||
"""Test that validate_messages_append_only blocks removals."""
|
||||
|
||||
def chatbot(state: MessagesState) -> MessagesState:
|
||||
return {"messages": []}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("chatbot", chatbot)
|
||||
builder.set_entry_point("chatbot")
|
||||
builder.set_finish_point("chatbot")
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = builder.compile(
|
||||
checkpointer=checkpointer, validate_input=validate_messages_append_only
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "test"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="Hello", id="1")]}, config)
|
||||
|
||||
# Try to remove - should fail
|
||||
with pytest.raises(ValueError, match="Cannot remove existing message"):
|
||||
graph.invoke({"messages": [RemoveMessage(id="1")]}, config)
|
||||
|
||||
|
||||
def test_validate_with_command_update():
|
||||
"""Test that Command.update is also validated."""
|
||||
|
||||
def chatbot(state: MessagesState) -> MessagesState:
|
||||
return {"messages": [AIMessage(content="Response")]}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("chatbot", chatbot)
|
||||
builder.set_entry_point("chatbot")
|
||||
builder.set_finish_point("chatbot")
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = builder.compile(
|
||||
checkpointer=checkpointer, validate_input=validate_messages_append_only
|
||||
)
|
||||
|
||||
config = {"configurable": {"thread_id": "test"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="Hello", id="1")]}, config)
|
||||
|
||||
# Try to update via Command - should fail
|
||||
with pytest.raises(ValueError, match="Cannot update existing message"):
|
||||
graph.invoke(
|
||||
Command(update={"messages": [HumanMessage(content="Modified", id="1")]}),
|
||||
config,
|
||||
)
|
||||
|
||||
|
||||
def test_custom_validator():
|
||||
"""Test with custom validation function."""
|
||||
|
||||
def my_validator(input: dict, current_state: dict) -> None:
|
||||
if "forbidden" in str(input):
|
||||
raise ValueError("Forbidden content")
|
||||
|
||||
def node(state: MessagesState) -> MessagesState:
|
||||
return {"messages": [AIMessage(content="OK")]}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("node", node)
|
||||
builder.set_entry_point("node")
|
||||
builder.set_finish_point("node")
|
||||
|
||||
graph = builder.compile(validate_input=my_validator)
|
||||
|
||||
# Should work
|
||||
graph.invoke({"messages": [("user", "Hello")]})
|
||||
|
||||
# Should fail
|
||||
with pytest.raises(ValueError, match="Forbidden content"):
|
||||
graph.invoke({"messages": [("user", "forbidden word")]})
|
||||
|
||||
|
||||
def test_node_bypasses_validation():
|
||||
"""Test that internal node operations bypass validation."""
|
||||
|
||||
def node_that_modifies(state: MessagesState) -> MessagesState:
|
||||
# Node modifies existing message - should work even with strict validator
|
||||
messages = state["messages"]
|
||||
if messages:
|
||||
updated = messages[0].model_copy(update={"content": "Modified by node"})
|
||||
return {"messages": [updated]}
|
||||
return {"messages": []}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("modifier", node_that_modifies)
|
||||
builder.set_entry_point("modifier")
|
||||
builder.set_finish_point("modifier")
|
||||
|
||||
graph = builder.compile(validate_input=validate_messages_append_only)
|
||||
|
||||
# Node can modify - validation only applies to external input
|
||||
result = graph.invoke({"messages": [HumanMessage(content="Original", id="1")]})
|
||||
assert any("Modified by node" in m.content for m in result["messages"])
|
||||
|
||||
|
||||
def test_without_validator_allows_everything():
|
||||
"""Test backwards compatibility - without validator, everything is allowed."""
|
||||
|
||||
def chatbot(state: MessagesState) -> MessagesState:
|
||||
return {"messages": []}
|
||||
|
||||
builder = StateGraph(MessagesState)
|
||||
builder.add_node("chatbot", chatbot)
|
||||
builder.set_entry_point("chatbot")
|
||||
builder.set_finish_point("chatbot")
|
||||
|
||||
checkpointer = MemorySaver()
|
||||
graph = builder.compile(checkpointer=checkpointer) # No validator
|
||||
|
||||
config = {"configurable": {"thread_id": "test"}}
|
||||
graph.invoke({"messages": [HumanMessage(content="Hello", id="1")]}, config)
|
||||
|
||||
# Should allow update (old behavior)
|
||||
result = graph.invoke(
|
||||
{"messages": [HumanMessage(content="Modified", id="1")]}, config
|
||||
)
|
||||
assert result["messages"][0].content == "Modified"
|
||||
Generated
+1
-1
@@ -1372,7 +1372,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -308,13 +308,7 @@ def create_react_agent(
|
||||
) -> CompiledStateGraph:
|
||||
"""Creates an agent graph that calls tools in a loop until a stopping condition is met.
|
||||
|
||||
!!! warning
|
||||
|
||||
This function is deprecated in favor of
|
||||
[`create_agent`][langchain.agents.create_agent] from the `langchain`
|
||||
package, which provides an equivalent agent factory with a flexible
|
||||
middleware system. For migration guidance, see
|
||||
[Migrating from LangGraph v0](https://docs.langchain.com/oss/python/migrate/langgraph-v1).
|
||||
For more details on using `create_react_agent`, visit [Agents](https://langchain-ai.github.io/langgraph/agents/overview/) documentation.
|
||||
|
||||
Args:
|
||||
model: The language model for the agent. Supports static and dynamic
|
||||
|
||||
Generated
+1
-1
@@ -268,7 +268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.8"
|
||||
version = "1.0.7"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import EncryptionContext
|
||||
|
||||
__version__ = "0.3.4"
|
||||
__version__ = "0.3.3"
|
||||
|
||||
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
|
||||
|
||||
@@ -737,16 +737,21 @@ class CronsCreate(typing.TypedDict, total=False):
|
||||
|
||||
```python
|
||||
create_params = {
|
||||
"assistant_id": UUID("123e4567-e89b-12d3-a456-426614173999")
|
||||
"payload": {"key": "value"},
|
||||
"schedule": "0 0 * * *",
|
||||
"cron_id": UUID("123e4567-e89b-12d3-a456-426614174000"),
|
||||
"thread_id": UUID("123e4567-e89b-12d3-a456-426614174001"),
|
||||
"user_id": "user123",
|
||||
"end_time": datetime(2024, 3, 16, 10, 0, 0)
|
||||
"end_time": datetime(2024, 3, 16, 10, 0, 0),
|
||||
"enabled": true,
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
assistant_id: UUID
|
||||
"""Unique identifier for the assistant."""
|
||||
|
||||
payload: dict[str, typing.Any]
|
||||
"""Payload for the cron job."""
|
||||
|
||||
@@ -765,6 +770,9 @@ class CronsCreate(typing.TypedDict, total=False):
|
||||
end_time: datetime | None
|
||||
"""typing.Optional end time for the cron job."""
|
||||
|
||||
enabled: bool | None
|
||||
"""typing.Optional enabled status of the cron job."""
|
||||
|
||||
|
||||
class CronsDelete(typing.TypedDict):
|
||||
"""Payload for deleting a cron job.
|
||||
@@ -807,7 +815,8 @@ class CronsUpdate(typing.TypedDict, total=False):
|
||||
update_params = {
|
||||
"cron_id": UUID("123e4567-e89b-12d3-a456-426614174000"),
|
||||
"payload": {"key": "value"},
|
||||
"schedule": "0 0 * * *"
|
||||
"schedule": "0 0 * * *",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
"""
|
||||
@@ -821,6 +830,9 @@ class CronsUpdate(typing.TypedDict, total=False):
|
||||
schedule: str | None
|
||||
"""typing.Optional schedule to update."""
|
||||
|
||||
enabled: bool | None
|
||||
"""typing.Optional enabled status of the cron job."""
|
||||
|
||||
|
||||
class CronsSearch(typing.TypedDict, total=False):
|
||||
"""Payload for searching cron jobs.
|
||||
|
||||
Reference in New Issue
Block a user