mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-09 09:55:13 +02:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ed63ba8fc | ||
|
|
2da005b4bd | ||
|
|
58e2824ea3 | ||
|
|
f239b39060 |
@@ -122,7 +122,7 @@ def workflow(topic: str) -> dict:
|
||||
|
||||
## Functional API vs. Graph API
|
||||
|
||||
The **Functional API** and the [Graph APIs (StateGraph)](./low_level.md#stategraph) provide two different paradigms to create applications with LangGraph. Here are some key differences:
|
||||
The **Functional API** and the [Graph APIs (StateGraph)](./low_level.md#stategraph) provide two different paradigms to create in LangGraph. Here are some key differences:
|
||||
|
||||
- **Control flow**: The Functional API does not require thinking about graph structure. You can use standard Python constructs to define workflows. This will usually trim the amount of code you need to write.
|
||||
- **State management**: The **GraphAPI** requires declaring a [**State**](./low_level.md#state) and may require defining [**reducers**](./low_level.md#reducers) to manage updates to the graph state. `@entrypoint` and `@tasks` do not require explicit state management as their state is scoped to the function and is not shared across functions.
|
||||
|
||||
@@ -21,12 +21,6 @@ Resource Allocation:
|
||||
|
||||
See the [how-to guide](../cloud/deployment/cloud.md#create-new-deployment) for creating a new deployment.
|
||||
|
||||
## Revision
|
||||
|
||||
A revision is an iteration of a [deployment](#deployment). When a new deployment is created, an initial revision is automatically created. To deploy new code changes or update environment variable configurations for a deployment, a new revision must be created. When a revision is created, a new container image is built automatically.
|
||||
|
||||
See the [how-to guide](../cloud/deployment/cloud.md#create-new-revision) for creating a new revision.
|
||||
|
||||
## Persistence
|
||||
|
||||
A dedicated database is automatically created for each deployment. The database serves as the [persistence layer](../concepts/persistence.md) for the deployment.
|
||||
@@ -47,6 +41,12 @@ Scale down actions are delayed for 30 minutes before any action is taken. In oth
|
||||
|
||||
In the future, the autoscaling implementation may evolve to accommodate other metrics such as background run queue size.
|
||||
|
||||
## Revision
|
||||
|
||||
A revision is an iteration of a [deployment](#deployment). When a new deployment is created, an initial revision is automatically created. To deploy new code changes or update environment variable configurations for a deployment, a new revision must be created. When a revision is created, a new container image is built automatically.
|
||||
|
||||
See the [how-to guide](../cloud/deployment/cloud.md#create-new-revision) for creating a new revision.
|
||||
|
||||
## Asynchronous Deployment
|
||||
|
||||
Infrastructure for [deployments](#deployment) and [revisions](#revision) are provisioned and deployed asynchronously. They are not deployed immediately after submission. Currently, deployment can take up to several minutes.
|
||||
@@ -55,12 +55,6 @@ Infrastructure for [deployments](#deployment) and [revisions](#revision) are pro
|
||||
- When a subsequent revision is created for a deployment, there is no database creation step. The deployment time for a subsequent revision is significantly faster compared to the deployment time of the initial revision.
|
||||
- The deployment process for each revision contains a build step, which can take up to a few minutes.
|
||||
|
||||
## LangSmith Integration
|
||||
|
||||
A [LangSmith](https://docs.smith.langchain.com/) tracing project is automatically created for each deployemnt. The tracing project has the same name as the deployment. When creating a deployment, the `LANGCHAIN_TRACING_V2` and `LANGCHAIN_API_KEY` environment variables do not need to be specified; they are set internally, automatically. Traces are created for each run and are emitted to the tracing project automatically.
|
||||
|
||||
When a deployment is deleted, the traces and the tracing project are not deleted.
|
||||
|
||||
## Automatic Deletion
|
||||
|
||||
Deployments are automatically deleted after 28 consecutive days of non-use (it is in an unused state). A deployment is in an unused state if there are no traces emitted to LangSmith from the deployment after 28 consecutive days. On any given day, if a deployment emits a trace to LangSmith, the counter for consecutive days of non-use is reset.
|
||||
|
||||
@@ -22,6 +22,10 @@ A super-step can be considered a single iteration over the graph nodes. Nodes th
|
||||
|
||||
The `StateGraph` class is the main graph class to use. This is parameterized by a user defined `State` object.
|
||||
|
||||
### MessageGraph
|
||||
|
||||
The `MessageGraph` class is a special type of graph. The `State` of a `MessageGraph` is ONLY a list of messages. This class is rarely used except for chatbots, as most applications require the `State` to be more complex than a list of messages.
|
||||
|
||||
### Compiling your graph
|
||||
|
||||
To build your graph, you first define the [state](#state), you then add [nodes](#nodes) and [edges](#edges), and then you compile it. What exactly is compiling your graph and why is it needed?
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -183,16 +183,6 @@ def validate_config(config: Config) -> Config:
|
||||
"No graphs found in config. "
|
||||
"Add at least one graph to 'graphs' dictionary."
|
||||
)
|
||||
|
||||
# Validate auth config
|
||||
if auth_conf := config.get("auth"):
|
||||
if "path" in auth_conf:
|
||||
if ":" not in auth_conf["path"]:
|
||||
raise ValueError(
|
||||
f"Invalid auth.path format: '{auth_conf['path']}'. "
|
||||
"Must be in format './path/to/file.py:attribute_name'"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -383,47 +373,6 @@ def _update_graph_paths(
|
||||
config["graphs"][graph_id] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
def _update_auth_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update auth.path to use Docker container paths."""
|
||||
auth_conf = config.get("auth")
|
||||
if not auth_conf or not (path_str := auth_conf.get("path")):
|
||||
return
|
||||
|
||||
module_str, sep, attr_str = path_str.partition(":")
|
||||
if not sep or not module_str.startswith("."):
|
||||
return # Already validated or absolute path
|
||||
|
||||
resolved = config_path.parent / module_str
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Auth file not found: {resolved} (from {path_str})")
|
||||
if not resolved.is_file():
|
||||
raise IsADirectoryError(f"Auth path must be a file: {resolved}")
|
||||
|
||||
# Check faux packages first (higher priority)
|
||||
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_path):
|
||||
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
|
||||
auth_conf["path"] = new_path
|
||||
return
|
||||
|
||||
# Check real packages
|
||||
for real_path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(real_path):
|
||||
new_path = (
|
||||
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
|
||||
)
|
||||
auth_conf["path"] = new_path
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Auth file '{resolved}' not covered by dependencies.\n"
|
||||
"Add its parent directory to the 'dependencies' array in your config.\n"
|
||||
f"Current dependencies: {config['dependencies']}"
|
||||
)
|
||||
|
||||
|
||||
def python_config_to_docker(config_path: pathlib.Path, config: Config, base_image: str):
|
||||
# configure pip
|
||||
pip_install = (
|
||||
@@ -440,9 +389,9 @@ def python_config_to_docker(config_path: pathlib.Path, config: Config, base_imag
|
||||
# collect dependencies
|
||||
pypi_deps = [dep for dep in config["dependencies"] if not dep.startswith(".")]
|
||||
local_deps = _assemble_local_deps(config_path, config)
|
||||
|
||||
# rewrite graph paths
|
||||
_update_graph_paths(config_path, config, local_deps)
|
||||
_update_auth_path(config_path, config, local_deps)
|
||||
|
||||
pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else ""
|
||||
if local_deps.pip_reqs:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.70"
|
||||
version = "0.1.69"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
|
||||
@@ -27,6 +27,7 @@ from langchain_core.runnables.config import (
|
||||
get_config_list,
|
||||
get_executor_for_config,
|
||||
)
|
||||
from langchain_core.runnables.utils import Input
|
||||
from langchain_core.tools import BaseTool, InjectedToolArg
|
||||
from langchain_core.tools import tool as create_tool
|
||||
from langchain_core.tools.base import get_all_basemodel_annotations
|
||||
@@ -209,7 +210,7 @@ class ToolNode(RunnableCallable):
|
||||
],
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
store: Optional[BaseStore],
|
||||
store: BaseStore,
|
||||
) -> Any:
|
||||
tool_calls, input_type = self._parse_input(input, store)
|
||||
config_list = get_config_list(config, len(tool_calls))
|
||||
@@ -219,14 +220,12 @@ class ToolNode(RunnableCallable):
|
||||
*executor.map(self._run_one, tool_calls, input_types, config_list)
|
||||
]
|
||||
|
||||
# preserve existing behavior for non-command tool outputs for backwards
|
||||
# compatibility
|
||||
# preserve existing behavior for non-command tool outputs for backwards compatibility
|
||||
if not any(isinstance(output, Command) for output in outputs):
|
||||
# TypedDict, pydantic, dataclass, etc. should all be able to load from dict
|
||||
return outputs if input_type == "list" else {self.messages_key: outputs}
|
||||
|
||||
# LangGraph will automatically handle list of Command and non-command node
|
||||
# updates
|
||||
# LangGraph will automatically handle list of Command and non-command node updates
|
||||
combined_outputs: list[
|
||||
Command | list[ToolMessage] | dict[str, list[ToolMessage]]
|
||||
] = []
|
||||
@@ -239,6 +238,20 @@ class ToolNode(RunnableCallable):
|
||||
)
|
||||
return combined_outputs
|
||||
|
||||
def invoke(
|
||||
self, input: Input, config: Optional[RunnableConfig] = None, **kwargs: Any
|
||||
) -> Any:
|
||||
if "store" not in kwargs:
|
||||
kwargs["store"] = None
|
||||
return super().invoke(input, config, **kwargs)
|
||||
|
||||
async def ainvoke(
|
||||
self, input: Input, config: Optional[RunnableConfig] = None, **kwargs: Any
|
||||
) -> Any:
|
||||
if "store" not in kwargs:
|
||||
kwargs["store"] = None
|
||||
return await super().ainvoke(input, config, **kwargs)
|
||||
|
||||
async def _afunc(
|
||||
self,
|
||||
input: Union[
|
||||
@@ -248,7 +261,7 @@ class ToolNode(RunnableCallable):
|
||||
],
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
store: Optional[BaseStore],
|
||||
store: BaseStore,
|
||||
) -> Any:
|
||||
tool_calls, input_type = self._parse_input(input, store)
|
||||
outputs = await asyncio.gather(
|
||||
@@ -391,7 +404,7 @@ class ToolNode(RunnableCallable):
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
store: Optional[BaseStore],
|
||||
store: BaseStore,
|
||||
) -> Tuple[list[ToolCall], Literal["list", "dict"]]:
|
||||
if isinstance(input, list):
|
||||
input_type = "list"
|
||||
@@ -471,9 +484,7 @@ class ToolNode(RunnableCallable):
|
||||
}
|
||||
return tool_call
|
||||
|
||||
def _inject_store(
|
||||
self, tool_call: ToolCall, store: Optional[BaseStore]
|
||||
) -> ToolCall:
|
||||
def _inject_store(self, tool_call: ToolCall, store: BaseStore) -> ToolCall:
|
||||
store_arg = self.tool_to_store_arg[tool_call["name"]]
|
||||
if not store_arg:
|
||||
return tool_call
|
||||
@@ -498,7 +509,7 @@ class ToolNode(RunnableCallable):
|
||||
dict[str, Any],
|
||||
BaseModel,
|
||||
],
|
||||
store: Optional[BaseStore],
|
||||
store: BaseStore,
|
||||
) -> ToolCall:
|
||||
if tool_call["name"] not in self.tools_by_name:
|
||||
return tool_call
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import (
|
||||
Iterator,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
@@ -67,10 +66,9 @@ class StrEnum(str, enum.Enum):
|
||||
# Special type to denote any type is accepted
|
||||
ANY_TYPE = object()
|
||||
|
||||
|
||||
ASYNCIO_ACCEPTS_CONTEXT = sys.version_info >= (3, 11)
|
||||
|
||||
# List of keyword arguments that can be injected at runtime from the config object.
|
||||
# A named argument may appear multiple times if it appears with distinct types.
|
||||
KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = (
|
||||
(
|
||||
sys.intern("writer"),
|
||||
@@ -79,31 +77,11 @@ KWARGS_CONFIG_KEYS: tuple[tuple[str, tuple[Any, ...], str, Any], ...] = (
|
||||
lambda _: None,
|
||||
),
|
||||
(
|
||||
# Covers store that is not optional (will raise an error if a store
|
||||
# cannot be injected).
|
||||
sys.intern("store"),
|
||||
(
|
||||
BaseStore,
|
||||
"BaseStore",
|
||||
inspect.Parameter.empty,
|
||||
),
|
||||
(BaseStore, "BaseStore", inspect.Parameter.empty),
|
||||
CONFIG_KEY_STORE,
|
||||
inspect.Parameter.empty,
|
||||
),
|
||||
(
|
||||
# Covers store that is optional. Will set to None if not found in config.
|
||||
sys.intern("store"),
|
||||
(
|
||||
Optional[BaseStore],
|
||||
# Best effort to catch some forward references.
|
||||
# This will not work for cases like `"Union[None, BaseStore]"`,
|
||||
# we'll need to re-write logic to use get_type_hints()
|
||||
# to resolve forward references.
|
||||
"Optional[BaseStore]",
|
||||
),
|
||||
CONFIG_KEY_STORE,
|
||||
None,
|
||||
),
|
||||
(
|
||||
sys.intern("previous"),
|
||||
(ANY_TYPE,),
|
||||
@@ -171,24 +149,15 @@ class RunnableCallable(Runnable):
|
||||
params = inspect.signature(cast(Callable, func or afunc)).parameters
|
||||
|
||||
self.func_accepts_config = "config" in params
|
||||
# Mapping from kwarg name to (config key, default value) to be used.
|
||||
# The default value is used if the config key is not found in the config.
|
||||
self.func_accepts: dict[str, Tuple[str, Any]] = {}
|
||||
|
||||
for kw, typ, config_key, default in KWARGS_CONFIG_KEYS:
|
||||
self.func_accepts: dict[str, bool] = {}
|
||||
for kw, typ, _, _ in KWARGS_CONFIG_KEYS:
|
||||
p = params.get(kw)
|
||||
|
||||
if p is None or p.kind not in VALID_KINDS:
|
||||
# If parameter is not found or is not a valid kind, skip
|
||||
continue
|
||||
|
||||
if typ != (ANY_TYPE,) and p.annotation not in typ:
|
||||
# A specific type is required, but the function annotation does
|
||||
# not match the expected type.
|
||||
continue
|
||||
|
||||
# If the kwarg is accepted by the function, store the default value
|
||||
self.func_accepts[kw] = (config_key, default)
|
||||
if typ == (ANY_TYPE,):
|
||||
self.func_accepts[kw] = p is not None and p.kind in VALID_KINDS
|
||||
else:
|
||||
self.func_accepts[kw] = (
|
||||
p is not None and p.annotation in typ and p.kind in VALID_KINDS
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
repr_args = {
|
||||
@@ -218,22 +187,20 @@ class RunnableCallable(Runnable):
|
||||
if self.func_accepts_config:
|
||||
kwargs["config"] = config
|
||||
_conf = config[CONF]
|
||||
|
||||
for kw, (config_key, default_value) in self.func_accepts.items():
|
||||
# If the kwarg is already set, use the set value
|
||||
if kw in kwargs:
|
||||
for kw, _, config_key, default_value in KWARGS_CONFIG_KEYS:
|
||||
if not self.func_accepts[kw]:
|
||||
continue
|
||||
|
||||
if (
|
||||
# If the kwarg is requested, but isn't in the config AND has no
|
||||
# default value, raise an error
|
||||
config_key not in _conf and default_value is inspect.Parameter.empty
|
||||
default_value is inspect.Parameter.empty
|
||||
and kw not in kwargs
|
||||
and config_key not in _conf
|
||||
):
|
||||
raise ValueError(
|
||||
f"Missing required config key '{config_key}' for '{self.name}'."
|
||||
)
|
||||
|
||||
kwargs[kw] = _conf.get(config_key, default_value)
|
||||
elif kwargs.get(kw) is None:
|
||||
kwargs[kw] = _conf.get(config_key, default_value)
|
||||
|
||||
context = copy_context()
|
||||
if self.trace:
|
||||
@@ -277,20 +244,20 @@ class RunnableCallable(Runnable):
|
||||
if self.func_accepts_config:
|
||||
kwargs["config"] = config
|
||||
_conf = config[CONF]
|
||||
for kw, (config_key, default_value) in self.func_accepts.items():
|
||||
# If the kwarg has already been set, use the set value
|
||||
if kw in kwargs:
|
||||
for kw, _, config_key, default_value in KWARGS_CONFIG_KEYS:
|
||||
if not self.func_accepts[kw]:
|
||||
continue
|
||||
|
||||
if (
|
||||
# If the kwarg is requested, but isn't in the config AND has no
|
||||
# default value, raise an error
|
||||
config_key not in _conf and default_value is inspect.Parameter.empty
|
||||
default_value is inspect.Parameter.empty
|
||||
and kw not in kwargs
|
||||
and config_key not in _conf
|
||||
):
|
||||
raise ValueError(
|
||||
f"Missing required config key '{config_key}' for '{self.name}'."
|
||||
)
|
||||
kwargs[kw] = _conf.get(config_key, default_value)
|
||||
elif kwargs.get(kw) is None:
|
||||
kwargs[kw] = _conf.get(config_key, default_value)
|
||||
context = copy_context()
|
||||
if self.trace:
|
||||
callback_manager = get_async_callback_manager_for_config(config, self.tags)
|
||||
|
||||
@@ -6220,25 +6220,6 @@ def test_entrypoint_with_return_and_save() -> None:
|
||||
assert previous_ == ["hello", "goodbye"]
|
||||
|
||||
|
||||
def test_overriding_injectable_args_with_tasks() -> None:
|
||||
"""Test overriding injectable args in tasks."""
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
@task
|
||||
def foo(store: BaseStore, writer: StreamWriter, value: Any) -> None:
|
||||
assert store is value
|
||||
assert writer is value
|
||||
|
||||
@entrypoint(store=InMemoryStore())
|
||||
def main(inputs, store: BaseStore) -> str:
|
||||
assert store is not None
|
||||
foo(store=None, writer=None, value=None).result()
|
||||
foo(store="hello", writer="hello", value="hello").result()
|
||||
return "OK"
|
||||
|
||||
assert main.invoke({}) == "OK"
|
||||
|
||||
|
||||
def test_named_tasks_functional() -> None:
|
||||
class Foo:
|
||||
def foo(self, value: str) -> dict:
|
||||
|
||||
@@ -7446,23 +7446,3 @@ async def test_named_tasks_functional() -> None:
|
||||
{"qux": "foo|bar|baz|custom_baz|qux"},
|
||||
{"workflow": "foo|bar|baz|custom_baz|qux"},
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_overriding_injectable_args_with_async_task() -> None:
|
||||
"""Test overriding injectable args in tasks."""
|
||||
from langgraph.store.memory import InMemoryStore
|
||||
|
||||
@task
|
||||
async def foo(store: BaseStore, writer: StreamWriter, value: Any) -> None:
|
||||
assert store is value
|
||||
assert writer is value
|
||||
|
||||
@entrypoint(store=InMemoryStore())
|
||||
async def main(inputs, store: BaseStore) -> str:
|
||||
assert store is not None
|
||||
await foo(store=None, writer=None, value=None)
|
||||
await foo(store="hello", writer="hello", value="hello")
|
||||
return "OK"
|
||||
|
||||
assert await main.ainvoke({}) == "OK"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -43,15 +43,8 @@ def test_runnable_callable_func_accepts():
|
||||
expected_writer = {"with_writer": True, "awith_writer": True}
|
||||
|
||||
for name, runnable in runnables.items():
|
||||
if expected_writer.get(name, False):
|
||||
assert "writer" in runnable.func_accepts
|
||||
else:
|
||||
assert "writer" not in runnable.func_accepts
|
||||
|
||||
if expected_store.get(name, False):
|
||||
assert "store" in runnable.func_accepts
|
||||
else:
|
||||
assert "store" not in runnable.func_accepts
|
||||
assert runnable.func_accepts["writer"] == expected_writer.get(name, False)
|
||||
assert runnable.func_accepts["store"] == expected_store.get(name, False)
|
||||
|
||||
|
||||
async def test_runnable_callable_basic():
|
||||
@@ -70,192 +63,3 @@ async def test_runnable_callable_basic():
|
||||
# Test asynchronous ainvoke
|
||||
result_async = await runnable_async.ainvoke("test")
|
||||
assert result_async == "test"
|
||||
|
||||
|
||||
def test_runnable_callable_injectable_arguments() -> None:
|
||||
"""Test injectable arguments for RunnableCallable.
|
||||
|
||||
This test verifies that injectable arguments like BaseStore work correctly.
|
||||
It tests:
|
||||
- Optional store injection
|
||||
- Required store injection
|
||||
- Store injection via config
|
||||
- Store injection override behavior
|
||||
- Store value injection and validation
|
||||
"""
|
||||
|
||||
# Test Optional[BaseStore] annotation.
|
||||
def func_optional_store(inputs: Any, store: Optional[BaseStore]) -> str:
|
||||
"""Test function that accepts an optional store parameter."""
|
||||
assert store is None
|
||||
return "success"
|
||||
|
||||
assert RunnableCallable(func_optional_store).invoke({"x": "1"}) == "success"
|
||||
|
||||
# Test BaseStore annotation
|
||||
def func_required_store(inputs: Any, store: BaseStore) -> str:
|
||||
"""Test function that requires a store parameter."""
|
||||
assert store is None
|
||||
return "success"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
# Should fail b/c store is not Optional and config is not populated with store.
|
||||
assert RunnableCallable(func_required_store).invoke({}) == "success"
|
||||
|
||||
# Manually provide store
|
||||
assert RunnableCallable(func_required_store).invoke({}, store=None) == "success"
|
||||
|
||||
# Specify a value for store in the config
|
||||
assert (
|
||||
RunnableCallable(func_required_store).invoke(
|
||||
{}, config={"configurable": {"__pregel_store": None}}
|
||||
)
|
||||
== "success"
|
||||
)
|
||||
|
||||
# Specify a value for store in config, but override with None
|
||||
assert (
|
||||
RunnableCallable(func_optional_store).invoke(
|
||||
{"x": "1"},
|
||||
store=None,
|
||||
config={"configurable": {"__pregel_store": "foobar"}},
|
||||
)
|
||||
== "success"
|
||||
)
|
||||
|
||||
# Set of tests where we verify that 'foobar' is injected as the store value.
|
||||
def func_required_store_v2(inputs: Any, store: BaseStore) -> str:
|
||||
"""Test function that requires a store parameter and validates its value.
|
||||
|
||||
The store value is expected to be 'foobar' when injected.
|
||||
"""
|
||||
assert store == "foobar"
|
||||
return "success"
|
||||
|
||||
assert (
|
||||
RunnableCallable(func_required_store_v2).invoke(
|
||||
{}, config={"configurable": {"__pregel_store": "foobar"}}
|
||||
)
|
||||
== "success"
|
||||
)
|
||||
|
||||
assert RunnableCallable(func_required_store_v2).invoke(
|
||||
# And manual override takes precedence.
|
||||
{},
|
||||
store="foobar",
|
||||
config={"configurable": {"__pregel_store": "barbar"}},
|
||||
)
|
||||
|
||||
|
||||
async def test_runnable_callable_injectable_arguments_async() -> None:
|
||||
"""Test injectable arguments for async RunnableCallable.
|
||||
|
||||
This test verifies that injectable arguments like BaseStore work correctly
|
||||
in the async context. It tests:
|
||||
- Optional store injection
|
||||
- Required store injection
|
||||
- Store injection via config
|
||||
- Store injection override behavior
|
||||
"""
|
||||
|
||||
# Test Optional[BaseStore] annotation.
|
||||
def func_optional_store(inputs: Any, store: Optional[BaseStore]) -> str:
|
||||
"""Test function that accepts an optional store parameter."""
|
||||
assert store is None
|
||||
return "success"
|
||||
|
||||
async def afunc_optional_store(inputs: Any, store: Optional[BaseStore]) -> str:
|
||||
"""Async version of func_optional_store."""
|
||||
assert store is None
|
||||
return "success"
|
||||
|
||||
assert (
|
||||
await RunnableCallable(
|
||||
func=func_optional_store, afunc=afunc_optional_store
|
||||
).ainvoke({"x": "1"})
|
||||
== "success"
|
||||
)
|
||||
|
||||
# Test BaseStore annotation
|
||||
def func_required_store(inputs: Any, store: BaseStore) -> str:
|
||||
"""Test function that requires a store parameter."""
|
||||
assert store is None
|
||||
return "success"
|
||||
|
||||
async def afunc_required_store(inputs: Any, store: BaseStore) -> str:
|
||||
"""Async version of func_required_store."""
|
||||
assert store is None
|
||||
return "success"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
# Should fail b/c store is not Optional and config is not populated with store.
|
||||
assert (
|
||||
await RunnableCallable(
|
||||
func=func_required_store, afunc=afunc_required_store
|
||||
).ainvoke({})
|
||||
== "success"
|
||||
)
|
||||
|
||||
# Manually provide store
|
||||
assert (
|
||||
await RunnableCallable(
|
||||
func=func_required_store, afunc=afunc_required_store
|
||||
).ainvoke({}, store=None)
|
||||
== "success"
|
||||
)
|
||||
|
||||
# Specify a value for store in the config
|
||||
assert (
|
||||
await RunnableCallable(
|
||||
func=func_required_store, afunc=afunc_required_store
|
||||
).ainvoke({}, config={"configurable": {"__pregel_store": None}})
|
||||
== "success"
|
||||
)
|
||||
|
||||
# Specify a value for store in config, but override with None
|
||||
assert (
|
||||
await RunnableCallable(
|
||||
func=func_optional_store, afunc=afunc_optional_store
|
||||
).ainvoke(
|
||||
{"x": "1"},
|
||||
store=None,
|
||||
config={"configurable": {"__pregel_store": "foobar"}},
|
||||
)
|
||||
== "success"
|
||||
)
|
||||
|
||||
# Set of tests where we verify that 'foobar' is injected as the store value.
|
||||
def func_required_store_v2(inputs: Any, store: BaseStore) -> str:
|
||||
"""Test function that requires a store parameter with specific value.
|
||||
|
||||
The store parameter is expected to be 'foobar' when injected.
|
||||
"""
|
||||
assert store == "foobar"
|
||||
return "success"
|
||||
|
||||
async def afunc_required_store_v2(inputs: Any, store: BaseStore) -> str:
|
||||
"""Async version of func_required_store_v2.
|
||||
|
||||
The store parameter is expected to be 'foobar' when injected.
|
||||
"""
|
||||
assert store == "foobar"
|
||||
return "success"
|
||||
|
||||
assert (
|
||||
await RunnableCallable(
|
||||
func=func_required_store_v2, afunc=afunc_required_store_v2
|
||||
).ainvoke({}, config={"configurable": {"__pregel_store": "foobar"}})
|
||||
== "success"
|
||||
)
|
||||
|
||||
assert (
|
||||
await RunnableCallable(
|
||||
func=func_required_store_v2, afunc=afunc_required_store_v2
|
||||
).ainvoke(
|
||||
# And manual override takes precedence.
|
||||
{},
|
||||
store="foobar",
|
||||
config={"configurable": {"__pregel_store": "barbar"}},
|
||||
)
|
||||
== "success"
|
||||
)
|
||||
|
||||
@@ -26,10 +26,7 @@ from langgraph.utils.fields import (
|
||||
get_enhanced_type_hints,
|
||||
get_field_default,
|
||||
)
|
||||
from langgraph.utils.runnable import (
|
||||
is_async_callable,
|
||||
is_async_generator,
|
||||
)
|
||||
from langgraph.utils.runnable import is_async_callable, is_async_generator
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
Reference in New Issue
Block a user