Compare commits

..
Author SHA1 Message Date
William Fu-Hinthorn bf26d5f592 Update tests
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-22 08:19:08 -07:00
baiiandWilliam Fu-Hinthorn 654c5f21e4 fix(langgraph): Fix deserialization issue for AnyMessage objects, and… (#4317)
Fix deserialization issue for AnyMessage objects, and add support for
deserializing Pydantic generic and polymorphic models.

bug detail:
https://github.com/langchain-ai/langgraph/issues/4316
2025-04-22 08:19:08 -07:00
William Fu-Hinthorn 38d806733d Update site_description
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-22 08:18:10 -07:00
William FHandGitHub 86ddd8da10 Add docs on tunneling (#4371)
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-22 15:04:04 +00:00
William FHandGitHub a5f5d0c4df Expose --tunnel flag to dev command (#4370)
Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-22 14:23:09 +00:00
lc-arjunandGitHub 7486adabdf fix: threads search sorting defaults (#4365)
Removes default from https://github.com/langchain-ai/langgraph/pull/4362
2025-04-21 20:49:16 -04:00
William FHandGitHub 12ad47e4e8 Use model_validate if needed (#4363)
If the state schema uses validators, skip the model construct
optimization.

For context, pydantic state can be significantly slower to run than
typed dict and dataclass states due to the full recursive validation.

We have some optimizations to reduce the impact of this (using cached
validators with model_construct), but this doesn't handle things like
field_validator.

We prefer correctness over performance, obviously.

Resolves: https://github.com/langchain-ai/langgraph/issues/4074

Signed-off-by: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com>
2025-04-21 21:53:58 +00:00
lc-arjunandGitHub 90f7f776cf feat: threads sorting sdk spec (#4362) 2025-04-21 14:42:43 -07:00
William FHandGitHub c7306f7aed Add log json env var (#4348) 2025-04-18 20:44:50 +00:00
William FHandGitHub 20bd71e289 Bump lockfile (#4346) 2025-04-18 08:43:58 -07:00
William Fu-Hinthorn 283485753f Format notebook 2025-04-18 08:35:31 -07:00
ba7f9975fa Fix text fields naming (#4345)
The configuration expects the key "fields", not "text_fields": I had
failed to update across all implementations in the original PR

Thank you to Vincent Min for the fix!
---------

Co-authored-by: Vincent Min <93780551+VMinB12@users.noreply.github.com>
2025-04-18 08:21:46 -07:00
David DuongandGitHub 8c4904bee9 fix(sdk-js): make sure to wrap client component in UseStreamContext (#4338) 2025-04-18 01:08:38 +02:00
Tat Dat Duong 6bb06b8702 fix(sdk-js): make sure to wrap client component in UseStreamContext 2025-04-18 01:07:13 +02:00
Vadym BardaandGitHub 7a16e33833 docs: fix notebook runner (#4337) 2025-04-17 22:43:41 +00:00
26 changed files with 1171 additions and 479 deletions
+21 -2
View File
@@ -20,7 +20,6 @@ BLOCKLIST_COMMANDS = (
NOTEBOOKS_NO_CASSETTES = (
"docs/how-tos/visualization.ipynb",
"docs/how-tos/many-tools.ipynb"
)
NOTEBOOKS_NO_EXECUTION = [
@@ -49,7 +48,10 @@ NOTEBOOKS_NO_EXECUTION = [
"docs/how-tos/map-reduce.ipynb", # flakiness from structured output, only when running with VCR
"docs/tutorials/tot/tot.ipynb",
"docs/how-tos/visualization.ipynb",
"docs/tutorials/llm-compiler/LLMCompiler.ipynb"
"docs/how-tos/streaming-specific-nodes.ipynb",
"docs/tutorials/llm-compiler/LLMCompiler.ipynb",
"docs/tutorials/customer-support/customer-support.ipynb", # relies on openai embeddings, doesn't play well w/ VCR
"docs/how-tos/many-tools.ipynb", # relies on openai embeddings, doesn't play well w/ VCR
]
@@ -86,6 +88,12 @@ def has_blocklisted_command(code: str, metadata: dict) -> bool:
return True
return False
def add_mermaid_retries(code: str) -> str:
return code.replace(
"draw_mermaid_png()",
"draw_mermaid_png(max_retries=10, retry_delay=2.0)"
)
def add_vcr_to_notebook(
notebook: nbformat.NotebookNode, cassette_prefix: str
@@ -180,6 +188,15 @@ def add_vcr_to_notebook(
return notebook
def add_mermaid_retries_to_notebook(notebook: nbformat.NotebookNode) -> nbformat.NotebookNode:
for cell in notebook.cells:
if cell.cell_type != "code":
continue
cell.source = add_mermaid_retries(cell.source)
return notebook
def process_notebooks(should_comment_install_cells: bool) -> None:
for directory in NOTEBOOK_DIRS:
for root, _, files in os.walk(directory):
@@ -201,6 +218,8 @@ def process_notebooks(should_comment_install_cells: bool) -> None:
notebook, cassette_prefix=cassette_prefix
)
notebook = add_mermaid_retries_to_notebook(notebook)
if notebook_path in NOTEBOOKS_NO_EXECUTION:
# Add a cell at the beginning to indicate that this notebook should not be executed
warning_cell = nbformat.v4.new_markdown_cell(
@@ -1,7 +1,7 @@
# LangGraph Studio With Local Deployment
!!! warning "Browser Compatibility"
Viewing the studio page of a local LangGraph deployment does not work in Safari. Use Chrome instead.
Safari blocks `localhost` connections to Studio. To work around this, start the server with `--tunnel` and you’ll be able to access Studio from Safari via a secure tunnel.
## Setup
+10 -3
View File
@@ -10,9 +10,6 @@ The LangGraph command line interface includes commands to build and run a LangGr
=== "Python"
```bash
pip install langgraph-cli
# Install via Homebrew
brew install langgraph-cli
```
=== "JS"
@@ -298,6 +295,11 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
| `--no-reload` | | Disable auto-reload |
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
| `--debug-port INTEGER` | | Port for debugger to listen on |
| `--wait-for-client` | `False` | Wait for a debugger client to connect to the debug port before starting the server |
| `--no-browser` | | Skip automatically opening the browser when the server starts |
| `--studio-url TEXT` | | URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com |
| `--allow-blocking` | `False` | Do not raise errors for synchronous I/O blocking operations in your code (added in `0.2.6`) |
| `--tunnel` | `False` | Expose the local server via a public tunnel (Cloudflare) for remote frontend access. This avoids issues with browsers like Safari or networks blocking localhost connections |
| `--help` | | Display command documentation |
@@ -321,6 +323,11 @@ The LangGraph CLI requires a JSON configuration file that follows this [schema](
| `--no-reload` | | Disable auto-reload |
| `--n-jobs-per-worker INTEGER` | | Number of jobs per worker. Default is 10 |
| `--debug-port INTEGER` | | Port for debugger to listen on |
| `--wait-for-client` | `False` | Wait for a debugger client to connect to the debug port before starting the server |
| `--no-browser` | | Skip automatically opening the browser when the server starts |
| `--studio-url TEXT` | | URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com |
| `--allow-blocking` | `False` | Do not raise errors for synchronous I/O blocking operations in your code |
| `--tunnel` | `False` | Expose the local server via a public tunnel (Cloudflare) for remote frontend access. This avoids issues with browsers or networks blocking localhost connections |
| `--help` | | Display command documentation |
### `build`
+8
View File
@@ -55,6 +55,14 @@ Set `LANGSMITH_TRACING` to `false` to disable tracing to LangSmith.
Configure [log level](https://docs.python.org/3/library/logging.html#logging-levels). Defaults to `INFO`.
## `LOG_JSON`
Set `LOG_JSON` to `true` to render all log messages as JSON objects using the configured `JSONRenderer`. This produces structured logs that can be easily parsed or ingested by log management systems. Defaults to `false`.
## `LOG_COLOR`
This is mainly relevant in the context of using the dev server via the `langgraph dev` command. Set `LOG_COLOR` to `true` to enable ANSI-colored console output when using the default console renderer. Disabling color output by setting this variable to `false` produces monochrome logs. Defaults to `true`.
## `N_JOBS_PER_WORKER`
Number of jobs per worker for the LangGraph Server task queue. Defaults to `10`.
+3
View File
@@ -0,0 +1,3 @@
.safari {
color: #0070C9;
}
@@ -14,3 +14,4 @@ Errors referenced below will have an `lc_error_code` property corresponding to o
These guides provide troubleshooting information for errors that are specific to the LangGraph Platform.
- [INVALID_LICENSE](./INVALID_LICENSE.md)
- [Studio Errors](../studio.md)
+45
View File
@@ -0,0 +1,45 @@
# Troubleshooting LangGraph Studio
## :fontawesome-brands-safari:{ .safari } Safari connection error with local dev server
Safari blocks plain‑HTTP traffic on localhost. If you start Studio with a vanilla
`langgraph dev`, the page may report a "Failed to load assistants" error (or something similar) and the browser DevTools will show network errors.
#### Quick fix — run Studio through a secure Cloudflare tunnel
=== "Python"
```shell
pip install -U langgraph-cli>=0.2.6 # Python
langgraph dev --tunnel
```
=== "JS"
```shell
# Requires @langchain/langgraph-cli>=0.0.26
npx @langchain/langgraph-cli dev
```
The command prints a URL like:
```shell
https://smith.langchain.com/studio/?baseUrl=https://hamilton-praise-heart-costumes.trycloudflare.com
```
where
```shell
?baseUrl=https://hamilton-praise-heart-costumes.trycloudflare.com
```
indicates the endpoint where your agent server is exposed.
Open that URL in Safari and Studio should load immediately.
#### Alternative — use a Chromium‑based browser
Chrome, Edge, and Brave allow HTTP on localhost, so a plain `langgraph dev` should work without extra steps.
#### If it’s still not loading
1. Make sure the `baseUrl` query parameter in the studio URL points to the **tunnel URL** NOT to localhost.
2. Confirm your CLI version with `langgraph --version`.
No other configuration, certificates, or CORS tweaks are required.
+1 -7
View File
@@ -741,13 +741,7 @@
"from IPython.display import Image, display\n",
"from langchain_core.runnables.graph import MermaidDrawMethod\n",
"\n",
"display(\n",
" Image(\n",
" app.get_graph().draw_mermaid_png(\n",
" draw_method=MermaidDrawMethod.API,\n",
" )\n",
" )\n",
")"
"display(Image(app.get_graph().draw_mermaid_png()))"
]
},
{
+4 -2
View File
@@ -1,5 +1,5 @@
site_name: ""
site_description: Build language agents as graphs
site_name: "LangGraph"
site_description: Build reliable, stateful AI systems, without giving up control
site_url: https://langchain-ai.github.io/langgraph/
repo_url: https://github.com/langchain-ai/langgraph
edit_uri: edit/main/docs/docs/
@@ -400,6 +400,7 @@ nav:
- troubleshooting/errors/MULTIPLE_SUBGRAPHS.md
- troubleshooting/errors/INVALID_CHAT_HISTORY.md
- troubleshooting/errors/INVALID_LICENSE.md
- troubleshooting/studio.md
- LangGraph Academy Course: https://academy.langchain.com/courses/intro-to-langgraph
- Agents:
@@ -549,3 +550,4 @@ copyright: >
Copyright &copy; 2025 LangChain, Inc | <a href="#__consent">Consent Preferences</a>
extra_css:
- stylesheets/version_admonitions.css
- stylesheets/logos.css
+6 -8
View File
@@ -3387,14 +3387,14 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10"
[[package]]
name = "langchain-core"
version = "0.3.52"
version = "0.3.54"
description = "Building applications with LLMs through composability"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["docs", "test"]
files = [
{file = "langchain_core-0.3.52-py3-none-any.whl", hash = "sha256:cd137109c1e3d04f5a582c2cae9539b2cd5e4b795f486b58969dbc3d0387fe7c"},
{file = "langchain_core-0.3.52.tar.gz", hash = "sha256:f1981ec9efa4fceb11ff5ca57f5f9c8e22859cea3a94f8a044e6de8815afbd57"},
{file = "langchain_core-0.3.54-py3-none-any.whl", hash = "sha256:cd42155d9089e2fd4695ee02a4b2bc6daf55b9d4e1a37639647cf2455ed4fa04"},
{file = "langchain_core-0.3.54.tar.gz", hash = "sha256:55ce38939038e19b1271f36f512335462d7f64057b531598b3651d2b403e1b42"},
]
[package.dependencies]
@@ -3530,7 +3530,7 @@ langchain-core = ">=0.3.45,<1.0.0"
[[package]]
name = "langgraph"
version = "0.3.30"
version = "0.3.31"
description = "Building stateful, multi-actor applications with LLMs"
optional = false
python-versions = ">=3.9.0,<4.0"
@@ -3541,7 +3541,7 @@ develop = true
[package.dependencies]
langchain-core = ">=0.1,<0.4"
langgraph-checkpoint = "^2.0.10"
langgraph-prebuilt = ">=0.1.1,<0.2"
langgraph-prebuilt = ">=0.1.8,<0.2"
langgraph-sdk = "^0.1.42"
xxhash = "^3.5.0"
@@ -5987,7 +5987,6 @@ optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"},
{file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"},
]
@@ -5999,7 +5998,6 @@ optional = false
python-versions = ">=3.8"
groups = ["test"]
files = [
{file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"},
{file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"},
]
@@ -8902,4 +8900,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = "^3.10"
content-hash = "45bbc644a3b878063f5cbb75eed56540423315784f8dd42cfd3937c910dfc9c5"
content-hash = "36d7e4c4eba50d5e4dfb2e99964d7b51fe17d36238a912765cca8fc360216079"
+1
View File
@@ -43,6 +43,7 @@ langchain-cohere = "^0.4.2"
[tool.poetry.group.test.dependencies]
langchain = "^0.3.8"
langchain-core = "^0.3.54"
langchain-openai = "^0.3.7"
langchain-anthropic = "^0.3.8"
langchain-nomic = "^0.1.3"
@@ -1320,7 +1320,7 @@ def _ensure_index_config(
index_config = index_config.copy()
tokenized: list[tuple[str, Union[Literal["$"], list[str]]]] = []
tot = 0
text_fields = index_config.get("text_fields") or ["$"]
text_fields = index_config.get("fields") or ["$"]
if isinstance(text_fields, str):
text_fields = [text_fields]
if not isinstance(text_fields, list):
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint-postgres"
version = "2.0.20"
version = "2.0.21"
description = "Library with a Postgres implementation of LangGraph checkpoint saver."
authors = []
license = "MIT"
@@ -377,7 +377,7 @@ async def _create_vector_store(
"vector_type": vector_type,
},
"distance_type": distance_type,
"text_fields": text_fields,
"fields": text_fields,
}
async with await AsyncConnection.connect(
+1 -1
View File
@@ -401,7 +401,7 @@ def _create_vector_store(
"vector_type": vector_type,
},
"distance_type": distance_type,
"text_fields": text_fields,
"fields": text_fields,
}
with Connection.connect(admin_conn_string, autocommit=True) as conn:
+10
View File
@@ -572,6 +572,14 @@ def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -
help="Don't raise errors for synchronous I/O blocking operations in your code.",
default=False,
)
@click.option(
"--tunnel",
is_flag=True,
help="Expose the local server via a public tunnel (in this case, Cloudflare) "
"for remote frontend access. This avoids issues with browsers "
"or networks blocking localhost connections.",
default=False,
)
@cli.command(
"dev",
help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
@@ -588,6 +596,7 @@ def dev(
wait_for_client: bool,
studio_url: Optional[str],
allow_blocking: bool,
tunnel: bool,
):
"""CLI entrypoint for running the LangGraph API server."""
try:
@@ -655,6 +664,7 @@ def dev(
ui_config=config_json.get("ui_config"),
studio_url=studio_url,
allow_blocking=allow_blocking,
tunnel=tunnel,
)
+551 -392
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.2.5"
version = "0.2.6"
description = "CLI for interacting with LangGraph API"
authors = []
license = "MIT"
@@ -14,7 +14,7 @@ langgraph = "langgraph_cli.cli:cli"
[tool.poetry.dependencies]
python = "^3.9.0,<4.0"
click = "^8.1.7"
langgraph-api = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-api = { version = ">=0.1.12,<0.2.0", optional = true, python = ">=3.11,<4.0" }
langgraph-runtime-inmem = { version = ">=0.0.1,<0.1.0", optional = true, python = ">=3.11,<4.0" }
langgraph-sdk = { version = ">=0.1.0,<0.2.0", optional = true, python = ">=3.11,<4.0" }
python-dotenv = { version = ">=0.8.0", optional = true }
+166 -24
View File
@@ -5,23 +5,25 @@ from inspect import isclass
from typing import (
Any,
Callable,
Hashable,
Optional,
Type,
TypeVar,
Union,
get_args,
get_origin,
get_type_hints,
)
from pydantic import BaseModel
from pydantic import BaseModel, Discriminator
from pydantic.fields import FieldInfo
from pydantic.v1 import BaseModel as BaseModelV1
from typing_extensions import Annotated
from typing_extensions import Annotated, Literal
__all__ = ["SchemaCoercionMapper"]
logger = logging.getLogger(__name__)
_cache: weakref.WeakKeyDictionary[Type[Any], dict[int, "SchemaCoercionMapper"]] = (
weakref.WeakKeyDictionary()
)
@@ -61,7 +63,9 @@ class SchemaCoercionMapper:
self.type_hints = (
type_hints
if type_hints is not None
else get_type_hints(schema, localns={schema.__name__: schema})
else get_type_hints(
schema, localns={schema.__name__: schema}, include_extras=True
)
)
if issubclass(schema, BaseModelV1):
@@ -70,6 +74,17 @@ class SchemaCoercionMapper:
for n, f in schema.__fields__.items()
}
self._construct = schema.construct
unhandled_attrs = (
"__pre_root_validators__",
"__post_root_validators__",
"__validators__",
)
if any(getattr(schema, c, None) for c in unhandled_attrs):
self.coerce: Callable[[Any, Any], Union[BaseModelV1, BaseModel]] = (
lambda v, _: schema(**v)
)
else:
self.coerce = self._coerce
elif issubclass(schema, BaseModel):
self._fields = {
@@ -77,6 +92,13 @@ class SchemaCoercionMapper:
for n, f in schema.model_fields.items()
}
self._construct: Callable[..., Any] = schema.model_construct # type: ignore
unhandled_attrs = ("validators", "field_validators", "root_validators")
if (decorators := getattr(schema, "__pydantic_decorators__", None)) and any(
getattr(decorators, attr, None) for attr in unhandled_attrs
):
self.coerce = lambda v, _: schema.model_validate(v)
else:
self.coerce = self._coerce
else:
raise TypeError("Schema is neither a Pydantic v1 nor v2 model.")
@@ -86,7 +108,7 @@ class SchemaCoercionMapper:
def __call__(self, input_data: Any, depth: Optional[int] = None) -> Any:
return self.coerce(input_data, depth)
def coerce(self, input_data: Any, depth: Optional[int] = None) -> Any:
def _coerce(self, input_data: Any, depth: Optional[int] = None) -> Any:
if depth is None:
depth = self.max_depth
if not isinstance(input_data, dict) or depth <= 0:
@@ -109,15 +131,38 @@ class SchemaCoercionMapper:
if depth == 0:
return self._passthrough
field_type, metadata = self._unwrap_annotated(field_type)
origin = get_origin(field_type)
if (field_type in _IDENTITY_TYPES) or (origin in _IDENTITY_TYPES):
return self._passthrough
if origin is Annotated:
real_type, *_ = get_args(field_type)
sub = self._build_coercer(real_type, depth - 1)
return lambda v, d: sub(v, d)
if isinstance(field_type, TypeVar):
concrete = self.type_hints.get(field_type) # type: ignore
if concrete is not None:
return self._build_coercer(concrete, depth - 1)
return self._passthrough
if hasattr(field_type, "__parameters__") and hasattr(
field_type, "model_fields"
):
try:
type_hints = self.resolve_concrete_type_hints(field_type)
def generic_model_coercer(v: Any, d: int) -> Any:
if not isinstance(v, dict):
if throw:
raise TypeError(
f"Expected dict for {field_type}, got {type(v)}"
)
return v
mapper = SchemaCoercionMapper(field_type, type_hints, max_depth=d)
return mapper.coerce(v, d)
return generic_model_coercer
except Exception as e:
logger.debug(f"Generic type resolution failed: {e}")
return self._passthrough
if isclass(field_type):
# This is needed bcs. of issubclass issues on older versions of python
@@ -162,6 +207,7 @@ class SchemaCoercionMapper:
return {sub(x, d - 1) for x in v}
return set_coercer
if origin is dict or field_type is dict:
args = get_args(field_type)
if len(args) != 2:
@@ -200,27 +246,75 @@ class SchemaCoercionMapper:
)
if origin is Union:
uargs = get_args(field_type)
subs, none_in_union = [], False
for ix, arg in enumerate(uargs):
args = get_args(field_type)
discriminator_key = self._extract_discriminator_key(metadata)
none_in_union = False
discriminator_map = {}
for arg in args:
if arg is type(None):
none_in_union = True
else:
subs.append(
self._build_coercer(arg, depth - 1, throw=ix < len(uargs) - 1)
)
continue
base_type = arg
if get_origin(arg) is Annotated:
base_type, _ = get_args(arg)[0], get_args(arg)[1:]
try:
hint = get_type_hints(base_type)
lit = hint.get(discriminator_key)
if get_origin(lit) is Literal:
for val in get_args(lit):
discriminator_map[val] = base_type
except Exception as e:
if throw:
raise e
else:
logger.debug(f"Failed to extract discriminator: {e}")
def union_coercer(v: Any, d: Any) -> Any:
if v is None and none_in_union:
return None
err = None
for sp in subs:
tag = None
if callable(discriminator_key):
try:
return sp(v, d - 1)
except TypeError as e:
err = e
if err:
raise err
tag = discriminator_key(v)
except Exception as e:
logger.debug(f"Failed to call discriminator func: {e}")
elif (
isinstance(v, dict)
and isinstance(discriminator_key, str)
and discriminator_key in v
):
tag = v[discriminator_key]
if tag is not None:
for arg in args:
base_type = arg
if get_origin(arg) is Annotated:
base_type, _ = get_args(arg)[0], get_args(arg)[1:]
try:
if issubclass(base_type, (BaseModel, BaseModelV1)):
return SchemaCoercionMapper(
base_type, max_depth=d
).coerce(v, d)
except Exception as e:
logger.debug(
f"Coercion with {base_type} failed for tag={tag}: {e}"
)
continue
# fallback: try coercing each branch
for arg in args:
try:
sub = self._build_coercer(arg, d - 1)
return sub(v, d - 1)
except Exception as e:
if throw:
raise e
else:
logger.debug(f"Fallback coercion failed for arg={arg}: {e}")
return v
return union_coercer
@@ -232,10 +326,58 @@ class SchemaCoercionMapper:
def _passthrough(v: Any, _d: Any) -> Any: # noqa: D401
return v
@staticmethod
def _extract_discriminator_key(meta: list[Any]) -> str | Callable[[Any], Hashable]:
"""Extract discriminator field name or function from Annotated metadata"""
for m in meta:
if isinstance(m, FieldInfo):
disc = getattr(m, "discriminator", None)
if isinstance(disc, Discriminator):
return disc.discriminator
elif isinstance(disc, str):
return disc
return "type"
@staticmethod
def _unwrap_annotated(tp: Any) -> tuple[Any, list[Any]]:
"""Unwrap nested Annotated types, extracting the base type and all metadata"""
metadata = []
while get_origin(tp) is Annotated:
tp, *meta = get_args(tp)
metadata.extend(meta)
return tp, metadata
@staticmethod
def resolve_concrete_type_hints(generic_model_type: Any) -> dict[Any, Any]:
"""Resolve concrete type hints in a generic model"""
origin = get_origin(generic_model_type)
args = get_args(generic_model_type)
param_names = getattr(origin, "__parameters__", [])
if not args or not param_names:
return {}
type_map = dict(zip(param_names, args))
result = {}
for field_name, model_field in origin.model_fields.items():
anno = model_field.annotation
if get_origin(anno) is Annotated:
base, *meta = get_args(anno)
if isinstance(base, TypeVar) and base in type_map:
result[field_name] = Annotated[type_map[base], *meta]
else:
result[field_name] = anno
elif isinstance(anno, TypeVar) and anno in type_map:
result[field_name] = type_map[anno]
else:
result[field_name] = anno
return result
_adapter_cache: dict[Any, Callable[[Any], Any]] = {}
_IDENTITY_TYPES: tuple[type[Any], ...] = (
int,
float,
+96 -14
View File
@@ -1339,22 +1339,26 @@ def test_pending_writes_resume(
"configurable": {
"thread_id": "1",
"checkpoint_ns": "",
"checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"]
if checkpoint_during
else AnyStr(),
"checkpoint_id": (
checkpoints[2].config["configurable"]["checkpoint_id"]
if checkpoint_during
else AnyStr()
),
}
},
pending_writes=UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
(AnyStr(), "value", 3),
)
if checkpoint_during
else UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
# the write against the previous checkpoint is not saved, as it is
# produced in a run where only the next checkpoint (the last) is saved
pending_writes=(
UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
(AnyStr(), "value", 3),
)
if checkpoint_during
else UnsortedSequence(
(AnyStr(), "value", 2),
(AnyStr(), "__error__", 'ConnectionError("I\'m not good")'),
# the write against the previous checkpoint is not saved, as it is
# produced in a run where only the next checkpoint (the last) is saved
)
),
)
if not checkpoint_during:
@@ -3119,8 +3123,10 @@ def test_nested_pydantic_models(version: str) -> None:
from pydantic import ( # type: ignore
BaseModel,
ByteSize,
Discriminator,
Field,
SecretStr,
Tag,
confloat,
conint,
conlist,
@@ -3169,6 +3175,16 @@ def test_nested_pydantic_models(version: str) -> None:
conlist_type = conlist(item_type=int, min_length=2, max_length=5)
else:
conlist_type = conlist(item_type=int, min_items=2, max_items=5)
if version == "v2":
FuncDiscriminatorPet = Annotated[
Union[
Annotated[Dog, Tag(tag="dog")],
Annotated[Cat, Tag(tag="cat")],
],
Field(discriminator=Discriminator(lambda obj: obj.get("pet_type"))),
]
else:
FuncDiscriminatorPet = Union[Dog, Cat]
class State(BaseModel):
# Basic nested model tests
@@ -3208,6 +3224,7 @@ def test_nested_pydantic_models(version: str) -> None:
pattern: re.Pattern
secret: SecretStr
file_size: ByteSize
discriminated_pet: FuncDiscriminatorPet
# Constrained types
positive_value: PositiveInt
@@ -3279,6 +3296,7 @@ def test_nested_pydantic_models(version: str) -> None:
"pattern": "^test$",
"secret": "password123",
"file_size": 1024,
"discriminated_pet": {"pet_type": "cat", "meow": "indubitably"},
# Constrained types
"positive_value": 42,
"non_negative": 0.0,
@@ -3355,6 +3373,70 @@ def test_nested_pydantic_models(version: str) -> None:
assert {**new_inputs, **update} == graph.invoke(new_inputs.copy())
def test_pydantic_state_field_validator():
from pydantic import BaseModel, field_validator, model_validator
class State(BaseModel):
name: str
text: str = ""
only_root: int = 13
@field_validator("name", mode="after")
@classmethod
def validate_name(cls, value):
if value[0].islower():
raise ValueError("Name must start with a capital letter")
return "Validated " + value
@model_validator(mode="before")
@classmethod
def validate_amodel(cls, values: "State"):
return values | {"only_root": 392}
input_state = {"name": "John"}
def process_node(state: State):
assert State.model_validate(input_state) == state
return {"text": "Hello, " + state.name + "!"}
builder = StateGraph(state_schema=State)
builder.add_node("process", process_node)
builder.add_edge(START, "process")
builder.add_edge("process", END)
g = builder.compile()
res = g.invoke(input_state)
assert res["text"] == "Hello, Validated John!"
def test_pydantic_v1_state_root_validator():
from pydantic.v1 import BaseModel, root_validator
class State(BaseModel):
name: str
text: str = ""
only_root: int = 13
@root_validator(pre=True)
@classmethod
def validate(cls, values: dict):
values["name"] = "Validated " + values["name"]
return values | {"only_root": 396}
input_state = {"name": "John"}
def process_node(state: State):
assert State(**input_state) == state
return {"text": "Hello, " + state.name + "!"}
builder = StateGraph(state_schema=State)
builder.add_node("process", process_node)
builder.add_edge(START, "process")
builder.add_edge("process", END)
g = builder.compile()
res = g.invoke(input_state)
assert res["text"] == "Hello, Validated John!"
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_in_one_fan_out_state_graph_waiting_edge_plus_regular(
request: pytest.FixtureRequest, checkpointer_name: str
@@ -0,0 +1,197 @@
from typing import Dict, Generic, List, Literal, Optional, Set, Tuple, TypeVar, Union
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
from pydantic import BaseModel, Discriminator, Field, Tag
from typing_extensions import Annotated
from langgraph.graph.schema_utils import SchemaCoercionMapper
def test_any_message():
class MyMessage(BaseModel):
msg: List[AnyMessage]
data = {
"msg": [
{"type": "human", "content": "Hello"},
{"type": "ai", "content": "Hi there!"},
]
}
MyMessage.model_validate(data)
mapper = SchemaCoercionMapper(MyMessage)
result = mapper(data)
assert isinstance(result, MyMessage)
assert isinstance(result.msg, list)
assert len(result.msg) == 2
assert isinstance(result.msg[0], (HumanMessage))
assert isinstance(result.msg[1], (AIMessage))
# ==== 基础模型 ====
class SimpleModel(BaseModel):
name: str
age: int
def test_simple_model():
data = {"name": "Alice", "age": 30}
mapper = SchemaCoercionMapper(SimpleModel)
result = mapper(data)
assert isinstance(result, SimpleModel)
assert result.name == "Alice"
assert result.age == 30
# ==== 容器类型 ====
class ContainerModel(BaseModel):
items: List[int]
mapping: Dict[str, float]
tags: Set[str]
coords: Tuple[int, int]
def test_container_model():
data = {
"items": [1, 2, 3],
"mapping": {"a": 1.1},
"tags": ["x", "y"],
"coords": [10, 20],
}
mapper = SchemaCoercionMapper(ContainerModel)
result = mapper(data)
assert isinstance(result.items, list)
assert isinstance(result.mapping, dict)
assert isinstance(result.tags, set)
assert isinstance(result.coords, tuple)
# ==== 泛型 ====
T = TypeVar("T")
class Wrapper(BaseModel, Generic[T]):
value: T
def test_generic_model():
class IntWrapper(Wrapper[int]):
pass
data = {"value": 123}
mapper = SchemaCoercionMapper(IntWrapper)
result = mapper(data)
assert result.value == 123
# ==== Union 类型 ====
class Dog(BaseModel):
type: Literal["dog"]
age: int
class Cat(BaseModel):
type: Literal["cat"]
name: str
Pet = Union[Dog, Cat]
class Owner(BaseModel):
pet: Pet
def test_union_type():
data = {"pet": {"type": "dog", "age": 5}}
mapper = SchemaCoercionMapper(Owner)
result = mapper(data)
assert isinstance(result.pet, Dog)
# ==== Annotated + Tag + discriminator ====
TaggedPet = Annotated[
Union[
Annotated[Dog, Tag(tag="dog")],
Annotated[Cat, Tag(tag="cat")],
],
Field(discriminator="type"),
]
class TaggedOwner(BaseModel):
pet: TaggedPet
def test_tagged_union():
data = {"pet": {"type": "cat", "name": "Mimi"}}
mapper = SchemaCoercionMapper(TaggedOwner)
result = mapper(data)
assert isinstance(result.pet, Cat)
# ==== Annotated + Field(discriminator=Discriminator(func)) ====
def _get_type(obj):
return obj.get("type")
FuncDiscriminatorPet = Annotated[
Union[
Annotated[Dog, Tag(tag="dog")],
Annotated[Cat, Tag(tag="cat")],
],
Field(discriminator=Discriminator(_get_type)),
]
class FuncOwner(BaseModel):
pet: FuncDiscriminatorPet
def test_func_discriminator():
data = {"pet": {"type": "dog", "age": 9}}
mapper = SchemaCoercionMapper(FuncOwner)
result = mapper(data)
assert isinstance(result.pet, Dog)
# ==== Optional + 泛型 + 多态嵌套 ====
class Box(BaseModel, Generic[T]):
content: Optional[T]
class Crate(BaseModel, Generic[T]):
payload: Box[T]
class Zoo(BaseModel):
animal: Box[TaggedPet]
class Warehouse(BaseModel):
cage: Crate[TaggedPet]
def test_nested_optional_generic_union():
# Box[TaggedPet]
data1 = {"animal": {"content": {"type": "cat", "name": "Kitty"}}}
mapper1 = SchemaCoercionMapper(Zoo)
result1 = mapper1(data1)
assert isinstance(result1.animal.content, Cat)
# Crate[TaggedPet]
data2 = {"cage": {"payload": {"content": {"type": "dog", "age": 8}}}}
mapper2 = SchemaCoercionMapper(Warehouse)
result2 = mapper2(data2)
assert isinstance(result2.cage.payload.content, Dog)
# Optional None
data3 = {"animal": {"content": None}}
result3 = mapper1(data3)
assert result3.animal.content is None
# deeply nested Optional
data4 = {"cage": {"payload": {"content": None}}}
result4 = mapper2(data4)
assert result4.cage.payload.content is None
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.67",
"version": "0.0.70",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
+27 -17
View File
@@ -1,42 +1,41 @@
import {
Assistant,
AssistantGraph,
AssistantVersion,
CancelAction,
Checkpoint,
Config,
Cron,
CronCreateForThreadResponse,
CronCreateResponse,
DefaultValues,
GraphSchema,
Item,
ListNamespaceResponse,
Metadata,
Run,
RunStatus,
SearchItemsResponse,
Subgraphs,
Thread,
ThreadState,
Cron,
AssistantVersion,
Subgraphs,
Checkpoint,
SearchItemsResponse,
ListNamespaceResponse,
Item,
ThreadStatus,
CronCreateResponse,
CronCreateForThreadResponse,
} from "./schema.js";
import { AsyncCaller, AsyncCallerParams } from "./utils/async_caller.js";
import { IterableReadableStream } from "./utils/stream.js";
import type {
Command,
CronsCreatePayload,
OnConflictBehavior,
RunsCreatePayload,
RunsStreamPayload,
RunsWaitPayload,
StreamEvent,
CronsCreatePayload,
OnConflictBehavior,
Command,
} from "./types.js";
import { mergeSignals } from "./utils/signals.js";
import type { StreamMode, TypedAsyncGenerator } from "./types.stream.js";
import { AsyncCaller, AsyncCallerParams } from "./utils/async_caller.js";
import { getEnvironmentVariable } from "./utils/env.js";
import { _getFetchImplementation } from "./singletons/fetch.js";
import type { TypedAsyncGenerator, StreamMode } from "./types.stream.js";
import { mergeSignals } from "./utils/signals.js";
import { BytesLineDecoder, SSEDecoder } from "./utils/sse.js";
import { IterableReadableStream } from "./utils/stream.js";
/**
* Get the API key from the environment.
* Precedence:
@@ -619,6 +618,15 @@ export class ThreadsClient<
* Must be one of 'idle', 'busy', 'interrupted' or 'error'.
*/
status?: ThreadStatus;
/**
* Sort by.
*/
sortBy?: "thread_id" | "status" | "created_at" | "updated_at";
/**
* Sort order.
* Must be one of 'asc' or 'desc'.
*/
sortOrder?: "asc" | "desc";
}): Promise<Thread<ValuesType>[]> {
return this.fetch<Thread<ValuesType>[]>("/threads/search", {
method: "POST",
@@ -627,6 +635,8 @@ export class ThreadsClient<
limit: query?.limit ?? 10,
offset: query?.offset ?? 0,
status: query?.status,
sort_by: query?.sortBy,
sort_order: query?.sortOrder,
},
});
}
+5 -1
View File
@@ -175,7 +175,11 @@ export function LoadExternalComponent({
}, [uiClient, uiNamespace, message.name, shadowRootId, hasClientComponent]);
if (hasClientComponent) {
return React.createElement(clientComponent, message.props);
return (
<UseStreamContext.Provider value={{ stream, meta }}>
{React.createElement(clientComponent, message.props)}
</UseStreamContext.Provider>
);
}
return (
+10
View File
@@ -1043,6 +1043,10 @@ class ThreadsClient:
status: Optional[ThreadStatus] = None,
limit: int = 10,
offset: int = 0,
sort_by: Optional[
Literal["thread_id", "status", "created_at", "updated_at"]
] = None,
sort_order: Optional[Literal["asc", "desc"]] = None,
headers: Optional[dict[str, str]] = None,
) -> list[Thread]:
"""Search for threads.
@@ -1054,6 +1058,8 @@ class ThreadsClient:
Must be one of 'idle', 'busy', 'interrupted' or 'error'.
limit: Limit on number of threads to return.
offset: Offset in threads table to start search from.
sort_by: Sort by field.
sort_order: Sort order.
headers: Optional custom headers to include with the request.
Returns:
@@ -1079,6 +1085,10 @@ class ThreadsClient:
payload["values"] = values
if status:
payload["status"] = status
if sort_by:
payload["sort_by"] = sort_by
if sort_order:
payload["sort_order"] = sort_order
return await self.http.post(
"/threads/search",
json=payload,
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-sdk"
version = "0.1.61"
version = "0.1.63"
description = "SDK for interacting with LangGraph API"
authors = []
license = "MIT"