Compare commits

..
Author SHA1 Message Date
Elior Nataf Lackritz 4c52e129eb release(langgraph): 1.2.17 (#9418)
Bumps langgraph from 1.2.16 to 1.2.17.

Includes:

- Apply an explicit `recursion_limit` equal to the default (#9417)
- Keep the caller's namespace in `RemoteGraph.stream(subgraphs=True)`
(#9401)
2026-10-24 01:02:32 +00:00
Elior Nataf Lackritz 6c4ceb1332 fix(langgraph): apply an explicit recursion_limit equal to the default (#9417)
Fixes #9416.

`merge_configs` decided whether a `recursion_limit` was set by comparing
it with `DEFAULT_RECURSION_LIMIT`, so an explicit limit equal to the
default was dropped. `LANGGRAPH_DEFAULT_RECURSION_LIMIT` sets that
default, so with it at 1000, `agent.with_config(recursion_limit=1000)`
silently kept the 9999 that `create_agent` and `create_deep_agent` bind,
and a looping agent ran to 9999 steps.

The default is now a sentinel, as #7313 proposed: `ensure_config` fills
in one `int` subclass instance, and `merge_configs` skips that exact
object instead of any equal value. The filled-in default still never
overrides a bound limit (a subgraph bound to 50 keeps 50 when its parent
runs at the default), while an explicit limit equal to the default now
applies wherever configs are merged: `with_config`, `RemoteGraph` calls,
and the limit a parent passes to its subgraphs.

langchain-core has the same check in its own `merge_configs`
(langchain-ai/langchain#39994); this PR does not touch it.

Thanks @awg66 for the report and the repro.
2026-10-24 00:56:19 +00:00
Elior Nataf Lackritz 4897c51365 fix(langgraph): keep the caller's namespace in RemoteGraph.stream(subgraphs=True) (#9401)
`RemoteGraph.stream(..., subgraphs=True)` and `astream` dropped the
caller's namespace prefix from the events they yield. Called inside a
node of a graph running under `outer:123`, a remote subgraph event came
back as `("sub:abc",)` instead of `("outer:123", "sub:abc")`, while the
events RemoteGraph pushes to the parent graph's stream, and an in-memory
subgraph streamed the same way, keep the prefix. Fixes #6604.

The `subgraphs` branch parsed the namespace from the event name a second
time, after the caller prefix was added. This removes that second parse
in `stream` and `astream`.

The new tests fail on `main`.

Same fix as #6605 by @Rakshit-gen.

Co-authored-by: Rakshit Sisodiya
<64471316+rakshit-gen@users.noreply.github.com>
2026-10-23 19:24:04 -04:00
ataller-lc eb066bd31a release(cli): 0.4.38 (#9411)
Bumps the CLI to 0.4.38 to cut a release including #9393 (the `priority`
section in `langgraph.json`), the only CLI change merged since 0.4.37.
The section takes effect with `langgraph-api` 0.18.0 or later; older
servers ignore it, and configs without it build the same images as
before.

Merging doesn't publish by itself: after it merges, run the `release`
workflow with `working-directory: libs/cli`.
2026-10-23 14:18:08 +00:00
ataller-lc 6b83b9b019 release(sdk-py): 0.4.9 (#9398)
Bumps the Python SDK version from 0.4.8 to 0.4.9. Adds `Priority`
(beta), which lets a deployment set each thread's priority settings in
its own code (#9392); it needs `langgraph-api` 0.18.0 or later and a
`langgraph-cli` that supports the `priority` section. Also picks up the
`certifi` bump (#9370).
2026-10-23 09:32:43 -04:00
13 changed files with 127 additions and 61 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.37"
__version__ = "0.4.38"
+14 -2
View File
@@ -29,7 +29,19 @@ from langgraph._internal._constants import (
NS_SEP,
)
DEFAULT_RECURSION_LIMIT = int(getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007"))
class _DefaultRecursionLimit(int):
"""The limit `ensure_config` fills in when none is given.
`merge_configs` skips values of this type rather than any equal value, so an
explicit limit that happens to equal the default still applies. It checks the
type, not identity, because copying or pickling a config rebuilds the value.
"""
DEFAULT_RECURSION_LIMIT = _DefaultRecursionLimit(
getenv("LANGGRAPH_DEFAULT_RECURSION_LIMIT", "10007")
)
DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT = int(
getenv("LANGGRAPH_DELTA_MAX_SUPERSTEPS_SINCE_SNAPSHOT", "5000")
)
@@ -182,7 +194,7 @@ def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
base.get("callbacks"), cast(Callbacks, value)
)
elif key == "recursion_limit":
if config["recursion_limit"] != DEFAULT_RECURSION_LIMIT:
if not isinstance(config["recursion_limit"], _DefaultRecursionLimit):
base["recursion_limit"] = config["recursion_limit"]
else:
base[key] = config[key] # type: ignore[literal-required]
+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):
-10
View File
@@ -861,11 +861,6 @@ class RemoteGraph(PregelProtocol):
)
yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
elif subgraphs:
if NS_SEP in chunk.event:
mode, ns_ = chunk.event.split(NS_SEP, 1)
ns = tuple(ns_.split(NS_SEP))
else:
mode, ns = chunk.event, ()
if req_single:
yield ns, chunk.data
else:
@@ -1016,11 +1011,6 @@ class RemoteGraph(PregelProtocol):
)
yield {"type": mode, "ns": ns, "data": chunk.data, "interrupts": ints}
elif subgraphs:
if NS_SEP in chunk.event:
mode, ns_ = chunk.event.split(NS_SEP, 1)
ns = tuple(ns_.split(NS_SEP))
else:
mode, ns = chunk.event, ()
if req_single:
yield ns, chunk.data
else:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "langgraph"
version = "1.2.16"
version = "1.2.17"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
requires-python = ">=3.10"
+33 -1
View File
@@ -1,11 +1,19 @@
from collections.abc import Callable
import pytest
from langchain_core.callbacks import AsyncCallbackManager, BaseCallbackHandler
from langgraph._internal._config import get_async_callback_manager_for_config
from langgraph._internal._config import (
DEFAULT_RECURSION_LIMIT,
get_async_callback_manager_for_config,
)
from langgraph.graph import StateGraph
from langgraph.pregel import Pregel
pytestmark = pytest.mark.anyio
EXPLICIT_DEFAULT_LIMIT = int(DEFAULT_RECURSION_LIMIT)
def test_new_async_manager_includes_tags() -> None:
config = {"callbacks": None}
@@ -114,3 +122,27 @@ async def test_with_config_tags_preserved_on_invoke() -> None:
await graph.ainvoke({}, {"tags": ["invoke"]})
assert "bound" in captured, "bound tag was dropped by ensure_config overwrite"
assert "invoke" in captured, "invoke-time tag not present"
@pytest.mark.parametrize(
"rebind",
[
lambda g: g.with_config(recursion_limit=EXPLICIT_DEFAULT_LIMIT),
lambda g: g.with_config({"recursion_limit": EXPLICIT_DEFAULT_LIMIT}),
lambda g: g.with_config(
{"recursion_limit": 1}, recursion_limit=EXPLICIT_DEFAULT_LIMIT
),
lambda g: g.with_config(recursion_limit=EXPLICIT_DEFAULT_LIMIT).with_config(
tags=["t"]
),
],
ids=["kwarg", "config", "kwarg_over_config", "kept_by_later_with_config"],
)
def test_with_config_applies_recursion_limit_equal_to_default(
rebind: Callable[[Pregel], Pregel],
) -> None:
builder = StateGraph(dict)
builder.add_node("node", lambda state: state)
builder.add_edge("__start__", "node")
graph = builder.compile().with_config(recursion_limit=EXPLICIT_DEFAULT_LIMIT + 1)
assert rebind(graph).config["recursion_limit"] == EXPLICIT_DEFAULT_LIMIT
-33
View File
@@ -7495,39 +7495,6 @@ async def test_entrypoint_from_async_generator() -> None:
yield "b"
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("version", ["v1", "v2"])
async def test_awaited_task_returns_its_value_under_astream_events(
version: Literal["v1", "v2"],
) -> None:
class State(TypedDict):
doubled: int
zeroed: int
@task
async def double(x: int) -> int:
return x * 2
@task
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:
+44
View File
@@ -819,6 +819,50 @@ async def test_astream():
]
_CALLER_CONFIG: RunnableConfig = {
"configurable": {"thread_id": "thread_1", "checkpoint_ns": "outer:123"}
}
_SUBGRAPH_PARTS = [
StreamPart(event="values|sub:abc", data={"chunk": "inner"}),
StreamPart(event="values", data={"chunk": "root"}),
]
_CALLER_PREFIXED = [
(("outer:123", "sub:abc"), {"chunk": "inner"}),
(("outer:123",), {"chunk": "root"}),
]
def test_stream_subgraphs_keeps_caller_namespace():
mock_sync_client = MagicMock()
mock_sync_client.runs.stream.return_value = _SUBGRAPH_PARTS
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
stream_parts = list(
remote_pregel.stream(
{"input": "data"}, _CALLER_CONFIG, stream_mode="values", subgraphs=True
)
)
assert stream_parts == _CALLER_PREFIXED
async def test_astream_subgraphs_keeps_caller_namespace():
mock_async_client = MagicMock()
async_iter = MagicMock()
async_iter.__aiter__.return_value = _SUBGRAPH_PARTS
mock_async_client.runs.stream.return_value = async_iter
remote_pregel = RemoteGraph("test_graph_id", client=mock_async_client)
stream_parts = [
part
async for part in remote_pregel.astream(
{"input": "data"}, _CALLER_CONFIG, stream_mode="values", subgraphs=True
)
]
assert stream_parts == _CALLER_PREFIXED
def test_invoke():
# set up test
mock_sync_client = MagicMock()
+27
View File
@@ -1,4 +1,6 @@
import copy
import functools
import pickle
import sys
import uuid
from collections.abc import Callable
@@ -25,6 +27,7 @@ from pydantic import BaseModel, Field
from typing_extensions import NotRequired, Required, TypedDict
from langgraph._internal._config import (
DEFAULT_RECURSION_LIMIT,
_is_not_empty,
_merge_callbacks,
ensure_config,
@@ -620,6 +623,30 @@ def test_merge_configs_merges_metadata_lc_versions() -> None:
assert merged["metadata"]["lc_agent_name"] == "agent"
@pytest.mark.parametrize(
"rebuild",
[
lambda config: config,
copy.deepcopy,
lambda config: pickle.loads(pickle.dumps(config)),
],
ids=["as_is", "deepcopy", "pickle"],
)
def test_merge_configs_skips_the_injected_default_recursion_limit(
rebuild: Callable[[RunnableConfig], RunnableConfig],
) -> None:
merged = merge_configs({"recursion_limit": 50}, rebuild(ensure_config()))
assert merged["recursion_limit"] == 50
def test_merge_configs_applies_an_explicit_recursion_limit_equal_to_default() -> None:
explicit_default = int(DEFAULT_RECURSION_LIMIT)
merged = merge_configs(
{"recursion_limit": 50}, {"recursion_limit": explicit_default}
)
assert merged["recursion_limit"] == explicit_default
def test_ensure_config_merges_metadata_lc_versions() -> None:
a = {
"metadata": {
+1 -1
View File
@@ -1451,7 +1451,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.16"
version = "1.2.17"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -286,7 +286,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.16"
version = "1.2.17"
source = { editable = "../langgraph" }
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.8"
__version__ = "0.4.9"
__all__ = [
"Auth",
+1 -1
View File
@@ -301,7 +301,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.16"
version = "1.2.17"
source = { editable = "../langgraph" }
dependencies = [
{ name = "langchain-core" },