Compare commits

..
Author SHA1 Message Date
Lance Martin 6ed63ba8fc Updates 2025-01-29 15:23:26 -08:00
Lance Martin 2da005b4bd rename, update 2025-01-28 15:24:35 -08:00
Lance Martin 58e2824ea3 Update with time travel 2025-01-28 12:49:02 -08:00
Lance Martin f239b39060 fxn api w agent 2025-01-28 06:31:27 -08:00
12 changed files with 1094 additions and 376 deletions
+1 -1
View File
@@ -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.
+6 -12
View File
@@ -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.
+4
View File
@@ -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
+1 -52
View File
@@ -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 -1
View File
@@ -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"
+22 -11
View File
@@ -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
+24 -57
View File
@@ -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)
-19
View File
@@ -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:
-20
View File
@@ -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"
+3 -199
View File
@@ -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"
)
+1 -4
View File
@@ -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