Compare commits

..
Author SHA1 Message Date
David Asamu bd5f14101c fix(cli): include build environment hash in Docker cache keys 2026-10-26 19:03:47 +00:00
27 changed files with 200 additions and 402 deletions

No files matched your search

+1 -1
View File
@@ -279,7 +279,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -278,7 +278,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -285,7 +285,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
requires-python = ">=3.10"
+1 -1
View File
@@ -301,7 +301,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
+4
View File
@@ -1589,6 +1589,8 @@ ADD {relpath} /deps/{name}
docker_file_contents.extend(
[
f"FROM {image_str}",
# BuildKit secrets do not invalidate RUN caches; hosted builds supply this hash.
"ARG USER_ENV_VARS_HASH",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
@@ -1679,6 +1681,8 @@ def node_config_to_docker(
docker_file_contents = [
f"FROM {image_str}",
# BuildKit secrets do not invalidate RUN caches; hosted builds supply this hash.
"ARG USER_ENV_VARS_HASH",
"",
os.linesep.join(config["dockerfile_lines"]),
"",
+2
View File
@@ -955,6 +955,8 @@ def python_config_to_docker_uv_lock(
docker_plan.add_blank()
docker_plan.add_instruction("FROM", image_str)
# BuildKit secrets do not invalidate RUN caches; hosted builds supply this hash.
docker_plan.add_instruction("ARG", "USER_ENV_VARS_HASH")
docker_plan.add_blank()
docker_plan.extend_nonempty(config["dockerfile_lines"])
if config["dockerfile_lines"]:
@@ -148,6 +148,7 @@ services:
dockerfile_inline: |
# syntax=docker/dockerfile:1.4
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Adding local package . --
ADD . /deps/cli
# -- End of local package . --
+15
View File
@@ -910,6 +910,7 @@ def test_config_to_docker_simple():
expected_docker_stdin = f"""\
# syntax=docker/dockerfile:1.4
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Installing local requirements --
COPY --from=outer-requirements.txt requirements.txt /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
@@ -974,6 +975,7 @@ def test_config_to_docker_outside_path():
"""\
# syntax=docker/dockerfile:1.4
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
@@ -1034,6 +1036,7 @@ def test_config_to_docker_pipconfig():
expected_docker_stdin = (
"""\
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
ADD pipconfig.txt /pipconfig.txt
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
@@ -1097,6 +1100,7 @@ def test_config_to_docker_local_deps():
)
expected_docker_stdin = f"""\
FROM langchain/langgraph-api-custom:3.11
ARG USER_ENV_VARS_HASH
# -- Adding non-package dependency graphs --
ADD ./graphs /deps/outer-graphs/src
RUN set -ex && \\
@@ -1144,6 +1148,7 @@ dependencies = ["langchain"]"""
os.remove(pyproject_path)
expected_docker_stdin = (
"""FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Adding local package . --
ADD . /deps/unit_tests
# -- End of local package . --
@@ -1177,6 +1182,7 @@ def test_config_to_docker_end_to_end():
base_image="langchain/langgraph-api",
)
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.12
ARG USER_ENV_VARS_HASH
ARG meow
ARG foo
ADD pipconfig.txt /pipconfig.txt
@@ -1222,6 +1228,7 @@ def test_config_to_docker_nodejs():
base_image="langchain/langgraphjs-api",
)
expected_docker_stdin = """FROM langchain/langgraphjs-api:20
ARG USER_ENV_VARS_HASH
ARG meow
ARG foo
ADD . /deps/unit_tests
@@ -1413,6 +1420,7 @@ def test_config_to_docker_nodejs_internal_docker_tag():
base_image="langchain/langgraphjs-api",
)
expected_docker_stdin = """FROM langchain/langgraphjs-api:my-tag
ARG USER_ENV_VARS_HASH
ARG meow
ARG foo
ADD . /deps/unit_tests
@@ -1524,6 +1532,7 @@ def test_config_to_docker_gen_ui_python():
)
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
@@ -1571,6 +1580,7 @@ def test_config_to_docker_multiplatform():
)
expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
@@ -2843,6 +2853,7 @@ def test_config_to_compose_simple_config():
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
@@ -2884,6 +2895,7 @@ def test_config_to_compose_env_vars():
context: .
dockerfile_inline: |
FROM langchain/langgraph-api-custom:3.11
ARG USER_ENV_VARS_HASH
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
@@ -2929,6 +2941,7 @@ def test_config_to_compose_env_file():
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
@@ -2967,6 +2980,7 @@ def test_config_to_compose_watch():
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
@@ -3014,6 +3028,7 @@ def test_config_to_compose_end_to_end():
context: .
dockerfile_inline: |
FROM langchain/langgraph-api:3.11
ARG USER_ENV_VARS_HASH
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
+142
View File
@@ -0,0 +1,142 @@
"""Generated Dockerfile cache keys; opt into the real BuildKit test locally."""
import hashlib
import os
import pathlib
import subprocess
import uuid
import pytest
from langgraph_cli.config import config_to_docker, validate_config
def generated_prefix(tmp_path: pathlib.Path, runtime: str) -> str:
config = {
"graphs": {"agent": "agent:graph"},
"dockerfile_lines": [
f"RUN --mount=type=secret,id=build_marker echo {tmp_path.name} >/dev/null; "
"sha256sum /run/secrets/build_marker | cut -d ' ' -f 1 > /result"
],
}
if runtime == "node":
config["node_version"] = "20"
config["graphs"] = {"agent": "./agent.js:graph"}
else:
config.update(python_version="3.11", dependencies=["requests"])
if runtime == "uv":
config.pop("dependencies")
config["source"] = {"kind": "uv", "root": "."}
(tmp_path / "uv.lock").write_text("version = 1\n")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "agent"\nversion = "0.1.0"\n'
'[build-system]\nrequires = ["setuptools"]\n'
'build-backend = "setuptools.build_meta"\n'
)
dockerfile, _ = config_to_docker(
tmp_path / "langgraph.json",
validate_config(config),
base_image="langchain/langgraph-api",
)
# Keep the generated stage header and custom RUN exactly as emitted. Avoid
# installing real graph dependencies: this test exercises the cache boundary.
return dockerfile.split(" > /result", 1)[0] + " > /result\n"
@pytest.mark.parametrize("runtime", ["python", "uv", "node"])
def test_env_hash_in_build_stage_before_custom_run(tmp_path, runtime):
prefix = generated_prefix(tmp_path, runtime)
assert prefix.index("FROM ") < prefix.index("ARG USER_ENV_VARS_HASH")
assert prefix.index("ARG USER_ENV_VARS_HASH") < prefix.index("RUN ")
@pytest.mark.skipif(
os.environ.get("LANGGRAPH_TEST_BUILDKIT") != "1",
reason="Set LANGGRAPH_TEST_BUILDKIT=1 with a local Docker BuildKit daemon",
)
@pytest.mark.parametrize("runtime", ["python", "uv", "node"])
def test_secret_hash_invalidates_real_buildkit_cache(tmp_path, runtime, request):
prefix = generated_prefix(tmp_path, runtime)
lines = prefix.splitlines()
lines[0] = "FROM alpine:3.21"
prefix = "\n".join(lines) + "\n"
dockerfile = tmp_path / "Dockerfile"
marker = tmp_path / "build-marker"
(tmp_path / ".dockerignore").write_text("*\n!Dockerfile\n")
output_counter = 0
cache_image = "langgraph-cli-cache-test:" + uuid.uuid4().hex
request.addfinalizer(
lambda: subprocess.run(
["docker", "image", "rm", cache_image], capture_output=True, check=False
)
)
def build(contents, value, *, pass_hash=True, export_image=False):
nonlocal output_counter
output_counter += 1
marker.write_text(value)
dockerfile.write_text(
contents + "FROM scratch\nCOPY --from=0 /result /result\n"
)
output = tmp_path / f"output-{output_counter}"
args = [
"docker",
"buildx",
"build",
"--progress=plain",
"--secret",
f"id=build_marker,src={marker}",
"--output",
f"type=local,dest={output}",
"-f",
str(dockerfile),
str(tmp_path),
]
if pass_hash:
args.extend(
[
"--build-arg",
f"USER_ENV_VARS_HASH={hashlib.sha256(value.encode()).hexdigest()}",
]
)
if output_counter > 1:
args.extend(["--cache-from", cache_image])
if export_image:
# Export a legacy image with inline cache, then explicitly import it.
image_args = args.copy()
output_index = image_args.index("--output")
del image_args[output_index : output_index + 2]
image_args.extend(
["--load", "--tag", cache_image, "--cache-to", "type=inline"]
)
subprocess.run(image_args, capture_output=True, text=True, check=True)
# List arguments only; all inputs are local public test markers.
result = subprocess.run(args, capture_output=True, text=True, check=True)
return (output / "result").read_text().strip(), result.stderr
first = f"{runtime}-{tmp_path.name}-first"
second = f"{runtime}-{tmp_path.name}-second"
legacy = prefix.replace("ARG USER_ENV_VARS_HASH\n", "")
legacy_output, _ = build(legacy, first, export_image=True)
assert legacy_output == hashlib.sha256(first.encode()).hexdigest()
stale_output, _ = build(legacy, second)
assert stale_output == legacy_output # Secret contents alone do not bust cache.
fixed_output, _ = build(prefix, second)
assert fixed_output == hashlib.sha256(second.encode()).hexdigest()
assert fixed_output != stale_output # Old cache is safe when adopting the ARG.
repeated_output, repeated_log = build(prefix, second)
assert repeated_output == fixed_output
run_vertex = next(
line.split()[0]
for line in repeated_log.splitlines()
if "] RUN " in line and "sha256sum" in line
)
assert f"{run_vertex} CACHED" in repeated_log
changed_output, _ = build(prefix, first)
assert changed_output == hashlib.sha256(first.encode()).hexdigest()
assert changed_output != repeated_output
# Ordinary CLI callers can still build without supplying the optional hash.
unset_output, _ = build(prefix, first, pass_hash=False)
assert unset_output == hashlib.sha256(first.encode()).hexdigest()
-38
View File
@@ -228,12 +228,6 @@ def draw_graph(
metadata["__interrupt"] = "before"
elif name in interrupt_after_nodes:
metadata["__interrupt"] = "after"
# Duck-typed so core doesn't import ToolNode from langgraph-prebuilt.
tools_by_name = getattr(node.bound, "tools_by_name", None)
if isinstance(tools_by_name, Mapping) and tools_by_name:
metadata["__tools"] = list(tools_by_name)
if subagents := _get_tool_subagents(tools_by_name):
metadata["__subagents"] = subagents
graph.add_node(node.bound, name, metadata=metadata or None)
# add start node
if START not in nodes:
@@ -283,38 +277,6 @@ def draw_graph(
return graph
def _get_tool_subagents(
tools_by_name: Mapping[str, Any],
) -> dict[str, list[dict[str, Any]]]:
"""Collect the subagents each tool can run, keyed by tool name."""
subagents: dict[str, list[dict[str, Any]]] = {}
for tool_name, tool in tools_by_name.items():
tool_subagents = getattr(tool, "subagents", None)
if (
isinstance(tool_subagents, Sequence)
and not isinstance(tool_subagents, str)
and tool_subagents
and all(
isinstance(subagent, Mapping) and isinstance(subagent.get("name"), str)
for subagent in tool_subagents
)
):
subagents[tool_name] = [
_subagent_entry(subagent) for subagent in tool_subagents
]
return subagents
def _subagent_entry(subagent: Mapping[str, Any]) -> dict[str, Any]:
"""Keep only the subagent fields graph viewers display."""
entry: dict[str, Any] = {"name": subagent["name"]}
if isinstance(description := subagent.get("description"), str):
entry["description"] = description
if subagent.get("forked") is True:
entry["forked"] = True
return entry
def add_edge(
graph: Graph,
source: str,
+3 -9
View File
@@ -34,7 +34,6 @@ from langgraph._internal._constants import (
CONFIG_KEY_THREAD_ID,
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
NS_SEP,
RETURN,
)
from langgraph._internal._runnable import create_task_in_config_context
from langgraph._internal._timeout import sync_timeout_unsupported
@@ -734,14 +733,15 @@ async def arun_with_retry(
if stream:
async for _ in task.proc.astream(task.input, config):
pass
return _return_value(task)
break
return await task.proc.ainvoke(task.input, config)
result = await _arun_with_timeout(
task, config, resolved_timeout, attempt_ctx, stream=stream
)
_finish_timed_attempt(config, attempt_ctx)
if stream:
return _return_value(task)
# if successful, end
break
return result
except ParentCommand as exc:
ns: str = config[CONF][CONFIG_KEY_CHECKPOINT_NS]
@@ -831,12 +831,6 @@ async def arun_with_retry(
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
def _return_value(task: PregelExecutableTask) -> Any:
# Streaming drains the task instead of returning its result, so a caller
# awaiting a `@task` needs the value the task wrote.
return next((value for channel, value in task.writes if channel == RETURN), None)
def _should_retry_on(retry_policy: RetryPolicy, exc: Exception) -> bool:
"""Check if the given exception should be retried based on the retry policy."""
if isinstance(retry_policy.retry_on, Sequence):
@@ -9,7 +9,6 @@ from langchain_core.callbacks import BaseCallbackHandler
from langgraph._internal._constants import NS_SEP
from langgraph.constants import TAG_NOSTREAM
from langgraph.errors import GraphDrained, GraphInterrupt
from langgraph.pregel.protocol import StreamChunk
try:
@@ -189,10 +188,6 @@ class StreamToolCallHandler(BaseCallbackHandler, _StreamingCallbackHandler):
return
ns, tool_call_id, token = info
self._reset_writer(token)
# An interrupted or drained tool is paused, not failed: it runs again
# on resume and reports its start and end then.
if isinstance(error, (GraphInterrupt, GraphDrained)):
return
self.stream(
(
ns,
+1 -3
View File
@@ -3310,9 +3310,7 @@ class Pregel(
True
for h in run_manager.handlers
if isinstance(h, _StreamingCallbackHandler)
and not isinstance(
h, (StreamMessagesHandler, StreamToolCallHandler)
)
and not isinstance(h, StreamMessagesHandler)
),
False,
)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.18"
version = "1.2.17"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
@@ -133,11 +133,6 @@
"ToolNode"
],
"name": "tools"
},
"metadata": {
"__tools": [
"search_api"
]
}
},
{
@@ -226,11 +221,6 @@
"ToolNode"
],
"name": "tools"
},
"metadata": {
"__tools": [
"search_api"
]
}
},
{
+1 -44
View File
@@ -36,7 +36,7 @@ from langchain_core.runnables import (
RunnablePassthrough,
)
from langchain_core.runnables.graph import Edge
from langchain_core.tools import StructuredTool, tool
from langchain_core.tools import tool
from langchain_core.version import VERSION as LANGCHAIN_CORE_VERSION
from langgraph.cache.base import BaseCache
from langgraph.checkpoint.base import (
@@ -3105,49 +3105,6 @@ def test_simple_multi_edge(snapshot: SnapshotAssertion) -> None:
)
def test_tool_node_lists_tool_subagents_in_graph_metadata() -> None:
class TaskTool(StructuredTool):
subagents: list[dict[str, Any]] = []
def run_task(description: str, subagent_type: str) -> str:
return subagent_type
task = TaskTool.from_function(run_task, name="task", description="Run a subagent.")
task.subagents = [
{"name": "researcher", "description": "Researches a topic."},
{
"name": "critic",
"description": "Reviews drafts.",
"forked": True,
"system_prompt": "You are a critic.",
"runnable": object(),
},
{"name": "planner", "description": None, "forked": "yes"},
]
@tool
def search(query: str) -> str:
"""Search the web."""
return query
builder = StateGraph(MessagesState)
builder.add_node("agent", lambda state: state)
builder.add_node("tools", ToolNode([search, task]))
builder.add_edge(START, "agent")
builder.add_edge("agent", "tools")
graph = builder.compile().get_graph().to_json()
nodes = {node["id"]: node for node in graph["nodes"]}
assert nodes["tools"]["metadata"]["__subagents"] == {
"task": [
{"name": "researcher", "description": "Researches a topic."},
{"name": "critic", "description": "Reviews drafts.", "forked": True},
{"name": "planner"},
]
}
assert "__subagents" not in (nodes["agent"].get("metadata") or {})
def test_nested_graph_xray(snapshot: SnapshotAssertion) -> None:
class State(TypedDict):
my_key: Annotated[str, operator.add]
-59
View File
@@ -6834,31 +6834,6 @@ async def test_multiple_updates() -> None:
]
@NEEDS_CONTEXTVARS
async def test_task_in_subgraph_node_returns_its_value_with_tools_mode() -> None:
class State(TypedDict):
out: int | None
@task
async def double(x: int) -> int:
return x * 2
async def node(state: State) -> State:
return {"out": await double(21)}
inner = StateGraph(State).add_node("node", node).add_edge(START, "node").compile()
graph = (
StateGraph(State).add_node("inner", inner).add_edge(START, "inner").compile()
)
chunks = [
chunk
async for chunk in graph.astream({"out": None}, stream_mode=["values", "tools"])
]
assert chunks[-1] == ("values", {"out": 42})
@NEEDS_CONTEXTVARS
async def test_falsy_return_from_task(async_checkpointer: BaseCheckpointSaver) -> None:
"""Test with a falsy return from a task."""
@@ -7623,40 +7598,6 @@ async def test_entrypoint_from_async_generator() -> None:
yield "b"
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("timeout", [None, 30])
@pytest.mark.parametrize("version", ["v1", "v2"])
async def test_awaited_task_returns_its_value_under_astream_events(
version: Literal["v1", "v2"], timeout: int | None
) -> None:
class State(TypedDict):
doubled: int
zeroed: int
@task(timeout=timeout)
async def double(x: int) -> int:
return x * 2
@task(timeout=timeout)
async def zero() -> int:
return 0
async def node(state: State) -> State:
return {"doubled": await double(21), "zeroed": await zero()}
graph = StateGraph(State).add_node("node", node).add_edge(START, "node").compile()
outputs = [
event["data"]["output"]
async for event in graph.astream_events(
{"doubled": 0, "zeroed": 1}, version=version
)
if event["event"] == "on_chain_end" and event["name"] == "node"
]
assert outputs == [{"doubled": 42, "zeroed": 0}]
@NEEDS_CONTEXTVARS
async def test_named_tasks_functional() -> None:
class Foo:
@@ -13,17 +13,13 @@ from typing import Annotated, Any
import pytest
from langchain_core.messages import AIMessage
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.prebuilt import ToolNode, ToolRuntime
from typing_extensions import TypedDict
from langgraph.constants import END, START
from langgraph.errors import GraphDrained
from langgraph.graph import StateGraph
from langgraph.graph.message import add_messages
from langgraph.pregel._tools import _tool_call_writer
from langgraph.runtime import RunControl
from langgraph.types import Command, interrupt
class _State(TypedDict):
@@ -58,14 +54,14 @@ def _caller_async(tool_name: str, tool_args: dict[str, Any], tc_id: str = "tc1")
return caller
def _build_graph(caller, tools, checkpointer=None) -> Any:
def _build_graph(caller, tools) -> Any:
sg = StateGraph(_State)
sg.add_node("caller", caller)
sg.add_node("tools", ToolNode(tools))
sg.add_edge(START, "caller")
sg.add_edge("caller", "tools")
sg.add_edge("tools", END)
return sg.compile(checkpointer=checkpointer)
return sg.compile()
def _tool_events(stream) -> list[tuple[tuple[str, ...], dict]]:
@@ -150,105 +146,6 @@ class TestSyncGraphSyncTool:
assert kinds == ["tool-started", "tool-error"]
assert events[1][1]["message"] == "nope"
def test_interrupt_in_tool_is_not_a_tool_error(self) -> None:
@tool
def ask() -> str:
"""asks."""
return interrupt("ok?")
graph = _build_graph(_caller_sync("ask", {}), [ask], InMemorySaver())
config = {"configurable": {"thread_id": "1"}}
paused = _tool_events(
graph.stream(
{"messages": []}, config, stream_mode=["tools"], subgraphs=True
)
)
resumed = _tool_events(
graph.stream(
Command(resume="yes"), config, stream_mode=["tools"], subgraphs=True
)
)
assert [p["event"] for _, p in paused] == ["tool-started"]
assert [(p["event"], p["tool_call_id"]) for _, p in resumed] == [
("tool-started", "tc1"),
("tool-finished", "tc1"),
]
def test_tool_handing_off_to_the_parent_still_ends_its_call(self) -> None:
class State(TypedDict):
messages: Annotated[list, add_messages]
done: bool
def hand_off(state: State) -> Command:
return Command(graph=Command.PARENT, goto="finish", update={"done": True})
inner = StateGraph(State).add_node("hand_off", hand_off)
inner = inner.add_edge(START, "hand_off").compile()
@tool
def handoff() -> str:
"""hands off."""
inner.invoke({"messages": [], "done": False})
return "unreachable"
graph = (
StateGraph(State)
.add_node("caller", _caller_sync("handoff", {}))
.add_node("tools", ToolNode([handoff]))
.add_node("finish", lambda state: {"done": True})
.add_edge(START, "caller")
.add_edge("caller", "tools")
.add_edge("tools", END)
.add_edge("finish", END)
.compile()
)
events = [
payload["event"]
for _, payload in _tool_events(
graph.stream(
{"messages": [], "done": False},
stream_mode=["tools"],
subgraphs=True,
)
)
]
assert events[0] == "tool-started"
assert events[-1] == "tool-error", events
def test_drained_tool_is_not_a_tool_error(self) -> None:
control = RunControl()
def request_drain(state: _State) -> dict:
control.request_drain()
return {}
inner = (
StateGraph(_State)
.add_node("request_drain", request_drain)
.add_node("after", lambda state: {})
.add_edge(START, "request_drain")
.add_edge("request_drain", "after")
.compile()
)
@tool
def drained() -> str:
"""drains."""
inner.invoke({"messages": []}, control=control)
return "unreachable"
graph = _build_graph(_caller_sync("drained", {}), [drained])
chunks: list = []
with pytest.raises(GraphDrained):
for chunk in graph.stream(
{"messages": []}, stream_mode=["tools"], subgraphs=True
):
chunks.append(chunk)
assert [p["event"] for _, p in _tool_events(chunks)] == ["tool-started"]
def test_writer_unset_outside_tool(self) -> None:
# Outside any tool body the ContextVar that ToolRuntime reads
# is unset — emitting from there would be a no-op.
@@ -299,25 +196,6 @@ class TestAsyncGraphAsyncTool:
assert kinds == ["tool-started", "tool-output-delta", "tool-finished"]
assert events[1][1]["delta"] == "hi"
@pytest.mark.anyio
async def test_interrupt_in_async_tool_is_not_a_tool_error(self) -> None:
@tool
async def aask() -> str:
"""asks."""
return interrupt("ok?")
graph = _build_graph(_caller_async("aask", {}), [aask], InMemorySaver())
config = {"configurable": {"thread_id": "1"}}
events = []
for graph_input in ({"messages": []}, Command(resume="yes")):
async for ns, mode, payload in graph.astream(
graph_input, config, stream_mode=["tools"], subgraphs=True
):
if mode == "tools":
events.append(payload["event"])
assert events == ["tool-started", "tool-started", "tool-finished"]
class TestConcurrentToolCalls:
def test_parallel_tool_calls_do_not_bleed(self) -> None:
+2 -2
View File
@@ -1439,7 +1439,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.18"
version = "1.2.17"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -1625,7 +1625,7 @@ wheels = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+2 -2
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.18"
version = "1.2.17"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -370,7 +370,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -5,7 +5,7 @@ from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
from langgraph_sdk.priority import Priority
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
__version__ = "0.4.10"
__version__ = "0.4.9"
__all__ = [
"Auth",
-6
View File
@@ -217,12 +217,6 @@ class HttpClient:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id is not None:
current_headers = {
key: value
for key, value in current_headers.items()
if key.lower() != "last-event-id"
}
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
-6
View File
@@ -219,12 +219,6 @@ class SyncHttpClient:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id is not None:
current_headers = {
key: value
for key, value in current_headers.items()
if key.lower() != "last-event-id"
}
if last_event_id:
current_headers["Last-Event-ID"] = last_event_id
+1 -1
View File
@@ -104,7 +104,7 @@ class SSEDecoder:
if (
not self._event
and not self._data
and self._last_event_id is None
and not self._last_event_id
and self._retry is None
):
return None
+15 -84
View File
@@ -312,14 +312,18 @@ async def test_http_client_stream_recovers_after_disconnect():
]
def _drop_after(chunks: list[bytes], stream_cls: type, reconnects: list[httpx.Request]):
def _drop_after_an_empty_id(stream_cls: type, reconnects: list[httpx.Request]):
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream", "Location": "/reconnect"},
stream=stream_cls(
chunks, httpx.RemoteProtocolError("incomplete chunked read")
[
b"id: 1\nevent: values\ndata: 1\n\n",
b"id:\nevent: values\ndata: 2\n\n",
],
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
reconnects.append(request)
@@ -332,99 +336,26 @@ def _drop_after(chunks: list[bytes], stream_cls: type, reconnects: list[httpx.Re
return handler
def _sync_reconnect(
chunks: list[bytes], headers: dict[str, str] | None
) -> httpx.Request:
def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id():
reconnects: list[httpx.Request] = []
transport = httpx.MockTransport(_drop_after(chunks, ListByteStream, reconnects))
transport = httpx.MockTransport(_drop_after_an_empty_id(ListByteStream, reconnects))
with httpx.Client(transport=transport, base_url="https://example.com") as client:
list(SyncHttpClient(client).stream("/stream", "POST", json={}, headers=headers))
return reconnects[0]
list(SyncHttpClient(client).stream("/stream", "POST", json={}))
assert "last-event-id" not in reconnects[0].headers
async def _async_reconnect(
chunks: list[bytes], headers: dict[str, str] | None
) -> httpx.Request:
async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id():
reconnects: list[httpx.Request] = []
transport = httpx.MockTransport(
_drop_after(chunks, AsyncListByteStream, reconnects)
_drop_after_an_empty_id(AsyncListByteStream, reconnects)
)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
[
part
async for part in HttpClient(client).stream(
"/stream", "POST", json={}, headers=headers
)
]
return reconnects[0]
[part async for part in HttpClient(client).stream("/stream", "POST", json={})]
_EMPTY_IDS = pytest.mark.parametrize(
"empty_id",
[b"id:\nevent: values\ndata: 2\n\n", b"id:\n\n"],
ids=["with-an-event", "alone"],
)
_CALLER_CURSORS = pytest.mark.parametrize(
"headers",
[None, {"Last-Event-ID": "5"}, {"last-event-id": "5"}],
ids=["no-caller-cursor", "caller-cursor", "lowercase-caller-cursor"],
)
@_EMPTY_IDS
@_CALLER_CURSORS
def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id(
empty_id: bytes, headers: dict[str, str] | None
):
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
assert "last-event-id" not in reconnect.headers
@_EMPTY_IDS
@_CALLER_CURSORS
async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id(
empty_id: bytes, headers: dict[str, str] | None
):
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n", empty_id], headers
)
assert "last-event-id" not in reconnect.headers
def test_sync_stream_id_replaces_a_lowercase_caller_cursor():
reconnect = _sync_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
)
assert reconnect.headers.get_list("last-event-id") == ["1"]
async def test_async_stream_id_replaces_a_lowercase_caller_cursor():
reconnect = await _async_reconnect(
[b"id: 1\nevent: values\ndata: 1\n\n"], {"last-event-id": "5"}
)
assert reconnect.headers.get_list("last-event-id") == ["1"]
def test_sync_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
reconnect = _sync_reconnect([b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"})
assert reconnect.headers.get_list("last-event-id") == ["5"]
async def test_async_stream_keeps_the_caller_cursor_until_the_stream_sends_an_id():
reconnect = await _async_reconnect(
[b"event: values\ndata: 1\n\n"], {"Last-Event-ID": "5"}
)
assert reconnect.headers.get_list("last-event-id") == ["5"]
assert "last-event-id" not in reconnects[0].headers
# --- _sse_to_v2_dict conversion ---
+2 -2
View File
@@ -301,7 +301,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.18"
version = "1.2.17"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },
@@ -385,7 +385,7 @@ test = [
[[package]]
name = "langgraph-checkpoint"
version = "4.3.1"
version = "4.3.0"
source = { editable = "../checkpoint" }
dependencies = [
{ name = "langchain-core" },