Compare commits

...
Author SHA1 Message Date
William FHandGitHub cbf26a5d98 Fix indentation in docstring (#3727) 2025-03-06 13:35:20 -08:00
William FHandGitHub 09bd5990d4 Add TTL option for store items (#3704) 2025-03-06 13:20:32 -08:00
Nuno CamposandGitHub 79595d43a5 feat: bump sdk versions js and py (#3725) 2025-03-06 11:16:12 -08:00
Arjun Natarajan 49a6704bdc bump sdk versions js and py 2025-03-06 14:06:03 -05:00
David DuongandGitHub a430b7fcfb feat(cli): allow sending UI args (#3722) 2025-03-06 19:59:05 +01:00
Nuno CamposandGitHub 48c287d107 feat: update assistant version class (#3702) 2025-03-06 10:21:51 -08:00
Tat Dat Duong 0fdc787597 Bump to 0.1.75 2025-03-06 18:04:21 +01:00
Tat Dat Duong 3bd86fc6f5 Update schema 2025-03-06 17:57:51 +01:00
Tat Dat Duong f1024f7341 feat(cli): allow sending UI args 2025-03-06 17:55:42 +01:00
David DuongandGitHub f9ac88012f feat(sdk-js): loading external components (#3689)
- **chore: use prepack hook instead of prepublish**
- **feat(sdk-js): add react-ui implementation**
- **Add apiUrl, assistantId to props**
- **Bump to 0.0.46-experimental.0**
2025-03-06 17:11:50 +01:00
Tat Dat Duong 9d0186b5bf Bump to 0.0.47 2025-03-06 17:05:02 +01:00
Tat Dat Duong 40062c40df Add fallback for components defined at client level 2025-03-06 17:05:02 +01:00
Tat Dat Duong 36bbe059df Bump to 0.0.47-experimental.0 2025-03-06 17:05:01 +01:00
Tat Dat Duong 52627010cb Cache promises 2025-03-06 17:05:01 +01:00
Tat Dat Duong f6781d19ab Undo version experimental bump 2025-03-06 17:05:01 +01:00
Tat Dat Duong fcf134a452 Remove @langchain/langgraph-sdk/react-ui/types entrypoint 2025-03-06 17:05:01 +01:00
Tat Dat Duong 80331b88e0 Remove a nesting level 2025-03-06 17:05:01 +01:00
Tat Dat Duong b37f894f38 Stabilise boostrapping UI context 2025-03-06 17:05:01 +01:00
Tat Dat Duong 79de3dbad3 Fix require symbol 2025-03-06 17:05:00 +01:00
Tat Dat Duong 677fd3ce28 Update entrypoint 2025-03-06 17:05:00 +01:00
Tat Dat Duong 4ee863f30d Reexport as @langchain/langgraph-sdk/react-ui 2025-03-06 17:05:00 +01:00
Tat Dat Duong 7e060f88a2 Introduce useStreamContext 2025-03-06 17:05:00 +01:00
Tat Dat Duong f49856af0b Fix types for collect 2025-03-06 17:05:00 +01:00
Tat Dat Duong 5a7d384e2f Bump to 0.0.46-experimental.0 2025-03-06 17:05:00 +01:00
Tat Dat Duong b73b34ddd5 Add apiUrl, assistantId to props 2025-03-06 17:04:59 +01:00
Tat Dat Duong c9ffd753f5 feat(sdk-js): add react-ui implementation 2025-03-06 17:04:59 +01:00
Tat Dat Duong e85e157e8f chore: use prepack hook instead of prepublish 2025-03-06 17:04:59 +01:00
Vadym BardaandGitHub 88e7868885 prebuilt: release 0.1.2 (#3708) 2025-03-05 21:40:58 -05:00
Vadym BardaandGitHub e8dd682320 prebuilt: allow passing RunnableSequence as a model (#3706) 2025-03-06 02:37:55 +00:00
Nuno CamposandGitHub 75143b966c Make pydntic input test stricter (#3703)
- Now tests a model with inherited fields
2025-03-05 15:30:25 -08:00
Nuno Campos 490e1aab3b Don't enforce stream order 2025-03-05 15:21:16 -08:00
Arjun Natarajan 003226cef4 expose assistantbase 2025-03-05 16:47:18 -05:00
Arjun Natarajan 098a199cb9 update assistant version class 2025-03-05 15:39:34 -05:00
Nuno Campos 97f6f45993 Make pydntic input test stricter
- Now tests a model with inherited fields
2025-03-05 11:15:08 -08:00
Xiangyu YinandGitHub e8631c052a Update packages.yml to propose a new entry (#3629)
Hi, I have built a package `nodeology` that empowers researchers to
rapidly develop, test, adapt, and execute foundation AI-integrated
scientific workflows by leveraging langgraph's state machine framework.
Please take a look and let me know if it can be added into this list.
Thank you :)
2025-03-05 16:00:16 +00:00
David DuongandGitHub 16a86c8b8e feat(sdk-js): bump to 0.0.46 (#3693) 2025-03-05 11:12:50 +01:00
Tat Dat Duong 5fa6bb5f55 feat(sdk-js): bump to 0.0.46 2025-03-05 11:08:59 +01:00
David DuongandGitHub d29e9e22c7 feat(sdk-js): useStream expose callerOptions and defaultHeaders (#3688) 2025-03-05 10:42:57 +01:00
Tat Dat Duong 6990e1fcf5 retrigger checks 2025-03-05 10:36:45 +01:00
David DuongandGitHub 22e60c47cc fix(sdk-js): stream intermediate values with messages-tuple (#3664)
We still need intermediate messages to ensure the client state and
server state is in-sync as quickly as possible.
2025-03-05 10:33:05 +01:00
Nuno CamposandGitHub d9396c38ea chore(langgraph): fix typing of task decorator (#3670)
An async function of the form `def foo(P) -> T` has type `Callable[[P],
Awaitable[T]]`. The old type annotations then converted the function
into a `Callable[[P], SyncAsyncFuture[Awaitable[T]]]` which is
incorrect.

The change introduced in this commit updates the type annotations to
ensure the `Awaitable[T]` is correctly unwrapped.

I've tested it locally and confirmed it work on:

```python
@task 
def sync_fn(a: int) -> int: ...

@task 
def async_fn(a: int) -> int: ...
```

Let me know if you want me to add tests, just let me know how you test
type annotations.
2025-03-04 17:05:35 -08:00
Vadym BardaandGitHub ff60ee8c9a langgraph: release 0.3.5 (#3690) 2025-03-04 19:29:05 -05:00
Vadym BardaandGitHub 8761721fb9 langgraph: do not pass subgraph state on Command.parent updates (#3686) 2025-03-04 19:21:03 -05:00
William FHandGitHub de85e7c246 Add json schema to CLI (#3684)
So you have cute IDE autocomplete / language server checking.
2025-03-04 23:48:27 +00:00
David DuongandGitHub d333f4438f fix(sdk-js): handle threadId: undefined as controlled (#3687) 2025-03-05 00:46:26 +01:00
Tat Dat Duong af14a2abbc feat(sdk-js): useStream expose callerOptions and defaultHeaders 2025-03-05 00:29:25 +01:00
Tat Dat Duong e466c2c90c fix(sdk-js): handle threadId: undefined as controlled 2025-03-05 00:20:59 +01:00
Nuno Campos 815a67ef55 0.3.4 2025-03-04 14:33:27 -08:00
Nuno CamposandGitHub 38f1b415a0 When rehydrating a pydantic module, fallback to returning the kwargs dict (#3685)
- when the class can't be found, or can't be constructed, fallback to
returning the kwargs dict, instead of returning nothing
2025-03-04 14:31:57 -08:00
Nuno Campos ed78174adf Lint 2025-03-04 14:21:18 -08:00
Nuno Campos 5da6971a95 When rehydrating a pydantic module, fallback to returning the kwargs dict
- when the class can't be found, or can't be constructed, fallback to returning the kwargs dict, instead of returning nothing
2025-03-04 14:10:49 -08:00
256e92bfb3 Pregel.config_schema should use config_type directly when present (#3641)
- the previous behavior of re-creating model through config_specs would
lose custom annotations on config_type

---------

Co-authored-by: Eugene Yurtsev <eyurtsev@gmail.com>
2025-03-04 16:14:54 -05:00
Vadym BardaandGitHub 3d4e5c0471 sdk-py: fix decode_json in sdk (#3681) 2025-03-04 20:05:16 +00:00
ccurmeandGitHub 013a12334e docs: document langgraph-bigtool (#3682) 2025-03-04 20:03:13 +00:00
ccurmeandGitHub c7211e03e9 docs: fix typo (#3677)
https://platform.openai.com/docs/guides/embeddings#embedding-models
2025-03-04 19:05:56 +00:00
Nuno Campos ffc916e38c 0.3.3 2025-03-04 10:22:01 -08:00
Nuno CamposandGitHub 03bf149ebd Retry condition for resuming flag should apply only to top graphs (#3676) 2025-03-04 10:20:35 -08:00
Nuno Campos 137dcce5b5 Retry condition for resuming flag should apply only to top graphs 2025-03-04 09:51:13 -08:00
JP-EllisandGitHub 48164a95da chore: whitespace cleanup (#3671)
Uncovered while working on #3670. Feel free to close if too minor to
merge.

Signed-off-by: JP-Ellis <josh@jpellis.me>
2025-03-04 23:45:47 +13:00
JP-Ellis 3926e83884 chore(langgraph): fix typing of task decorator
An async function of the form `def foo(P) -> T` has type `Callable[[P],
Awaitable[T]]`. The old type annotations then converted the function
into a `Callable[[P], SyncAsyncFuture[Awaitable[T]]]` which is
incorrect.

The change introduced in this commit updates the type annotations to
ensure the `Awaitable[T]` is correctly unwrapped.

Signed-off-by: JP-Ellis <josh@jpellis.me>
2025-03-04 16:54:53 +11:00
Nuno CamposandGitHub 43709a16bf When retrying a previously attempted run, resume from previous checkpoint (#3668)
- Ignore input if being passed in when a checkpoint already exists for
that run_id
2025-03-03 17:43:11 -08:00
Nuno Campos d98c7248dc Oops 2025-03-03 17:33:27 -08:00
Nuno Campos ac2736f18e When retrying a previously attempted run, resume from previous checkpoint
- Ignore input if being passed in when a checkpoint already exists for that run_id
2025-03-03 17:31:45 -08:00
Tat Dat Duong fc130a52ef fix(sdk-js): stream intermediate values with messages-tuple
We still need intermediate messages to ensure the client state and server state is in-sync as quickly as possible.
2025-03-03 17:19:45 +01:00
44 changed files with 2911 additions and 340 deletions
+38
View File
@@ -114,6 +114,42 @@ jobs:
- name: Run check_sdk_methods script
run: python .github/scripts/check_sdk_methods.py
check-schema:
needs: changes
if: needs.changes.outputs.python == 'true'
name: "Check CLI schema hasn't changed #${{ matrix.python-version }}"
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- "3.11"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} + Poetry ${{ env.POETRY_VERSION }}
uses: "./.github/actions/poetry_setup"
with:
python-version: "3.11"
poetry-version: ${{ env.POETRY_VERSION }}
cache-key: schema-check-cli
- name: Install CLI dependencies
run: |
cd libs/cli
poetry install
- name: Generate schema and check for changes
run: |
cd libs/cli
# Create a temporary copy of the current schema
cp schemas/schema.json schemas/schema.current.json
# Generate new schema
poetry run python generate_schema.py
# Compare the new schema with the original
if ! diff -q schemas/schema.json schemas/schema.current.json > /dev/null; then
echo "Error: Langgraph.json configuration schema has changed. Please run 'poetry run python generate_schema.py' in the libs/cli directory and commit the changes."
diff schemas/schema.json schemas/schema.current.json
exit 1
fi
echo "Schema check passed - no changes detected"
integration-test:
needs: changes
if: needs.changes.outputs.python == 'true'
@@ -180,6 +216,8 @@ jobs:
test,
test-langgraph,
test-scheduler-kafka,
check-sdk-methods,
check-schema,
integration-test,
test-js,
]
+7 -1
View File
@@ -23,4 +23,10 @@ packages:
description: "Build swarm-style multi-agent systems using LangGraph."
- name: "delve-taxonomy-generator"
repo: "andrestorres123/delve"
description: "A taxonomy generator for unstructured data"
description: "A taxonomy generator for unstructured data"
- name: "nodeology"
repo: "xyin-anl/Nodeology"
description: "Enable researcher to build scientific workflows easily with simplified interface."
- name: "langgraph-bigtool"
repo: "langchain-ai/langgraph-bigtool"
description: "Build LangGraph agents with large numbers of tools."
@@ -17,7 +17,7 @@ This guide explains how to add semantic search to your LangGraph deployment's cr
...
"store": {
"index": {
"embed": "openai:text-embeddings-3-small",
"embed": "openai:text-embedding-3-small",
"dims": 1536,
"fields": ["$"]
}
@@ -27,7 +27,7 @@ This guide explains how to add semantic search to your LangGraph deployment's cr
This configuration:
- Uses OpenAI's text-embeddings-3-small model for generating embeddings
- Uses OpenAI's text-embedding-3-small model for generating embeddings
- Sets the embedding dimension to 1536 (matching the model's output)
- Indexes all fields in your stored data (`["$"]` means index everything, or specify specific fields like `["text", "metadata.title"]`)
@@ -20,7 +20,7 @@ from langgraph.store.base import (
)
from langgraph.store.base.batch import AsyncBatchedBaseStore
from langgraph.store.postgres.base import (
_PLACEHOLDER,
PLACEHOLDER,
BasePostgresStore,
PoolConfig,
PostgresIndexConfig,
@@ -360,7 +360,7 @@ class AsyncPostgresStore(AsyncBatchedBaseStore, BasePostgresStore[_ainternal.Con
for (idx, _), vector in zip(embedding_requests, vectors):
_paramslist = queries[idx][1]
for i in range(len(_paramslist)):
if _paramslist[i] is _PLACEHOLDER:
if _paramslist[i] is PLACEHOLDER:
_paramslist[i] = vector
for (idx, _), (query, params) in zip(search_ops, queries):
@@ -370,7 +370,7 @@ class BasePostgresStore(Generic[C]):
if op.query and self.index_config:
embedding_requests.append((idx, op.query))
score_operator, post_operator = _get_distance_operator(self)
score_operator, post_operator = get_distance_operator(self)
vector_type = (
cast(PostgresIndexConfig, self.index_config)
.get("ann_index_config", {})
@@ -430,10 +430,10 @@ class BasePostgresStore(Generic[C]):
OFFSET %s
"""
params = [
_PLACEHOLDER, # Vector placeholder
PLACEHOLDER, # Vector placeholder
*ns_args,
*filter_params,
_PLACEHOLDER,
PLACEHOLDER,
expanded_limit,
op.limit,
op.offset,
@@ -828,7 +828,7 @@ class PostgresStore(BaseStore, BasePostgresStore[_pg_internal.Conn]):
for (idx, _), embedding in zip(embedding_requests, embeddings):
_paramslist = queries[idx][1]
for i in range(len(_paramslist)):
if _paramslist[i] is _PLACEHOLDER:
if _paramslist[i] is PLACEHOLDER:
_paramslist[i] = embedding
for (idx, _), (query, params) in zip(search_ops, queries):
@@ -1055,7 +1055,7 @@ def _decode_ns_bytes(namespace: Union[str, bytes, list]) -> tuple[str, ...]:
return tuple(namespace.split("."))
def _get_distance_operator(store: Any) -> tuple[str, str]:
def get_distance_operator(store: Any) -> tuple[str, str]:
"""Get the distance operator and score expression based on config."""
# Note: Today, we are not using ANN indices due to restrictions
# on PGVector's support for mixing vector and non-vector filters
@@ -1121,4 +1121,4 @@ def _ensure_index_config(
return embeddings, index_config
_PLACEHOLDER = object()
PLACEHOLDER = object()
@@ -487,7 +487,12 @@ def _msgpack_ext_hook(code: int, data: bytes) -> Any:
except Exception:
return cls.construct(**tup[2])
except Exception:
return
# for pydantic objects we can't find/reconstruct
# let's return the kwargs dict instead
try:
return tup[2]
except NameError:
return
elif code == EXT_PYDANTIC_V2:
try:
tup = msgpack.unpackb(
@@ -500,7 +505,12 @@ def _msgpack_ext_hook(code: int, data: bytes) -> Any:
except Exception:
return cls.model_construct(**tup[2])
except Exception:
return
# for pydantic objects we can't find/reconstruct
# let's return the kwargs dict instead
try:
return tup[2]
except NameError:
return
def _msgpack_enc(data: Any) -> bytes:
@@ -166,6 +166,13 @@ class GetOp(NamedTuple):
"doc456" # For a document
```
"""
refresh_ttl: bool = True
"""Whether to refresh TTLs for the returned item.
If no TTL was specified for the original item(s),
or if TTL support is not enabled for your adapter,
this argument is ignored.
"""
class SearchOp(NamedTuple):
@@ -260,6 +267,13 @@ class SearchOp(NamedTuple):
- "technical documentation about REST APIs"
- "machine learning papers from 2023"
"""
refresh_ttl: bool = True
"""Whether to refresh TTLs for the returned item.
If no TTL was specified for the original item(s),
or if TTL support is not enabled for your adapter,
this argument is ignored.
"""
# Type representing a namespace path that can include wildcards
@@ -463,6 +477,15 @@ class PutOp(NamedTuple):
]
```
"""
ttl: Optional[float] = None
"""Controls the TTL (time-to-live) for the item in minutes.
If provided, and if the store you are using supports this feature, the item
will expire this many minutes after it was last accessed. The expiration timer
refreshes on both read operations (get/search) and write operations (put/update).
When the TTL expires, the item will be scheduled for deletion on a best-effort basis.
Defaults to None (no expiration).
"""
Op = Union[GetOp, SearchOp, PutOp, ListNamespacesOp]
@@ -612,8 +635,13 @@ class BaseStore(ABC):
by providing an `index` configuration at creation time. Without this
configuration, semantic search is disabled and any `index` arguments
to storage operations will have no effect.
Similarly, TTL (time-to-live) support is disabled by default.
Subclasses must explicitly set `supports_ttl = True` to enable this feature.
"""
supports_ttl = False
__slots__ = ("__weakref__",)
@abstractmethod
@@ -640,17 +668,21 @@ class BaseStore(ABC):
The order of results matches the order of input operations.
"""
def get(self, namespace: tuple[str, ...], key: str) -> Optional[Item]:
def get(
self, namespace: tuple[str, ...], key: str, *, refresh_ttl: bool = True
) -> Optional[Item]:
"""Retrieve a single item.
Args:
namespace: Hierarchical path for the item.
key: Unique identifier within the namespace.
refresh_ttl: Whether to refresh TTLs for the returned item.
If no TTL is specified, this argument is ignored.
Returns:
The retrieved item or None if not found.
"""
return self.batch([GetOp(namespace, key)])[0]
return self.batch([GetOp(namespace, key, refresh_ttl)])[0]
def search(
self,
@@ -661,6 +693,7 @@ class BaseStore(ABC):
filter: Optional[dict[str, Any]] = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: bool = True,
) -> list[SearchItem]:
"""Search for items within a namespace prefix.
@@ -670,6 +703,8 @@ class BaseStore(ABC):
filter: Key-value pairs to filter results.
limit: Maximum number of items to return.
offset: Number of items to skip before returning results.
refresh_ttl: Whether to refresh TTLs for the returned items.
If no TTL is specified, this argument is ignored.
Returns:
List of items matching the search criteria.
@@ -707,7 +742,9 @@ class BaseStore(ABC):
Note: Natural language search support depends on your store implementation
and requires proper embedding configuration.
"""
return self.batch([SearchOp(namespace_prefix, filter, limit, offset, query)])[0]
return self.batch(
[SearchOp(namespace_prefix, filter, limit, offset, query, refresh_ttl)]
)[0]
def put(
self,
@@ -715,6 +752,8 @@ class BaseStore(ABC):
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
*,
ttl: Optional[float] = None,
) -> None:
"""Store or update an item in the store.
@@ -735,12 +774,20 @@ class BaseStore(ABC):
- Nested fields: "metadata.title"
- Array access: "chapters[*].content" (each indexed separately)
- Specific indices: "authors[0].name"
ttl: Time to live in minutes. Support for this argument depends on your store adapter.
If specified, the item will expire after this many minutes from when it was last accessed.
None means no expiration. Expired runs will be deleted opportunistically.
By default, the expiration timer refreshes on both read operations (get/search)
and write operations (put/update), whenever the item is included in the operation.
Note:
Indexing support depends on your store implementation.
If you do not initialize the store with indexing capabilities,
the `index` parameter will be ignored.
Similarly, TTL support depends on the specific store implementation.
Some implementations may not support expiration of items.
???+ example "Examples"
Store item. Indexing depends on how you configure the store.
```python
@@ -759,7 +806,12 @@ class BaseStore(ABC):
```
"""
_validate_namespace(namespace)
self.batch([PutOp(namespace, key, value, index=index)])
if ttl is not None and not self.supports_ttl:
raise NotImplementedError(
f"TTL is not supported by {self.__class__.__name__}. "
f"Use a store implementation that supports TTL or set ttl=None."
)
self.batch([PutOp(namespace, key, value, index=index, ttl=ttl)])
def delete(self, namespace: tuple[str, ...], key: str) -> None:
"""Delete an item.
@@ -768,7 +820,7 @@ class BaseStore(ABC):
namespace: Hierarchical path for the item.
key: Unique identifier within the namespace.
"""
self.batch([PutOp(namespace, key, None)])
self.batch([PutOp(namespace, key, None, ttl=None)])
def list_namespaces(
self,
@@ -823,7 +875,9 @@ class BaseStore(ABC):
)
return self.batch([op])[0]
async def aget(self, namespace: tuple[str, ...], key: str) -> Optional[Item]:
async def aget(
self, namespace: tuple[str, ...], key: str, *, refresh_ttl: bool = True
) -> Optional[Item]:
"""Asynchronously retrieve a single item.
Args:
@@ -833,7 +887,7 @@ class BaseStore(ABC):
Returns:
The retrieved item or None if not found.
"""
return (await self.abatch([GetOp(namespace, key)]))[0]
return (await self.abatch([GetOp(namespace, key, refresh_ttl)]))[0]
async def asearch(
self,
@@ -844,6 +898,7 @@ class BaseStore(ABC):
filter: Optional[dict[str, Any]] = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: bool = True,
) -> list[SearchItem]:
"""Asynchronously search for items within a namespace prefix.
@@ -853,6 +908,9 @@ class BaseStore(ABC):
filter: Key-value pairs to filter results.
limit: Maximum number of items to return.
offset: Number of items to skip before returning results.
refresh_ttl: Whether to refresh TTLs for the returned items.
Defaults to True. If no TTL is specified, this argument
is ignored.
Returns:
List of items matching the search criteria.
@@ -892,7 +950,7 @@ class BaseStore(ABC):
"""
return (
await self.abatch(
[SearchOp(namespace_prefix, filter, limit, offset, query)]
[SearchOp(namespace_prefix, filter, limit, offset, query, refresh_ttl)]
)
)[0]
@@ -902,6 +960,8 @@ class BaseStore(ABC):
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
*,
ttl: Optional[float] = None,
) -> None:
"""Asynchronously store or update an item in the store.
@@ -922,12 +982,20 @@ class BaseStore(ABC):
- Nested fields: "metadata.title"
- Array access: "chapters[*].content" (each indexed separately)
- Specific indices: "authors[0].name"
ttl: Time to live in minutes. Support for this argument depends on your store adapter.
If specified, the item will expire after this many minutes from when it was last accessed.
None means no expiration. Expired runs will be deleted opportunistically.
By default, the expiration timer refreshes on both read operations (get/search)
and write operations (put/update), whenever the item is included in the operation.
Note:
Indexing support depends on your store implementation.
If you do not initialize the store with indexing capabilities,
the `index` parameter will be ignored.
Similarly, TTL support depends on the specific store implementation.
Some implementations may not support expiration of items.
???+ example "Examples"
Store item. Indexing depends on how you configure the store.
```python
@@ -954,7 +1022,12 @@ class BaseStore(ABC):
```
"""
_validate_namespace(namespace)
await self.abatch([PutOp(namespace, key, value, index=index)])
if ttl is not None and not self.supports_ttl:
raise NotImplementedError(
f"TTL is not supported by {self.__class__.__name__}. "
f"Use a store implementation that supports TTL or set ttl=None."
)
await self.abatch([PutOp(namespace, key, value, index=index, ttl=ttl)])
async def adelete(self, namespace: tuple[str, ...], key: str) -> None:
"""Asynchronously delete an item.
+29 -12
View File
@@ -65,13 +65,11 @@ class AsyncBatchedBaseStore(BaseStore):
pass
async def aget(
self,
namespace: tuple[str, ...],
key: str,
self, namespace: tuple[str, ...], key: str, *, refresh_ttl: bool = True
) -> Optional[Item]:
assert not self._task.done()
fut = self._loop.create_future()
self._aqueue.put_nowait((fut, GetOp(namespace, key)))
self._aqueue.put_nowait((fut, GetOp(namespace, key, refresh_ttl=refresh_ttl)))
return await fut
async def asearch(
@@ -83,11 +81,22 @@ class AsyncBatchedBaseStore(BaseStore):
filter: Optional[dict[str, Any]] = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: bool = True,
) -> list[SearchItem]:
assert not self._task.done()
fut = self._loop.create_future()
self._aqueue.put_nowait(
(fut, SearchOp(namespace_prefix, filter, limit, offset, query))
(
fut,
SearchOp(
namespace_prefix,
filter,
limit,
offset,
query,
refresh_ttl=refresh_ttl,
),
)
)
return await fut
@@ -97,11 +106,13 @@ class AsyncBatchedBaseStore(BaseStore):
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
*,
ttl: Optional[float] = None,
) -> None:
assert not self._task.done()
_validate_namespace(namespace)
fut = self._loop.create_future()
self._aqueue.put_nowait((fut, PutOp(namespace, key, value, index)))
self._aqueue.put_nowait((fut, PutOp(namespace, key, value, index, ttl=ttl)))
return await fut
async def adelete(
@@ -146,12 +157,10 @@ class AsyncBatchedBaseStore(BaseStore):
@_check_loop
def get(
self,
namespace: tuple[str, ...],
key: str,
self, namespace: tuple[str, ...], key: str, *, refresh_ttl: bool = True
) -> Optional[Item]:
return asyncio.run_coroutine_threadsafe(
self.aget(namespace, key=key), self._loop
self.aget(namespace, key=key, refresh_ttl=refresh_ttl), self._loop
).result()
@_check_loop
@@ -164,10 +173,16 @@ class AsyncBatchedBaseStore(BaseStore):
filter: Optional[dict[str, Any]] = None,
limit: int = 10,
offset: int = 0,
refresh_ttl: bool = True,
) -> list[SearchItem]:
return asyncio.run_coroutine_threadsafe(
self.asearch(
namespace_prefix, query=query, filter=filter, limit=limit, offset=offset
namespace_prefix,
query=query,
filter=filter,
limit=limit,
offset=offset,
refresh_ttl=refresh_ttl,
),
self._loop,
).result()
@@ -179,10 +194,12 @@ class AsyncBatchedBaseStore(BaseStore):
key: str,
value: dict[str, Any],
index: Optional[Union[Literal[False], list[str]]] = None,
*,
ttl: Optional[float] = None,
) -> None:
_validate_namespace(namespace)
asyncio.run_coroutine_threadsafe(
self.aput(namespace, key=key, value=value, index=index), self._loop
self.aput(namespace, key=key, value=value, index=index, ttl=ttl), self._loop
).result()
@_check_loop
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-checkpoint"
version = "2.0.16"
version = "2.0.17"
description = "Library with base interfaces for LangGraph checkpoint savers."
authors = []
license = "MIT"
+4 -4
View File
@@ -148,8 +148,8 @@ async def test_async_batch_store(mocker: MockerFixture) -> None:
assert abatch.call_count == 1
assert [tuple(c.args[0]) for c in abatch.call_args_list] == [
(
GetOp(("a",), "b"),
GetOp(("c",), "d"),
GetOp(("a",), "b", refresh_ttl=True),
GetOp(("c",), "d", refresh_ttl=True),
),
]
@@ -467,8 +467,8 @@ async def test_async_batch_store_deduplication(mocker: MockerFixture) -> None:
assert len(abatch.call_args_list) == 1
ops = list(abatch.call_args_list[0].args[1])
assert len(ops) == 2
assert GetOp(("test",), "same") in ops
assert GetOp(("test",), "different") in ops
assert GetOp(("test",), "same", refresh_ttl=True) in ops
assert GetOp(("test",), "different", refresh_ttl=True) in ops
abatch.reset_mock()
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""
Script to generate a JSON schema for the langgraph-cli Config class.
This script creates a schema.json file that can be referenced in langgraph.json files
to provide IDE autocompletion and validation.
"""
import inspect
import json
import textwrap
from pathlib import Path
import msgspec
from langgraph_cli.config import (
AuthConfig,
Config,
CorsConfig,
HttpConfig,
IndexConfig,
SecurityConfig,
StoreConfig,
)
def add_descriptions_to_schema(schema, cls):
"""Add docstring descriptions to the schema properties."""
if schema.get("description"):
schema["description"] = inspect.cleandoc(schema["description"])
elif class_doc := inspect.getdoc(cls):
schema["description"] = inspect.cleandoc(class_doc)
# Get attribute docstrings from the class
attr_docs = {}
# Also check class annotations for docstrings
source_lines = inspect.getsourcelines(cls)[0]
current_attr = None
docstring_lines = []
for line in source_lines:
line = line.strip()
# Check for attribute definition (TypedDict style)
if ":" in line and not line.startswith("#") and not line.startswith('"""'):
parts = line.split(":", 1)
if len(parts) == 2 and parts[0].strip().isidentifier():
# If we were collecting a docstring, save it for the previous attribute
if current_attr and docstring_lines:
attr_docs[current_attr] = "\n".join(docstring_lines).strip('"')
docstring_lines = []
current_attr = parts[0].strip()
# Check for docstring after attribute
elif line.startswith('"""') and current_attr:
# Start or end of a docstring
if len(line) > 3 and line.endswith('"""'):
# Single line docstring
attr_docs[current_attr] = line.strip('"')
current_attr = None
elif docstring_lines:
# End of multi-line docstring
docstring_lines.append(line.rstrip('"'))
attr_docs[current_attr] = "\n".join(docstring_lines).strip('"')
docstring_lines = []
current_attr = None
else:
# Start of multi-line docstring
docstring_lines.append(line.lstrip('"'))
# Continue multi-line docstring
elif docstring_lines and current_attr:
docstring_lines.append(line.strip('"'))
# Add the last docstring if there is one
if current_attr and docstring_lines:
attr_docs[current_attr] = "\n".join(docstring_lines).strip('"')
# Add descriptions to properties
if "properties" in schema:
for prop_name, prop_schema in schema["properties"].items():
# First try to get from attribute docstrings
if prop_name in attr_docs and "description" not in prop_schema:
prop_schema["description"] = textwrap.dedent(attr_docs[prop_name])
# Fall back to class docstring parsing
elif class_doc:
for line in class_doc.split("\n"):
if line.strip().startswith(
f"{prop_name}:"
) or line.strip().startswith(f'"{prop_name}"'):
description = line.split(":", 1)[1].strip()
if description and "description" not in prop_schema:
prop_schema["description"] = description
break
# Recursively process nested definitions
if "$defs" in schema:
for def_name, def_schema in schema["$defs"].items():
# Find the class that corresponds to this definition
for potential_cls in [
Config,
StoreConfig,
IndexConfig,
AuthConfig,
SecurityConfig,
HttpConfig,
CorsConfig,
]:
if potential_cls.__name__ == def_name:
add_descriptions_to_schema(def_schema, potential_cls)
break
return schema
def generate_schema():
"""Generate a JSON schema for the Config class using msgspec."""
# Generate the basic schema
schema = msgspec.json.schema(Config)
# Add title and description
schema["title"] = "LangGraph CLI Configuration"
schema["description"] = "Configuration schema for langgraph-cli"
# Add docstring descriptions
schema = add_descriptions_to_schema(schema, Config)
# Add constraint that only one of python_version or node_version should be specified
config_schema = schema["$defs"]["Config"]
# Create two subschemas: one with python_version and one with node_version
# Define properties specific to Python projects
python_specific_props = ["python_version", "pip_config_file"]
# Define properties specific to Node.js projects
node_specific_props = ["node_version"]
# Define properties common to both project types
common_props = [
k
for k in config_schema["properties"]
if k not in python_specific_props and k not in node_specific_props
]
# Create Python schema with python_version and pip_config_file
python_schema = {
"type": "object",
"properties": {
# Include Python-specific properties
**{k: config_schema["properties"][k].copy() for k in python_specific_props},
# Include common properties
**{k: config_schema["properties"][k].copy() for k in common_props},
},
"required": ["dependencies", "graphs"],
}
# Add enum constraint for python_version
if "python_version" in python_schema["properties"]:
python_schema["properties"]["python_version"]["enum"] = ["3.11", "3.12"]
# Create Node.js schema with node_version
node_schema = {
"type": "object",
"properties": {
# Include Node-specific properties
**{k: config_schema["properties"][k].copy() for k in node_specific_props},
# Include common properties
**{k: config_schema["properties"][k].copy() for k in common_props},
},
"required": ["node_version", "graphs"],
}
# Add enum constraint for node_version
if "node_version" in node_schema["properties"]:
node_schema["properties"]["node_version"]["anyOf"] = [
{"type": "string", "enum": ["20"]},
{"type": "null"},
]
# Replace the Config schema with a oneOf constraint
config_schema["oneOf"] = [python_schema, node_schema]
# Remove the properties field as it's now defined in the oneOf subschemas
if "properties" in config_schema:
del config_schema["properties"]
return schema
def main():
"""Generate the schema and write it to a file."""
schema = generate_schema()
# Add versioning to the schema
import importlib.metadata
try:
version = importlib.metadata.version("langgraph_cli").split(".")
schema_version = f"v{version[0]}"
except importlib.metadata.PackageNotFoundError:
schema_version = "v1"
# Add version to schema
schema["version"] = schema_version
config_dir = Path(__file__).parent / "schemas"
# Create versioned schema file
versioned_path = config_dir / f"schema.{schema_version}.json"
with open(versioned_path, "w") as f:
json.dump(schema, f, indent=2)
# Also create a latest version
latest_path = config_dir / "schema.json"
with open(latest_path, "w") as f:
json.dump(schema, f, indent=2)
print(f"Schema written to {versioned_path} and {latest_path}")
print(
f"You can now add '$schema: https://raw.githubusercontent.com/langchain-ai/langgraph/refs/heads/main/libs/cli/schemas/schema.json'"
f" or '$schema: https://raw.githubusercontent.com/langchain-ai/langgraph/refs/heads/main/libs/cli/schemas/schema.{schema_version}.json'"
" to your langgraph.json files"
)
if __name__ == "__main__":
main()
+248 -57
View File
@@ -3,7 +3,7 @@ import os
import pathlib
import textwrap
from collections import Counter
from typing import NamedTuple, Optional, TypedDict, Union
from typing import Any, NamedTuple, Optional, TypedDict, Union
import click
@@ -12,12 +12,18 @@ MIN_PYTHON_VERSION = "3.11"
class IndexConfig(TypedDict, total=False):
"""Configuration for indexing documents for semantic search in the store."""
"""Configuration for indexing documents for semantic search in the store.
This governs how text is converted into embeddings and stored for vector-based lookups.
"""
dims: int
"""Number of dimensions in the embedding vectors.
"""Required. Dimensionality of the embedding vectors you will store.
Common embedding models have the following dimensions:
Must match the output dimension of your selected embedding model or custom embed function.
If mismatched, you will likely encounter shape/size errors when inserting or querying vectors.
Common embedding model output dimensions:
- openai:text-embedding-3-large: 3072
- openai:text-embedding-3-small: 1536
- openai:text-embedding-ada-002: 1536
@@ -28,42 +34,123 @@ class IndexConfig(TypedDict, total=False):
"""
embed: str
"""Optional model (string) to generate embeddings from text or path to model or function.
"""Required. Identifier or reference to the embedding model or a custom embedding function.
Examples:
The format can vary:
- "<provider>:<model_name>" for recognized providers (e.g., "openai:text-embedding-3-large")
- "path/to/module.py:function_name" for your own local embedding function
- "my_custom_embed" if it's a known alias in your system
Examples:
- "openai:text-embedding-3-large"
- "cohere:embed-multilingual-v3.0"
- "src/app.py:embeddings
- "src/app.py:embeddings"
Note: Must return embeddings of dimension `dims`.
"""
fields: Optional[list[str]]
"""Fields to extract text from for embedding generation.
"""Optional. List of JSON fields to extract before generating embeddings.
Defaults to the root ["$"], which embeds the json object as a whole.
Defaults to ["$"], which means the entire JSON object is embedded as one piece of text.
If you provide multiple fields (e.g. ["title", "content"]), each is extracted and embedded separately,
often saving token usage if you only care about certain parts of the data.
Example:
fields=["title", "abstract", "author.biography"]
"""
class StoreConfig(TypedDict, total=False):
embed: Optional[IndexConfig]
"""Configuration for vector embeddings in store."""
"""Configuration for the built-in long-term memory store.
This store can optionally perform semantic search. If you omit `index`,
the store will just handle traditional (non-embedded) data without vector lookups.
"""
index: Optional[IndexConfig]
"""Optional. Defines the vector-based semantic search configuration.
If provided, the store will:
- Generate embeddings according to `index.embed`
- Enforce the embedding dimension given by `index.dims`
- Embed only specified JSON fields (if any) from `index.fields`
If omitted, no vector index is initialized.
"""
class SecurityConfig(TypedDict, total=False):
securitySchemes: dict
security: list
"""Configuration for OpenAPI security definitions and requirements.
Useful for specifying global or path-level authentication and authorization flows
(e.g., OAuth2, API key headers, etc.).
"""
securitySchemes: dict[str, dict[str, Any]]
"""Required. Dict describing each security scheme recognized by your OpenAPI spec.
Keys are scheme names (e.g. "OAuth2", "ApiKeyAuth") and values are their definitions.
Example:
{
"OAuth2": {
"type": "oauth2",
"flows": {
"password": {
"tokenUrl": "/token",
"scopes": {"read": "Read data", "write": "Write data"}
}
}
}
}
"""
security: list[dict[str, list[str]]]
"""Optional. Global security requirements across all endpoints.
Each element in the list maps a security scheme (e.g. "OAuth2") to a list of scopes (e.g. ["read", "write"]).
Example:
[
{"OAuth2": ["read", "write"]},
{"ApiKeyAuth": []}
]
"""
# path => {method => security}
paths: dict[str, dict[str, list]]
paths: dict[str, dict[str, list[dict[str, list[str]]]]]
"""Optional. Path-specific security overrides.
Keys are path templates (e.g., "/items/{item_id}"), mapping to:
- Keys that are HTTP methods (e.g., "GET", "POST"),
- Values are lists of security definitions (just like `security`) for that method.
Example:
{
"/private_data": {
"GET": [{"OAuth2": ["read"]}],
"POST": [{"OAuth2": ["write"]}]
}
}
"""
class AuthConfig(TypedDict, total=False):
path: str
"""Path to the authentication function in a Python file."""
disable_studio_auth: bool
"""Whether to disable auth when connecting from the LangSmith Studio."""
openapi: SecurityConfig
"""The schema to use for updating the openapi spec.
"""Configuration for custom authentication logic and how it integrates into the OpenAPI spec."""
Example:
path: str
"""Required. Path to an instance of the Auth() class that implements custom authentication.
Format: "path/to/file.py:my_auth"
"""
disable_studio_auth: bool
"""Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.
Defaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header
value is a valid API key for the deployment's workspace. If True, all requests will go through your custom
authentication logic, regardless of origin of the request.
"""
openapi: SecurityConfig
"""Required. Detailed security configuration that merges into your deployment's OpenAPI spec.
Example (OAuth2):
{
"securitySchemes": {
"OAuth2": {
@@ -71,88 +158,185 @@ class AuthConfig(TypedDict, total=False):
"flows": {
"password": {
"tokenUrl": "/token",
"scopes": {
"me": "Read information about the current user",
"items": "Access to create and manage items"
}
"scopes": {"me": "Read user info", "items": "Manage items"}
}
}
}
},
"security": [
{"OAuth2": ["me"]} # Default security requirement for all endpoints
{"OAuth2": ["me"]}
]
}
"""
class CorsConfig(TypedDict, total=False):
"""Specifies Cross-Origin Resource Sharing (CORS) rules for your server.
If omitted, defaults are typically very restrictive (often no cross-origin requests).
Configure carefully if you want to allow usage from browsers hosted on other domains.
"""
allow_origins: list[str]
"""Optional. List of allowed origins (e.g., "https://example.com").
Default is often an empty list (no external origins).
Use "*" only if you trust all origins, as that bypasses most restrictions.
"""
allow_methods: list[str]
"""Optional. HTTP methods permitted for cross-origin requests (e.g. ["GET", "POST"]).
Default might be ["GET", "POST", "OPTIONS"] depending on your server framework.
"""
allow_headers: list[str]
"""Optional. HTTP headers that can be used in cross-origin requests (e.g. ["Content-Type", "Authorization"])."""
allow_credentials: bool
"""Optional. If True, cross-origin requests can include credentials (cookies, auth headers).
Default False to avoid accidentally exposing secured endpoints to untrusted sites.
"""
allow_origin_regex: str
"""Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.
Example: "^https://.*\.mycompany\.com$"
"""
expose_headers: list[str]
"""Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."""
max_age: int
"""Optional. How many seconds the browser may cache preflight responses.
Default might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.
"""
class HttpConfig(TypedDict, total=False):
"""Configuration for the built-in HTTP server that powers your deployment's routes and endpoints."""
app: str
"""Import path for a custom Starlette/FastAPI app to mount"""
"""Optional. Import path to a custom Starlette/FastAPI application to mount.
Format: "path/to/module.py:app_var"
If provided, it can override or extend the default routes.
"""
disable_assistants: bool
"""Disable /assistants routes"""
"""Optional. If True, /assistants routes are removed from the server.
Default is False (meaning /assistants is enabled).
"""
disable_threads: bool
"""Disable /threads routes"""
"""Optional. If True, /threads routes are removed.
Default is False.
"""
disable_runs: bool
"""Disable /runs routes"""
"""Optional. If True, /runs routes are removed.
Default is False.
"""
disable_store: bool
"""Disable /store routes"""
"""Optional. If True, /store routes are removed, disabling direct store interactions via HTTP.
Default is False.
"""
disable_meta: bool
"""Disable /ok, /info, /metrics, and /docs routes"""
"""Optional. If True, all meta endpoints (/ok, /info, /metrics, /docs) are disabled.
Default is False.
"""
cors: Optional[CorsConfig]
"""Cross-Origin Resource Sharing (CORS) configuration"""
"""Optional. Defines CORS restrictions. If omitted, no special rules are set and
cross-origin behavior depends on default server settings.
"""
class Config(TypedDict, total=False):
"""Configuration for langgraph-cli."""
"""Top-level config for langgraph-cli or similar deployment tooling."""
python_version: str
"""Python version to use."""
"""Optional. Python version in 'major.minor' format (e.g. '3.11').
Must be at least 3.11 or greater for this deployment to function properly.
"""
node_version: Optional[str]
"""Node.js version to use."""
"""Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.
Must be >= 20 if provided.
"""
pip_config_file: Optional[str]
"""Path to a pip configuration file."""
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
package installation (custom indices, credentials, etc.).
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
"""
dockerfile_lines: list[str]
"""Additional lines to add to the Dockerfile."""
"""Optional. Additional Docker instructions that will be appended to your base Dockerfile.
Useful for installing OS packages, setting environment variables, etc.
Example:
dockerfile_lines=[
"RUN apt-get update && apt-get install -y libmagic-dev",
"ENV MY_CUSTOM_VAR=hello_world"
]
"""
dependencies: list[str]
"""Additional Python dependencies to install."""
"""List of Python dependencies to install, either from PyPI or local paths.
Examples:
- "." or "./src" if you have a local Python package
- str (aka "anthropic") for a PyPI package
- "git+https://github.com/org/repo.git@main" for a Git-based package
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
"""
graphs: dict[str, str]
"""Mapping of graph names to their definitions."""
"""Optional. Named definitions of graphs, each pointing to a Python object.
Graphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context
managers that accept a single configuration argument (of type RunnableConfig) and return a pregel object
(instance of Stategraph, etc.).
Keys are graph names, values are "path/to/file.py:object_name".
Example:
{
"mygraph": "graphs/my_graph.py:graph_definition",
"anothergraph": "graphs/another.py:get_graph"
}
"""
env: Union[dict[str, str], str]
"""Environment variables to set.
If a dictionary is provided, the keys are environment variable names
and the values are the corresponding environment variable values.
If a string is provided, it is interpreted as a path to a file containing
environment variables in the format KEY=VALUE, with one environment variable
per line.
"""Optional. Environment variables to set for your deployment.
- If given as a dict, keys are variable names and values are their values.
- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.
Example as a dict:
env={"API_TOKEN": "abc123", "DEBUG": "true"}
Example as a file path:
env=".env"
"""
store: Optional[StoreConfig]
"""Configuration for vector embeddings in store."""
"""Optional. Configuration for the built-in long-term memory store, including semantic search indexing.
If omitted, no vector index is set up (the object store will still be present, however).
"""
auth: Optional[AuthConfig]
"""Configuration for authentication."""
"""Optional. Custom authentication config, including the path to your Python auth logic and
the OpenAPI security definitions it uses.
"""
http: Optional[HttpConfig]
"""Configuration for HTTP server."""
"""Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed
and how cross-origin requests are handled.
"""
ui: Optional[dict[str, str]]
"""Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.
"""
def _parse_version(version_str: str) -> tuple[int, int]:
@@ -189,6 +373,7 @@ def validate_config(config: Config) -> Config:
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"ui": config.get("ui"),
}
if config.get("node_version")
else {
@@ -201,6 +386,7 @@ def validate_config(config: Config) -> Config:
"store": config.get("store"),
"auth": config.get("auth"),
"http": config.get("http"),
"ui": config.get("ui"),
}
)
@@ -687,9 +873,11 @@ def python_config_to_docker(
pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
if local_deps.pip_reqs:
pip_reqs_str = os.linesep.join(
f"COPY --from=__outer_{reqpath.name} requirements.txt {destpath}"
if reqpath.parent in local_deps.additional_contexts
else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
(
f"COPY --from=__outer_{reqpath.name} requirements.txt {destpath}"
if reqpath.parent in local_deps.additional_contexts
else f"ADD {reqpath.relative_to(config_path.parent)} {destpath}"
)
for reqpath, destpath in local_deps.pip_reqs
)
pip_reqs_str += f'{os.linesep}RUN {pip_install} {" ".join("-r " + r for _,r in local_deps.pip_reqs)}'
@@ -724,13 +912,15 @@ RUN set -ex && \\
)
local_pkgs_str = os.linesep.join(
f"""# -- Adding local package {relpath} --
(
f"""# -- Adding local package {relpath} --
COPY --from={name} . /deps/{name}
# -- End of local package {relpath} --"""
if fullpath in local_deps.additional_contexts
else f"""# -- Adding local package {relpath} --
if fullpath in local_deps.additional_contexts
else f"""# -- Adding local package {relpath} --
ADD {relpath} /deps/{name}
# -- End of local package {relpath} --"""
)
for fullpath, (relpath, name) in local_deps.real_pkgs.items()
)
@@ -845,6 +1035,7 @@ ADD . {faux_path}
RUN cd {faux_path} && {install_cmd}
{env_additional_config}
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
{f"ENV LANGGRAPH_UI='{json.dumps(config['ui'])}'" if config.get("ui") else ""}
WORKDIR {faux_path}
+53 -1
View File
@@ -707,6 +707,58 @@ files = [
{file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"},
]
[[package]]
name = "msgspec"
version = "0.19.0"
description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML."
optional = false
python-versions = ">=3.9"
files = [
{file = "msgspec-0.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d8dd848ee7ca7c8153462557655570156c2be94e79acec3561cf379581343259"},
{file = "msgspec-0.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0553bbc77662e5708fe66aa75e7bd3e4b0f209709c48b299afd791d711a93c36"},
{file = "msgspec-0.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fe2c4bf29bf4e89790b3117470dea2c20b59932772483082c468b990d45fb947"},
{file = "msgspec-0.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e87ecfa9795ee5214861eab8326b0e75475c2e68a384002aa135ea2a27d909"},
{file = "msgspec-0.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c4ec642689da44618f68c90855a10edbc6ac3ff7c1d94395446c65a776e712a"},
{file = "msgspec-0.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2719647625320b60e2d8af06b35f5b12d4f4d281db30a15a1df22adb2295f633"},
{file = "msgspec-0.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:695b832d0091edd86eeb535cd39e45f3919f48d997685f7ac31acb15e0a2ed90"},
{file = "msgspec-0.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa77046904db764b0462036bc63ef71f02b75b8f72e9c9dd4c447d6da1ed8f8e"},
{file = "msgspec-0.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:047cfa8675eb3bad68722cfe95c60e7afabf84d1bd8938979dd2b92e9e4a9551"},
{file = "msgspec-0.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e78f46ff39a427e10b4a61614a2777ad69559cc8d603a7c05681f5a595ea98f7"},
{file = "msgspec-0.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c7adf191e4bd3be0e9231c3b6dc20cf1199ada2af523885efc2ed218eafd011"},
{file = "msgspec-0.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f04cad4385e20be7c7176bb8ae3dca54a08e9756cfc97bcdb4f18560c3042063"},
{file = "msgspec-0.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45c8fb410670b3b7eb884d44a75589377c341ec1392b778311acdbfa55187716"},
{file = "msgspec-0.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:70eaef4934b87193a27d802534dc466778ad8d536e296ae2f9334e182ac27b6c"},
{file = "msgspec-0.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f98bd8962ad549c27d63845b50af3f53ec468b6318400c9f1adfe8b092d7b62f"},
{file = "msgspec-0.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:43bbb237feab761b815ed9df43b266114203f53596f9b6e6f00ebd79d178cdf2"},
{file = "msgspec-0.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4cfc033c02c3e0aec52b71710d7f84cb3ca5eb407ab2ad23d75631153fdb1f12"},
{file = "msgspec-0.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d911c442571605e17658ca2b416fd8579c5050ac9adc5e00c2cb3126c97f73bc"},
{file = "msgspec-0.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:757b501fa57e24896cf40a831442b19a864f56d253679f34f260dcb002524a6c"},
{file = "msgspec-0.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5f0f65f29b45e2816d8bded36e6b837a4bf5fb60ec4bc3c625fa2c6da4124537"},
{file = "msgspec-0.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:067f0de1c33cfa0b6a8206562efdf6be5985b988b53dd244a8e06f993f27c8c0"},
{file = "msgspec-0.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f12d30dd6266557aaaf0aa0f9580a9a8fbeadfa83699c487713e355ec5f0bd86"},
{file = "msgspec-0.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82b2c42c1b9ebc89e822e7e13bbe9d17ede0c23c187469fdd9505afd5a481314"},
{file = "msgspec-0.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19746b50be214a54239aab822964f2ac81e38b0055cca94808359d779338c10e"},
{file = "msgspec-0.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60ef4bdb0ec8e4ad62e5a1f95230c08efb1f64f32e6e8dd2ced685bcc73858b5"},
{file = "msgspec-0.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac7f7c377c122b649f7545810c6cd1b47586e3aa3059126ce3516ac7ccc6a6a9"},
{file = "msgspec-0.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5bc1472223a643f5ffb5bf46ccdede7f9795078194f14edd69e3aab7020d327"},
{file = "msgspec-0.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:317050bc0f7739cb30d257ff09152ca309bf5a369854bbf1e57dffc310c1f20f"},
{file = "msgspec-0.19.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15c1e86fff77184c20a2932cd9742bf33fe23125fa3fcf332df9ad2f7d483044"},
{file = "msgspec-0.19.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3b5541b2b3294e5ffabe31a09d604e23a88533ace36ac288fa32a420aa38d229"},
{file = "msgspec-0.19.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f5c043ace7962ef188746e83b99faaa9e3e699ab857ca3f367b309c8e2c6b12"},
{file = "msgspec-0.19.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca06aa08e39bf57e39a258e1996474f84d0dd8130d486c00bec26d797b8c5446"},
{file = "msgspec-0.19.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e695dad6897896e9384cf5e2687d9ae9feaef50e802f93602d35458e20d1fb19"},
{file = "msgspec-0.19.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3be5c02e1fee57b54130316a08fe40cca53af92999a302a6054cd451700ea7db"},
{file = "msgspec-0.19.0-cp39-cp39-win_amd64.whl", hash = "sha256:0684573a821be3c749912acf5848cce78af4298345cb2d7a8b8948a0a5a27cfe"},
{file = "msgspec-0.19.0.tar.gz", hash = "sha256:604037e7cd475345848116e89c553aa9a233259733ab51986ac924ab1b976f8e"},
]
[package.extras]
dev = ["attrs", "coverage", "eval-type-backport", "furo", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli", "tomli_w"]
doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"]
test = ["attrs", "eval-type-backport", "msgpack", "pytest", "pyyaml", "tomli", "tomli_w"]
toml = ["tomli", "tomli_w"]
yaml = ["pyyaml"]
[[package]]
name = "mypy"
version = "1.15.0"
@@ -1665,4 +1717,4 @@ inmem = ["langgraph-api", "python-dotenv"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9.0,<4.0"
content-hash = "04a0b0e203ae00f30cca7e454c632be0b41cbb4820d0c05315a346d514b5f98e"
content-hash = "d0e2bdcb600ad031867413025fcc58bb162609209359d63ca99a77060cf8cbb4"
+2 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-cli"
version = "0.1.74"
version = "0.1.75"
description = "CLI for interacting with LangGraph API"
authors = []
license = "MIT"
@@ -25,6 +25,7 @@ pytest-asyncio = "^0.21.1"
pytest-mock = "^3.11.1"
pytest-watch = "^4.2.0"
mypy = "^1.10.0"
msgspec = "^0.19.0"
[tool.poetry.extras]
inmem = ["langgraph-api", "python-dotenv"]
+438
View File
@@ -0,0 +1,438 @@
{
"$ref": "#/$defs/Config",
"$defs": {
"Config": {
"title": "Config",
"description": "Top-level config for langgraph-cli or similar deployment tooling.",
"type": "object",
"required": [],
"oneOf": [
{
"type": "object",
"properties": {
"python_version": {
"type": "string",
"description": "Optional. Python version in 'major.minor' format (e.g. '3.11').\nMust be at least 3.11 or greater for this deployment to function properly.\n",
"enum": [
"3.11",
"3.12"
]
},
"pip_config_file": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
},
"auth": {
"anyOf": [
{
"$ref": "#/$defs/AuthConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
},
"dependencies": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of Python dependencies to install, either from PyPI or local paths.\n"
},
"dockerfile_lines": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
},
"env": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "string"
}
],
"description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
},
"graphs": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
},
"http": {
"anyOf": [
{
"$ref": "#/$defs/HttpConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"store": {
"anyOf": [
{
"$ref": "#/$defs/StoreConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
},
"ui": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
}
},
"required": [
"dependencies",
"graphs"
]
},
{
"type": "object",
"properties": {
"node_version": {
"anyOf": [
{
"type": "string",
"enum": [
"20"
]
},
{
"type": "null"
}
],
"description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n"
},
"auth": {
"anyOf": [
{
"$ref": "#/$defs/AuthConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
},
"dependencies": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of Python dependencies to install, either from PyPI or local paths.\n"
},
"dockerfile_lines": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
},
"env": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "string"
}
],
"description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
},
"graphs": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
},
"http": {
"anyOf": [
{
"$ref": "#/$defs/HttpConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"store": {
"anyOf": [
{
"$ref": "#/$defs/StoreConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
},
"ui": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
}
},
"required": [
"node_version",
"graphs"
]
}
]
},
"AuthConfig": {
"title": "AuthConfig",
"description": "Configuration for custom authentication logic and how it integrates into the OpenAPI spec.",
"type": "object",
"properties": {
"disable_studio_auth": {
"type": "boolean",
"description": "Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.\n\nDefaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header\nvalue is a valid API key for the deployment's workspace. If True, all requests will go through your custom\nauthentication logic, regardless of origin of the request.\n"
},
"openapi": {
"$ref": "#/$defs/SecurityConfig",
"description": "Required. Detailed security configuration that merges into your deployment's OpenAPI spec.\n\n{\n}\n}\n}\n},\n]\n}\n"
},
"path": {
"type": "string",
"description": "Required. Path to an instance of the Auth() class that implements custom authentication.\n"
}
},
"required": []
},
"SecurityConfig": {
"title": "SecurityConfig",
"description": "Configuration for OpenAPI security definitions and requirements.\n\nUseful for specifying global or path-level authentication and authorization flows\n(e.g., OAuth2, API key headers, etc.).",
"type": "object",
"properties": {
"paths": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"description": "Optional. Path-specific security overrides.\n\n- Keys that are HTTP methods (e.g., \"GET\", \"POST\"),\n- Values are lists of security definitions (just like `security`) for that method.\n"
},
"security": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"description": "Optional. Global security requirements across all endpoints.\n\nEach element in the list maps a security scheme (e.g. \"OAuth2\") to a list of scopes (e.g. [\"read\", \"write\"])."
},
"securitySchemes": {
"type": "object",
"additionalProperties": {
"type": "object"
},
"description": "Required. Dict describing each security scheme recognized by your OpenAPI spec.\n\nKeys are scheme names (e.g. \"OAuth2\", \"ApiKeyAuth\") and values are their definitions."
}
},
"required": []
},
"HttpConfig": {
"title": "HttpConfig",
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
"type": "object",
"properties": {
"app": {
"type": "string",
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
},
"cors": {
"anyOf": [
{
"$ref": "#/$defs/CorsConfig"
},
{
"type": "null"
}
],
"description": "Optional. Defines CORS restrictions. If omitted, no special rules are set and\ncross-origin behavior depends on default server settings.\n"
},
"disable_assistants": {
"type": "boolean",
"description": "Optional. If True, /assistants routes are removed from the server.\n\nDefault is False (meaning /assistants is enabled).\n"
},
"disable_meta": {
"type": "boolean",
"description": "Optional. If True, all meta endpoints (/ok, /info, /metrics, /docs) are disabled.\n\nDefault is False.\n"
},
"disable_runs": {
"type": "boolean",
"description": "Optional. If True, /runs routes are removed.\n\nDefault is False.\n"
},
"disable_store": {
"type": "boolean",
"description": "Optional. If True, /store routes are removed, disabling direct store interactions via HTTP.\n\nDefault is False.\n"
},
"disable_threads": {
"type": "boolean",
"description": "Optional. If True, /threads routes are removed.\n\nDefault is False.\n"
}
},
"required": []
},
"CorsConfig": {
"title": "CorsConfig",
"description": "Specifies Cross-Origin Resource Sharing (CORS) rules for your server.\n\nIf omitted, defaults are typically very restrictive (often no cross-origin requests).\nConfigure carefully if you want to allow usage from browsers hosted on other domains.",
"type": "object",
"properties": {
"allow_credentials": {
"type": "boolean",
"description": "Optional. If True, cross-origin requests can include credentials (cookies, auth headers).\n\nDefault False to avoid accidentally exposing secured endpoints to untrusted sites.\n"
},
"allow_headers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. HTTP headers that can be used in cross-origin requests (e.g. [\"Content-Type\", \"Authorization\"])."
},
"allow_methods": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. HTTP methods permitted for cross-origin requests (e.g. [\"GET\", \"POST\"]).\n\nDefault might be [\"GET\", \"POST\", \"OPTIONS\"] depending on your server framework.\n"
},
"allow_origin_regex": {
"type": "string",
"description": "Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.\n"
},
"allow_origins": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. List of allowed origins (e.g., \"https://example.com\").\n\nDefault is often an empty list (no external origins).\nUse \"*\" only if you trust all origins, as that bypasses most restrictions.\n"
},
"expose_headers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."
},
"max_age": {
"type": "integer",
"description": "Optional. How many seconds the browser may cache preflight responses.\n\nDefault might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.\n"
}
},
"required": []
},
"StoreConfig": {
"title": "StoreConfig",
"description": "Configuration for the built-in long-term memory store.\n\nThis store can optionally perform semantic search. If you omit `index`,\nthe store will just handle traditional (non-embedded) data without vector lookups.",
"type": "object",
"properties": {
"index": {
"anyOf": [
{
"$ref": "#/$defs/IndexConfig"
},
{
"type": "null"
}
],
"description": "Optional. Defines the vector-based semantic search configuration.\n\n- Generate embeddings according to `index.embed`\n- Enforce the embedding dimension given by `index.dims`\n- Embed only specified JSON fields (if any) from `index.fields`\n\nIf omitted, no vector index is initialized.\n"
}
},
"required": []
},
"IndexConfig": {
"title": "IndexConfig",
"description": "Configuration for indexing documents for semantic search in the store.\n\nThis governs how text is converted into embeddings and stored for vector-based lookups.",
"type": "object",
"properties": {
"dims": {
"type": "integer",
"description": "Required. Dimensionality of the embedding vectors you will store.\n\nMust match the output dimension of your selected embedding model or custom embed function.\nIf mismatched, you will likely encounter shape/size errors when inserting or querying vectors.\n\n"
},
"embed": {
"type": "string",
"description": "Required. Identifier or reference to the embedding model or a custom embedding function.\n\n- \"my_custom_embed\" if it's a known alias in your system\n"
},
"fields": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. List of JSON fields to extract before generating embeddings.\n\nDefaults to [\"$\"], which means the entire JSON object is embedded as one piece of text.\nIf you provide multiple fields (e.g. [\"title\", \"content\"]), each is extracted and embedded separately,\noften saving token usage if you only care about certain parts of the data.\n"
}
},
"required": []
}
},
"title": "LangGraph CLI Configuration",
"description": "Configuration schema for langgraph-cli",
"version": "v0"
}
+438
View File
@@ -0,0 +1,438 @@
{
"$ref": "#/$defs/Config",
"$defs": {
"Config": {
"title": "Config",
"description": "Top-level config for langgraph-cli or similar deployment tooling.",
"type": "object",
"required": [],
"oneOf": [
{
"type": "object",
"properties": {
"python_version": {
"type": "string",
"description": "Optional. Python version in 'major.minor' format (e.g. '3.11').\nMust be at least 3.11 or greater for this deployment to function properly.\n",
"enum": [
"3.11",
"3.12"
]
},
"pip_config_file": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
},
"auth": {
"anyOf": [
{
"$ref": "#/$defs/AuthConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
},
"dependencies": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of Python dependencies to install, either from PyPI or local paths.\n"
},
"dockerfile_lines": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
},
"env": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "string"
}
],
"description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
},
"graphs": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
},
"http": {
"anyOf": [
{
"$ref": "#/$defs/HttpConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"store": {
"anyOf": [
{
"$ref": "#/$defs/StoreConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
},
"ui": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
}
},
"required": [
"dependencies",
"graphs"
]
},
{
"type": "object",
"properties": {
"node_version": {
"anyOf": [
{
"type": "string",
"enum": [
"20"
]
},
{
"type": "null"
}
],
"description": "Optional. Node.js version as a major version (e.g. '20'), if your deployment needs Node.\nMust be >= 20 if provided.\n"
},
"auth": {
"anyOf": [
{
"$ref": "#/$defs/AuthConfig"
},
{
"type": "null"
}
],
"description": "Optional. Custom authentication config, including the path to your Python auth logic and\nthe OpenAPI security definitions it uses.\n"
},
"dependencies": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of Python dependencies to install, either from PyPI or local paths.\n"
},
"dockerfile_lines": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
},
"env": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "string"
}
],
"description": "Optional. Environment variables to set for your deployment.\n\n- If given as a dict, keys are variable names and values are their values.\n- If given as a string, it must be a path to a file containing lines in KEY=VALUE format.\n\nenv=\".env\n"
},
"graphs": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "Optional. Named definitions of graphs, each pointing to a Python object.\n\n\nGraphs can be StateGraph, @entrypoint, or any other Pregel object OR they can point to (async) context\nmanagers that accept a single configuration argument (of type RunnableConfig) and return a pregel object\n(instance of Stategraph, etc.).\n"
},
"http": {
"anyOf": [
{
"$ref": "#/$defs/HttpConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed\nand how cross-origin requests are handled.\n"
},
"store": {
"anyOf": [
{
"$ref": "#/$defs/StoreConfig"
},
{
"type": "null"
}
],
"description": "Optional. Configuration for the built-in long-term memory store, including semantic search indexing.\n\nIf omitted, no vector index is set up (the object store will still be present, however).\n"
},
"ui": {
"anyOf": [
{
"type": "object",
"additionalProperties": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. Named definitions of UI components emitted by the agent, each pointing to a JS/TS file.\n"
}
},
"required": [
"node_version",
"graphs"
]
}
]
},
"AuthConfig": {
"title": "AuthConfig",
"description": "Configuration for custom authentication logic and how it integrates into the OpenAPI spec.",
"type": "object",
"properties": {
"disable_studio_auth": {
"type": "boolean",
"description": "Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.\n\nDefaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header\nvalue is a valid API key for the deployment's workspace. If True, all requests will go through your custom\nauthentication logic, regardless of origin of the request.\n"
},
"openapi": {
"$ref": "#/$defs/SecurityConfig",
"description": "Required. Detailed security configuration that merges into your deployment's OpenAPI spec.\n\n{\n}\n}\n}\n},\n]\n}\n"
},
"path": {
"type": "string",
"description": "Required. Path to an instance of the Auth() class that implements custom authentication.\n"
}
},
"required": []
},
"SecurityConfig": {
"title": "SecurityConfig",
"description": "Configuration for OpenAPI security definitions and requirements.\n\nUseful for specifying global or path-level authentication and authorization flows\n(e.g., OAuth2, API key headers, etc.).",
"type": "object",
"properties": {
"paths": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"description": "Optional. Path-specific security overrides.\n\n- Keys that are HTTP methods (e.g., \"GET\", \"POST\"),\n- Values are lists of security definitions (just like `security`) for that method.\n"
},
"security": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"description": "Optional. Global security requirements across all endpoints.\n\nEach element in the list maps a security scheme (e.g. \"OAuth2\") to a list of scopes (e.g. [\"read\", \"write\"])."
},
"securitySchemes": {
"type": "object",
"additionalProperties": {
"type": "object"
},
"description": "Required. Dict describing each security scheme recognized by your OpenAPI spec.\n\nKeys are scheme names (e.g. \"OAuth2\", \"ApiKeyAuth\") and values are their definitions."
}
},
"required": []
},
"HttpConfig": {
"title": "HttpConfig",
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
"type": "object",
"properties": {
"app": {
"type": "string",
"description": "Optional. Import path to a custom Starlette/FastAPI application to mount.\n"
},
"cors": {
"anyOf": [
{
"$ref": "#/$defs/CorsConfig"
},
{
"type": "null"
}
],
"description": "Optional. Defines CORS restrictions. If omitted, no special rules are set and\ncross-origin behavior depends on default server settings.\n"
},
"disable_assistants": {
"type": "boolean",
"description": "Optional. If True, /assistants routes are removed from the server.\n\nDefault is False (meaning /assistants is enabled).\n"
},
"disable_meta": {
"type": "boolean",
"description": "Optional. If True, all meta endpoints (/ok, /info, /metrics, /docs) are disabled.\n\nDefault is False.\n"
},
"disable_runs": {
"type": "boolean",
"description": "Optional. If True, /runs routes are removed.\n\nDefault is False.\n"
},
"disable_store": {
"type": "boolean",
"description": "Optional. If True, /store routes are removed, disabling direct store interactions via HTTP.\n\nDefault is False.\n"
},
"disable_threads": {
"type": "boolean",
"description": "Optional. If True, /threads routes are removed.\n\nDefault is False.\n"
}
},
"required": []
},
"CorsConfig": {
"title": "CorsConfig",
"description": "Specifies Cross-Origin Resource Sharing (CORS) rules for your server.\n\nIf omitted, defaults are typically very restrictive (often no cross-origin requests).\nConfigure carefully if you want to allow usage from browsers hosted on other domains.",
"type": "object",
"properties": {
"allow_credentials": {
"type": "boolean",
"description": "Optional. If True, cross-origin requests can include credentials (cookies, auth headers).\n\nDefault False to avoid accidentally exposing secured endpoints to untrusted sites.\n"
},
"allow_headers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. HTTP headers that can be used in cross-origin requests (e.g. [\"Content-Type\", \"Authorization\"])."
},
"allow_methods": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. HTTP methods permitted for cross-origin requests (e.g. [\"GET\", \"POST\"]).\n\nDefault might be [\"GET\", \"POST\", \"OPTIONS\"] depending on your server framework.\n"
},
"allow_origin_regex": {
"type": "string",
"description": "Optional. A regex pattern for matching allowed origins, used if you have dynamic subdomains.\n"
},
"allow_origins": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. List of allowed origins (e.g., \"https://example.com\").\n\nDefault is often an empty list (no external origins).\nUse \"*\" only if you trust all origins, as that bypasses most restrictions.\n"
},
"expose_headers": {
"type": "array",
"items": {
"type": "string"
},
"description": "Optional. List of headers that browsers are allowed to read from the response in cross-origin contexts."
},
"max_age": {
"type": "integer",
"description": "Optional. How many seconds the browser may cache preflight responses.\n\nDefault might be 600 (10 minutes). Larger values reduce preflight requests but can cause stale configurations.\n"
}
},
"required": []
},
"StoreConfig": {
"title": "StoreConfig",
"description": "Configuration for the built-in long-term memory store.\n\nThis store can optionally perform semantic search. If you omit `index`,\nthe store will just handle traditional (non-embedded) data without vector lookups.",
"type": "object",
"properties": {
"index": {
"anyOf": [
{
"$ref": "#/$defs/IndexConfig"
},
{
"type": "null"
}
],
"description": "Optional. Defines the vector-based semantic search configuration.\n\n- Generate embeddings according to `index.embed`\n- Enforce the embedding dimension given by `index.dims`\n- Embed only specified JSON fields (if any) from `index.fields`\n\nIf omitted, no vector index is initialized.\n"
}
},
"required": []
},
"IndexConfig": {
"title": "IndexConfig",
"description": "Configuration for indexing documents for semantic search in the store.\n\nThis governs how text is converted into embeddings and stored for vector-based lookups.",
"type": "object",
"properties": {
"dims": {
"type": "integer",
"description": "Required. Dimensionality of the embedding vectors you will store.\n\nMust match the output dimension of your selected embedding model or custom embed function.\nIf mismatched, you will likely encounter shape/size errors when inserting or querying vectors.\n\n"
},
"embed": {
"type": "string",
"description": "Required. Identifier or reference to the embedding model or a custom embedding function.\n\n- \"my_custom_embed\" if it's a known alias in your system\n"
},
"fields": {
"anyOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "null"
}
],
"description": "Optional. List of JSON fields to extract before generating embeddings.\n\nDefaults to [\"$\"], which means the entire JSON object is embedded as one piece of text.\nIf you provide multiple fields (e.g. [\"title\", \"content\"]), each is extracted and embedded separately,\noften saving token usage if you only care about certain parts of the data.\n"
}
},
"required": []
}
},
"title": "LangGraph CLI Configuration",
"description": "Configuration schema for langgraph-cli",
"version": "v0"
}
+4
View File
@@ -33,6 +33,7 @@ def test_validate_config():
"store": None,
"auth": None,
"http": None,
"ui": None,
**expected_config,
}
actual_config = validate_config(expected_config)
@@ -52,6 +53,7 @@ def test_validate_config():
"store": None,
"auth": None,
"http": None,
"ui": None,
}
actual_config = validate_config(expected_config)
assert actual_config == expected_config
@@ -467,6 +469,7 @@ def test_config_to_docker_nodejs():
"node_version": "20",
"graphs": graphs,
"dockerfile_lines": ["ARG meow", "ARG foo"],
"ui": {"agent": "./graphs/agent.ui.jsx"},
}
),
"langchain/langgraphjs-api",
@@ -477,6 +480,7 @@ ARG foo
ADD . /deps/unit_tests
RUN cd /deps/unit_tests && npm i
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
WORKDIR /deps/unit_tests
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
+14 -6
View File
@@ -36,23 +36,31 @@ from langgraph.types import _DC_KWARGS, RetryPolicy, StreamMode
@overload
def task(
*, name: Optional[str] = None, retry: Optional[RetryPolicy] = None
) -> Callable[[Callable[P, T]], Callable[P, SyncAsyncFuture[T]]]: ...
*,
name: Optional[str] = None,
retry: Optional[RetryPolicy] = None,
) -> Callable[
[Union[Callable[P, Awaitable[T]], Callable[P, T]]],
Callable[P, SyncAsyncFuture[T]],
]: ...
@overload
def task(
__func_or_none__: Callable[P, T],
__func_or_none__: Union[Callable[P, Awaitable[T]], Callable[P, T]],
) -> Callable[P, SyncAsyncFuture[T]]: ...
def task(
__func_or_none__: Optional[Union[Callable[P, T], Callable[P, Awaitable[T]]]] = None,
__func_or_none__: Optional[Union[Callable[P, Awaitable[T]], Callable[P, T]]] = None,
*,
name: Optional[str] = None,
retry: Optional[RetryPolicy] = None,
) -> Union[
Callable[[Callable[P, T]], Callable[P, SyncAsyncFuture[T]]],
Callable[
[Union[Callable[P, Awaitable[T]], Callable[P, T]]],
Callable[P, SyncAsyncFuture[T]],
],
Callable[P, SyncAsyncFuture[T]],
]:
"""Define a LangGraph task using the `task` decorator.
@@ -345,7 +353,7 @@ class entrypoint:
value: R
"""Value to return. A value will always be returned even if it is None."""
save: S
"""The value for the state for the next checkpoint.
"""The value for the state for the next checkpoint.
A value will always be saved even if it is None.
"""
+34 -3
View File
@@ -18,6 +18,7 @@ from typing import (
Type,
Union,
cast,
get_type_hints,
overload,
)
from uuid import UUID, uuid5
@@ -119,7 +120,7 @@ from langgraph.utils.config import (
recast_checkpoint_ns,
)
from langgraph.utils.fields import get_enhanced_type_hints
from langgraph.utils.pydantic import create_model
from langgraph.utils.pydantic import create_model, is_supported_by_pydantic
from langgraph.utils.queue import AsyncQueue, SyncQueue # type: ignore[attr-defined]
WriteValue = Union[Callable[[Input], Output], Any]
@@ -609,6 +610,36 @@ class Pregel(PregelProtocol):
]
]
def config_schema(
self, *, include: Optional[Sequence[str]] = None
) -> Type[BaseModel]:
# If the config type is not set explicitly, we will try to infer it.
# If the config type is provided, but isn't directly supported by pydantic
# (e.g., vanilla python class), we will also delegate to the parent class,
# which handles cases where Pydantic doesn't support the type.
if self.config_type is None or not is_supported_by_pydantic(self.config_type):
return super().config_schema(include=include)
include = include or []
fields = {
"configurable": (self.config_type, None),
**{
field_name: (field_type, None)
for field_name, field_type in get_type_hints(RunnableConfig).items()
if field_name in [i for i in include if i != "configurable"]
},
}
return create_model(self.get_name("Config"), field_definitions=fields)
def get_config_jsonschema(
self, *, include: Optional[Sequence[str]] = None
) -> Dict[str, Any]:
schema = self.config_schema(include=include)
if hasattr(schema, "model_json_schema"):
return schema.model_json_schema()
else:
return schema.schema()
@property
def InputType(self) -> Any:
if isinstance(self.input_channels, str):
@@ -634,7 +665,7 @@ class Pregel(PregelProtocol):
def get_input_jsonschema(
self, config: Optional[RunnableConfig] = None
) -> Dict[All, Any]:
) -> Dict[str, Any]:
schema = self.get_input_schema(config)
if hasattr(schema, "model_json_schema"):
return schema.model_json_schema()
@@ -666,7 +697,7 @@ class Pregel(PregelProtocol):
def get_output_jsonschema(
self, config: Optional[RunnableConfig] = None
) -> Dict[All, Any]:
) -> Dict[str, Any]:
schema = self.get_output_schema(config)
if hasattr(schema, "model_json_schema"):
return schema.model_json_schema()
+8 -12
View File
@@ -2,7 +2,6 @@ import asyncio
import concurrent.futures
from collections import defaultdict, deque
from contextlib import AsyncExitStack, ExitStack
from dataclasses import replace
from inspect import signature
from types import TracebackType
from typing import (
@@ -55,6 +54,7 @@ from langgraph.constants import (
ERROR,
INPUT,
INTERRUPT,
MISSING,
NS_SEP,
NULL_TASK_ID,
PUSH,
@@ -67,7 +67,6 @@ from langgraph.errors import (
EmptyInputError,
GraphDelegate,
GraphInterrupt,
ParentCommand,
)
from langgraph.managed.base import (
ManagedValueMapping,
@@ -566,7 +565,13 @@ class PregelLoop(LoopProtocol):
is_resuming = bool(self.checkpoint["channel_versions"]) and bool(
configurable.get(
CONFIG_KEY_RESUMING,
self.input is None or isinstance(self.input, Command),
self.input is None
or isinstance(self.input, Command)
or (
not self.is_nested
and self.config.get("metadata", {}).get("run_id")
== self.checkpoint_metadata.get("run_id", MISSING)
),
)
)
@@ -742,15 +747,6 @@ class PregelLoop(LoopProtocol):
exc_value: Optional[BaseException],
traceback: Optional[TracebackType],
) -> Optional[bool]:
# add current state to parent command
if isinstance(exc_value, ParentCommand):
cmd = exc_value.args[0]
state = (
[(self.output_keys, read_channels(self.channels, self.output_keys))]
if isinstance(self.output_keys, str)
else list(read_channels(self.channels, self.output_keys).items())
)
exc_value.args = (replace(cmd, update=[*state, *cmd._update_as_tuples()]),)
# suppress interrupt
suppress = isinstance(exc_value, GraphInterrupt) and not self.is_nested
if suppress:
@@ -1,5 +1,9 @@
import sys
import typing
from dataclasses import is_dataclass
from typing import Any, Dict, Optional, Union
import typing_extensions
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
@@ -35,3 +39,31 @@ def create_model(
v1_kwargs["__root__"] = root
return create_model(model_name, **v1_kwargs, **(field_definitions or {}))
def is_supported_by_pydantic(type_: Any) -> bool:
"""Check if a given "complex" type is supported by pydantic.
This will return False for primitive types like int, str, etc.
The check is meant for container types like dataclasses, TypedDicts, etc.
"""
if is_dataclass(type_):
return True
# Pydantic does not support mixing .v1 and root namespaces, so
# we only check for BaseModel (not pydantic.v1.BaseModel).
if isinstance(type_, type) and issubclass(type_, BaseModel):
return True
if hasattr(type_, "__orig_bases__"):
for base in type_.__orig_bases__:
if base is typing_extensions.TypedDict:
return True
elif base is typing.TypedDict: # noqa: TID251
# ignoring TID251 since it's OK to use typing.TypedDict in this case.
# Pydantic supports typing.TypedDict from Python 3.12
# For older versions, only typing_extensions.TypedDict is supported.
if sys.version_info >= (3, 12):
return True
return False
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph"
version = "0.3.2"
version = "0.3.5"
description = "Building stateful, multi-actor applications with LLMs"
authors = []
license = "MIT"
@@ -1217,6 +1217,426 @@
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[memory]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[memory].1
dict({
'$defs': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/$defs/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[memory].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres].1
dict({
'$defs': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/$defs/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_pipe]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_pipe].1
dict({
'$defs': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/$defs/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_pipe].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_pool]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_pool].1
dict({
'$defs': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/$defs/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_pool].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_shallow]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_shallow].1
dict({
'$defs': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/$defs/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[postgres_shallow].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[sqlite]
'''
graph TD;
__start__ --> rewrite_query;
analyzer_one --> retriever_one;
qa --> __end__;
retriever_one --> qa;
retriever_two --> qa;
rewrite_query --> analyzer_one;
rewrite_query -.-> retriever_two;
'''
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[sqlite].1
dict({
'$defs': dict({
'InnerObject': dict({
'properties': dict({
'yo': dict({
'title': 'Yo',
'type': 'integer',
}),
}),
'required': list([
'yo',
]),
'title': 'InnerObject',
'type': 'object',
}),
}),
'properties': dict({
'inner': dict({
'$ref': '#/$defs/InnerObject',
}),
'query': dict({
'title': 'Query',
'type': 'string',
}),
}),
'required': list([
'query',
'inner',
]),
'title': 'Input',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input[sqlite].2
dict({
'properties': dict({
'answer': dict({
'title': 'Answer',
'type': 'string',
}),
'docs': dict({
'items': dict({
'type': 'string',
}),
'title': 'Docs',
'type': 'array',
}),
}),
'required': list([
'answer',
'docs',
]),
'title': 'Output',
'type': 'object',
})
# ---
# name: test_in_one_fan_out_state_graph_waiting_edge_via_branch[memory]
'''
graph TD;
@@ -1528,7 +1948,7 @@
'''
# ---
# name: test_state_graph_w_config_inherited_state_keys
'{"$defs": {"Configurable": {"properties": {"tools": {"default": null, "items": {"type": "string"}, "title": "Tools", "type": "array"}}, "title": "Configurable", "type": "object"}}, "properties": {"configurable": {"$ref": "#/$defs/Configurable", "default": null}}, "title": "LangGraphConfig", "type": "object"}'
'{"$defs": {"Config": {"properties": {"tools": {"items": {"type": "string"}, "title": "Tools", "type": "array"}}, "title": "Config", "type": "object"}}, "properties": {"configurable": {"$ref": "#/$defs/Config", "default": null}}, "title": "LangGraphConfig", "type": "object"}'
# ---
# name: test_state_graph_w_config_inherited_state_keys.1
'{"$defs": {"AgentAction": {"description": "Represents a request to execute an action by an agent.\\n\\nThe action consists of the name of the tool to execute and the input to pass\\nto the tool. The log is used to pass along extra information about the action.", "properties": {"tool": {"title": "Tool", "type": "string"}, "tool_input": {"anyOf": [{"type": "string"}, {"type": "object"}], "title": "Tool Input"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentAction", "default": "AgentAction", "enum": ["AgentAction"], "title": "Type", "type": "string"}}, "required": ["tool", "tool_input", "log"], "title": "AgentAction", "type": "object"}, "AgentFinish": {"description": "Final return value of an ActionAgent.\\n\\nAgents return an AgentFinish when they have reached a stopping condition.", "properties": {"return_values": {"title": "Return Values", "type": "object"}, "log": {"title": "Log", "type": "string"}, "type": {"const": "AgentFinish", "default": "AgentFinish", "enum": ["AgentFinish"], "title": "Type", "type": "string"}}, "required": ["return_values", "log"], "title": "AgentFinish", "type": "object"}}, "properties": {"input": {"title": "Input", "type": "string"}, "agent_outcome": {"anyOf": [{"$ref": "#/$defs/AgentAction"}, {"$ref": "#/$defs/AgentFinish"}, {"type": "null"}], "default": null, "title": "Agent Outcome"}, "intermediate_steps": {"default": null, "items": {"maxItems": 2, "minItems": 2, "prefixItems": [{"$ref": "#/$defs/AgentAction"}, {"type": "string"}], "type": "array"}, "title": "Intermediate Steps", "type": "array"}}, "required": ["input"], "title": "LangGraphInput", "type": "object"}'
+209 -163
View File
@@ -10,7 +10,7 @@ import warnings
from collections import Counter, deque
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from dataclasses import dataclass, field
from random import randrange
from typing import (
Annotated,
@@ -275,6 +275,61 @@ def test_checkpoint_errors() -> None:
graph.invoke("", {"configurable": {"thread_id": "thread-1"}})
def test_config_json_schema() -> None:
"""Test that config json schema is generated properly."""
chain = Channel.subscribe_to("input") | Channel.write_to("output")
@dataclass
class Foo:
x: int
y: str = field(default="foo")
app = Pregel(
nodes={
"one": chain,
},
channels={
"ephemeral": EphemeralValue(Any),
"input": LastValue(int),
"output": LastValue(int),
},
input_channels=["input", "ephemeral"],
output_channels="output",
config_type=Foo,
)
assert app.get_config_jsonschema() == {
"$defs": {
"Foo": {
"properties": {
"x": {
"title": "X",
"type": "integer",
},
"y": {
"default": "foo",
"title": "Y",
"type": "string",
},
},
"required": [
"x",
],
"title": "Foo",
"type": "object",
},
},
"properties": {
"configurable": {
"$ref": "#/$defs/Foo",
"default": None,
},
},
"title": "LangGraphConfig",
"type": "object",
}
def test_node_schemas_custom_output() -> None:
class State(TypedDict):
hello: str
@@ -1444,7 +1499,7 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
mapper_calls = 0
class Config:
class Configurable:
model: str
@task()
@@ -1454,7 +1509,7 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non
time.sleep(input / 100)
return str(input) * 2
@entrypoint(checkpointer=checkpointer, config_schema=Config)
@entrypoint(checkpointer=checkpointer, config_schema=Configurable)
def graph(input: list[int]) -> list[str]:
futures = [mapper(i) for i in input]
mapped = [f.result() for f in futures]
@@ -2839,6 +2894,139 @@ def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic2(
}
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_in_one_fan_out_state_graph_waiting_edge_custom_state_class_pydantic_input(
snapshot: SnapshotAssertion,
mocker: MockerFixture,
request: pytest.FixtureRequest,
checkpointer_name: str,
) -> None:
from pydantic import BaseModel
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
def sorted_add(
x: list[str], y: Union[list[str], list[tuple[str, str]]]
) -> list[str]:
if isinstance(y[0], tuple):
for rem, _ in y:
x.remove(rem)
y = [t[1] for t in y]
return sorted(operator.add(x, y))
class InnerObject(BaseModel):
yo: int
class QueryModel(BaseModel):
query: str
class State(QueryModel):
inner: InnerObject
answer: Optional[str] = None
docs: Annotated[list[str], sorted_add]
class StateUpdate(BaseModel):
query: Optional[str] = None
answer: Optional[str] = None
docs: Optional[list[str]] = None
class Input(QueryModel):
inner: InnerObject
class Output(BaseModel):
answer: str
docs: list[str]
def rewrite_query(data: State) -> State:
return {"query": f"query: {data.query}"}
def analyzer_one(data: State) -> State:
return StateUpdate(query=f"analyzed: {data.query}")
def retriever_one(data: State) -> State:
return {"docs": ["doc1", "doc2"]}
def retriever_two(data: State) -> State:
time.sleep(0.1)
return {"docs": ["doc3", "doc4"]}
def qa(data: State) -> State:
return {"answer": ",".join(data.docs)}
def decider(data: State) -> str:
assert isinstance(data, State)
return "retriever_two"
workflow = StateGraph(State, input=Input, output=Output)
workflow.add_node("rewrite_query", rewrite_query)
workflow.add_node("analyzer_one", analyzer_one)
workflow.add_node("retriever_one", retriever_one)
workflow.add_node("retriever_two", retriever_two)
workflow.add_node("qa", qa)
workflow.set_entry_point("rewrite_query")
workflow.add_edge("rewrite_query", "analyzer_one")
workflow.add_edge("analyzer_one", "retriever_one")
workflow.add_conditional_edges(
"rewrite_query", decider, {"retriever_two": "retriever_two"}
)
workflow.add_edge(["retriever_one", "retriever_two"], "qa")
workflow.set_finish_point("qa")
app = workflow.compile()
assert app.invoke(
Input(query="what is weather in sf", inner=InnerObject(yo=1))
) == {
"docs": ["doc1", "doc2", "doc3", "doc4"],
"answer": "doc1,doc2,doc3,doc4",
}
assert [
*app.stream(Input(query="what is weather in sf", inner=InnerObject(yo=1)))
] == [
{"rewrite_query": {"query": "query: what is weather in sf"}},
{"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
{"retriever_two": {"docs": ["doc3", "doc4"]}},
{"retriever_one": {"docs": ["doc1", "doc2"]}},
{"qa": {"answer": "doc1,doc2,doc3,doc4"}},
]
app_w_interrupt = workflow.compile(
checkpointer=checkpointer,
interrupt_after=["retriever_one"],
)
config = {"configurable": {"thread_id": "1"}}
assert [
c
for c in app_w_interrupt.stream(
Input(query="what is weather in sf", inner=InnerObject(yo=1)), config
)
] == [
{"rewrite_query": {"query": "query: what is weather in sf"}},
{"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
{"retriever_two": {"docs": ["doc3", "doc4"]}},
{"retriever_one": {"docs": ["doc1", "doc2"]}},
{"__interrupt__": ()},
]
assert [c for c in app_w_interrupt.stream(None, config)] == [
{"qa": {"answer": "doc1,doc2,doc3,doc4"}},
]
assert app_w_interrupt.update_state(
config, {"docs": ["doc5"]}, as_node="rewrite_query"
) == {
"configurable": {
"thread_id": "1",
"checkpoint_id": AnyStr(),
"checkpoint_ns": "",
}
}
@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
@@ -2905,14 +3093,24 @@ def test_in_one_fan_out_state_graph_waiting_edge_plus_regular(
"answer": "doc1,doc2,doc3,doc4",
}
assert [*app.stream({"query": "what is weather in sf"})] == [
{"rewrite_query": {"query": "query: what is weather in sf"}},
{"qa": {"answer": ""}},
{"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
{"retriever_two": {"docs": ["doc3", "doc4"]}},
{"retriever_one": {"docs": ["doc1", "doc2"]}},
{"qa": {"answer": "doc1,doc2,doc3,doc4"}},
]
assert [*app.stream({"query": "what is weather in sf"})] in (
[
{"rewrite_query": {"query": "query: what is weather in sf"}},
{"qa": {"answer": ""}},
{"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
{"retriever_two": {"docs": ["doc3", "doc4"]}},
{"retriever_one": {"docs": ["doc1", "doc2"]}},
{"qa": {"answer": "doc1,doc2,doc3,doc4"}},
],
[
{"rewrite_query": {"query": "query: what is weather in sf"}},
{"analyzer_one": {"query": "analyzed: query: what is weather in sf"}},
{"qa": {"answer": ""}},
{"retriever_two": {"docs": ["doc3", "doc4"]}},
{"retriever_one": {"docs": ["doc1", "doc2"]}},
{"qa": {"answer": "doc1,doc2,doc3,doc4"}},
],
)
app_w_interrupt = workflow.compile(
checkpointer=checkpointer,
@@ -4883,13 +5081,6 @@ def test_parent_command(request: pytest.FixtureRequest, checkpointer_name: str)
"source": "loop",
"writes": {
"alice": {
"messages": [
_AnyIdHumanMessage(
content="get user name",
additional_kwargs={},
response_metadata={},
),
],
"user_name": "Meow",
}
},
@@ -6229,151 +6420,6 @@ def test_multiple_subgraphs_checkpointer(
]
def test_merging_updates_command_parent():
# simple reducer
def append_unique(left, right):
combined = list(left)
for item in right:
if item in combined:
continue
else:
combined.append(item)
return combined
class State(TypedDict):
foo: str
bar: Annotated[list[str], append_unique]
# Define subgraph
def subgraph_node_1(state: State):
return Command(
goto="subgraph_node_2",
update={
"foo": "foo",
"bar": ["subgraph_node_1"],
},
)
def subgraph_node_2(state: State):
return Command(
goto="node_3",
update={"bar": ["subgraph_node_2"]},
graph=Command.PARENT,
)
subgraph_builder = StateGraph(State)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.add_node(subgraph_node_2)
subgraph_builder.add_edge(START, "subgraph_node_1")
# Define main graph
def node_1(state: State):
return Command(
goto="node_2",
update={"bar": ["node_1"]},
)
def node_3(state: State, store):
return Command(
update={"bar": ["node_3"]},
)
main_builder = StateGraph(State)
main_builder.add_node("node_1", node_1)
main_builder.add_node("node_2", subgraph_builder.compile())
main_builder.add_node("node_3", node_3)
main_builder.add_edge(START, "node_1")
main_builder.add_edge("node_2", "node_3")
main_graph = main_builder.compile()
assert main_graph.invoke({"foo": ""}) == {
"foo": "foo",
"bar": ["node_1", "subgraph_node_1", "subgraph_node_2", "node_3"],
}
assert list(
main_graph.stream({"foo": ""}, stream_mode="updates", subgraphs=True)
) == [
((), {"node_1": {"bar": ["node_1"]}}),
(
(AnyStr("node_2:"),),
{"subgraph_node_1": {"foo": "foo", "bar": ["subgraph_node_1"]}},
),
(
(),
{
"node_2": [
{"foo": "foo"},
{"bar": ["node_1", "subgraph_node_1"]},
{"bar": ["subgraph_node_2"]},
]
},
),
((), {"node_3": {"bar": ["node_3"]}}),
]
def test_merging_non_overlapping_updates_command_parent():
# simple reducer
def append_unique(left, right):
combined = list(left)
for item in right:
if item in combined:
continue
else:
combined.append(item)
return combined
class State(TypedDict):
foo: Annotated[list, append_unique]
# Define subgraph
def subgraph_node_1(state: State):
return Command(
goto="subgraph_node_2",
update={
"foo": ["bar"],
"bar": ["subgraph_node_1"],
},
)
def subgraph_node_2(state: State):
return Command(
goto="node_3",
update={"bar": ["subgraph_node_2"]},
graph=Command.PARENT,
)
subgraph_builder = StateGraph(State)
subgraph_builder.add_node(subgraph_node_1)
subgraph_builder.add_node(subgraph_node_2)
subgraph_builder.add_edge(START, "subgraph_node_1")
# Define main graph
def node_1(state: State):
return Command(
goto="node_2",
update={"foo": ["foo"]},
)
def node_3(state: State, store):
return Command(
update={"foo": ["baz"]},
)
main_builder = StateGraph(State)
main_builder.add_node("node_1", node_1)
main_builder.add_node("node_2", subgraph_builder.compile())
main_builder.add_node("node_3", node_3)
main_builder.add_edge(START, "node_1")
main_builder.add_edge("node_2", "node_3")
main_graph = main_builder.compile()
assert main_graph.invoke({"foo": []}) == {
"foo": ["foo", "bar", "baz"],
}
def test_entrypoint_output_schema_with_return_and_save() -> None:
"""Test output schema inference with entrypoint.final."""
@@ -6148,13 +6148,6 @@ async def test_parent_command(checkpointer_name: str) -> None:
"source": "loop",
"writes": {
"alice": {
"messages": [
_AnyIdHumanMessage(
content="get user name",
additional_kwargs={},
response_metadata={},
),
],
"user_name": "Meow",
}
},
+43
View File
@@ -0,0 +1,43 @@
import sys
import typing
import pydantic
import typing_extensions
from langgraph.utils.pydantic import is_supported_by_pydantic
def test_is_supported_by_pydantic() -> None:
"""Test if types are supported by pydantic."""
class TypedDictExtensions(typing_extensions.TypedDict):
x: int
assert is_supported_by_pydantic(TypedDictExtensions) is True
class VanillaClass:
x: int
assert is_supported_by_pydantic(VanillaClass) is False
class BuiltinTypedDict(typing.TypedDict): # noqa: TID251
x: int
if sys.version_info >= (3, 12):
assert is_supported_by_pydantic(BuiltinTypedDict) is True
else:
assert is_supported_by_pydantic(BuiltinTypedDict) is False
class PydanticModel(pydantic.BaseModel):
x: int
assert is_supported_by_pydantic(PydanticModel) is True
if hasattr(pydantic, "v1"):
class PydanticModelV1(pydantic.v1.BaseModel):
x: int
assert is_supported_by_pydantic(PydanticModelV1) is False
assert is_supported_by_pydantic(int) is False
@@ -22,6 +22,7 @@ from langchain_core.runnables import (
Runnable,
RunnableBinding,
RunnableConfig,
RunnableSequence,
)
from langchain_core.tools import BaseTool
from pydantic import BaseModel
@@ -133,6 +134,16 @@ def _convert_modifier_to_prompt(func: F) -> F:
def _should_bind_tools(model: LanguageModelLike, tools: Sequence[BaseTool]) -> bool:
if isinstance(model, RunnableSequence):
model = next(
(
step
for step in model.steps
if isinstance(step, (RunnableBinding, BaseChatModel))
),
model,
)
if not isinstance(model, RunnableBinding):
return True
@@ -168,6 +179,16 @@ def _should_bind_tools(model: LanguageModelLike, tools: Sequence[BaseTool]) -> b
def _get_model(model: LanguageModelLike) -> BaseChatModel:
"""Get the underlying model from a RunnableBinding or return the model itself."""
if isinstance(model, RunnableSequence):
model = next(
(
step
for step in model.steps
if isinstance(step, (RunnableBinding, BaseChatModel))
),
model,
)
if isinstance(model, RunnableBinding):
model = model.bound
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-prebuilt"
version = "0.1.1"
version = "0.1.2"
description = "Library with high-level APIs for creating and executing LangGraph agents and tools."
authors = []
license = "MIT"
+65
View File
@@ -35,6 +35,8 @@ from langgraph.prebuilt import (
)
from langgraph.prebuilt.chat_agent_executor import (
AgentState,
_get_model,
_should_bind_tools,
_validate_chat_history,
)
from langgraph.prebuilt.tool_node import (
@@ -1324,3 +1326,66 @@ def test_tool_node_node_interrupt(
ns=[AnyStr("tools:")],
),
)
@pytest.mark.parametrize("tool_style", ["openai", "anthropic"])
def test_should_bind_tools(tool_style: str) -> None:
@dec_tool
def some_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
@dec_tool
def some_other_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
model = FakeToolCallingModel(tool_style=tool_style)
# should bind when a regular model
assert _should_bind_tools(model, [])
assert _should_bind_tools(model, [some_tool])
# should bind when a seq
seq = model | RunnableLambda(lambda message: message)
assert _should_bind_tools(seq, [])
assert _should_bind_tools(seq, [some_tool])
# should not bind when a model with tools
assert not _should_bind_tools(model.bind_tools([some_tool]), [some_tool])
# should not bind when a seq with tools
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
lambda message: message
)
assert not _should_bind_tools(seq_with_tools, [some_tool])
# should raise on invalid inputs
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [])
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [some_other_tool])
with pytest.raises(ValueError):
_should_bind_tools(model.bind_tools([some_tool]), [some_tool, some_other_tool])
def test_get_model() -> None:
model = FakeToolCallingModel(tool_calls=[])
assert _get_model(model) == model
@dec_tool
def some_tool(some_val: int) -> str:
"""Tool docstring."""
return "meow"
model_with_tools = model.bind_tools([some_tool])
assert _get_model(model_with_tools) == model
seq = model | RunnableLambda(lambda message: message)
assert _get_model(seq) == model
seq_with_tools = model.bind_tools([some_tool]) | RunnableLambda(
lambda message: message
)
assert _get_model(seq_with_tools) == model
with pytest.raises(TypeError):
_get_model(RunnableLambda(lambda message: message))
+8
View File
@@ -10,6 +10,14 @@ react.cjs
react.js
react.d.ts
react.d.cts
react-ui.cjs
react-ui.js
react-ui.d.ts
react-ui.d.cts
react-ui/server.cjs
react-ui/server.js
react-ui/server.d.ts
react-ui/server.d.cts
node_modules
dist
.yarn
+7 -1
View File
@@ -11,7 +11,13 @@ function abs(relativePath) {
export const config = {
internals: [/react/],
entrypoints: { index: "index", client: "client", react: "react/index" },
entrypoints: {
index: "index",
client: "client",
react: "react/index",
"react-ui": "react-ui/index",
"react-ui/server": "react-ui/server/index",
},
tsConfigPath: resolve("./tsconfig.json"),
cjsSource: "./dist-cjs",
cjsDestination: "./dist",
+33 -5
View File
@@ -1,13 +1,13 @@
{
"name": "@langchain/langgraph-sdk",
"version": "0.0.45",
"version": "0.0.48",
"description": "Client library for interacting with the LangGraph API",
"type": "module",
"packageManager": "yarn@1.22.19",
"scripts": {
"clean": "rm -rf dist/ dist-cjs/",
"build": "yarn clean && yarn lc_build --create-entrypoints --pre --tree-shaking",
"prepublish": "yarn run build",
"prepack": "yarn run build",
"format": "prettier --write src",
"lint": "prettier --check src && tsc --noEmit",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --testPathIgnorePatterns=\\.int\\.test.ts",
@@ -29,7 +29,8 @@
"@types/jest": "^29.5.12",
"@types/node": "^20.12.12",
"@types/uuid": "^9.0.1",
"@types/react": "18.3.2",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"concat-md": "^0.5.1",
"jest": "^29.7.0",
"prettier": "^3.2.5",
@@ -37,7 +38,8 @@
"typedoc": "^0.27.7",
"typedoc-plugin-markdown": "^4.4.2",
"typescript": "^5.4.5",
"react": "^18.3.1"
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependencies": {
"react": "^18 || ^19",
@@ -79,6 +81,24 @@
"import": "./react.js",
"require": "./react.cjs"
},
"./react-ui": {
"types": {
"import": "./react-ui.d.ts",
"require": "./react-ui.d.cts",
"default": "./react-ui.d.ts"
},
"import": "./react-ui.js",
"require": "./react-ui.cjs"
},
"./react-ui/server": {
"types": {
"import": "./react-ui/server.d.ts",
"require": "./react-ui/server.d.cts",
"default": "./react-ui/server.d.ts"
},
"import": "./react-ui/server.js",
"require": "./react-ui/server.cjs"
},
"./package.json": "./package.json"
},
"files": [
@@ -94,6 +114,14 @@
"react.cjs",
"react.js",
"react.d.ts",
"react.d.cts"
"react.d.cts",
"react-ui.cjs",
"react-ui.js",
"react-ui.d.ts",
"react-ui.d.cts",
"react-ui/server.cjs",
"react-ui/server.js",
"react-ui/server.d.ts",
"react-ui/server.d.cts"
]
}
+1
View File
@@ -1,6 +1,7 @@
export { Client } from "./client.js";
export type {
AssistantBase,
Assistant,
AssistantVersion,
AssistantGraph,
+251
View File
@@ -0,0 +1,251 @@
import { useStream } from "../react/index.js";
import type { UIMessage } from "./types.js";
import * as React from "react";
import * as ReactDOM from "react-dom";
import * as JsxRuntime from "react/jsx-runtime";
import type { UseStream } from "../react/stream.js";
const UseStreamContext = React.createContext<{
stream: ReturnType<typeof useStream>;
meta: unknown;
}>(null!);
type BagTemplate = {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
MetaType?: unknown;
};
type GetMetaType<Bag extends BagTemplate> = Bag extends { MetaType: unknown }
? Bag["MetaType"]
: unknown;
interface UseStreamContext<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate,
> extends UseStream<StateType, Bag> {
meta?: GetMetaType<Bag>;
}
export function useStreamContext<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends {
ConfigurableType?: Record<string, unknown>;
InterruptType?: unknown;
CustomEventType?: unknown;
UpdateType?: unknown;
MetaType?: unknown;
} = BagTemplate,
>(): UseStreamContext<StateType, Bag> {
const ctx = React.useContext(UseStreamContext);
if (!ctx) {
throw new Error(
"useStreamContext must be used within a LoadExternalComponent",
);
}
return new Proxy(ctx, {
get(target, prop: keyof UseStreamContext<StateType, Bag>) {
if (prop === "meta") return target.meta;
return target.stream[prop];
},
}) as unknown as UseStreamContext<StateType, Bag>;
}
interface ComponentTarget {
comp: React.FunctionComponent | React.ComponentClass;
target: HTMLElement;
}
class ComponentStore {
private cache: Record<string, ComponentTarget> = {};
private boundCache: Record<
string,
{
subscribe: (onStoreChange: () => void) => () => void;
getSnapshot: () => ComponentTarget | undefined;
}
> = {};
private callbacks: Record<
string,
((
comp: React.FunctionComponent | React.ComponentClass,
el: HTMLElement,
) => void)[]
> = {};
respond(
shadowRootId: string,
comp: React.FunctionComponent | React.ComponentClass,
targetElement: HTMLElement,
) {
this.cache[shadowRootId] = { comp, target: targetElement };
this.callbacks[shadowRootId]?.forEach((c) => c(comp, targetElement));
}
getBoundStore(shadowRootId: string) {
this.boundCache[shadowRootId] ??= {
subscribe: (onStoreChange: () => void) => {
this.callbacks[shadowRootId] ??= [];
this.callbacks[shadowRootId].push(onStoreChange);
return () => {
this.callbacks[shadowRootId] = this.callbacks[shadowRootId].filter(
(c) => c !== onStoreChange,
);
};
},
getSnapshot: () => this.cache[shadowRootId],
};
return this.boundCache[shadowRootId];
}
}
const COMPONENT_STORE = new ComponentStore();
const COMPONENT_PROMISE_CACHE: Record<string, Promise<string> | undefined> = {};
const EXT_STORE_SYMBOL = Symbol.for("LGUI_EXT_STORE");
const REQUIRE_SYMBOL = Symbol.for("LGUI_REQUIRE");
interface LoadExternalComponentProps
extends Pick<React.HTMLAttributes<HTMLDivElement>, "style" | "className"> {
/** API URL of the LangGraph Platform */
apiUrl?: string;
/** ID of the assistant */
assistantId: string;
/** Stream of the assistant */
stream: ReturnType<typeof useStream>;
/** UI message to be rendered */
message: UIMessage;
/** Additional context to be passed to the child component */
meta?: unknown;
/** Fallback to be rendered when the component is loading */
fallback?: React.ReactNode;
/**
* Map of components that can be rendered directly without fetching the UI code
* from the server.
*/
components?: Record<string, React.FunctionComponent | React.ComponentClass>;
}
function fetchComponent(
apiUrl: string,
assistantId: string,
agentName: string,
): Promise<string> {
const cacheKey = `${apiUrl}-${assistantId}-${agentName}`;
if (COMPONENT_PROMISE_CACHE[cacheKey] != null) {
return COMPONENT_PROMISE_CACHE[cacheKey] as Promise<string>;
}
const request: Promise<string> = fetch(`${apiUrl}/ui/${assistantId}`, {
headers: { Accept: "text/html", "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ name: agentName }),
}).then((a) => a.text());
COMPONENT_PROMISE_CACHE[cacheKey] = request;
return request;
}
export function LoadExternalComponent({
apiUrl = "http://localhost:2024",
assistantId,
stream,
message,
meta,
fallback,
components,
...props
}: LoadExternalComponentProps) {
const ref = React.useRef<HTMLDivElement>(null);
const id = React.useId();
const shadowRootId = `child-shadow-${id}`;
const store = React.useMemo(
() => COMPONENT_STORE.getBoundStore(shadowRootId),
[shadowRootId],
);
const state = React.useSyncExternalStore(store.subscribe, store.getSnapshot);
const clientComponent = components?.[message.name];
const hasClientComponent = clientComponent != null;
React.useEffect(() => {
if (hasClientComponent) return;
fetchComponent(apiUrl, assistantId, message.name).then((html) => {
const dom = ref.current;
if (!dom) return;
const root = dom.shadowRoot ?? dom.attachShadow({ mode: "open" });
const fragment = document
.createRange()
.createContextualFragment(
html.replace("{{shadowRootId}}", shadowRootId),
);
root.appendChild(fragment);
});
}, [apiUrl, assistantId, message.name, shadowRootId, hasClientComponent]);
if (hasClientComponent) {
return React.createElement(clientComponent, message.content);
}
return (
<>
<div id={shadowRootId} ref={ref} {...props} />
<UseStreamContext.Provider value={{ stream, meta }}>
{state?.target != null
? ReactDOM.createPortal(
React.createElement(state.comp, message.content),
state.target,
)
: fallback}
</UseStreamContext.Provider>
</>
);
}
declare global {
interface Window {
[EXT_STORE_SYMBOL]: ComponentStore;
[REQUIRE_SYMBOL]: (name: string) => unknown;
}
}
export function bootstrapUiContext() {
if (typeof window === "undefined") {
console.warn(
"Attempting to bootstrap UI context outside of browser environment. " +
"Avoid importing from `@langchain/langgraph-sdk/react-ui` in server context.",
);
return;
}
window[EXT_STORE_SYMBOL] = COMPONENT_STORE;
window[REQUIRE_SYMBOL] = (name: string) => {
if (name === "react") return React;
if (name === "react-dom") return ReactDOM;
if (name === "react/jsx-runtime") return JsxRuntime;
if (name === "@langchain/langgraph-sdk/react") return { useStream };
if (name === "@langchain/langgraph-sdk/react-ui") {
return {
useStreamContext,
LoadExternalComponent: () => {
throw new Error("Nesting LoadExternalComponent is not supported");
},
};
}
throw new Error(`Unknown module...: ${name}`);
};
}
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapUiContext } from "./client.js";
bootstrapUiContext();
export { useStreamContext, LoadExternalComponent } from "./client.js";
export type { UIMessage, RemoveUIMessage } from "./types.js";
+6
View File
@@ -0,0 +1,6 @@
export { typedUi } from "./server.js";
export {
uiMessageReducer,
type UIMessage,
type RemoveUIMessage,
} from "../types.js";
+49
View File
@@ -0,0 +1,49 @@
import { v4 as uuidv4 } from "uuid";
import type { ComponentPropsWithoutRef, ElementType } from "react";
import type { RemoveUIMessage, UIMessage } from "../types.js";
export const typedUi = <Decl extends Record<string, ElementType>>(config: {
writer?: (chunk: unknown) => void;
runId?: string;
metadata?: Record<string, unknown>;
tags?: string[];
runName?: string;
}) => {
type PropMap = { [K in keyof Decl]: ComponentPropsWithoutRef<Decl[K]> };
let collect: (UIMessage | RemoveUIMessage)[] = [];
const runId = (config.metadata?.run_id as string | undefined) ?? config.runId;
if (!runId) throw new Error("run_id is required");
const metadata = {
...config.metadata,
tags: config.tags,
name: config.runName,
run_id: runId,
};
const create = <K extends keyof PropMap & string>(
name: K,
props: PropMap[K],
): UIMessage => ({
type: "ui" as const,
id: uuidv4(),
name,
content: props,
additional_kwargs: metadata,
});
const remove = (id: string): RemoveUIMessage => ({ type: "remove-ui", id });
return {
create,
remove,
collect,
write: <K extends keyof PropMap & string>(name: K, props: PropMap[K]) => {
const evt: UIMessage = create(name, props);
collect.push(evt);
config.writer?.(evt);
},
};
};
+40
View File
@@ -0,0 +1,40 @@
export interface UIMessage {
type: "ui";
id: string;
name: string;
content: Record<string, unknown>;
additional_kwargs: {
run_id: string;
[key: string]: unknown;
};
}
export interface RemoveUIMessage {
type: "remove-ui";
id: string;
}
export function uiMessageReducer(
state: UIMessage[],
update: UIMessage | RemoveUIMessage | (UIMessage | RemoveUIMessage)[],
) {
const events = Array.isArray(update) ? update : [update];
let newState = state.slice();
for (const event of events) {
if (event.type === "remove-ui") {
newState = newState.filter((ui) => ui.id !== event.id);
continue;
}
const index = state.findIndex((ui) => ui.id === event.id);
if (index !== -1) {
newState[index] = event;
} else {
newState.push(event);
}
}
return newState;
}
+34 -10
View File
@@ -366,11 +366,11 @@ const useControllableThreadId = (options?: {
onThreadIdRef.current?.(threadId);
}, []);
if (typeof options?.threadId === "undefined") {
if (!options || !("threadId" in options)) {
return [localThreadId, onThreadId];
}
return [options.threadId, onThreadId];
return [options.threadId ?? null, onThreadId];
};
type BagTemplate = {
@@ -424,6 +424,16 @@ interface UseStreamOptions<
*/
apiKey?: ClientConfig["apiKey"];
/**
* Custom call options, such as custom fetch implementation.
*/
callerOptions?: ClientConfig["callerOptions"];
/**
* Default headers to send with requests.
*/
defaultHeaders?: ClientConfig["defaultHeaders"];
/**
* Specify the key within the state that contains messages.
* Defaults to "messages".
@@ -472,7 +482,7 @@ interface UseStreamOptions<
onThreadId?: (threadId: string) => void;
}
interface UseStream<
export interface UseStream<
StateType extends Record<string, unknown> = Record<string, unknown>,
Bag extends BagTemplate = BagTemplate,
> {
@@ -603,8 +613,19 @@ export function useStream<
messagesKey ??= "messages";
const client = useMemo(
() => new Client({ apiUrl: options.apiUrl, apiKey: options.apiKey }),
[options.apiKey, options.apiUrl],
() =>
new Client({
apiUrl: options.apiUrl,
apiKey: options.apiKey,
callerOptions: options.callerOptions,
defaultHeaders: options.defaultHeaders,
}),
[
options.apiKey,
options.apiUrl,
options.callerOptions,
options.defaultHeaders,
],
);
const [threadId, onThreadId] = useControllableThreadId(options);
@@ -623,9 +644,12 @@ export function useStream<
>([]);
const trackStreamMode = useCallback(
(mode: Exclude<StreamMode, "debug" | "messages">) => {
if (!trackStreamModeRef.current.includes(mode))
trackStreamModeRef.current.push(mode);
(...mode: Exclude<StreamMode, "debug" | "messages">[]) => {
for (const m of mode) {
if (!trackStreamModeRef.current.includes(m)) {
trackStreamModeRef.current.push(m);
}
}
},
[],
);
@@ -908,7 +932,7 @@ export function useStream<
},
get messages() {
trackStreamMode("messages-tuple");
trackStreamMode("messages-tuple", "values");
return getMessages(values);
},
@@ -916,7 +940,7 @@ export function useStream<
message: Message,
index?: number,
): MessageMetadata<StateType> | undefined {
trackStreamMode("messages-tuple");
trackStreamMode("messages-tuple", "values");
return messageMetadata?.find(
(m) => m.messageId === (message.id ?? index),
);
+3 -3
View File
@@ -110,6 +110,9 @@ export interface AssistantBase {
/** The version of the assistant. */
version: number;
/** The name of the assistant */
name: string;
}
export interface AssistantVersion extends AssistantBase {}
@@ -117,9 +120,6 @@ export interface AssistantVersion extends AssistantBase {}
export interface Assistant extends AssistantBase {
/** The last time the assistant was updated. */
updated_at: string;
/** The name of the assistant */
name: string;
}
export interface AssistantGraph {
+25 -23
View File
@@ -1005,17 +1005,16 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901"
integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==
"@types/prop-types@*":
version "15.7.14"
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2"
integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==
"@types/react-dom@^19.0.3":
version "19.0.3"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.3.tgz#0804dfd279a165d5a0ad8b53a5b9e65f338050a4"
integrity sha512-0Knk+HJiMP/qOZgMyNFamlIjw9OFCsyC2ZbigmEEyXXixgre6IQpm/4V+r3qH4GC1JPvRJKInw+on2rV6YZLeA==
"@types/react@18.3.2":
version "18.3.2"
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.3.2.tgz#462ae4904973bc212fa910424d901e3d137dbfcd"
integrity sha512-Btgg89dAnqD4vV7R3hlwOxgqobUQKgx3MmrQRi0yYbs/P0ym8XozIAlkqVilPqHQwXs4e9Tf63rrCgl58BcO4w==
"@types/react@^19.0.8":
version "19.0.9"
resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.9.tgz#675255eb7d978bdaf71f9d08f6b740d41b0b7f32"
integrity sha512-FedNTYgmMwSZmD1Sru/W1gJKuiYCN/3SuBkmZkcxX+FpO5zL76B22A9YNfAKg4HQO3Neh/30AiynP6BELdU0qQ==
dependencies:
"@types/prop-types" "*"
csstype "^3.0.2"
"@types/retry@0.12.0":
@@ -2896,7 +2895,7 @@ js-tiktoken@^1.0.12:
dependencies:
base64-js "^1.5.1"
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
@@ -2991,13 +2990,6 @@ longest-streak@^2.0.0:
resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4"
integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==
loose-envify@^1.1.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
dependencies:
js-tokens "^3.0.0 || ^4.0.0"
lru-cache@^10.2.0:
version "10.4.3"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
@@ -3640,17 +3632,22 @@ quick-lru@^4.0.1:
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
react-dom@^19.0.0:
version "19.0.0"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57"
integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==
dependencies:
scheduler "^0.25.0"
react-is@^18.0.0:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react@^18.3.1:
version "18.3.1"
resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891"
integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==
dependencies:
loose-envify "^1.1.0"
react@^19.0.0:
version "19.0.0"
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd"
integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==
read-pkg-up@^7.0.1:
version "7.0.1"
@@ -3840,6 +3837,11 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
scheduler@^0.25.0:
version "0.25.0"
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015"
integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==
"semver@2 || 3 || 4 || 5":
version "5.7.2"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8"
+5 -3
View File
@@ -388,7 +388,9 @@ class AssistantsClient:
'created_at': '2024-06-25T17:10:33.109781+00:00',
'updated_at': '2024-06-25T17:10:33.109781+00:00',
'config': {},
'metadata': {'created_by': 'system'}
'metadata': {'created_by': 'system'},
'version': 1,
'name': 'my_assistant'
}
""" # noqa: E501
@@ -742,7 +744,7 @@ class AssistantsClient:
offset: The number of versions to skip.
Returns:
list[Assistant]: A list of assistants.
list[AssistantVersion]: A list of assistant versions.
Example Usage:
@@ -2517,7 +2519,7 @@ def encode_json(json: Any) -> tuple[dict[str, str], bytes]:
def decode_json(r: httpx.Response) -> Any:
body = r.read()
return orjson.loads(body if body else None)
return orjson.loads(body) if body else None
class SyncAssistantsClient:
+2 -2
View File
@@ -169,6 +169,8 @@ class AssistantBase(TypedDict):
"""The assistant metadata."""
version: int
"""The version of the assistant"""
name: str
"""The name of the assistant"""
class AssistantVersion(AssistantBase):
@@ -182,8 +184,6 @@ class Assistant(AssistantBase):
updated_at: datetime
"""The last time the assistant was updated."""
name: str
"""The name of the assistant"""
class Interrupt(TypedDict, total=False):
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "langgraph-sdk"
version = "0.1.53"
version = "0.1.54"
description = "SDK for interacting with LangGraph API"
authors = []
license = "MIT"