mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-30 13:35:09 +02:00
libs: add cli, sdk-py, sdk-js and move core langgraph
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 LangChain, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,13 @@
|
||||
.PHONY: lint format
|
||||
|
||||
lint:
|
||||
poetry run ruff .
|
||||
poetry run ruff format . --diff
|
||||
poetry run mypy .
|
||||
|
||||
format:
|
||||
poetry run ruff format .
|
||||
poetry run ruff --select I --fix .
|
||||
|
||||
test:
|
||||
poetry run pytest tests
|
||||
@@ -0,0 +1,3 @@
|
||||
# langchain-cli
|
||||
|
||||
This package implements the official CLI for LangGraph API.
|
||||
@@ -0,0 +1,476 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
|
||||
import langgraph_cli.config
|
||||
import langgraph_cli.docker
|
||||
from langgraph_cli.config import Config
|
||||
from langgraph_cli.docker import DockerCapabilities
|
||||
from langgraph_cli.exec import Runner, subp_exec
|
||||
from langgraph_cli.progress import Progress
|
||||
|
||||
OPT_O = click.option(
|
||||
"--docker-compose",
|
||||
"-d",
|
||||
help="Advanced: Path to docker-compose.yml file with additional services to launch",
|
||||
type=click.Path(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
resolve_path=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
)
|
||||
OPT_C = click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
help="""Path to configuration file declaring dependencies, graphs and environment variables.
|
||||
|
||||
\b
|
||||
Config file must be a JSON file that has the following keys:
|
||||
- "dependencies": array of dependencies for langgraph API server. Dependencies can be one of the following:
|
||||
- ".", which would look for local python packages, as well as pyproject.toml, setup.py or requirements.txt in the app directory
|
||||
- "./local_package"
|
||||
- "<package_name>
|
||||
- "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where
|
||||
"variable" is an instance of langgraph.graph.graph.CompiledGraph
|
||||
- "env": (optional) path to .env file or a mapping from environment variable to its value
|
||||
- "python_version": (optional) 3.11 or 3.12. Defaults to 3.11
|
||||
- "pip_config_file": (optional) path to pip config file
|
||||
- "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph up -c langgraph.json
|
||||
|
||||
\b
|
||||
Example:
|
||||
{
|
||||
"dependencies": [
|
||||
"langchain_openai",
|
||||
"./your_package"
|
||||
],
|
||||
"graphs": {
|
||||
"my_graph_id": "./your_package/your_file.py:variable"
|
||||
},
|
||||
"env": "./.env"
|
||||
}
|
||||
|
||||
\b
|
||||
Example:
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": [
|
||||
"langchain_openai",
|
||||
"."
|
||||
],
|
||||
"graphs": {
|
||||
"my_graph_id": "./your_package/your_file.py:variable"
|
||||
},
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "secret-key"
|
||||
}
|
||||
}
|
||||
|
||||
Defaults to looking for langgraph.json in the current directory.""",
|
||||
default="langgraph.json",
|
||||
type=click.Path(
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
resolve_path=True,
|
||||
path_type=pathlib.Path,
|
||||
),
|
||||
)
|
||||
OPT_PORT = click.option(
|
||||
"--port",
|
||||
"-p",
|
||||
type=int,
|
||||
default=8123,
|
||||
show_default=True,
|
||||
help="""
|
||||
Port to expose.
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph up --port 8000
|
||||
\b
|
||||
""",
|
||||
)
|
||||
OPT_RECREATE = click.option(
|
||||
"--recreate/--no-recreate",
|
||||
default=False,
|
||||
show_default=True,
|
||||
help="Recreate containers even if their configuration and image haven't changed",
|
||||
)
|
||||
OPT_PULL = click.option(
|
||||
"--pull/--no-pull",
|
||||
default=True,
|
||||
show_default=True,
|
||||
help="""
|
||||
Pull latest images. Use --no-pull for running the server with locally-built images.
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph up --no-pull
|
||||
\b
|
||||
""",
|
||||
)
|
||||
OPT_VERBOSE = click.option(
|
||||
"--verbose",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Show more output from the server logs",
|
||||
)
|
||||
OPT_DEBUGGER_PORT = click.option(
|
||||
"--debugger-port",
|
||||
type=int,
|
||||
help="Pull the debugger image locally and serve the UI on specified port",
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli():
|
||||
pass
|
||||
|
||||
|
||||
@OPT_RECREATE
|
||||
@OPT_PULL
|
||||
@OPT_PORT
|
||||
@OPT_O
|
||||
@OPT_C
|
||||
@OPT_VERBOSE
|
||||
@OPT_DEBUGGER_PORT
|
||||
@click.option("--watch", is_flag=True, help="Restart on file changes")
|
||||
@click.option(
|
||||
"--langgraph-api-path",
|
||||
type=click.Path(exists=True, file_okay=False, dir_okay=True, resolve_path=True),
|
||||
hidden=True,
|
||||
)
|
||||
@click.option(
|
||||
"--wait",
|
||||
is_flag=True,
|
||||
help="Wait for services to start before returning. Implies --detach",
|
||||
)
|
||||
@cli.command(help="Start langgraph API server")
|
||||
def up(
|
||||
config: pathlib.Path,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
recreate: bool,
|
||||
pull: bool,
|
||||
watch: bool,
|
||||
langgraph_api_path: Optional[pathlib.Path],
|
||||
wait: bool,
|
||||
verbose: bool,
|
||||
debugger_port: Optional[int],
|
||||
):
|
||||
with Runner() as runner, Progress(message="Pulling...") as set:
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
args, stdin = prepare(
|
||||
runner,
|
||||
capabilities=capabilities,
|
||||
config_path=config,
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
pull=pull,
|
||||
watch=watch,
|
||||
langgraph_api_path=langgraph_api_path,
|
||||
verbose=verbose,
|
||||
debugger_port=debugger_port,
|
||||
)
|
||||
# add up + options
|
||||
args.extend(["up", "--remove-orphans"])
|
||||
if recreate:
|
||||
args.extend(["--force-recreate", "--renew-anon-volumes"])
|
||||
shutil.rmtree(config.parent / ".langgraph-data", ignore_errors=True)
|
||||
try:
|
||||
runner.run(subp_exec("docker", "volume", "rm", "langgraph-data"))
|
||||
except click.exceptions.Exit:
|
||||
pass
|
||||
if watch:
|
||||
args.append("--watch")
|
||||
if wait:
|
||||
args.append("--wait")
|
||||
# run docker compose
|
||||
set("Building...")
|
||||
|
||||
def on_stdout(line: str):
|
||||
if "unpacking to docker.io" in line:
|
||||
set("Starting...")
|
||||
elif "GET /ok" in line:
|
||||
debugger_origin = (
|
||||
f"http://localhost:{debugger_port}"
|
||||
if debugger_port
|
||||
else "https://dev.smith.langchain.com"
|
||||
)
|
||||
set("")
|
||||
sys.stdout.write(
|
||||
f"""Ready!
|
||||
- API: http://localhost:{port}
|
||||
- Docs: http://localhost:{port}/docs
|
||||
- Debugger: {debugger_origin}/studio/?baseUrl=http://127.0.0.1:{port}
|
||||
"""
|
||||
)
|
||||
sys.stdout.flush()
|
||||
return True
|
||||
|
||||
if capabilities.compose_type == "plugin":
|
||||
compose_cmd = ["docker", "compose"]
|
||||
elif capabilities.compose_type == "standalone":
|
||||
compose_cmd = ["docker-compose"]
|
||||
|
||||
runner.run(
|
||||
subp_exec(
|
||||
*compose_cmd,
|
||||
*args,
|
||||
input=stdin,
|
||||
verbose=verbose,
|
||||
on_stdout=on_stdout,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@OPT_PORT
|
||||
@OPT_O
|
||||
@OPT_C
|
||||
@OPT_VERBOSE
|
||||
@OPT_DEBUGGER_PORT
|
||||
@cli.command(help="Stop langgraph API server")
|
||||
def down(
|
||||
config: pathlib.Path,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
verbose: bool,
|
||||
debugger_port: Optional[int],
|
||||
):
|
||||
with Runner() as runner:
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
args, stdin = prepare(
|
||||
runner,
|
||||
capabilities=capabilities,
|
||||
config_path=config,
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
pull=False,
|
||||
watch=False,
|
||||
langgraph_api_path=None,
|
||||
verbose=verbose,
|
||||
debugger_port=debugger_port,
|
||||
)
|
||||
# add down + options
|
||||
args.append("down")
|
||||
# run docker compose
|
||||
if capabilities.compose_type == "plugin":
|
||||
compose_cmd = ["docker", "compose"]
|
||||
elif capabilities.compose_type == "standalone":
|
||||
compose_cmd = ["docker-compose"]
|
||||
|
||||
runner.run(subp_exec(*compose_cmd, *args, input=stdin, verbose=verbose))
|
||||
|
||||
|
||||
@OPT_O
|
||||
@OPT_C
|
||||
@click.option("--follow", "-f", is_flag=True, help="Follow logs")
|
||||
@cli.command(help="Show langgraph API server logs")
|
||||
def logs(
|
||||
config: pathlib.Path,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
follow: bool,
|
||||
):
|
||||
with Runner() as runner:
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
args, stdin = prepare(
|
||||
runner,
|
||||
capabilities=capabilities,
|
||||
config_path=config,
|
||||
docker_compose=docker_compose,
|
||||
port=8123,
|
||||
pull=False,
|
||||
watch=False,
|
||||
verbose=False,
|
||||
langgraph_api_path=None,
|
||||
)
|
||||
# add logs + options
|
||||
args.append("logs")
|
||||
if follow:
|
||||
args.extend(["-f"])
|
||||
# run docker compose
|
||||
if capabilities.compose_type == "plugin":
|
||||
compose_cmd = ["docker", "compose"]
|
||||
elif capabilities.compose_type == "standalone":
|
||||
compose_cmd = ["docker-compose"]
|
||||
|
||||
runner.run(subp_exec(*compose_cmd, *args, input=stdin, verbose=True))
|
||||
|
||||
|
||||
@OPT_C
|
||||
@OPT_PULL
|
||||
@click.option(
|
||||
"--tag",
|
||||
"-t",
|
||||
help="""Tag for the docker image.
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph build -t my-image
|
||||
|
||||
\b
|
||||
""",
|
||||
required=True,
|
||||
)
|
||||
@click.option(
|
||||
"--platform",
|
||||
help="""Target platform(s) to build the docker image for.
|
||||
|
||||
\b
|
||||
Example:
|
||||
langgraph build --platform linux/amd64,linux/arm64
|
||||
\b
|
||||
""",
|
||||
)
|
||||
@cli.command(help="Build langgraph API server docker image")
|
||||
def build(
|
||||
config: pathlib.Path,
|
||||
platform: Optional[str],
|
||||
pull: bool,
|
||||
tag: str,
|
||||
):
|
||||
with open(config) as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
with Runner() as runner:
|
||||
# check docker available
|
||||
langgraph_cli.docker.check_capabilities(runner)
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
f"langchain/langgraph-api:{config_json['python_version']}",
|
||||
)
|
||||
)
|
||||
# apply options
|
||||
args = [
|
||||
"-f",
|
||||
"-", # stdin
|
||||
"-t",
|
||||
tag,
|
||||
]
|
||||
if platform:
|
||||
args.extend(["--platform", platform])
|
||||
# apply config
|
||||
stdin = langgraph_cli.config.config_to_docker(config, config_json)
|
||||
# run docker build
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker", "build", *args, str(config.parent), input=stdin, verbose=True
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@OPT_PORT
|
||||
@OPT_O
|
||||
@OPT_C
|
||||
@cli.command(help="Build a helm chart to deploy to a Kubernetes cluster", hidden=True)
|
||||
def helm(
|
||||
config: pathlib.Path,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
):
|
||||
with open(config) as f:
|
||||
config_json = langgraph_cli.config.validate_config(json.load(f))
|
||||
with Runner() as runner:
|
||||
# check docker available
|
||||
capabilities = langgraph_cli.docker.check_capabilities(runner)
|
||||
# prepare args
|
||||
stdin = langgraph_cli.docker.compose(capabilities, port=port)
|
||||
args = [
|
||||
"--chart",
|
||||
"-o=./helm",
|
||||
"-v",
|
||||
"-f",
|
||||
"-", # stdin
|
||||
]
|
||||
# apply options
|
||||
if docker_compose:
|
||||
args.extend(["-f", str(docker_compose)])
|
||||
args.append("convert")
|
||||
# apply config
|
||||
stdin += langgraph_cli.config.config_to_compose(config, config_json)
|
||||
# run kompose convert
|
||||
runner.run(subp_exec("kompose", *args, input=stdin))
|
||||
|
||||
|
||||
def prepare_args_and_stdin(
|
||||
*,
|
||||
capabilities: DockerCapabilities,
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
watch: bool,
|
||||
langgraph_api_path: Optional[pathlib.Path],
|
||||
debugger_port: Optional[int] = None,
|
||||
):
|
||||
# prepare args
|
||||
stdin = langgraph_cli.docker.compose(
|
||||
capabilities, port=port, debugger_port=debugger_port
|
||||
)
|
||||
args = [
|
||||
"--project-directory",
|
||||
str(config_path.parent),
|
||||
]
|
||||
# apply options
|
||||
if docker_compose:
|
||||
args.extend(["-f", str(docker_compose)])
|
||||
args.extend(["-f", "-"]) # stdin
|
||||
# apply config
|
||||
stdin += langgraph_cli.config.config_to_compose(
|
||||
config_path, config, watch=watch, langgraph_api_path=langgraph_api_path
|
||||
)
|
||||
return args, stdin
|
||||
|
||||
|
||||
def prepare(
|
||||
runner,
|
||||
*,
|
||||
capabilities: DockerCapabilities,
|
||||
config_path: pathlib.Path,
|
||||
docker_compose: Optional[pathlib.Path],
|
||||
port: int,
|
||||
pull: bool,
|
||||
watch: bool,
|
||||
langgraph_api_path: Optional[pathlib.Path],
|
||||
verbose: bool,
|
||||
debugger_port: Optional[int] = None,
|
||||
):
|
||||
with open(config_path) as f:
|
||||
config = langgraph_cli.config.validate_config(json.load(f))
|
||||
# pull latest images
|
||||
if pull:
|
||||
runner.run(
|
||||
subp_exec(
|
||||
"docker",
|
||||
"pull",
|
||||
f"langchain/langgraph-api:{config['python_version']}",
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
args, stdin = prepare_args_and_stdin(
|
||||
capabilities=capabilities,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
docker_compose=docker_compose,
|
||||
port=port,
|
||||
watch=watch,
|
||||
langgraph_api_path=langgraph_api_path,
|
||||
debugger_port=debugger_port,
|
||||
)
|
||||
return args, stdin
|
||||
@@ -0,0 +1,306 @@
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import textwrap
|
||||
from typing import NamedTuple, Optional, TypedDict, Union
|
||||
|
||||
import click
|
||||
|
||||
|
||||
class Config(TypedDict):
|
||||
python_version: str
|
||||
pip_config_file: Optional[str]
|
||||
dockerfile_lines: list[str]
|
||||
dependencies: list[str]
|
||||
graphs: dict[str, str]
|
||||
env: Union[dict[str, str], str]
|
||||
|
||||
|
||||
def validate_config(config: Config) -> Config:
|
||||
config = {
|
||||
"python_version": config.get("python_version", "3.11"),
|
||||
"pip_config_file": config.get("pip_config_file"),
|
||||
"dockerfile_lines": config.get("dockerfile_lines", []),
|
||||
"dependencies": config.get("dependencies", []),
|
||||
"graphs": config.get("graphs", {}),
|
||||
"env": config.get("env", {}),
|
||||
}
|
||||
if config["python_version"] not in (
|
||||
"3.11",
|
||||
"3.12",
|
||||
):
|
||||
raise click.UsageError(
|
||||
f"Unsupported Python version: {config['python_version']}. "
|
||||
"Supported versions are 3.11 and 3.12."
|
||||
)
|
||||
if not config["dependencies"]:
|
||||
raise click.UsageError(
|
||||
"No dependencies found in config. "
|
||||
"Add at least one dependency to 'dependencies' list."
|
||||
)
|
||||
if not config["graphs"]:
|
||||
raise click.UsageError(
|
||||
"No graphs found in config. "
|
||||
"Add at least one graph to 'graphs' dictionary."
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
class LocalDeps(NamedTuple):
|
||||
pip_reqs: list[tuple[pathlib.Path, str]]
|
||||
real_pkgs: dict[pathlib.Path, str]
|
||||
faux_pkgs: dict[pathlib.Path, tuple[str, str]]
|
||||
# if . is in dependencies, use it as working_dir
|
||||
working_dir: Optional[str] = None
|
||||
|
||||
|
||||
def _assemble_local_deps(config_path: pathlib.Path, config: Config) -> LocalDeps:
|
||||
# ensure reserved package names are not used
|
||||
reserved = {
|
||||
"src",
|
||||
"langgraph-api",
|
||||
"langgraph_api",
|
||||
"langgraph",
|
||||
"langchain-core",
|
||||
"langchain_core",
|
||||
"pydantic",
|
||||
"orjson",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"psycopg",
|
||||
"httpx",
|
||||
"langsmith",
|
||||
}
|
||||
|
||||
def check_reserved(name: str, ref: str):
|
||||
if name in reserved:
|
||||
raise ValueError(
|
||||
f"Package name '{name}' used in local dep '{ref}' is reserved. "
|
||||
"Rename the directory."
|
||||
)
|
||||
reserved.add(name)
|
||||
|
||||
pip_reqs = []
|
||||
real_pkgs = {}
|
||||
faux_pkgs = {}
|
||||
working_dir = None
|
||||
|
||||
for local_dep in config["dependencies"]:
|
||||
if not local_dep.startswith("."):
|
||||
continue
|
||||
|
||||
resolved = config_path.parent / local_dep
|
||||
|
||||
# validate local dependency
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Could not find local dependency: {resolved}")
|
||||
elif not resolved.is_dir():
|
||||
raise NotADirectoryError(
|
||||
f"Local dependency must be a directory: {resolved}"
|
||||
)
|
||||
elif not resolved.is_relative_to(config_path.parent):
|
||||
raise ValueError(
|
||||
f"Local dependency '{resolved}' must be a subdirectory of '{config_path.parent}'"
|
||||
)
|
||||
|
||||
# if it's installable, add it to local_pkgs
|
||||
# otherwise, add it to faux_pkgs, and create a pyproject.toml
|
||||
files = os.listdir(resolved)
|
||||
if "pyproject.toml" in files:
|
||||
real_pkgs[resolved] = local_dep
|
||||
if local_dep == ".":
|
||||
working_dir = f"/deps/{resolved.name}"
|
||||
elif "setup.py" in files:
|
||||
real_pkgs[resolved] = local_dep
|
||||
if local_dep == ".":
|
||||
working_dir = f"/deps/{resolved.name}"
|
||||
else:
|
||||
if any(file == "__init__.py" for file in files):
|
||||
# flat layout
|
||||
if "-" in resolved.name:
|
||||
raise ValueError(
|
||||
f"Package name '{resolved.name}' contains a hyphen. "
|
||||
"Rename the directory to use it as flat-layout package."
|
||||
)
|
||||
check_reserved(resolved.name, local_dep)
|
||||
container_path = f"/deps/__outer_{resolved.name}/{resolved.name}"
|
||||
else:
|
||||
# src layout
|
||||
container_path = f"/deps/__outer_{resolved.name}/src"
|
||||
for file in files:
|
||||
rfile = resolved / file
|
||||
if (
|
||||
rfile.is_dir()
|
||||
and file != "__pycache__"
|
||||
and not file.startswith(".")
|
||||
):
|
||||
try:
|
||||
for subfile in os.listdir(rfile):
|
||||
if subfile.endswith(".py"):
|
||||
check_reserved(file, local_dep)
|
||||
break
|
||||
except PermissionError:
|
||||
pass
|
||||
faux_pkgs[resolved] = (local_dep, container_path)
|
||||
if local_dep == ".":
|
||||
working_dir = container_path
|
||||
if "requirements.txt" in files:
|
||||
rfile = resolved / "requirements.txt"
|
||||
pip_reqs.append(
|
||||
(
|
||||
rfile.relative_to(config_path.parent),
|
||||
f"{container_path}/requirements.txt",
|
||||
)
|
||||
)
|
||||
|
||||
return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir)
|
||||
|
||||
|
||||
def _update_graph_paths(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
for graph_id, import_str in config["graphs"].items():
|
||||
module_str, _, attr_str = import_str.partition(":")
|
||||
if not module_str or not attr_str:
|
||||
message = (
|
||||
'Import string "{import_str}" must be in format "<module>:<attribute>".'
|
||||
)
|
||||
raise ValueError(message.format(import_str=import_str))
|
||||
if "/" in module_str:
|
||||
resolved = config_path.parent / module_str
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(f"Could not find local module: {resolved}")
|
||||
elif not resolved.is_file():
|
||||
raise IsADirectoryError(f"Local module must be a file: {resolved}")
|
||||
else:
|
||||
for path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(path):
|
||||
module_str = f"/deps/{path.name}/{resolved.relative_to(path)}"
|
||||
break
|
||||
else:
|
||||
for faux_pkg, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_pkg):
|
||||
module_str = f"{destpath}/{resolved.relative_to(faux_pkg)}"
|
||||
break
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Module '{import_str}' not found in 'dependencies' list. "
|
||||
"Add its containing package to 'dependencies' list."
|
||||
)
|
||||
# update the config
|
||||
config["graphs"][graph_id] = f"{module_str}:{attr_str}"
|
||||
|
||||
|
||||
def config_to_docker(config_path: pathlib.Path, config: Config):
|
||||
# configure pip
|
||||
pip_install = "pip install -c /api/constraints.txt"
|
||||
if config.get("pip_config_file"):
|
||||
pip_install = f"PIP_CONFIG_FILE=/pipconfig.txt {pip_install}"
|
||||
pip_config_file_str = (
|
||||
f"ADD {config['pip_config_file']} /pipconfig.txt"
|
||||
if config.get("pip_config_file")
|
||||
else ""
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
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"ADD {reqpath} {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)}'
|
||||
|
||||
else:
|
||||
pip_reqs_str = ""
|
||||
|
||||
# https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data
|
||||
# https://til.simonwillison.net/python/pyproject
|
||||
faux_pkgs_str = f"{os.linesep}{os.linesep}".join(
|
||||
f"""ADD {relpath} {destpath}
|
||||
COPY <<EOF /deps/__outer_{fullpath.name}/pyproject.toml
|
||||
[project]
|
||||
name = "{fullpath.name}"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF"""
|
||||
for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items()
|
||||
)
|
||||
local_pkgs_str = os.linesep.join(
|
||||
f"ADD {relpath} /deps/{fullpath.name}"
|
||||
for fullpath, relpath in local_deps.real_pkgs.items()
|
||||
)
|
||||
|
||||
return f"""FROM langchain/langgraph-api:{config['python_version']}
|
||||
|
||||
{os.linesep.join(config["dockerfile_lines"])}
|
||||
|
||||
{pip_config_file_str}
|
||||
|
||||
{pip_pkgs_str}
|
||||
|
||||
{pip_reqs_str}
|
||||
|
||||
{local_pkgs_str}
|
||||
|
||||
{faux_pkgs_str}
|
||||
|
||||
RUN {pip_install} -e /deps/*
|
||||
|
||||
ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}'
|
||||
|
||||
{f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else ""}"""
|
||||
|
||||
|
||||
def config_to_compose(
|
||||
config_path: pathlib.Path,
|
||||
config: Config,
|
||||
watch: bool = False,
|
||||
langgraph_api_path: Optional[pathlib.Path] = None,
|
||||
):
|
||||
env_vars = config["env"].items() if isinstance(config["env"], dict) else {}
|
||||
env_vars_str = "\n".join(f" {k}: {v}" for k, v in env_vars)
|
||||
env_file_str = (
|
||||
f"env_file: {config['env']}" if isinstance(config["env"], str) else ""
|
||||
)
|
||||
if watch:
|
||||
watch_paths = [config_path] + [
|
||||
config_path.parent / dep
|
||||
for dep in config["dependencies"]
|
||||
if dep.startswith(".")
|
||||
]
|
||||
watch_actions = "\n".join(
|
||||
f"""- path: {path}
|
||||
action: rebuild
|
||||
ignore:
|
||||
- .langgraph-data"""
|
||||
for path in watch_paths
|
||||
)
|
||||
if langgraph_api_path:
|
||||
watch_actions += f"""\n- path: {langgraph_api_path}
|
||||
action: sync+restart
|
||||
target: /api/langgraph_api"""
|
||||
watch_str = f"""
|
||||
develop:
|
||||
watch:
|
||||
{textwrap.indent(watch_actions, " ")}
|
||||
"""
|
||||
else:
|
||||
watch_str = ""
|
||||
|
||||
return f"""
|
||||
{textwrap.indent(env_vars_str, " ")}
|
||||
{env_file_str}
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
{textwrap.indent(config_to_docker(config_path, config), " ")}
|
||||
{watch_str}
|
||||
"""
|
||||
@@ -0,0 +1,162 @@
|
||||
import json
|
||||
import pathlib
|
||||
import shutil
|
||||
from typing import Literal, NamedTuple, Optional
|
||||
|
||||
import click.exceptions
|
||||
|
||||
from langgraph_cli.exec import subp_exec
|
||||
|
||||
ROOT = pathlib.Path(__file__).parent.resolve()
|
||||
DEFAULT_POSTGRES_URI = (
|
||||
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
|
||||
)
|
||||
|
||||
|
||||
DB = """
|
||||
langgraph-postgres:
|
||||
image: postgres:16
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
"""
|
||||
|
||||
|
||||
DEBUGGER = """
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "{debugger_port}:80"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
"""
|
||||
|
||||
|
||||
class Version(NamedTuple):
|
||||
major: int
|
||||
minor: int
|
||||
patch: int
|
||||
|
||||
|
||||
DockerComposeType = Literal["plugin", "standalone"]
|
||||
|
||||
|
||||
class DockerCapabilities(NamedTuple):
|
||||
version_docker: Version
|
||||
version_compose: Version
|
||||
healthcheck_start_interval: bool
|
||||
compose_type: DockerComposeType = "plugin"
|
||||
|
||||
|
||||
def _parse_version(version: str) -> Version:
|
||||
parts = version.split(".", 2)
|
||||
if len(parts) == 1:
|
||||
major = parts[0]
|
||||
minor = "0"
|
||||
patch = "0"
|
||||
elif len(parts) == 2:
|
||||
major, minor = parts
|
||||
patch = "0"
|
||||
else:
|
||||
major, minor, patch = parts
|
||||
return Version(int(major.lstrip("v")), int(minor), int(patch.split("-")[0]))
|
||||
|
||||
|
||||
def check_capabilities(runner) -> DockerCapabilities:
|
||||
# check docker available
|
||||
try:
|
||||
stdout, _ = runner.run(subp_exec("docker", "info", "-f", "json", collect=True))
|
||||
info = json.loads(stdout)
|
||||
except (click.exceptions.Exit, json.JSONDecodeError):
|
||||
raise click.UsageError("Docker not installed or not running") from None
|
||||
|
||||
compose_type: DockerComposeType
|
||||
try:
|
||||
compose = next(
|
||||
p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
|
||||
)
|
||||
compose_type = "plugin"
|
||||
except (KeyError, StopIteration):
|
||||
if shutil.which("docker-compose") is None:
|
||||
raise click.UsageError("Docker Compose not installed") from None
|
||||
|
||||
compose_type = "standalone"
|
||||
|
||||
# parse versions
|
||||
docker_version = _parse_version(info["ServerVersion"])
|
||||
compose_version = _parse_version(compose["Version"])
|
||||
|
||||
# check capabilities
|
||||
return DockerCapabilities(
|
||||
version_docker=docker_version,
|
||||
version_compose=compose_version,
|
||||
healthcheck_start_interval=docker_version >= Version(25, 0, 0),
|
||||
compose_type=compose_type,
|
||||
)
|
||||
|
||||
|
||||
def compose(
|
||||
capabilities: DockerCapabilities,
|
||||
*,
|
||||
port: int,
|
||||
debugger_port: Optional[int] = None,
|
||||
# postgres://user:password@host:port/database?option=value
|
||||
postgres_uri: Optional[str] = None,
|
||||
) -> str:
|
||||
if postgres_uri is None:
|
||||
include_db = True
|
||||
postgres_uri = DEFAULT_POSTGRES_URI
|
||||
else:
|
||||
include_db = False
|
||||
|
||||
db = DB.format() if include_db else ""
|
||||
volumes = (
|
||||
"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
"""
|
||||
if include_db
|
||||
else ""
|
||||
)
|
||||
if db:
|
||||
if capabilities.healthcheck_start_interval:
|
||||
db += """
|
||||
interval: 60s
|
||||
start_interval: 1s"""
|
||||
else:
|
||||
db += """
|
||||
interval: 5s"""
|
||||
|
||||
compose_str = f"""{volumes}services:
|
||||
{db}
|
||||
{DEBUGGER.format(debugger_port=debugger_port) if debugger_port else ""}
|
||||
langgraph-api:
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_URI: {postgres_uri}
|
||||
"""
|
||||
if capabilities.healthcheck_start_interval:
|
||||
compose_str += """ healthcheck:
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s"""
|
||||
|
||||
return compose_str
|
||||
@@ -0,0 +1,140 @@
|
||||
import asyncio
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from typing import Callable, Optional, cast
|
||||
|
||||
import click.exceptions
|
||||
|
||||
|
||||
@contextmanager
|
||||
def Runner():
|
||||
if hasattr(asyncio, "Runner"):
|
||||
with asyncio.Runner() as runner:
|
||||
yield runner
|
||||
else:
|
||||
|
||||
class _Runner:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
def run(self, coro):
|
||||
asyncio.run(coro)
|
||||
|
||||
yield _Runner()
|
||||
|
||||
|
||||
async def subp_exec(
|
||||
cmd: str,
|
||||
*args: str,
|
||||
input: Optional[str] = None,
|
||||
wait: Optional[float] = None,
|
||||
verbose: bool = False,
|
||||
collect: bool = False,
|
||||
on_stdout: Optional[Callable[[str], Optional[bool]]] = None,
|
||||
) -> tuple[Optional[str], Optional[str]]:
|
||||
if verbose:
|
||||
cmd_str = f"+ {cmd} {' '.join(map(str, args))}"
|
||||
if input:
|
||||
print(cmd_str, " <\n", "\n".join(filter(None, input.splitlines())), sep="")
|
||||
else:
|
||||
print(cmd_str)
|
||||
if wait:
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
cmd,
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE if input else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
def signal_handler():
|
||||
# make sure process exists, then terminate it
|
||||
if proc.returncode is None:
|
||||
proc.terminate()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
||||
loop.add_signal_handler(signal.SIGTERM, signal_handler)
|
||||
|
||||
empty_fut: asyncio.Future = asyncio.Future()
|
||||
empty_fut.set_result(None)
|
||||
stdout, stderr, _ = await asyncio.gather(
|
||||
monitor_stream(
|
||||
cast(asyncio.StreamReader, proc.stdout),
|
||||
collect=True,
|
||||
display=verbose,
|
||||
on_line=on_stdout,
|
||||
),
|
||||
monitor_stream(
|
||||
cast(asyncio.StreamReader, proc.stderr),
|
||||
collect=True,
|
||||
display=verbose,
|
||||
),
|
||||
proc._feed_stdin(input.encode()) if input else empty_fut, # type: ignore[attr-defined]
|
||||
)
|
||||
returncode = await proc.wait()
|
||||
if (
|
||||
returncode is not None
|
||||
and returncode != 0 # success
|
||||
and returncode != 130 # user interrupt
|
||||
):
|
||||
sys.stdout.write(stdout.decode() if stdout else "")
|
||||
sys.stderr.write(stderr.decode() if stderr else "")
|
||||
raise click.exceptions.Exit(returncode)
|
||||
if collect:
|
||||
return (
|
||||
stdout.decode() if stdout else None,
|
||||
stderr.decode() if stderr else None,
|
||||
)
|
||||
else:
|
||||
return None, None
|
||||
finally:
|
||||
try:
|
||||
if proc.returncode is None:
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGINT)
|
||||
except (ProcessLookupError, KeyboardInterrupt):
|
||||
pass
|
||||
|
||||
loop.remove_signal_handler(signal.SIGINT)
|
||||
loop.remove_signal_handler(signal.SIGTERM)
|
||||
except UnboundLocalError:
|
||||
pass
|
||||
|
||||
|
||||
async def monitor_stream(
|
||||
stream: asyncio.StreamReader,
|
||||
collect: bool = False,
|
||||
display: bool = False,
|
||||
on_line: Optional[Callable[[str], Optional[bool]]] = None,
|
||||
) -> Optional[bytearray]:
|
||||
if collect:
|
||||
ba = bytearray()
|
||||
|
||||
def handle(line: bytes):
|
||||
nonlocal on_line
|
||||
nonlocal display
|
||||
|
||||
if collect:
|
||||
ba.extend(line)
|
||||
if display:
|
||||
sys.stdout.write(line.decode())
|
||||
if on_line:
|
||||
if on_line(line.decode()):
|
||||
on_line = None
|
||||
display = True
|
||||
|
||||
async for line in stream:
|
||||
await asyncio.to_thread(handle, line)
|
||||
if collect:
|
||||
return ba
|
||||
else:
|
||||
return None
|
||||
@@ -0,0 +1,64 @@
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class Progress:
|
||||
delay: float = 0.1
|
||||
|
||||
@staticmethod
|
||||
def spinning_cursor():
|
||||
while True:
|
||||
yield from "|/-\\"
|
||||
|
||||
def __init__(self, *, message=""):
|
||||
self.message = message
|
||||
self.spinner_generator = self.spinning_cursor()
|
||||
|
||||
def spinner_iteration(self):
|
||||
message = self.message
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
+ "\b" * (len(message) + 2)
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
def spinner_task(self):
|
||||
while self.message:
|
||||
message = self.message
|
||||
sys.stdout.write(next(self.spinner_generator) + " " + message)
|
||||
sys.stdout.flush()
|
||||
time.sleep(self.delay)
|
||||
# clear the spinner and message
|
||||
sys.stdout.write(
|
||||
"\b" * (len(message) + 2)
|
||||
+ " " * (len(message) + 2)
|
||||
+ "\b" * (len(message) + 2)
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
def __enter__(self) -> Callable[[str], None]:
|
||||
self.thread = threading.Thread(target=self.spinner_task)
|
||||
self.thread.start()
|
||||
|
||||
def set_message(message):
|
||||
self.message = message
|
||||
if not message:
|
||||
self.thread.join()
|
||||
|
||||
return set_message
|
||||
|
||||
def __exit__(self, exception, value, tb):
|
||||
self.message = ""
|
||||
try:
|
||||
self.thread.join()
|
||||
finally:
|
||||
del self.thread
|
||||
if exception is not None:
|
||||
return False
|
||||
Generated
+327
@@ -0,0 +1,327 @@
|
||||
# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.7"
|
||||
description = "Composable command line interface toolkit"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
|
||||
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "codespell"
|
||||
version = "2.2.6"
|
||||
description = "Codespell"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "codespell-2.2.6-py3-none-any.whl", hash = "sha256:9ee9a3e5df0990604013ac2a9f22fa8e57669c827124a2e961fe8a1da4cacc07"},
|
||||
{file = "codespell-2.2.6.tar.gz", hash = "sha256:a8c65d8eb3faa03deabab6b3bbe798bea72e1799c7e9e955d57eca4096abcff9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["Pygments", "build", "chardet", "pre-commit", "pytest", "pytest-cov", "pytest-dependency", "ruff", "tomli", "twine"]
|
||||
hard-encoding-detection = ["chardet"]
|
||||
toml = ["tomli"]
|
||||
types = ["chardet (>=5.1.0)", "mypy", "pytest", "pytest-cov", "pytest-dependency"]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
description = "Cross-platform colored terminal text."
|
||||
optional = false
|
||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||
files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "docopt"
|
||||
version = "0.6.2"
|
||||
description = "Pythonic argument parser, that will make you smile"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.2.0"
|
||||
description = "Backport of PEP 654 (exception groups)"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"},
|
||||
{file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
test = ["pytest (>=6)"]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.0.0"
|
||||
description = "brain-dead simple config-ini parsing"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
||||
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.10.0"
|
||||
description = "Optional static typing for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "mypy-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da1cbf08fb3b851ab3b9523a884c232774008267b1f83371ace57f412fe308c2"},
|
||||
{file = "mypy-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:12b6bfc1b1a66095ab413160a6e520e1dc076a28f3e22f7fb25ba3b000b4ef99"},
|
||||
{file = "mypy-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e36fb078cce9904c7989b9693e41cb9711e0600139ce3970c6ef814b6ebc2b2"},
|
||||
{file = "mypy-1.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2b0695d605ddcd3eb2f736cd8b4e388288c21e7de85001e9f85df9187f2b50f9"},
|
||||
{file = "mypy-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:cd777b780312ddb135bceb9bc8722a73ec95e042f911cc279e2ec3c667076051"},
|
||||
{file = "mypy-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3be66771aa5c97602f382230165b856c231d1277c511c9a8dd058be4784472e1"},
|
||||
{file = "mypy-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8b2cbaca148d0754a54d44121b5825ae71868c7592a53b7292eeb0f3fdae95ee"},
|
||||
{file = "mypy-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ec404a7cbe9fc0e92cb0e67f55ce0c025014e26d33e54d9e506a0f2d07fe5de"},
|
||||
{file = "mypy-1.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e22e1527dc3d4aa94311d246b59e47f6455b8729f4968765ac1eacf9a4760bc7"},
|
||||
{file = "mypy-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:a87dbfa85971e8d59c9cc1fcf534efe664d8949e4c0b6b44e8ca548e746a8d53"},
|
||||
{file = "mypy-1.10.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:a781f6ad4bab20eef8b65174a57e5203f4be627b46291f4589879bf4e257b97b"},
|
||||
{file = "mypy-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b808e12113505b97d9023b0b5e0c0705a90571c6feefc6f215c1df9381256e30"},
|
||||
{file = "mypy-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f55583b12156c399dce2df7d16f8a5095291354f1e839c252ec6c0611e86e2e"},
|
||||
{file = "mypy-1.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4cf18f9d0efa1b16478c4c129eabec36148032575391095f73cae2e722fcf9d5"},
|
||||
{file = "mypy-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:bc6ac273b23c6b82da3bb25f4136c4fd42665f17f2cd850771cb600bdd2ebeda"},
|
||||
{file = "mypy-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9fd50226364cd2737351c79807775136b0abe084433b55b2e29181a4c3c878c0"},
|
||||
{file = "mypy-1.10.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f90cff89eea89273727d8783fef5d4a934be2fdca11b47def50cf5d311aff727"},
|
||||
{file = "mypy-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcfc70599efde5c67862a07a1aaf50e55bce629ace26bb19dc17cece5dd31ca4"},
|
||||
{file = "mypy-1.10.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:075cbf81f3e134eadaf247de187bd604748171d6b79736fa9b6c9685b4083061"},
|
||||
{file = "mypy-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:3f298531bca95ff615b6e9f2fc0333aae27fa48052903a0ac90215021cdcfa4f"},
|
||||
{file = "mypy-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fa7ef5244615a2523b56c034becde4e9e3f9b034854c93639adb667ec9ec2976"},
|
||||
{file = "mypy-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3236a4c8f535a0631f85f5fcdffba71c7feeef76a6002fcba7c1a8e57c8be1ec"},
|
||||
{file = "mypy-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a2b5cdbb5dd35aa08ea9114436e0d79aceb2f38e32c21684dcf8e24e1e92821"},
|
||||
{file = "mypy-1.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:92f93b21c0fe73dc00abf91022234c79d793318b8a96faac147cd579c1671746"},
|
||||
{file = "mypy-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:28d0e038361b45f099cc086d9dd99c15ff14d0188f44ac883010e172ce86c38a"},
|
||||
{file = "mypy-1.10.0-py3-none-any.whl", hash = "sha256:f8c083976eb530019175aabadb60921e73b4f45736760826aa1689dda8208aee"},
|
||||
{file = "mypy-1.10.0.tar.gz", hash = "sha256:3d087fcbec056c4ee34974da493a826ce316947485cef3901f511848e687c131"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
mypy-extensions = ">=1.0.0"
|
||||
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
|
||||
typing-extensions = ">=4.1.0"
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
install-types = ["pip"]
|
||||
mypyc = ["setuptools (>=50)"]
|
||||
reports = ["lxml"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.0.0"
|
||||
description = "Type system extensions for programs checked with the mypy type checker."
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
files = [
|
||||
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
|
||||
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "23.2"
|
||||
description = "Core utilities for Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
|
||||
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.3.0"
|
||||
description = "plugin and hook calling mechanisms for python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"},
|
||||
{file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "tox"]
|
||||
testing = ["pytest", "pytest-benchmark"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "7.4.3"
|
||||
description = "pytest: simple powerful testing with Python"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"},
|
||||
{file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
|
||||
iniconfig = "*"
|
||||
packaging = "*"
|
||||
pluggy = ">=0.12,<2.0"
|
||||
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "0.21.1"
|
||||
description = "Pytest support for asyncio"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"},
|
||||
{file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=7.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
|
||||
testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.12.0"
|
||||
description = "Thin-wrapper around the mock package for easier use with pytest"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"},
|
||||
{file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pytest = ">=5.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["pre-commit", "pytest-asyncio", "tox"]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-watch"
|
||||
version = "4.2.0"
|
||||
description = "Local continuous test runner with pytest and watchdog."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
colorama = ">=0.3.3"
|
||||
docopt = ">=0.4.0"
|
||||
pytest = ">=2.6.4"
|
||||
watchdog = ">=0.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.1.6"
|
||||
description = "An extremely fast Python linter and code formatter, written in Rust."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"},
|
||||
{file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"},
|
||||
{file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"},
|
||||
{file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"},
|
||||
{file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"},
|
||||
{file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"},
|
||||
{file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"},
|
||||
{file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"},
|
||||
{file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"},
|
||||
{file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"},
|
||||
{file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"},
|
||||
{file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"},
|
||||
{file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"},
|
||||
{file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"},
|
||||
{file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"},
|
||||
{file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"},
|
||||
{file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
description = "A lil' TOML parser"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
|
||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.12.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "typing_extensions-4.12.0-py3-none-any.whl", hash = "sha256:b349c66bea9016ac22978d800cfff206d5f9816951f12a7d0ec5578b0a819594"},
|
||||
{file = "typing_extensions-4.12.0.tar.gz", hash = "sha256:8cbcdc8606ebcb0d95453ad7dc5065e6237b6aa230a31e81d0f440c30fed5fd8"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchdog"
|
||||
version = "3.0.0"
|
||||
description = "Filesystem events monitoring"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41"},
|
||||
{file = "watchdog-3.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397"},
|
||||
{file = "watchdog-3.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96"},
|
||||
{file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae"},
|
||||
{file = "watchdog-3.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9"},
|
||||
{file = "watchdog-3.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7"},
|
||||
{file = "watchdog-3.0.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674"},
|
||||
{file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f"},
|
||||
{file = "watchdog-3.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc"},
|
||||
{file = "watchdog-3.0.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3"},
|
||||
{file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3"},
|
||||
{file = "watchdog-3.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0"},
|
||||
{file = "watchdog-3.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8"},
|
||||
{file = "watchdog-3.0.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100"},
|
||||
{file = "watchdog-3.0.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346"},
|
||||
{file = "watchdog-3.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64"},
|
||||
{file = "watchdog-3.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a"},
|
||||
{file = "watchdog-3.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44"},
|
||||
{file = "watchdog-3.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a"},
|
||||
{file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709"},
|
||||
{file = "watchdog-3.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83"},
|
||||
{file = "watchdog-3.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d"},
|
||||
{file = "watchdog-3.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33"},
|
||||
{file = "watchdog-3.0.0-py3-none-win32.whl", hash = "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f"},
|
||||
{file = "watchdog-3.0.0-py3-none-win_amd64.whl", hash = "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c"},
|
||||
{file = "watchdog-3.0.0-py3-none-win_ia64.whl", hash = "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759"},
|
||||
{file = "watchdog-3.0.0.tar.gz", hash = "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
watchmedo = ["PyYAML (>=3.10)"]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9.0,<4.0"
|
||||
content-hash = "5efa2f1ed4bd611a45e5d43d7c3fb907a8fa4447e2d1c30ce26b830411e189dd"
|
||||
@@ -0,0 +1,53 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph-cli"
|
||||
version = "0.1.35"
|
||||
description = "CLI for interacting with LangGraph API"
|
||||
authors = ["Nuno Campos <nuno@langchain.dev>"]
|
||||
readme = "README.md"
|
||||
packages = [{include = "langgraph_cli"}]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
langgraph = "langgraph_cli.cli:cli"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.9.0,<4.0"
|
||||
click = "^8.1.7"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.1.4"
|
||||
codespell = "^2.2.0"
|
||||
pytest = "^7.2.1"
|
||||
pytest-asyncio = "^0.21.1"
|
||||
pytest-mock = "^3.11.1"
|
||||
pytest-watch = "^4.2.0"
|
||||
mypy = "^1.10.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
# --strict-markers will raise errors on unknown marks.
|
||||
# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
|
||||
#
|
||||
# https://docs.pytest.org/en/7.1.x/reference/reference.html
|
||||
# --strict-config any warnings encountered while parsing the `pytest`
|
||||
# section of the configuration file raise errors.
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
asyncio_mode = "auto"
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.ruff]
|
||||
select = [
|
||||
# pycodestyle
|
||||
"E",
|
||||
# Pyflakes
|
||||
"F",
|
||||
# pyupgrade
|
||||
"UP",
|
||||
# flake8-bugbear
|
||||
"B",
|
||||
# isort
|
||||
"I",
|
||||
]
|
||||
ignore = [ "E501", "B008" ]
|
||||
@@ -0,0 +1,2 @@
|
||||
def clean_empty_lines(input_str: str):
|
||||
return "\n".join(filter(None, input_str.splitlines()))
|
||||
@@ -0,0 +1,113 @@
|
||||
import pathlib
|
||||
|
||||
from langgraph_cli.cli import prepare_args_and_stdin
|
||||
from langgraph_cli.config import Config, validate_config
|
||||
from langgraph_cli.docker import DEFAULT_POSTGRES_URI, DockerCapabilities, Version
|
||||
|
||||
from .helpers import clean_empty_lines
|
||||
|
||||
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||||
version_docker=Version(26, 1, 1),
|
||||
version_compose=Version(2, 27, 0),
|
||||
healthcheck_start_interval=True,
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_args_and_stdin():
|
||||
# this basically serves as an end-to-end test for using config and docker helpers
|
||||
config_path = pathlib.Path("./langgraph.json")
|
||||
config = validate_config(
|
||||
Config(dependencies=["."], graphs={"agent": "agent.py:graph"})
|
||||
)
|
||||
port = 8000
|
||||
debugger_port = 8001
|
||||
|
||||
actual_args, actual_stdin = prepare_args_and_stdin(
|
||||
capabilities=DEFAULT_DOCKER_CAPABILITIES,
|
||||
config_path=config_path,
|
||||
config=config,
|
||||
docker_compose="custom-docker-compose.yml",
|
||||
port=port,
|
||||
debugger_port=debugger_port,
|
||||
watch=True,
|
||||
langgraph_api_path="path/to/langgraph-api",
|
||||
)
|
||||
|
||||
expected_args = [
|
||||
"--project-directory",
|
||||
".",
|
||||
"-f",
|
||||
"custom-docker-compose.yml",
|
||||
"-f",
|
||||
"-",
|
||||
]
|
||||
expected_stdin = f"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-postgres:
|
||||
image: postgres:16
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
langgraph-debugger:
|
||||
image: langchain/langgraph-debugger
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "{debugger_port}:80"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
langgraph-api:
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{{"agent": "agent.py:graph"}}'
|
||||
WORKDIR /deps/
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: langgraph.json
|
||||
action: rebuild
|
||||
ignore:
|
||||
- .langgraph-data
|
||||
- path: .
|
||||
action: rebuild
|
||||
ignore:
|
||||
- .langgraph-data
|
||||
- path: path/to/langgraph-api
|
||||
action: sync+restart
|
||||
target: /api/langgraph_api\
|
||||
"""
|
||||
assert actual_args == expected_args
|
||||
assert clean_empty_lines(actual_stdin) == expected_stdin
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
"dockerfile_lines": ["ARG meow"],
|
||||
"dependencies": [
|
||||
"langchain_openai",
|
||||
"."
|
||||
],
|
||||
"graphs": {
|
||||
"agent": "tests/unit_tests/agent.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.config import config_to_compose, config_to_docker, validate_config
|
||||
|
||||
from .helpers import clean_empty_lines
|
||||
|
||||
PATH_TO_CONFIG = pathlib.Path("tests/unit_tests/test_config.json")
|
||||
|
||||
|
||||
def test_validate_config():
|
||||
# minimal config
|
||||
expected_config = {
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph",
|
||||
},
|
||||
}
|
||||
expected_config = {
|
||||
"python_version": "3.11",
|
||||
"pip_config_file": None,
|
||||
"dockerfile_lines": [],
|
||||
"env": {},
|
||||
**expected_config,
|
||||
}
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
|
||||
# full config
|
||||
env = ".env"
|
||||
expected_config = {
|
||||
"python_version": "3.12",
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
"dockerfile_lines": ["ARG meow"],
|
||||
"dependencies": [".", "langchain"],
|
||||
"graphs": {
|
||||
"agent": "./agent.py:graph",
|
||||
},
|
||||
"env": env,
|
||||
}
|
||||
actual_config = validate_config(expected_config)
|
||||
assert actual_config == expected_config
|
||||
|
||||
# check wrong python version raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.9",
|
||||
}
|
||||
)
|
||||
|
||||
# check missing dependencies key raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config(
|
||||
{"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}},
|
||||
)
|
||||
|
||||
# check missing graphs key raises
|
||||
with pytest.raises(click.UsageError):
|
||||
validate_config({"python_version": "3.9", "dependencies": ["."]})
|
||||
|
||||
|
||||
# config_to_docker
|
||||
def test_config_to_docker_simple():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin = config_to_docker(
|
||||
PATH_TO_CONFIG, validate_config({"dependencies": ["."], "graphs": graphs})
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
|
||||
[project]
|
||||
name = "unit_tests"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_pipconfig():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
actual_docker_stdin = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
}
|
||||
),
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
|
||||
[project]
|
||||
name = "unit_tests"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_invalid_inputs():
|
||||
# test missing local dependencies
|
||||
with pytest.raises(FileNotFoundError):
|
||||
graphs = {"agent": "tests/unit_tests/agent.py:graph"}
|
||||
config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["./missing"], "graphs": graphs}),
|
||||
)
|
||||
|
||||
# test missing local module
|
||||
with pytest.raises(FileNotFoundError):
|
||||
graphs = {"agent": "./missing_agent.py:graph"}
|
||||
config_to_docker(
|
||||
PATH_TO_CONFIG, validate_config({"dependencies": ["."], "graphs": graphs})
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_docker_local_deps():
|
||||
graphs = {"agent": "./graphs/agent.py:graph"}
|
||||
actual_docker_stdin = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["./graphs"],
|
||||
"graphs": graphs,
|
||||
}
|
||||
),
|
||||
)
|
||||
expected_docker_stdin = """\
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD ./graphs /deps/__outer_graphs/src
|
||||
COPY <<EOF /deps/__outer_graphs/pyproject.toml
|
||||
[project]
|
||||
name = "graphs"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'\
|
||||
"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_pyproject():
|
||||
pyproject_str = """[project]
|
||||
name = "custom"
|
||||
version = "0.1"
|
||||
dependencies = ["langchain"]"""
|
||||
pyproject_path = "tests/unit_tests/pyproject.toml"
|
||||
with open(pyproject_path, "w") as f:
|
||||
f.write(pyproject_str)
|
||||
|
||||
graphs = {"agent": "./graphs/agent.py:graph"}
|
||||
actual_docker_stdin = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
}
|
||||
),
|
||||
)
|
||||
os.remove(pyproject_path)
|
||||
expected_docker_stdin = """FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/unit_tests
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
|
||||
WORKDIR /deps/unit_tests"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_end_to_end():
|
||||
graphs = {"agent": "./graphs/agent.py:graph"}
|
||||
actual_docker_stdin = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["./graphs/", "langchain", "langchain_openai"],
|
||||
"graphs": graphs,
|
||||
"pip_config_file": "pipconfig.txt",
|
||||
"dockerfile_lines": ["ARG meow", "ARG foo"],
|
||||
}
|
||||
),
|
||||
)
|
||||
expected_docker_stdin = """FROM langchain/langgraph-api:3.12
|
||||
ARG meow
|
||||
ARG foo
|
||||
ADD pipconfig.txt /pipconfig.txt
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt pip install -c /api/constraints.txt langchain langchain_openai
|
||||
ADD ./graphs/ /deps/__outer_graphs/src
|
||||
COPY <<EOF /deps/__outer_graphs/pyproject.toml
|
||||
[project]
|
||||
name = "graphs"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN PIP_CONFIG_FILE=/pipconfig.txt pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_graphs/src/agent.py:graph"}'"""
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
|
||||
|
||||
# config_to_compose
|
||||
def test_config_to_compose_simple_config():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
|
||||
[project]
|
||||
name = "unit_tests"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG, validate_config({"dependencies": ["."], "graphs": graphs})
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_env_vars():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """ OPENAI_API_KEY: key
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
|
||||
[project]
|
||||
name = "unit_tests"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
openai_api_key = "key"
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"env": {"OPENAI_API_KEY": openai_api_key},
|
||||
}
|
||||
),
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_env_file():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
env_file: .env
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
|
||||
[project]
|
||||
name = "unit_tests"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_watch():
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
|
||||
[project]
|
||||
name = "unit_tests"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: tests/unit_tests/test_config.json
|
||||
action: rebuild
|
||||
ignore:
|
||||
- .langgraph-data
|
||||
- path: tests/unit_tests
|
||||
action: rebuild
|
||||
ignore:
|
||||
- .langgraph-data\
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs}),
|
||||
watch=True,
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
|
||||
|
||||
def test_config_to_compose_end_to_end():
|
||||
# test all of the above + langgraph API path
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
expected_compose_stdin = """\
|
||||
env_file: .env
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile_inline: |
|
||||
FROM langchain/langgraph-api:3.11
|
||||
ADD . /deps/__outer_unit_tests/unit_tests
|
||||
COPY <<EOF /deps/__outer_unit_tests/pyproject.toml
|
||||
[project]
|
||||
name = "unit_tests"
|
||||
version = "0.1"
|
||||
[tool.setuptools.package-data]
|
||||
"*" = ["**/*"]
|
||||
EOF
|
||||
RUN pip install -c /api/constraints.txt -e /deps/*
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "/deps/__outer_unit_tests/unit_tests/agent.py:graph"}'
|
||||
WORKDIR /deps/__outer_unit_tests/unit_tests
|
||||
|
||||
develop:
|
||||
watch:
|
||||
- path: tests/unit_tests/test_config.json
|
||||
action: rebuild
|
||||
ignore:
|
||||
- .langgraph-data
|
||||
- path: tests/unit_tests
|
||||
action: rebuild
|
||||
ignore:
|
||||
- .langgraph-data
|
||||
- path: path/to/langgraph/api
|
||||
action: sync+restart
|
||||
target: /api/langgraph_api\
|
||||
"""
|
||||
actual_compose_stdin = config_to_compose(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
|
||||
watch=True,
|
||||
langgraph_api_path="path/to/langgraph/api",
|
||||
)
|
||||
assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin
|
||||
@@ -0,0 +1,114 @@
|
||||
from langgraph_cli.docker import (
|
||||
DEFAULT_POSTGRES_URI,
|
||||
DockerCapabilities,
|
||||
Version,
|
||||
compose,
|
||||
)
|
||||
from tests.unit_tests.helpers import clean_empty_lines
|
||||
|
||||
DEFAULT_DOCKER_CAPABILITIES = DockerCapabilities(
|
||||
version_docker=Version(26, 1, 1),
|
||||
version_compose=Version(2, 27, 0),
|
||||
healthcheck_start_interval=False,
|
||||
)
|
||||
|
||||
|
||||
def test_compose_with_no_debugger_and_custom_db():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES, port=port, postgres_uri=custom_postgres_uri
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-api:
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_URI: {custom_postgres_uri}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_no_debugger_and_custom_db_with_healthcheck():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES._replace(healthcheck_start_interval=True),
|
||||
port=port,
|
||||
postgres_uri=custom_postgres_uri,
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-api:
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_URI: {custom_postgres_uri}
|
||||
healthcheck:
|
||||
interval: 60s
|
||||
start_interval: 1s
|
||||
start_period: 10s"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_debugger_and_custom_db():
|
||||
port = 8123
|
||||
custom_postgres_uri = "custom_postgres_uri"
|
||||
actual_compose_str = compose(
|
||||
DEFAULT_DOCKER_CAPABILITIES,
|
||||
port=port,
|
||||
postgres_uri=custom_postgres_uri,
|
||||
)
|
||||
expected_compose_str = f"""services:
|
||||
langgraph-api:
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_URI: {custom_postgres_uri}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
|
||||
|
||||
def test_compose_with_debugger_and_default_db():
|
||||
port = 8123
|
||||
actual_compose_str = compose(DEFAULT_DOCKER_CAPABILITIES, port=port)
|
||||
expected_compose_str = f"""volumes:
|
||||
langgraph-data:
|
||||
driver: local
|
||||
services:
|
||||
langgraph-postgres:
|
||||
image: postgres:16
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "5433:5432"
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- langgraph-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
start_period: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
interval: 5s
|
||||
langgraph-api:
|
||||
restart: on-failure
|
||||
ports:
|
||||
- "{port}:8000"
|
||||
depends_on:
|
||||
langgraph-postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
POSTGRES_URI: {DEFAULT_POSTGRES_URI}"""
|
||||
assert clean_empty_lines(actual_compose_str) == expected_compose_str
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 LangChain, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,70 @@
|
||||
.PHONY: all format lint test test_watch integration_tests spell_check spell_fix
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
# Run unit tests and generate a coverage report.
|
||||
coverage:
|
||||
poetry run pytest --cov \
|
||||
--cov-config=.coveragerc \
|
||||
--cov-report xml \
|
||||
--cov-report term-missing:skip-covered
|
||||
|
||||
test:
|
||||
poetry run pytest
|
||||
|
||||
test_watch:
|
||||
poetry run ptw .
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
PYTHON_FILES=.
|
||||
MYPY_CACHE=.mypy_cache
|
||||
lint format: PYTHON_FILES=.
|
||||
lint_diff format_diff: PYTHON_FILES=$(shell git diff --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$')
|
||||
lint_package: PYTHON_FILES=langgraph
|
||||
lint_tests: PYTHON_FILES=tests
|
||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
||||
|
||||
lint lint_diff lint_package lint_tests:
|
||||
poetry run ruff .
|
||||
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff --select I $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
poetry run ruff format $(PYTHON_FILES)
|
||||
poetry run ruff --select I --fix $(PYTHON_FILES)
|
||||
|
||||
spell_check:
|
||||
poetry run codespell --toml pyproject.toml
|
||||
|
||||
spell_fix:
|
||||
poetry run codespell --toml pyproject.toml -w
|
||||
|
||||
|
||||
######################
|
||||
# HELP
|
||||
######################
|
||||
|
||||
help:
|
||||
@echo '===================='
|
||||
@echo '-- DOCUMENTATION --'
|
||||
|
||||
@echo '-- LINTING --'
|
||||
@echo 'format - run code formatters'
|
||||
@echo 'lint - run linters'
|
||||
@echo 'spell_check - run codespell on the project'
|
||||
@echo 'spell_fix - run codespell on the project and fix the errors'
|
||||
@echo '-- TESTS --'
|
||||
@echo 'coverage - run unit tests and generate coverage report'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||
@echo 'test_watch - run unit tests in watch mode'
|
||||
@@ -0,0 +1,3 @@
|
||||
from langgraph.version import __version__
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -0,0 +1,36 @@
|
||||
import functools
|
||||
import warnings
|
||||
from typing import Any, Callable, TypeVar, cast
|
||||
|
||||
|
||||
class LangGraphDeprecationWarning(DeprecationWarning):
|
||||
pass
|
||||
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def deprecated(
|
||||
version: str, alternative: str, *, example: str = ""
|
||||
) -> Callable[[F], F]:
|
||||
def decorator(func: F) -> F:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
message = (
|
||||
f"{func.__name__} is deprecated as of version {version} and will be"
|
||||
f" removed in a future version. Use {alternative} instead.{example}"
|
||||
)
|
||||
warnings.warn(message, LangGraphDeprecationWarning, stacklevel=2)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
docstring = (
|
||||
f"**Deprecated**: This function is deprecated as of version {version}. "
|
||||
f"Use `{alternative}` instead."
|
||||
)
|
||||
if func.__doc__:
|
||||
docstring = docstring + f"\n\n{func.__doc__}"
|
||||
wrapper.__doc__ = docstring
|
||||
|
||||
return cast(F, wrapper)
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,11 @@
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.context import Context
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.topic import Topic
|
||||
|
||||
__all__ = [
|
||||
"LastValue",
|
||||
"Topic",
|
||||
"Context",
|
||||
"BinaryOperatorAggregate",
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator, Generic, Optional, Sequence, Type
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError
|
||||
|
||||
|
||||
class AnyValue(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
"""Stores the last value received, assumes that if multiple values are
|
||||
received, they are all equal."""
|
||||
|
||||
def __init__(self, typ: Type[Value]) -> None:
|
||||
self.typ = typ
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Type[Value]:
|
||||
"""The type of the value stored in the channel."""
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[Value]:
|
||||
"""The type of the update received by the channel."""
|
||||
return self.typ
|
||||
|
||||
def checkpoint(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[Value] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ)
|
||||
if checkpoint is not None:
|
||||
empty.value = checkpoint
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
try:
|
||||
del empty.value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Value]) -> bool:
|
||||
if len(values) == 0:
|
||||
try:
|
||||
del self.value
|
||||
return True
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
self.value = values[-1]
|
||||
return True
|
||||
|
||||
def get(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
@@ -0,0 +1,87 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Generator,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
)
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
Value = TypeVar("Value")
|
||||
Update = TypeVar("Update")
|
||||
C = TypeVar("C")
|
||||
|
||||
|
||||
class BaseChannel(Generic[Value, Update, C], ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def ValueType(self) -> Any:
|
||||
"""The type of the value stored in the channel."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def UpdateType(self) -> Any:
|
||||
"""The type of the update received by the channel."""
|
||||
|
||||
# serialize/deserialize methods
|
||||
|
||||
@abstractmethod
|
||||
def checkpoint(self) -> Optional[C]:
|
||||
"""Return a serializable representation of the channel's current state.
|
||||
Raises EmptyChannelError if the channel is empty (never updated yet),
|
||||
or doesn't support checkpoints."""
|
||||
|
||||
@contextmanager
|
||||
@abstractmethod
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[C] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
"""Return a new identical channel, optionally initialized from a checkpoint.
|
||||
If the checkpoint contains complex data structures, they should be copied."""
|
||||
|
||||
@asynccontextmanager
|
||||
async def afrom_checkpoint(
|
||||
self, checkpoint: Optional[C] = None
|
||||
) -> AsyncGenerator[Self, None]:
|
||||
"""Return a new identical channel, optionally initialized from a checkpoint.
|
||||
If the checkpoint contains complex data structures, they should be copied."""
|
||||
with self.from_checkpoint(checkpoint) as value:
|
||||
yield value
|
||||
|
||||
# state methods
|
||||
|
||||
@abstractmethod
|
||||
def update(self, values: Sequence[Update]) -> bool:
|
||||
"""Update the channel's value with the given sequence of updates.
|
||||
The order of the updates in the sequence is arbitrary.
|
||||
This method is called by Pregel for all channels at the end of each step.
|
||||
If there are no updates, it is called with an empty sequence.
|
||||
Raises InvalidUpdateError if the sequence of updates is invalid.
|
||||
Returns True if the channel was updated, False otherwise."""
|
||||
|
||||
@abstractmethod
|
||||
def get(self) -> Value:
|
||||
"""Return the current value of the channel.
|
||||
|
||||
Raises EmptyChannelError if the channel is empty (never updated yet)."""
|
||||
|
||||
def consume(self) -> bool:
|
||||
"""Mark the current value of the channel as consumed. By default, no-op.
|
||||
This is called by Pregel before the start of the next step, for all
|
||||
channels that triggered a node. If the channel was updated, return True.
|
||||
"""
|
||||
return False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BaseChannel",
|
||||
"EmptyChannelError",
|
||||
"InvalidUpdateError",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
import collections.abc
|
||||
from contextlib import contextmanager
|
||||
from typing import (
|
||||
Callable,
|
||||
Generator,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
Type,
|
||||
)
|
||||
|
||||
from typing_extensions import NotRequired, Required, Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError
|
||||
|
||||
|
||||
# Adapted from typing_extensions
|
||||
def _strip_extras(t):
|
||||
"""Strips Annotated, Required and NotRequired from a given type."""
|
||||
if hasattr(t, "__origin__"):
|
||||
return _strip_extras(t.__origin__)
|
||||
if hasattr(t, "__origin__") and t.__origin__ in (Required, NotRequired):
|
||||
return _strip_extras(t.__args__[0])
|
||||
|
||||
return t
|
||||
|
||||
|
||||
class BinaryOperatorAggregate(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
"""Stores the result of applying a binary operator to the current value and each new value.
|
||||
|
||||
```python
|
||||
import operator
|
||||
|
||||
total = Channels.BinaryOperatorAggregate(int, operator.add)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, typ: Type[Value], operator: Callable[[Value, Value], Value]):
|
||||
self.operator = operator
|
||||
# keep the type exposed by ValueType/UpdateType as-is
|
||||
self.typ = typ
|
||||
# special forms from typing or collections.abc are not instantiable
|
||||
# so we need to replace them with their concrete counterparts
|
||||
typ = _strip_extras(typ)
|
||||
if typ in (collections.abc.Sequence, collections.abc.MutableSequence):
|
||||
typ = list
|
||||
if typ in (collections.abc.Set, collections.abc.MutableSet):
|
||||
typ = set
|
||||
if typ in (collections.abc.Mapping, collections.abc.MutableMapping):
|
||||
typ = dict
|
||||
try:
|
||||
self.value = typ()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Type[Value]:
|
||||
"""The type of the value stored in the channel."""
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[Value]:
|
||||
"""The type of the update received by the channel."""
|
||||
return self.typ
|
||||
|
||||
def checkpoint(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[Value] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ, self.operator)
|
||||
if checkpoint is not None:
|
||||
empty.value = checkpoint
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
try:
|
||||
del empty.value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Value]) -> bool:
|
||||
if not values:
|
||||
return False
|
||||
if not hasattr(self, "value"):
|
||||
self.value = values[0]
|
||||
values = values[1:]
|
||||
for value in values:
|
||||
self.value = self.operator(self.value, value)
|
||||
return True
|
||||
|
||||
def get(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
@@ -0,0 +1,107 @@
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncContextManager,
|
||||
AsyncGenerator,
|
||||
Callable,
|
||||
ContextManager,
|
||||
Generator,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
Type,
|
||||
)
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
|
||||
class Context(Generic[Value], BaseChannel[Value, None, None]):
|
||||
"""Exposes the value of a context manager, for the duration of an invocation.
|
||||
Context manager is entered before the first step, and exited after the last step.
|
||||
Optionally, provide an equivalent async context manager, which will be used
|
||||
instead for async invocations.
|
||||
|
||||
```python
|
||||
import httpx
|
||||
|
||||
client = Channels.Context(httpx.Client, httpx.AsyncClient)
|
||||
```
|
||||
"""
|
||||
|
||||
value: Value
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ctx: Optional[Callable[[], ContextManager[Value]]] = None,
|
||||
actx: Optional[Callable[[], AsyncContextManager[Value]]] = None,
|
||||
typ: Optional[Type[Value]] = None,
|
||||
) -> None:
|
||||
if ctx is None and actx is None:
|
||||
raise ValueError("Must provide either sync or async context manager.")
|
||||
|
||||
self.typ = typ
|
||||
self.ctx = ctx
|
||||
self.actx = actx
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Any:
|
||||
"""The type of the value stored in the channel."""
|
||||
return (
|
||||
self.typ
|
||||
or (self.ctx if hasattr(self.ctx, "__enter__") else None)
|
||||
or (self.actx if hasattr(self.actx, "__aenter__") else None)
|
||||
or None
|
||||
)
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[None]:
|
||||
"""The type of the update received by the channel."""
|
||||
raise InvalidUpdateError()
|
||||
|
||||
def checkpoint(self) -> None:
|
||||
raise EmptyChannelError()
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(self, checkpoint: None = None) -> Generator[Self, None, None]:
|
||||
if self.ctx is None:
|
||||
raise ValueError("Cannot enter sync context manager.")
|
||||
|
||||
empty = self.__class__(ctx=self.ctx, actx=self.actx, typ=self.typ)
|
||||
# ContextManager doesn't have a checkpoint
|
||||
ctx = self.ctx()
|
||||
empty.value = ctx.__enter__()
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
ctx.__exit__(None, None, None)
|
||||
|
||||
@asynccontextmanager
|
||||
async def afrom_checkpoint(
|
||||
self, checkpoint: Optional[str] = None
|
||||
) -> AsyncGenerator[Self, None]:
|
||||
if self.actx is not None:
|
||||
empty = self.__class__(ctx=self.ctx, actx=self.actx, typ=self.typ)
|
||||
# ContextManager doesn't have a checkpoint
|
||||
actx = self.actx()
|
||||
empty.value = await actx.__aenter__()
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
await actx.__aexit__(None, None, None)
|
||||
else:
|
||||
with self.from_checkpoint() as empty:
|
||||
yield empty
|
||||
|
||||
def update(self, values: Sequence[None]) -> bool:
|
||||
if values:
|
||||
raise InvalidUpdateError()
|
||||
return False
|
||||
|
||||
def get(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
@@ -0,0 +1,92 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator, Generic, NamedTuple, Optional, Sequence, Type, Union
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
|
||||
class WaitForNames(NamedTuple):
|
||||
names: set[Value]
|
||||
|
||||
|
||||
class DynamicBarrierValue(
|
||||
Generic[Value], BaseChannel[Value, Union[Value, WaitForNames], set[Value]]
|
||||
):
|
||||
"""A channel that switches between two states
|
||||
|
||||
- in the "priming" state it can't be read from.
|
||||
- if it receives a WaitForNames update, it switches to the "waiting" state.
|
||||
- in the "waiting" state it collects named values until all are received.
|
||||
- once all named values are received, it can be read once, and it switches
|
||||
back to the "priming" state.
|
||||
"""
|
||||
|
||||
names: Optional[set[Value]]
|
||||
seen: set[Value]
|
||||
|
||||
def __init__(self, typ: Type[Value]) -> None:
|
||||
self.typ = typ
|
||||
self.names = None
|
||||
self.seen = set()
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Type[Value]:
|
||||
"""The type of the value stored in the channel."""
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[Value]:
|
||||
"""The type of the update received by the channel."""
|
||||
return self.typ
|
||||
|
||||
def checkpoint(self) -> tuple[Optional[set[Value]], set[Value]]:
|
||||
return (self.names, self.seen)
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[tuple[Optional[set[Value]], set[Value]]] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ)
|
||||
if checkpoint is not None:
|
||||
names, seen = checkpoint
|
||||
empty.names = names.copy() if names is not None else None
|
||||
empty.seen = seen.copy()
|
||||
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Union[Value, WaitForNames]]) -> bool:
|
||||
if wait_for_names := [v for v in values if isinstance(v, WaitForNames)]:
|
||||
if len(wait_for_names) > 1:
|
||||
raise InvalidUpdateError(
|
||||
"Received multiple WaitForNames updates in the same step."
|
||||
)
|
||||
self.names = wait_for_names[0].names
|
||||
return True
|
||||
elif self.names is not None:
|
||||
updated = False
|
||||
for value in values:
|
||||
assert not isinstance(value, WaitForNames)
|
||||
if value in self.names:
|
||||
if value not in self.seen:
|
||||
self.seen.add(value)
|
||||
updated = True
|
||||
else:
|
||||
raise InvalidUpdateError(f"Value {value} not in {self.names}")
|
||||
return updated
|
||||
|
||||
def get(self) -> Value:
|
||||
if self.seen != self.names:
|
||||
raise EmptyChannelError()
|
||||
return None
|
||||
|
||||
def consume(self) -> bool:
|
||||
if self.seen == self.names:
|
||||
self.seen = set()
|
||||
self.names = None
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,67 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator, Generic, Optional, Sequence, Type
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
|
||||
class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
"""Stores the value received in the step immediately preceding, clears after."""
|
||||
|
||||
def __init__(self, typ: Type[Value], guard: bool = True) -> None:
|
||||
self.typ = typ
|
||||
self.guard = guard
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Type[Value]:
|
||||
"""The type of the value stored in the channel."""
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[Value]:
|
||||
"""The type of the update received by the channel."""
|
||||
return self.typ
|
||||
|
||||
def checkpoint(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[Value] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ, self.guard)
|
||||
if checkpoint is not None:
|
||||
empty.value = checkpoint
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
try:
|
||||
del empty.value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Value]) -> bool:
|
||||
if len(values) == 0:
|
||||
try:
|
||||
del self.value
|
||||
return True
|
||||
except AttributeError:
|
||||
return False
|
||||
if len(values) != 1 and self.guard:
|
||||
raise InvalidUpdateError(
|
||||
"EphemeralValue can only receive one value per step."
|
||||
)
|
||||
|
||||
self.value = values[-1]
|
||||
return True
|
||||
|
||||
def get(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
@@ -0,0 +1,60 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator, Generic, Optional, Sequence, Type
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
|
||||
class LastValue(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
"""Stores the last value received, can receive at most one value per step."""
|
||||
|
||||
def __init__(self, typ: Type[Value]) -> None:
|
||||
self.typ = typ
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Type[Value]:
|
||||
"""The type of the value stored in the channel."""
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[Value]:
|
||||
"""The type of the update received by the channel."""
|
||||
return self.typ
|
||||
|
||||
def checkpoint(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[Value] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ)
|
||||
if checkpoint is not None:
|
||||
empty.value = checkpoint
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
try:
|
||||
del empty.value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Value]) -> bool:
|
||||
if len(values) == 0:
|
||||
return False
|
||||
if len(values) != 1:
|
||||
raise InvalidUpdateError("LastValue can only receive one value per step.")
|
||||
|
||||
self.value = values[-1]
|
||||
return True
|
||||
|
||||
def get(self) -> Value:
|
||||
try:
|
||||
return self.value
|
||||
except AttributeError:
|
||||
raise EmptyChannelError()
|
||||
@@ -0,0 +1,65 @@
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, AsyncGenerator, Generator, Mapping
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.checkpoint.base import Checkpoint
|
||||
from langgraph.checkpoint.id import uuid6
|
||||
from langgraph.errors import EmptyChannelError
|
||||
|
||||
|
||||
@contextmanager
|
||||
def ChannelsManager(
|
||||
channels: Mapping[str, BaseChannel],
|
||||
checkpoint: Checkpoint,
|
||||
) -> Generator[Mapping[str, BaseChannel], None, None]:
|
||||
"""Manage channels for the lifetime of a Pregel invocation (multiple steps)."""
|
||||
# TODO use https://docs.python.org/3/library/contextlib.html#contextlib.ExitStack
|
||||
empty = {
|
||||
k: v.from_checkpoint(checkpoint["channel_values"].get(k))
|
||||
for k, v in channels.items()
|
||||
}
|
||||
try:
|
||||
yield {k: v.__enter__() for k, v in empty.items()}
|
||||
finally:
|
||||
for v in empty.values():
|
||||
v.__exit__(None, None, None)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def AsyncChannelsManager(
|
||||
channels: Mapping[str, BaseChannel],
|
||||
checkpoint: Checkpoint,
|
||||
) -> AsyncGenerator[Mapping[str, BaseChannel], None]:
|
||||
"""Manage channels for the lifetime of a Pregel invocation (multiple steps)."""
|
||||
empty = {
|
||||
k: v.afrom_checkpoint(checkpoint["channel_values"].get(k))
|
||||
for k, v in channels.items()
|
||||
}
|
||||
try:
|
||||
yield {k: await v.__aenter__() for k, v in empty.items()}
|
||||
finally:
|
||||
for v in empty.values():
|
||||
await v.__aexit__(None, None, None)
|
||||
|
||||
|
||||
def create_checkpoint(
|
||||
checkpoint: Checkpoint, channels: Mapping[str, BaseChannel], step: int
|
||||
) -> Checkpoint:
|
||||
"""Create a checkpoint for the given channels."""
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
values: dict[str, Any] = {}
|
||||
for k, v in channels.items():
|
||||
try:
|
||||
values[k] = v.checkpoint()
|
||||
except EmptyChannelError:
|
||||
pass
|
||||
return Checkpoint(
|
||||
v=1,
|
||||
ts=ts,
|
||||
id=str(uuid6(clock_seq=step)),
|
||||
channel_values=values,
|
||||
channel_versions=checkpoint["channel_versions"],
|
||||
versions_seen=checkpoint["versions_seen"],
|
||||
pending_sends=checkpoint.get("pending_sends", []),
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator, Generic, Optional, Sequence, Type
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
|
||||
class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, set[Value]]):
|
||||
"""A channel that waits until all named values are received before making the value available."""
|
||||
|
||||
def __init__(self, typ: Type[Value], names: set[Value]) -> None:
|
||||
self.typ = typ
|
||||
self.names = names
|
||||
self.seen = set()
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Type[Value]:
|
||||
"""The type of the value stored in the channel."""
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[Value]:
|
||||
"""The type of the update received by the channel."""
|
||||
return self.typ
|
||||
|
||||
def checkpoint(self) -> set[Value]:
|
||||
return self.seen
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[set[Value]] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ, self.names)
|
||||
if checkpoint is not None:
|
||||
empty.seen = checkpoint.copy()
|
||||
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Value]) -> bool:
|
||||
updated = False
|
||||
for value in values:
|
||||
if value in self.names:
|
||||
if value not in self.seen:
|
||||
self.seen.add(value)
|
||||
updated = True
|
||||
else:
|
||||
raise InvalidUpdateError(f"Value {value} not in {self.names}")
|
||||
return updated
|
||||
|
||||
def get(self) -> Value:
|
||||
if self.seen != self.names:
|
||||
raise EmptyChannelError()
|
||||
return None
|
||||
|
||||
def consume(self) -> bool:
|
||||
if self.seen == self.names:
|
||||
self.seen = set()
|
||||
return True
|
||||
return False
|
||||
@@ -0,0 +1,87 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Generator, Generic, Iterator, Optional, Sequence, Type, Union
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel, Value
|
||||
from langgraph.errors import EmptyChannelError
|
||||
|
||||
|
||||
def flatten(values: Sequence[Union[Value, list[Value]]]) -> Iterator[Value]:
|
||||
for value in values:
|
||||
if isinstance(value, list):
|
||||
yield from value
|
||||
else:
|
||||
yield value
|
||||
|
||||
|
||||
class Topic(
|
||||
Generic[Value],
|
||||
BaseChannel[
|
||||
Sequence[Value], Union[Value, list[Value]], tuple[set[Value], list[Value]]
|
||||
],
|
||||
):
|
||||
"""A configurable PubSub Topic.
|
||||
|
||||
Args:
|
||||
typ: The type of the value stored in the channel.
|
||||
unique: Whether to discard duplicate values.
|
||||
accumulate: Whether to accumulate values across steps. If False, the channel will be emptied after each step.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, typ: Type[Value], unique: bool = False, accumulate: bool = False
|
||||
) -> None:
|
||||
# attrs
|
||||
self.typ = typ
|
||||
self.unique = unique
|
||||
self.accumulate = accumulate
|
||||
# state
|
||||
self.seen = set[Value]()
|
||||
self.values = list[Value]()
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Any:
|
||||
"""The type of the value stored in the channel."""
|
||||
return Sequence[self.typ] # type: ignore[name-defined]
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Any:
|
||||
"""The type of the update received by the channel."""
|
||||
return Union[self.typ, list[self.typ]] # type: ignore[name-defined]
|
||||
|
||||
def checkpoint(self) -> tuple[set[Value], list[Value]]:
|
||||
return (self.seen, self.values)
|
||||
|
||||
@contextmanager
|
||||
def from_checkpoint(
|
||||
self, checkpoint: Optional[tuple[set[Value], list[Value]]] = None
|
||||
) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ, self.unique, self.accumulate)
|
||||
if checkpoint is not None:
|
||||
empty.seen = checkpoint[0].copy()
|
||||
empty.values = checkpoint[1].copy()
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Union[Value, list[Value]]]) -> None:
|
||||
current = list(self.values)
|
||||
if not self.accumulate:
|
||||
self.values = list[Value]()
|
||||
if flat_values := flatten(values):
|
||||
if self.unique:
|
||||
for value in flat_values:
|
||||
if value not in self.seen:
|
||||
self.seen.add(value)
|
||||
self.values.append(value)
|
||||
else:
|
||||
self.values.extend(flat_values)
|
||||
return self.values != current
|
||||
|
||||
def get(self) -> Sequence[Value]:
|
||||
if self.values:
|
||||
return list(self.values)
|
||||
else:
|
||||
raise EmptyChannelError
|
||||
@@ -0,0 +1,13 @@
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
SerializerProtocol,
|
||||
)
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
__all__ = [
|
||||
"BaseCheckpointSaver",
|
||||
"Checkpoint",
|
||||
"MemorySaver",
|
||||
"SerializerProtocol",
|
||||
]
|
||||
@@ -0,0 +1,343 @@
|
||||
import asyncio
|
||||
import functools
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from types import TracebackType
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, Optional, TypeVar
|
||||
|
||||
import aiosqlite
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
SerializerProtocol,
|
||||
)
|
||||
from langgraph.checkpoint.sqlite import JsonPlusSerializerCompat, search_where
|
||||
|
||||
T = TypeVar("T", bound=callable)
|
||||
|
||||
|
||||
def not_implemented_sync_method(func: T) -> T:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
raise NotImplementedError(
|
||||
"The AsyncSqliteSaver does not support synchronous methods. "
|
||||
"Consider using the SqliteSaver instead.\n"
|
||||
"from langgraph.checkpoint.sqlite import SqliteSaver\n"
|
||||
"See https://langchain-ai.github.io/langgraph/reference/checkpoints/#sqlitesaver "
|
||||
"for more information."
|
||||
)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager):
|
||||
"""An asynchronous checkpoint saver that stores checkpoints in a SQLite database.
|
||||
|
||||
Tip:
|
||||
Requires the [aiosqlite](https://pypi.org/project/aiosqlite/) package.
|
||||
Install it with `pip install aiosqlite`.
|
||||
|
||||
Note:
|
||||
While this class does support asynchronous checkpointing, it is not recommended
|
||||
for production workloads, due to limitations in SQLite's write performance. For
|
||||
production workloads, consider using a more robust database like PostgreSQL.
|
||||
|
||||
Args:
|
||||
conn (aiosqlite.Connection): The asynchronous SQLite database connection.
|
||||
serde (Optional[SerializerProtocol]): The serializer to use for serializing and deserializing checkpoints. Defaults to JsonPlusSerializerCompat.
|
||||
|
||||
Examples:
|
||||
Usage within a StateGraph:
|
||||
```pycon
|
||||
>>> import asyncio
|
||||
>>> import aiosqlite
|
||||
>>>
|
||||
>>> from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver
|
||||
>>> from langgraph.graph import StateGraph
|
||||
>>>
|
||||
>>> builder = StateGraph(int)
|
||||
>>> builder.add_node("add_one", lambda x: x + 1)
|
||||
>>> builder.set_entry_point("add_one")
|
||||
>>> builder.set_finish_point("add_one")
|
||||
>>> memory = AsyncSqliteSaver.from_conn_string("checkpoints.sqlite")
|
||||
>>> graph = builder.compile(checkpointer=memory)
|
||||
>>> coro = graph.ainvoke(1, {"configurable": {"thread_id": "thread-1"}})
|
||||
>>> asyncio.run(coro)
|
||||
Output: 2
|
||||
```
|
||||
|
||||
Raw usage:
|
||||
```pycon
|
||||
>>> import asyncio
|
||||
>>> import aiosqlite
|
||||
>>> from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver
|
||||
>>>
|
||||
>>> async def main():
|
||||
>>> async with aiosqlite.connect("checkpoints.db") as conn:
|
||||
... saver = AsyncSqliteSaver(conn)
|
||||
... config = {"configurable": {"thread_id": "1"}}
|
||||
... checkpoint = {"ts": "2023-05-03T10:00:00Z", "data": {"key": "value"}}
|
||||
... saved_config = await saver.aput(config, checkpoint)
|
||||
... print(saved_config)
|
||||
>>> asyncio.run(main())
|
||||
{"configurable": {"thread_id": "1", "thread_ts": "2023-05-03T10:00:00Z"}}
|
||||
```
|
||||
"""
|
||||
|
||||
serde = JsonPlusSerializerCompat()
|
||||
|
||||
conn: aiosqlite.Connection
|
||||
lock: asyncio.Lock
|
||||
is_setup: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: aiosqlite.Connection,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
):
|
||||
super().__init__(serde=serde)
|
||||
self.conn = conn
|
||||
self.lock = asyncio.Lock()
|
||||
self.is_setup = False
|
||||
|
||||
@classmethod
|
||||
def from_conn_string(cls, conn_string: str) -> "AsyncSqliteSaver":
|
||||
"""Create a new AsyncSqliteSaver instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The SQLite connection string.
|
||||
|
||||
Returns:
|
||||
AsyncSqliteSaver: A new AsyncSqliteSaver instance.
|
||||
"""
|
||||
return AsyncSqliteSaver(conn=aiosqlite.connect(conn_string))
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
__exc_type: Optional[type[BaseException]],
|
||||
__exc_value: Optional[BaseException],
|
||||
__traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
if self.is_setup:
|
||||
return await self.conn.close()
|
||||
|
||||
@not_implemented_sync_method
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database.
|
||||
|
||||
Note:
|
||||
This method is not implemented for the AsyncSqliteSaver. Use `aget` instead.
|
||||
Or consider using the [SqliteSaver](#sqlitesaver) checkpointer.
|
||||
"""
|
||||
|
||||
@not_implemented_sync_method
|
||||
def list(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[CheckpointTuple]:
|
||||
"""List checkpoints from the database.
|
||||
|
||||
Note:
|
||||
This method is not implemented for the AsyncSqliteSaver. Use `alist` instead.
|
||||
Or consider using the [SqliteSaver](#sqlitesaver) checkpointer.
|
||||
"""
|
||||
|
||||
@not_implemented_sync_method
|
||||
def put(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the database. FOO"""
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""Set up the checkpoint database asynchronously.
|
||||
|
||||
This method creates the necessary tables in the SQLite database if they don't
|
||||
already exist. It is called automatically when needed and should not be called
|
||||
directly by the user.
|
||||
"""
|
||||
async with self.lock:
|
||||
if self.is_setup:
|
||||
return
|
||||
if not self.conn.is_alive():
|
||||
await self.conn
|
||||
async with self.conn.executescript(
|
||||
"""
|
||||
PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
thread_ts TEXT NOT NULL,
|
||||
parent_ts TEXT,
|
||||
checkpoint BLOB,
|
||||
metadata BLOB,
|
||||
PRIMARY KEY (thread_id, thread_ts)
|
||||
);
|
||||
"""
|
||||
):
|
||||
await self.conn.commit()
|
||||
|
||||
self.is_setup = True
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database asynchronously.
|
||||
|
||||
This method retrieves a checkpoint tuple from the SQLite database based on the
|
||||
provided config. If the config contains a "thread_ts" key, the checkpoint with
|
||||
the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint
|
||||
for the given thread ID is retrieved.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for retrieving the checkpoint.
|
||||
|
||||
Returns:
|
||||
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
|
||||
"""
|
||||
await self.setup()
|
||||
if config["configurable"].get("thread_ts"):
|
||||
async with self.conn.execute(
|
||||
"SELECT checkpoint, parent_ts, metadata FROM checkpoints WHERE thread_id = ? AND thread_ts = ?",
|
||||
(
|
||||
str(config["configurable"]["thread_id"]),
|
||||
str(config["configurable"]["thread_ts"]),
|
||||
),
|
||||
) as cursor:
|
||||
if value := await cursor.fetchone():
|
||||
return CheckpointTuple(
|
||||
config,
|
||||
self.serde.loads(value[0]),
|
||||
self.serde.loads(value[2]) if value[2] is not None else {},
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": config["configurable"]["thread_id"],
|
||||
"thread_ts": value[1],
|
||||
}
|
||||
}
|
||||
if value[1]
|
||||
else None
|
||||
),
|
||||
)
|
||||
else:
|
||||
async with self.conn.execute(
|
||||
"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints WHERE thread_id = ? ORDER BY thread_ts DESC LIMIT 1",
|
||||
(str(config["configurable"]["thread_id"]),),
|
||||
) as cursor:
|
||||
if value := await cursor.fetchone():
|
||||
return CheckpointTuple(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": value[0],
|
||||
"thread_ts": value[1],
|
||||
}
|
||||
},
|
||||
self.serde.loads(value[3]),
|
||||
self.serde.loads(value[4]) if value[4] is not None else {},
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": value[0],
|
||||
"thread_ts": value[2],
|
||||
}
|
||||
}
|
||||
if value[2]
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
async def alist(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[CheckpointTuple]:
|
||||
"""List checkpoints from the database asynchronously.
|
||||
|
||||
This method retrieves a list of checkpoint tuples from the SQLite database based
|
||||
on the provided config. The checkpoints are ordered by timestamp in descending order.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for listing the checkpoints.
|
||||
before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None.
|
||||
limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None.
|
||||
|
||||
Yields:
|
||||
AsyncIterator[CheckpointTuple]: An asynchronous iterator of checkpoint tuples.
|
||||
"""
|
||||
await self.setup()
|
||||
where, param_values = search_where(config, filter, before)
|
||||
query = f"""SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata
|
||||
FROM checkpoints
|
||||
{where}
|
||||
ORDER BY thread_ts DESC"""
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
async with self.conn.execute(query, param_values) as cursor:
|
||||
async for thread_id, thread_ts, parent_ts, value, metadata in cursor:
|
||||
yield CheckpointTuple(
|
||||
{"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}},
|
||||
self.serde.loads(value),
|
||||
self.serde.loads(metadata) if metadata is not None else {},
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"thread_ts": parent_ts,
|
||||
}
|
||||
}
|
||||
if parent_ts
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
async def aput(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the database asynchronously.
|
||||
|
||||
This method saves a checkpoint to the SQLite database. The checkpoint is associated
|
||||
with the provided config and its parent config (if any).
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to associate with the checkpoint.
|
||||
checkpoint (Checkpoint): The checkpoint to save.
|
||||
|
||||
Returns:
|
||||
RunnableConfig: The updated config containing the saved checkpoint's timestamp.
|
||||
"""
|
||||
await self.setup()
|
||||
async with self.conn.execute(
|
||||
"INSERT OR REPLACE INTO checkpoints (thread_id, thread_ts, parent_ts, checkpoint, metadata) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
str(config["configurable"]["thread_id"]),
|
||||
checkpoint["id"],
|
||||
config["configurable"].get("thread_ts"),
|
||||
self.serde.dumps(checkpoint),
|
||||
self.serde.dumps(metadata),
|
||||
),
|
||||
):
|
||||
await self.conn.commit()
|
||||
return {
|
||||
"configurable": {
|
||||
"thread_id": config["configurable"]["thread_id"],
|
||||
"thread_ts": checkpoint["id"],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
from abc import ABC
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
from langchain_core.runnables import ConfigurableFieldSpec, RunnableConfig
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.checkpoint.id import uuid6
|
||||
from langgraph.constants import Send
|
||||
from langgraph.serde.base import SerializerProtocol
|
||||
from langgraph.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
V = TypeVar("V", int, float, str)
|
||||
|
||||
|
||||
# Marked as total=False to allow for future expansion.
|
||||
class CheckpointMetadata(TypedDict, total=False):
|
||||
source: Literal["input", "loop", "update"]
|
||||
"""The source of the checkpoint.
|
||||
- "input": The checkpoint was created from an input to invoke/stream/batch.
|
||||
- "loop": The checkpoint was created from inside the pregel loop.
|
||||
- "update": The checkpoint was created from a manual state update.
|
||||
"""
|
||||
step: int
|
||||
"""The step number of the checkpoint.
|
||||
-1 for the first "input" checkpoint.
|
||||
0 for the first "loop" checkpoint.
|
||||
... for the nth checkpoint afterwards.
|
||||
"""
|
||||
writes: dict[str, Any]
|
||||
"""The writes that were made between the previous checkpoint and this one.
|
||||
|
||||
Mapping from node name to writes emitted by that node.
|
||||
"""
|
||||
score: Optional[int]
|
||||
"""The score of the checkpoint.
|
||||
|
||||
The score can be used to mark a checkpoint as "good".
|
||||
"""
|
||||
|
||||
|
||||
class Checkpoint(TypedDict):
|
||||
"""State snapshot at a given point in time."""
|
||||
|
||||
v: int
|
||||
"""The version of the checkpoint format. Currently 1."""
|
||||
id: str
|
||||
"""The ID of the checkpoint. This is both unique and monotonically
|
||||
increasing, so can be used for sorting checkpoints from first to last."""
|
||||
ts: str
|
||||
"""The timestamp of the checkpoint in ISO 8601 format."""
|
||||
channel_values: dict[str, Any]
|
||||
"""The values of the channels at the time of the checkpoint.
|
||||
|
||||
Mapping from channel name to channel snapshot value.
|
||||
"""
|
||||
channel_versions: dict[str, Union[str, int, float]]
|
||||
"""The versions of the channels at the time of the checkpoint.
|
||||
|
||||
The keys are channel names and the values are the logical time step
|
||||
at which the channel was last updated.
|
||||
"""
|
||||
versions_seen: defaultdict[str, dict[str, Union[str, int, float]]]
|
||||
"""Map from node ID to map from channel name to version seen.
|
||||
|
||||
This keeps track of the versions of the channels that each node has seen.
|
||||
|
||||
Used to determine which nodes to execute next.
|
||||
"""
|
||||
pending_sends: List[Send]
|
||||
"""List of packets sent to nodes but not yet processed.
|
||||
Cleared by the next checkpoint."""
|
||||
|
||||
|
||||
def empty_checkpoint() -> Checkpoint:
|
||||
return Checkpoint(
|
||||
v=1,
|
||||
id=str(uuid6(clock_seq=-2)),
|
||||
ts=datetime.now(timezone.utc).isoformat(),
|
||||
channel_values={},
|
||||
channel_versions={},
|
||||
versions_seen=defaultdict(dict),
|
||||
pending_sends=[],
|
||||
)
|
||||
|
||||
|
||||
def copy_checkpoint(checkpoint: Checkpoint) -> Checkpoint:
|
||||
return Checkpoint(
|
||||
v=checkpoint["v"],
|
||||
ts=checkpoint["ts"],
|
||||
id=checkpoint["id"],
|
||||
channel_values=checkpoint["channel_values"].copy(),
|
||||
channel_versions=checkpoint["channel_versions"].copy(),
|
||||
versions_seen=defaultdict(
|
||||
dict,
|
||||
{k: v.copy() for k, v in checkpoint["versions_seen"].items()},
|
||||
),
|
||||
pending_sends=checkpoint.get("pending_sends", []).copy(),
|
||||
)
|
||||
|
||||
|
||||
class CheckpointTuple(NamedTuple):
|
||||
config: RunnableConfig
|
||||
checkpoint: Checkpoint
|
||||
metadata: CheckpointMetadata
|
||||
parent_config: Optional[RunnableConfig] = None
|
||||
|
||||
|
||||
CheckpointThreadId = ConfigurableFieldSpec(
|
||||
id="thread_id",
|
||||
annotation=str,
|
||||
name="Thread ID",
|
||||
description=None,
|
||||
default="",
|
||||
is_shared=True,
|
||||
)
|
||||
|
||||
CheckpointThreadTs = ConfigurableFieldSpec(
|
||||
id="thread_ts",
|
||||
annotation=Optional[str],
|
||||
name="Thread Timestamp",
|
||||
description="Pass to fetch a past checkpoint. If None, fetches the latest checkpoint.",
|
||||
default=None,
|
||||
is_shared=True,
|
||||
)
|
||||
|
||||
|
||||
class BaseCheckpointSaver(ABC):
|
||||
serde: SerializerProtocol = JsonPlusSerializer()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
self.serde = serde or self.serde
|
||||
|
||||
@property
|
||||
def config_specs(self) -> list[ConfigurableFieldSpec]:
|
||||
return [CheckpointThreadId, CheckpointThreadTs]
|
||||
|
||||
def get(self, config: RunnableConfig) -> Optional[Checkpoint]:
|
||||
if value := self.get_tuple(config):
|
||||
return value.checkpoint
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
raise NotImplementedError
|
||||
|
||||
def list(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[CheckpointTuple]:
|
||||
raise NotImplementedError
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
async def aget(self, config: RunnableConfig) -> Optional[Checkpoint]:
|
||||
if value := await self.aget_tuple(config):
|
||||
return value.checkpoint
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
raise NotImplementedError
|
||||
|
||||
def alist(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[CheckpointTuple]:
|
||||
raise NotImplementedError
|
||||
yield
|
||||
|
||||
async def aput(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_next_version(self, current: Optional[V], channel: BaseChannel) -> V:
|
||||
"""Get the next version of a channel. Default is to use int versions, incrementing by 1. If you override, you can use str/int/float versions,
|
||||
as long as they are monotonically increasing."""
|
||||
return current + 1 if current is not None else 1
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Adapted from
|
||||
https://github.com/oittaa/uuid6-python/blob/main/src/uuid6/__init__.py#L95
|
||||
Bundled in to avoid install issues with uuid6 package
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional, Tuple
|
||||
|
||||
_last_v6_timestamp = None
|
||||
|
||||
|
||||
class UUID(uuid.UUID):
|
||||
r"""UUID draft version objects"""
|
||||
|
||||
__slots__ = ()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hex: Optional[str] = None,
|
||||
bytes: Optional[bytes] = None,
|
||||
bytes_le: Optional[bytes] = None,
|
||||
fields: Optional[Tuple[int, int, int, int, int, int]] = None,
|
||||
int: Optional[int] = None,
|
||||
version: Optional[int] = None,
|
||||
*,
|
||||
is_safe: uuid.SafeUUID = uuid.SafeUUID.unknown,
|
||||
) -> None:
|
||||
r"""Create a UUID."""
|
||||
|
||||
if int is None or [hex, bytes, bytes_le, fields].count(None) != 4:
|
||||
return super().__init__(
|
||||
hex=hex,
|
||||
bytes=bytes,
|
||||
bytes_le=bytes_le,
|
||||
fields=fields,
|
||||
int=int,
|
||||
version=version,
|
||||
is_safe=is_safe,
|
||||
)
|
||||
if not 0 <= int < 1 << 128:
|
||||
raise ValueError("int is out of range (need a 128-bit value)")
|
||||
if version is not None:
|
||||
if not 6 <= version <= 8:
|
||||
raise ValueError("illegal version number")
|
||||
# Set the variant to RFC 4122.
|
||||
int &= ~(0xC000 << 48)
|
||||
int |= 0x8000 << 48
|
||||
# Set the version number.
|
||||
int &= ~(0xF000 << 64)
|
||||
int |= version << 76
|
||||
super().__init__(int=int, is_safe=is_safe)
|
||||
|
||||
@property
|
||||
def subsec(self) -> int:
|
||||
return ((self.int >> 64) & 0x0FFF) << 8 | ((self.int >> 54) & 0xFF)
|
||||
|
||||
@property
|
||||
def time(self) -> int:
|
||||
if self.version == 6:
|
||||
return (
|
||||
(self.time_low << 28)
|
||||
| (self.time_mid << 12)
|
||||
| (self.time_hi_version & 0x0FFF)
|
||||
)
|
||||
if self.version == 7:
|
||||
return self.int >> 80
|
||||
if self.version == 8:
|
||||
return (self.int >> 80) * 10**6 + _subsec_decode(self.subsec)
|
||||
return super().time
|
||||
|
||||
|
||||
def _subsec_decode(value: int) -> int:
|
||||
return -(-value * 10**6 // 2**20)
|
||||
|
||||
|
||||
def uuid6(node: Optional[int] = None, clock_seq: Optional[int] = None) -> UUID:
|
||||
r"""UUID version 6 is a field-compatible version of UUIDv1, reordered for
|
||||
improved DB locality. It is expected that UUIDv6 will primarily be
|
||||
used in contexts where there are existing v1 UUIDs. Systems that do
|
||||
not involve legacy UUIDv1 SHOULD consider using UUIDv7 instead.
|
||||
|
||||
If 'node' is not given, a random 48-bit number is chosen.
|
||||
|
||||
If 'clock_seq' is given, it is used as the sequence number;
|
||||
otherwise a random 14-bit sequence number is chosen."""
|
||||
|
||||
global _last_v6_timestamp
|
||||
|
||||
nanoseconds = time.time_ns()
|
||||
# 0x01b21dd213814000 is the number of 100-ns intervals between the
|
||||
# UUID epoch 1582-10-15 00:00:00 and the Unix epoch 1970-01-01 00:00:00.
|
||||
timestamp = nanoseconds // 100 + 0x01B21DD213814000
|
||||
if _last_v6_timestamp is not None and timestamp <= _last_v6_timestamp:
|
||||
timestamp = _last_v6_timestamp + 1
|
||||
_last_v6_timestamp = timestamp
|
||||
if clock_seq is None:
|
||||
clock_seq = secrets.randbits(14) # instead of stable storage
|
||||
if node is None:
|
||||
node = secrets.randbits(48)
|
||||
time_high_and_time_mid = (timestamp >> 12) & 0xFFFFFFFFFFFF
|
||||
time_low_and_version = timestamp & 0x0FFF
|
||||
uuid_int = time_high_and_time_mid << 80
|
||||
uuid_int |= time_low_and_version << 64
|
||||
uuid_int |= (clock_seq & 0x3FFF) << 48
|
||||
uuid_int |= node & 0xFFFFFFFFFFFF
|
||||
return UUID(int=uuid_int, version=6)
|
||||
@@ -0,0 +1,226 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, Optional
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
SerializerProtocol,
|
||||
)
|
||||
|
||||
|
||||
class MemorySaver(BaseCheckpointSaver):
|
||||
"""An in-memory checkpoint saver.
|
||||
|
||||
This checkpoint saver stores checkpoints in memory using a defaultdict.
|
||||
|
||||
Note:
|
||||
Since checkpoints are saved in memory, they will be lost when the program exits.
|
||||
Only use this saver for debugging or testing purposes.
|
||||
|
||||
Args:
|
||||
serde (Optional[SerializerProtocol]): The serializer to use for serializing and deserializing checkpoints. Defaults to None.
|
||||
|
||||
Examples:
|
||||
|
||||
import asyncio
|
||||
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.graph import StateGraph
|
||||
|
||||
builder = StateGraph(int)
|
||||
builder.add_node("add_one", lambda x: x + 1)
|
||||
builder.set_entry_point("add_one")
|
||||
builder.set_finish_point("add_one")
|
||||
|
||||
memory = MemorySaver()
|
||||
graph = builder.compile(checkpointer=memory)
|
||||
coro = graph.ainvoke(1, {"configurable": {"thread_id": "thread-1"}})
|
||||
asyncio.run(coro) # Output: 2
|
||||
"""
|
||||
|
||||
storage: defaultdict[str, dict[str, tuple[bytes, bytes]]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
super().__init__(serde=serde)
|
||||
self.storage = defaultdict(dict)
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the in-memory storage.
|
||||
|
||||
This method retrieves a checkpoint tuple from the in-memory storage based on the
|
||||
provided config. If the config contains a "thread_ts" key, the checkpoint with
|
||||
the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint
|
||||
for the given thread ID is retrieved.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for retrieving the checkpoint.
|
||||
|
||||
Returns:
|
||||
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
|
||||
"""
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
if ts := config["configurable"].get("thread_ts"):
|
||||
if saved := self.storage[thread_id].get(ts):
|
||||
checkpoint, metadata = saved
|
||||
return CheckpointTuple(
|
||||
config=config,
|
||||
checkpoint=self.serde.loads(checkpoint),
|
||||
metadata=self.serde.loads(metadata),
|
||||
)
|
||||
else:
|
||||
if checkpoints := self.storage[thread_id]:
|
||||
ts = max(checkpoints.keys())
|
||||
checkpoint, metadata = checkpoints[ts]
|
||||
return CheckpointTuple(
|
||||
config={"configurable": {"thread_id": thread_id, "thread_ts": ts}},
|
||||
checkpoint=self.serde.loads(checkpoint),
|
||||
metadata=self.serde.loads(metadata),
|
||||
)
|
||||
|
||||
def list(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[CheckpointTuple]:
|
||||
"""List checkpoints from the in-memory storage.
|
||||
|
||||
This method retrieves a list of checkpoint tuples from the in-memory storage based
|
||||
on the provided config. The checkpoints are ordered by timestamp in descending order.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for listing the checkpoints.
|
||||
before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None.
|
||||
limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None.
|
||||
|
||||
Yields:
|
||||
Iterator[CheckpointTuple]: An iterator of checkpoint tuples.
|
||||
"""
|
||||
thread_ids = (config["configurable"]["thread_id"],) if config else self.storage
|
||||
for thread_id in thread_ids:
|
||||
for ts, (checkpoint, metadata_b) in self.storage[thread_id].items():
|
||||
# filter by thread_ts
|
||||
if before and ts >= before["configurable"]["thread_ts"]:
|
||||
continue
|
||||
|
||||
# filter by metadata
|
||||
metadata = self.serde.loads(metadata_b)
|
||||
if filter and not all(
|
||||
query_value == metadata[query_key]
|
||||
for query_key, query_value in filter.items()
|
||||
):
|
||||
continue
|
||||
|
||||
# limit search results
|
||||
if limit is not None and limit <= 0:
|
||||
break
|
||||
elif limit is not None:
|
||||
limit -= 1
|
||||
|
||||
yield CheckpointTuple(
|
||||
config={"configurable": {"thread_id": thread_id, "thread_ts": ts}},
|
||||
checkpoint=self.serde.loads(checkpoint),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the in-memory storage.
|
||||
|
||||
This method saves a checkpoint to the in-memory storage. The checkpoint is associated
|
||||
with the provided config.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to associate with the checkpoint.
|
||||
checkpoint (Checkpoint): The checkpoint to save.
|
||||
|
||||
Returns:
|
||||
RunnableConfig: The updated config containing the saved checkpoint's timestamp.
|
||||
"""
|
||||
self.storage[config["configurable"]["thread_id"]].update(
|
||||
{
|
||||
checkpoint["id"]: (
|
||||
self.serde.dumps(checkpoint),
|
||||
self.serde.dumps(metadata),
|
||||
)
|
||||
}
|
||||
)
|
||||
return {
|
||||
"configurable": {
|
||||
"thread_id": config["configurable"]["thread_id"],
|
||||
"thread_ts": checkpoint["id"],
|
||||
}
|
||||
}
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Asynchronous version of get_tuple.
|
||||
|
||||
This method is an asynchronous wrapper around get_tuple that runs the synchronous
|
||||
method in a separate thread using asyncio.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for retrieving the checkpoint.
|
||||
|
||||
Returns:
|
||||
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
|
||||
"""
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, self.get_tuple, config
|
||||
)
|
||||
|
||||
async def alist(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[CheckpointTuple]:
|
||||
"""Asynchronous version of list.
|
||||
|
||||
This method is an asynchronous wrapper around list that runs the synchronous
|
||||
method in a separate thread using asyncio.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for listing the checkpoints.
|
||||
|
||||
Yields:
|
||||
AsyncIterator[CheckpointTuple]: An asynchronous iterator of checkpoint tuples.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
iter = await loop.run_in_executor(
|
||||
None, partial(self.list, before=before, limit=limit, filter=filter), config
|
||||
)
|
||||
while True:
|
||||
# handling StopIteration exception inside coroutine won't work
|
||||
# as expected, so using next() with default value to break the loop
|
||||
if item := await loop.run_in_executor(None, next, iter, None):
|
||||
yield item
|
||||
else:
|
||||
break
|
||||
|
||||
async def aput(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, self.put, config, checkpoint, metadata
|
||||
)
|
||||
@@ -0,0 +1,526 @@
|
||||
import json
|
||||
import pickle
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from hashlib import md5
|
||||
from types import TracebackType
|
||||
from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Tuple
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.checkpoint.base import (
|
||||
BaseCheckpointSaver,
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
CheckpointTuple,
|
||||
SerializerProtocol,
|
||||
)
|
||||
from langgraph.errors import EmptyChannelError
|
||||
from langgraph.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
|
||||
class JsonPlusSerializerCompat(JsonPlusSerializer):
|
||||
"""A serializer that supports loading pickled checkpoints for backwards compatibility.
|
||||
|
||||
This serializer extends the JsonPlusSerializer and adds support for loading pickled
|
||||
checkpoints. If the input data starts with b"\x80" and ends with b".", it is treated
|
||||
as a pickled checkpoint and loaded using pickle.loads(). Otherwise, the default
|
||||
JsonPlusSerializer behavior is used.
|
||||
|
||||
Examples:
|
||||
>>> import pickle
|
||||
>>> from langgraph.checkpoint.sqlite import JsonPlusSerializerCompat
|
||||
>>>
|
||||
>>> serializer = JsonPlusSerializerCompat()
|
||||
>>> pickled_data = pickle.dumps({"key": "value"})
|
||||
>>> loaded_data = serializer.loads(pickled_data)
|
||||
>>> print(loaded_data) # Output: {"key": "value"}
|
||||
>>>
|
||||
>>> json_data = '{"key": "value"}'.encode("utf-8")
|
||||
>>> loaded_data = serializer.loads(json_data)
|
||||
>>> print(loaded_data) # Output: {"key": "value"}
|
||||
"""
|
||||
|
||||
def loads(self, data: bytes) -> Any:
|
||||
if data.startswith(b"\x80") and data.endswith(b"."):
|
||||
return pickle.loads(data)
|
||||
return super().loads(data)
|
||||
|
||||
|
||||
_AIO_ERROR_MSG = (
|
||||
"The SqliteSaver does not support async methods. "
|
||||
"Consider using AsyncSqliteSaver instead.\n"
|
||||
"from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver\n"
|
||||
"Note: AsyncSqliteSaver requires the aiosqlite package to use.\n"
|
||||
"Install with:\n`pip install aiosqlite`\n"
|
||||
"See https://langchain-ai.github.io/langgraph/reference/checkpoints/#asyncsqlitesaver"
|
||||
"for more information."
|
||||
)
|
||||
|
||||
|
||||
class SqliteSaver(BaseCheckpointSaver, AbstractContextManager):
|
||||
"""A checkpoint saver that stores checkpoints in a SQLite database.
|
||||
|
||||
Note:
|
||||
This class is meant for lightweight, synchronous use cases
|
||||
(demos and small projects) and does not
|
||||
scale to multiple threads.
|
||||
For a similar sqlite saver with `async` support,
|
||||
consider using AsyncSqliteSaver.
|
||||
|
||||
Args:
|
||||
conn (sqlite3.Connection): The SQLite database connection.
|
||||
serde (Optional[SerializerProtocol]): The serializer to use for serializing and deserializing checkpoints. Defaults to JsonPlusSerializerCompat.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> import sqlite3
|
||||
>>> from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
>>> from langgraph.graph import StateGraph
|
||||
>>>
|
||||
>>> builder = StateGraph(int)
|
||||
>>> builder.add_node("add_one", lambda x: x + 1)
|
||||
>>> builder.set_entry_point("add_one")
|
||||
>>> builder.set_finish_point("add_one")
|
||||
>>> conn = sqlite3.connect("checkpoints.sqlite")
|
||||
>>> memory = SqliteSaver(conn)
|
||||
>>> graph = builder.compile(checkpointer=memory)
|
||||
>>> config = {"configurable": {"thread_id": "1"}}
|
||||
>>> graph.get_state(config)
|
||||
>>> result = graph.invoke(3, config)
|
||||
>>> graph.get_state(config)
|
||||
StateSnapshot(values=4, next=(), config={'configurable': {'thread_id': '1', 'thread_ts': '2024-05-04T06:32:42.235444+00:00'}}, parent_config=None)
|
||||
""" # noqa
|
||||
|
||||
serde = JsonPlusSerializerCompat()
|
||||
|
||||
conn: sqlite3.Connection
|
||||
is_setup: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
super().__init__(serde=serde)
|
||||
self.conn = conn
|
||||
self.is_setup = False
|
||||
self.lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def from_conn_string(cls, conn_string: str) -> "SqliteSaver":
|
||||
"""Create a new SqliteSaver instance from a connection string.
|
||||
|
||||
Args:
|
||||
conn_string (str): The SQLite connection string.
|
||||
|
||||
Returns:
|
||||
SqliteSaver: A new SqliteSaver instance.
|
||||
|
||||
Examples:
|
||||
|
||||
In memory:
|
||||
|
||||
memory = SqliteSaver.from_conn_string(":memory:")
|
||||
|
||||
To disk:
|
||||
|
||||
memory = SqliteSaver.from_conn_string("checkpoints.sqlite")
|
||||
"""
|
||||
return SqliteSaver(
|
||||
conn=sqlite3.connect(
|
||||
conn_string,
|
||||
# https://ricardoanderegg.com/posts/python-sqlite-thread-safety/
|
||||
check_same_thread=False,
|
||||
)
|
||||
)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
__exc_type: Optional[type[BaseException]],
|
||||
__exc_value: Optional[BaseException],
|
||||
__traceback: Optional[TracebackType],
|
||||
) -> Optional[bool]:
|
||||
return self.conn.close()
|
||||
|
||||
def setup(self) -> None:
|
||||
"""Set up the checkpoint database.
|
||||
|
||||
This method creates the necessary tables in the SQLite database if they don't
|
||||
already exist. It is called automatically when needed and should not be called
|
||||
directly by the user.
|
||||
"""
|
||||
if self.is_setup:
|
||||
return
|
||||
|
||||
self.conn.executescript(
|
||||
"""
|
||||
PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
thread_id TEXT NOT NULL,
|
||||
thread_ts TEXT NOT NULL,
|
||||
parent_ts TEXT,
|
||||
checkpoint BLOB,
|
||||
metadata BLOB,
|
||||
PRIMARY KEY (thread_id, thread_ts)
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
self.is_setup = True
|
||||
|
||||
@contextmanager
|
||||
def cursor(self, transaction: bool = True) -> Iterator[sqlite3.Cursor]:
|
||||
"""Get a cursor for the SQLite database.
|
||||
|
||||
This method returns a cursor for the SQLite database. It is used internally
|
||||
by the SqliteSaver and should not be called directly by the user.
|
||||
|
||||
Args:
|
||||
transaction (bool): Whether to commit the transaction when the cursor is closed. Defaults to True.
|
||||
|
||||
Yields:
|
||||
sqlite3.Cursor: A cursor for the SQLite database.
|
||||
"""
|
||||
self.setup()
|
||||
cur = self.conn.cursor()
|
||||
try:
|
||||
yield cur
|
||||
finally:
|
||||
if transaction:
|
||||
self.conn.commit()
|
||||
cur.close()
|
||||
|
||||
def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database.
|
||||
|
||||
This method retrieves a checkpoint tuple from the SQLite database based on the
|
||||
provided config. If the config contains a "thread_ts" key, the checkpoint with
|
||||
the matching thread ID and timestamp is retrieved. Otherwise, the latest checkpoint
|
||||
for the given thread ID is retrieved.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for retrieving the checkpoint.
|
||||
|
||||
Returns:
|
||||
Optional[CheckpointTuple]: The retrieved checkpoint tuple, or None if no matching checkpoint was found.
|
||||
|
||||
Examples:
|
||||
|
||||
Basic:
|
||||
>>> config = {"configurable": {"thread_id": "1"}}
|
||||
>>> checkpoint_tuple = memory.get_tuple(config)
|
||||
>>> print(checkpoint_tuple)
|
||||
CheckpointTuple(...)
|
||||
|
||||
With timestamp:
|
||||
|
||||
>>> config = {
|
||||
... "configurable": {
|
||||
... "thread_id": "1",
|
||||
... "thread_ts": "2024-05-04T06:32:42.235444+00:00",
|
||||
... }
|
||||
... }
|
||||
>>> checkpoint_tuple = memory.get_tuple(config)
|
||||
>>> print(checkpoint_tuple)
|
||||
CheckpointTuple(...)
|
||||
""" # noqa
|
||||
with self.cursor(transaction=False) as cur:
|
||||
if config["configurable"].get("thread_ts"):
|
||||
cur.execute(
|
||||
"SELECT checkpoint, parent_ts, metadata FROM checkpoints WHERE thread_id = ? AND thread_ts = ?",
|
||||
(
|
||||
str(config["configurable"]["thread_id"]),
|
||||
str(config["configurable"]["thread_ts"]),
|
||||
),
|
||||
)
|
||||
if value := cur.fetchone():
|
||||
return CheckpointTuple(
|
||||
config,
|
||||
self.serde.loads(value[0]),
|
||||
self.serde.loads(value[2]) if value[2] is not None else {},
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": config["configurable"]["thread_id"],
|
||||
"thread_ts": value[1],
|
||||
}
|
||||
}
|
||||
if value[1]
|
||||
else None
|
||||
),
|
||||
)
|
||||
else:
|
||||
cur.execute(
|
||||
"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints WHERE thread_id = ? ORDER BY thread_ts DESC LIMIT 1",
|
||||
(str(config["configurable"]["thread_id"]),),
|
||||
)
|
||||
if value := cur.fetchone():
|
||||
return CheckpointTuple(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": value[0],
|
||||
"thread_ts": value[1],
|
||||
}
|
||||
},
|
||||
self.serde.loads(value[3]),
|
||||
self.serde.loads(value[4]) if value[4] is not None else {},
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": value[0],
|
||||
"thread_ts": value[2],
|
||||
}
|
||||
}
|
||||
if value[2]
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def list(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> Iterator[CheckpointTuple]:
|
||||
"""List checkpoints from the database.
|
||||
|
||||
This method retrieves a list of checkpoint tuples from the SQLite database based
|
||||
on the provided config. The checkpoints are ordered by timestamp in descending order.
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to use for listing the checkpoints.
|
||||
before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None.
|
||||
limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None.
|
||||
|
||||
Yields:
|
||||
Iterator[CheckpointTuple]: An iterator of checkpoint tuples.
|
||||
|
||||
Examples:
|
||||
>>> from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
>>> memory = SqliteSaver.from_conn_string(":memory:")
|
||||
... # Run a graph, then list the checkpoints
|
||||
>>> config = {"configurable": {"thread_id": "1"}}
|
||||
>>> checkpoints = list(memory.list(config, limit=2))
|
||||
>>> print(checkpoints)
|
||||
[CheckpointTuple(...), CheckpointTuple(...)]
|
||||
|
||||
>>> config = {"configurable": {"thread_id": "1"}}
|
||||
>>> before = {"configurable": {"thread_ts": "2024-05-04T06:32:42.235444+00:00"}}
|
||||
>>> checkpoints = list(memory.list(config, before=before))
|
||||
>>> print(checkpoints)
|
||||
[CheckpointTuple(...), ...]
|
||||
"""
|
||||
where, param_values = search_where(config, filter, before)
|
||||
query = f"""SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata
|
||||
FROM checkpoints
|
||||
{where}
|
||||
ORDER BY thread_ts DESC"""
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
with self.cursor(transaction=False) as cur:
|
||||
cur.execute(query, param_values)
|
||||
for thread_id, thread_ts, parent_ts, value, metadata in cur:
|
||||
yield CheckpointTuple(
|
||||
{"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}},
|
||||
self.serde.loads(value),
|
||||
self.serde.loads(metadata) if metadata is not None else {},
|
||||
(
|
||||
{
|
||||
"configurable": {
|
||||
"thread_id": thread_id,
|
||||
"thread_ts": parent_ts,
|
||||
}
|
||||
}
|
||||
if parent_ts
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the database.
|
||||
|
||||
This method saves a checkpoint to the SQLite database. The checkpoint is associated
|
||||
with the provided config and its parent config (if any).
|
||||
|
||||
Args:
|
||||
config (RunnableConfig): The config to associate with the checkpoint.
|
||||
checkpoint (Checkpoint): The checkpoint to save.
|
||||
metadata (Optional[dict[str, Any]]): Additional metadata to save with the checkpoint. Defaults to None.
|
||||
|
||||
Returns:
|
||||
RunnableConfig: The updated config containing the saved checkpoint's timestamp.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
>>> memory = SqliteSaver.from_conn_string(":memory:")
|
||||
... # Run a graph, then list the checkpoints
|
||||
>>> config = {"configurable": {"thread_id": "1"}}
|
||||
>>> checkpoint = {"ts": "2024-05-04T06:32:42.235444+00:00", "data": {"key": "value"}}
|
||||
>>> saved_config = memory.put(config, checkpoint, {"source": "input", "step": 1, "writes": {"key": "value"}})
|
||||
>>> print(saved_config)
|
||||
{"configurable": {"thread_id": "1", "thread_ts": 2024-05-04T06:32:42.235444+00:00"}}
|
||||
"""
|
||||
with self.lock, self.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT OR REPLACE INTO checkpoints (thread_id, thread_ts, parent_ts, checkpoint, metadata) VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
str(config["configurable"]["thread_id"]),
|
||||
checkpoint["id"],
|
||||
config["configurable"].get("thread_ts"),
|
||||
self.serde.dumps(checkpoint),
|
||||
self.serde.dumps(metadata),
|
||||
),
|
||||
)
|
||||
return {
|
||||
"configurable": {
|
||||
"thread_id": config["configurable"]["thread_id"],
|
||||
"thread_ts": checkpoint["id"],
|
||||
}
|
||||
}
|
||||
|
||||
async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
|
||||
"""Get a checkpoint tuple from the database asynchronously.
|
||||
|
||||
Note:
|
||||
This async method is not supported by the SqliteSaver class.
|
||||
Use get_tuple() instead, or consider using [AsyncSqliteSaver](#asyncsqlitesaver).
|
||||
"""
|
||||
raise NotImplementedError(_AIO_ERROR_MSG)
|
||||
|
||||
async def alist(
|
||||
self,
|
||||
config: Optional[RunnableConfig],
|
||||
*,
|
||||
filter: Optional[Dict[str, Any]] = None,
|
||||
before: Optional[RunnableConfig] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> AsyncIterator[CheckpointTuple]:
|
||||
"""List checkpoints from the database asynchronously.
|
||||
|
||||
Note:
|
||||
This async method is not supported by the SqliteSaver class.
|
||||
Use list() instead, or consider using [AsyncSqliteSaver](#asyncsqlitesaver).
|
||||
"""
|
||||
raise NotImplementedError(_AIO_ERROR_MSG)
|
||||
yield
|
||||
|
||||
async def aput(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
"""Save a checkpoint to the database asynchronously.
|
||||
|
||||
Note:
|
||||
This async method is not supported by the SqliteSaver class.
|
||||
Use put() instead, or consider using [AsyncSqliteSaver](#asyncsqlitesaver).
|
||||
"""
|
||||
raise NotImplementedError(_AIO_ERROR_MSG)
|
||||
|
||||
def get_next_version(self, current: Optional[str], channel: BaseChannel) -> str:
|
||||
if current is None:
|
||||
current_v = 0
|
||||
else:
|
||||
current_v = int(current.split(".")[0])
|
||||
next_v = current_v + 1
|
||||
try:
|
||||
next_h = md5(self.serde.dumps(channel.checkpoint())).hexdigest()
|
||||
except EmptyChannelError:
|
||||
next_h = ""
|
||||
return f"{next_v:032}.{next_h}"
|
||||
|
||||
|
||||
def _metadata_predicate(
|
||||
metadata_filter: Dict[str, Any],
|
||||
) -> Tuple[Sequence[str], Sequence[Any]]:
|
||||
"""Return WHERE clause predicates for (a)search() given metadata filter.
|
||||
|
||||
This method returns a tuple of a string and a tuple of values. The string
|
||||
is the parametered WHERE clause predicate (excluding the WHERE keyword):
|
||||
"column1 = ? AND column2 IS ?". The tuple of values contains the values
|
||||
for each of the corresponding parameters.
|
||||
"""
|
||||
|
||||
def _where_value(query_value: Any) -> Tuple[str, Any]:
|
||||
"""Return tuple of operator and value for WHERE clause predicate."""
|
||||
if query_value is None:
|
||||
return ("IS ?", None)
|
||||
elif (
|
||||
isinstance(query_value, str)
|
||||
or isinstance(query_value, int)
|
||||
or isinstance(query_value, float)
|
||||
):
|
||||
return ("= ?", query_value)
|
||||
elif isinstance(query_value, bool):
|
||||
return ("= ?", 1 if query_value else 0)
|
||||
elif isinstance(query_value, dict) or isinstance(query_value, list):
|
||||
# query value for JSON object cannot have trailing space after separators (, :)
|
||||
# SQLite json_extract() returns JSON string without whitespace
|
||||
return ("= ?", json.dumps(query_value, separators=(",", ":")))
|
||||
else:
|
||||
return ("= ?", str(query_value))
|
||||
|
||||
predicates = []
|
||||
param_values = []
|
||||
|
||||
# process metadata query
|
||||
for query_key, query_value in metadata_filter.items():
|
||||
operator, param_value = _where_value(query_value)
|
||||
predicates.append(
|
||||
f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}"
|
||||
)
|
||||
param_values.append(param_value)
|
||||
|
||||
return (predicates, param_values)
|
||||
|
||||
|
||||
def search_where(
|
||||
config: Optional[RunnableConfig],
|
||||
filter: Optional[Dict[str, Any]],
|
||||
before: Optional[RunnableConfig] = None,
|
||||
) -> Tuple[str, Sequence[Any]]:
|
||||
"""Return WHERE clause predicates for (a)search() given metadata filter
|
||||
and `before` config.
|
||||
|
||||
This method returns a tuple of a string and a tuple of values. The string
|
||||
is the parametered WHERE clause predicate (including the WHERE keyword):
|
||||
"WHERE column1 = ? AND column2 IS ?". The tuple of values contains the
|
||||
values for each of the corresponding parameters.
|
||||
"""
|
||||
wheres = []
|
||||
param_values = []
|
||||
|
||||
# construct predicate for config filter
|
||||
if config is not None:
|
||||
wheres.append("thread_id = ?")
|
||||
param_values.append(config["configurable"]["thread_id"])
|
||||
|
||||
# construct predicate for metadata filter
|
||||
if filter:
|
||||
metadata_predicates, metadata_values = _metadata_predicate(filter)
|
||||
wheres.extend(metadata_predicates)
|
||||
param_values.extend(metadata_values)
|
||||
|
||||
# construct predicate for `before`
|
||||
if before is not None:
|
||||
wheres.append("thread_ts < ?")
|
||||
param_values.append(before["configurable"]["thread_ts"])
|
||||
|
||||
return ("WHERE " + " AND ".join(wheres) if wheres else "", param_values)
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Any
|
||||
|
||||
CONFIG_KEY_SEND = "__pregel_send"
|
||||
CONFIG_KEY_READ = "__pregel_read"
|
||||
INTERRUPT = "__interrupt__"
|
||||
TASKS = "__pregel_tasks"
|
||||
RESERVED = {INTERRUPT, TASKS, CONFIG_KEY_SEND, CONFIG_KEY_READ}
|
||||
TAG_HIDDEN = "langsmith:hidden"
|
||||
|
||||
START = "__start__"
|
||||
END = "__end__"
|
||||
|
||||
|
||||
class Send:
|
||||
"""A message or packet to send to a specific node in the graph.
|
||||
|
||||
The `Send` class is used within a `StateGraph`'s conditional edges to dynamically
|
||||
route states to different nodes based on certain conditions. This enables
|
||||
creating "map-reduce" like workflows, where a node can be invoked multiple times
|
||||
in parallel on different states, and the results can be aggregated back into the
|
||||
main graph's state.
|
||||
|
||||
Attributes:
|
||||
node (str): The name of the target node to send the message to.
|
||||
arg (Any): The state or message to send to the target node.
|
||||
|
||||
Examples:
|
||||
>>> from typing import Annotated
|
||||
>>> import operator
|
||||
>>> class OverallState(TypedDict):
|
||||
... subjects: list[str]
|
||||
... jokes: Annotated[list[str], operator.add]
|
||||
...
|
||||
>>> from langgraph.constants import Send
|
||||
>>> from langgraph.graph import END, START
|
||||
>>> def continue_to_jokes(state: OverallState):
|
||||
... return [Send("generate_joke", {"subject": s}) for s in state['subjects']]
|
||||
...
|
||||
>>> from langgraph.graph import StateGraph
|
||||
>>> builder = StateGraph(OverallState)
|
||||
>>> builder.add_node("generate_joke", lambda state: {"jokes": [f"Joke about {state['subject']}"]})
|
||||
>>> builder.add_conditional_edges(START, continue_to_jokes)
|
||||
>>> builder.add_edge("generate_joke", END)
|
||||
>>> graph = builder.compile()
|
||||
>>> graph.invoke({"subjects": ["cats", "dogs"]})
|
||||
{'subjects': ['cats', 'dogs'], 'jokes': ['Joke about cats', 'Joke about dogs']}
|
||||
"""
|
||||
|
||||
node: str
|
||||
arg: Any
|
||||
|
||||
def __init__(self, /, node: str, arg: Any) -> None:
|
||||
"""
|
||||
Initialize a new instance of the Send class.
|
||||
|
||||
Args:
|
||||
node (str): The name of the target node to send the message to.
|
||||
arg (Any): The state or message to send to the target node.
|
||||
"""
|
||||
self.node = node
|
||||
self.arg = arg
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash((self.node, self.arg))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Send(node={self.node!r}, arg={self.arg!r})"
|
||||
|
||||
def __eq__(self, value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, Send)
|
||||
and self.node == value.node
|
||||
and self.arg == value.arg
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
class GraphRecursionError(RecursionError):
|
||||
"""Raised when the graph has exhausted the maximum number of steps.
|
||||
|
||||
This prevents infinite loops. To increase the maximum number of steps,
|
||||
run your graph with a config specifying a higher `recursion_limit`.
|
||||
|
||||
Examples:
|
||||
|
||||
graph = builder.compile()
|
||||
graph.invoke(
|
||||
{"messages": [("user", "Hello, world!")]},
|
||||
# The config is the second positional argument
|
||||
{"recursion_limit": 1000},
|
||||
)
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class EmptyChannelError(Exception):
|
||||
"""Raised when attempting to get the value of a channel that hasn't been updated
|
||||
for the first time yet."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InvalidUpdateError(Exception):
|
||||
"""Raised when attempting to update a channel with an invalid sequence of updates."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,13 @@
|
||||
from langgraph.graph.graph import END, START, Graph
|
||||
from langgraph.graph.message import MessageGraph, MessagesState, add_messages
|
||||
from langgraph.graph.state import StateGraph
|
||||
|
||||
__all__ = [
|
||||
"END",
|
||||
"START",
|
||||
"Graph",
|
||||
"StateGraph",
|
||||
"MessageGraph",
|
||||
"add_messages",
|
||||
"MessagesState",
|
||||
]
|
||||
@@ -0,0 +1,492 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Hashable,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Sequence,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
overload,
|
||||
)
|
||||
|
||||
from langchain_core.runnables import Runnable
|
||||
from langchain_core.runnables.base import RunnableLike
|
||||
from langchain_core.runnables.config import RunnableConfig
|
||||
from langchain_core.runnables.graph import (
|
||||
Node as RunnableGraphNode,
|
||||
)
|
||||
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.checkpoint import BaseCheckpointSaver
|
||||
from langgraph.constants import END, START, TAG_HIDDEN, Send
|
||||
from langgraph.errors import InvalidUpdateError
|
||||
from langgraph.pregel import Channel, Pregel
|
||||
from langgraph.pregel.read import PregelNode
|
||||
from langgraph.pregel.types import All
|
||||
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.utils import DrawableGraph, RunnableCallable, coerce_to_runnable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Branch(NamedTuple):
|
||||
path: Runnable[Any, Union[Hashable, list[Hashable]]]
|
||||
ends: Optional[dict[Hashable, str]]
|
||||
then: Optional[str] = None
|
||||
|
||||
def run(
|
||||
self,
|
||||
writer: Callable[[list[str]], Optional[Runnable]],
|
||||
reader: Optional[Callable[[RunnableConfig], Any]] = None,
|
||||
) -> None:
|
||||
return ChannelWrite.register_writer(
|
||||
RunnableCallable(
|
||||
func=self._route,
|
||||
afunc=self._aroute,
|
||||
writer=writer,
|
||||
reader=reader,
|
||||
name=None,
|
||||
trace=False,
|
||||
)
|
||||
)
|
||||
|
||||
def _route(
|
||||
self,
|
||||
input: Any,
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
reader: Optional[Callable[[], Any]],
|
||||
writer: Callable[[list[str]], Optional[Runnable]],
|
||||
) -> Runnable:
|
||||
if reader:
|
||||
value = reader(config)
|
||||
# passthrough additional keys from node to branch
|
||||
# only doable when using dict states
|
||||
if isinstance(value, dict) and isinstance(input, dict):
|
||||
value = {**input, **value}
|
||||
else:
|
||||
value = input
|
||||
result = self.path.invoke(value, config)
|
||||
return self._finish(writer, input, result)
|
||||
|
||||
async def _aroute(
|
||||
self,
|
||||
input: Any,
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
reader: Optional[Callable[[], Any]],
|
||||
writer: Callable[[list[str]], Optional[Runnable]],
|
||||
) -> Runnable:
|
||||
if reader:
|
||||
value = reader(config)
|
||||
# passthrough additional keys from node to branch
|
||||
# only doable when using dict states
|
||||
if isinstance(value, dict) and isinstance(input, dict):
|
||||
value = {**input, **value}
|
||||
else:
|
||||
value = input
|
||||
result = await self.path.ainvoke(value, config)
|
||||
return self._finish(writer, input, result)
|
||||
|
||||
def _finish(
|
||||
self, writer: Callable[[list[str]], Optional[Runnable]], input: Any, result: Any
|
||||
):
|
||||
if not isinstance(result, list):
|
||||
result = [result]
|
||||
if self.ends:
|
||||
destinations = [r if isinstance(r, Send) else self.ends[r] for r in result]
|
||||
else:
|
||||
destinations = result
|
||||
if any(dest is None or dest == START for dest in destinations):
|
||||
raise ValueError("Branch did not return a valid destination")
|
||||
if any(p.node == END for p in destinations if isinstance(p, Send)):
|
||||
raise InvalidUpdateError("Cannot send a packet to the END node")
|
||||
return writer(destinations) or input
|
||||
|
||||
|
||||
class Graph:
|
||||
def __init__(self) -> None:
|
||||
self.nodes: dict[str, Runnable] = {}
|
||||
self.edges = set[tuple[str, str]]()
|
||||
self.branches: defaultdict[str, dict[str, Branch]] = defaultdict(dict)
|
||||
self.support_multiple_edges = False
|
||||
self.compiled = False
|
||||
|
||||
@property
|
||||
def _all_edges(self) -> set[tuple[str, str]]:
|
||||
return self.edges
|
||||
|
||||
@overload
|
||||
def add_node(self, node: RunnableLike) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def add_node(self, node: str, action: RunnableLike) -> None:
|
||||
...
|
||||
|
||||
def add_node(
|
||||
self, node: Union[str, RunnableLike], action: Optional[RunnableLike] = None
|
||||
) -> None:
|
||||
if self.compiled:
|
||||
logger.warning(
|
||||
"Adding a node to a graph that has already been compiled. This will "
|
||||
"not be reflected in the compiled graph."
|
||||
)
|
||||
if not isinstance(node, str):
|
||||
action = node
|
||||
node = getattr(action, "name", action.__name__)
|
||||
if node in self.nodes:
|
||||
raise ValueError(f"Node `{node}` already present.")
|
||||
if node == END or node == START:
|
||||
raise ValueError(f"Node `{node}` is reserved.")
|
||||
|
||||
self.nodes[node] = coerce_to_runnable(action, name=node, trace=False)
|
||||
|
||||
def add_edge(self, start_key: str, end_key: str) -> None:
|
||||
if self.compiled:
|
||||
logger.warning(
|
||||
"Adding an edge to a graph that has already been compiled. This will "
|
||||
"not be reflected in the compiled graph."
|
||||
)
|
||||
if start_key == END:
|
||||
raise ValueError("END cannot be a start node")
|
||||
if end_key == START:
|
||||
raise ValueError("START cannot be an end node")
|
||||
if not self.support_multiple_edges and start_key in set(
|
||||
start for start, _ in self.edges
|
||||
):
|
||||
raise ValueError(
|
||||
f"Already found path for node '{start_key}'.\n"
|
||||
"For multiple edges, use StateGraph with an annotated state key."
|
||||
)
|
||||
|
||||
self.edges.add((start_key, end_key))
|
||||
|
||||
def add_conditional_edges(
|
||||
self,
|
||||
source: str,
|
||||
path: Union[
|
||||
Callable[..., Union[Hashable, list[Hashable]]],
|
||||
Callable[..., Awaitable[Union[Hashable, list[Hashable]]]],
|
||||
Runnable[Any, Union[Hashable, list[Hashable]]],
|
||||
],
|
||||
path_map: Optional[Union[dict[Hashable, str], list[str]]] = None,
|
||||
then: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Add a conditional edge from the starting node to any number of destination nodes.
|
||||
|
||||
Args:
|
||||
source (str): The starting node. This conditional edge will run when
|
||||
exiting this node.
|
||||
path (Union[Callable, Runnable]): The callable that determines the next
|
||||
node or nodes. If not specifying `path_map` it should return one or
|
||||
more nodes. If it returns END, the graph will stop execution.
|
||||
path_map (Optional[dict[Hashable, str]]): Optional mapping of paths to node
|
||||
names. If omitted the paths returned by `path` should be node names.
|
||||
then (Optional[str]): The name of a node to execute after the nodes
|
||||
selected by `path`.
|
||||
|
||||
Returns:
|
||||
None
|
||||
""" # noqa: E501
|
||||
if self.compiled:
|
||||
logger.warning(
|
||||
"Adding an edge to a graph that has already been compiled. This will "
|
||||
"not be reflected in the compiled graph."
|
||||
)
|
||||
# coerce path_map to a dictionary
|
||||
if isinstance(path_map, dict):
|
||||
path_map = path_map.copy()
|
||||
elif isinstance(path_map, list):
|
||||
path_map = {name: name for name in path_map}
|
||||
elif rtn_type := get_type_hints(path).get("return"):
|
||||
if get_origin(rtn_type) is Literal:
|
||||
path_map = {name: name for name in get_args(rtn_type)}
|
||||
# find a name for the condition
|
||||
path = coerce_to_runnable(path, name=None, trace=True)
|
||||
name = path.name or "condition"
|
||||
# validate the condition
|
||||
if name in self.branches[source]:
|
||||
raise ValueError(
|
||||
f"Branch with name `{path.name}` already exists for node " f"`{source}`"
|
||||
)
|
||||
# save it
|
||||
self.branches[source][name] = Branch(path, path_map, then)
|
||||
|
||||
def set_entry_point(self, key: str) -> None:
|
||||
"""Specifies the first node to be called in the graph.
|
||||
|
||||
Parameters:
|
||||
key (str): The key of the node to set as the entry point.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
return self.add_edge(START, key)
|
||||
|
||||
def set_conditional_entry_point(
|
||||
self,
|
||||
path: Union[
|
||||
Callable[..., Union[Hashable, list[Hashable]]],
|
||||
Callable[..., Awaitable[Union[Hashable, list[Hashable]]]],
|
||||
Runnable[Any, Union[Hashable, list[Hashable]]],
|
||||
],
|
||||
path_map: Optional[Union[dict[Hashable, str], list[str]]] = None,
|
||||
then: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Sets a conditional entry point in the graph.
|
||||
|
||||
Args:
|
||||
path (Union[Callable, Runnable]): The callable that determines the next
|
||||
node or nodes. If not specifying `path_map` it should return one or
|
||||
more nodes. If it returns END, the graph will stop execution.
|
||||
path_map (Optional[dict[str, str]]): Optional mapping of paths to node
|
||||
names. If omitted the paths returned by `path` should be node names.
|
||||
then (Optional[str]): The name of a node to execute after the nodes
|
||||
selected by `path`.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
return self.add_conditional_edges(START, path, path_map, then)
|
||||
|
||||
def set_finish_point(self, key: str) -> None:
|
||||
"""Marks a node as a finish point of the graph.
|
||||
|
||||
If the graph reaches this node, it will cease execution.
|
||||
|
||||
Parameters:
|
||||
key (str): The key of the node to set as the finish point.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
return self.add_edge(key, END)
|
||||
|
||||
def validate(self, interrupt: Optional[Sequence[str]] = None) -> None:
|
||||
# assemble sources
|
||||
all_sources = {src for src, _ in self._all_edges}
|
||||
for start, branches in self.branches.items():
|
||||
all_sources.add(start)
|
||||
for cond, branch in branches.items():
|
||||
if branch.then is not None:
|
||||
if branch.ends is not None:
|
||||
for end in branch.ends.values():
|
||||
if end != END:
|
||||
all_sources.add(end)
|
||||
else:
|
||||
for node in self.nodes:
|
||||
if node != start and node != branch.then:
|
||||
all_sources.add(node)
|
||||
# validate sources
|
||||
for source in all_sources:
|
||||
if source not in self.nodes and source != START:
|
||||
raise ValueError(f"Found edge starting at unknown node '{source}'")
|
||||
|
||||
# assemble targets
|
||||
all_targets = {end for _, end in self._all_edges}
|
||||
for start, branches in self.branches.items():
|
||||
for cond, branch in branches.items():
|
||||
if branch.then is not None:
|
||||
all_targets.add(branch.then)
|
||||
if branch.ends is not None:
|
||||
for end in branch.ends.values():
|
||||
if end not in self.nodes and end != END:
|
||||
raise ValueError(
|
||||
f"At '{start}' node, '{cond}' branch found unknown target '{end}'"
|
||||
)
|
||||
all_targets.add(end)
|
||||
else:
|
||||
all_targets.add(END)
|
||||
for node in self.nodes:
|
||||
if node != start and node != branch.then:
|
||||
all_targets.add(node)
|
||||
# validate targets
|
||||
for node in self.nodes:
|
||||
if node not in all_targets:
|
||||
raise ValueError(f"Node `{node}` is not reachable")
|
||||
for target in all_targets:
|
||||
if target not in self.nodes and target != END:
|
||||
raise ValueError(f"Found edge ending at unknown node `{target}`")
|
||||
# validate interrupts
|
||||
if interrupt:
|
||||
for node in interrupt:
|
||||
if node not in self.nodes:
|
||||
raise ValueError(f"Interrupt node `{node}` not found")
|
||||
|
||||
self.compiled = True
|
||||
|
||||
def compile(
|
||||
self,
|
||||
checkpointer: Optional[BaseCheckpointSaver] = None,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
debug: bool = False,
|
||||
) -> "CompiledGraph":
|
||||
# assign default values
|
||||
interrupt_before = interrupt_before or []
|
||||
interrupt_after = interrupt_after or []
|
||||
|
||||
# validate the graph
|
||||
self.validate(
|
||||
interrupt=(
|
||||
(interrupt_before if interrupt_before != "*" else []) + interrupt_after
|
||||
if interrupt_after != "*"
|
||||
else []
|
||||
)
|
||||
)
|
||||
|
||||
# create empty compiled graph
|
||||
compiled = CompiledGraph(
|
||||
builder=self,
|
||||
nodes={},
|
||||
channels={START: EphemeralValue(Any), END: EphemeralValue(Any)},
|
||||
input_channels=START,
|
||||
output_channels=END,
|
||||
stream_mode="values",
|
||||
stream_channels=[],
|
||||
checkpointer=checkpointer,
|
||||
interrupt_before_nodes=interrupt_before,
|
||||
interrupt_after_nodes=interrupt_after,
|
||||
auto_validate=False,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
# attach nodes, edges, and branches
|
||||
for key, node in self.nodes.items():
|
||||
compiled.attach_node(key, node)
|
||||
|
||||
for start, end in self.edges:
|
||||
compiled.attach_edge(start, end)
|
||||
|
||||
for start, branches in self.branches.items():
|
||||
for name, branch in branches.items():
|
||||
compiled.attach_branch(start, name, branch)
|
||||
|
||||
# validate the compiled graph
|
||||
return compiled.validate()
|
||||
|
||||
|
||||
class CompiledGraph(Pregel):
|
||||
builder: Graph
|
||||
|
||||
def attach_node(self, key: str, node: Runnable) -> None:
|
||||
self.channels[key] = EphemeralValue(Any)
|
||||
self.nodes[key] = (
|
||||
PregelNode(channels=[], triggers=[])
|
||||
| node
|
||||
| ChannelWrite([ChannelWriteEntry(key)], tags=[TAG_HIDDEN])
|
||||
)
|
||||
cast(list[str], self.stream_channels).append(key)
|
||||
|
||||
def attach_edge(self, start: str, end: str) -> None:
|
||||
if end == END:
|
||||
# publish to end channel
|
||||
self.nodes[start].writers.append(
|
||||
ChannelWrite([ChannelWriteEntry(END)], tags=[TAG_HIDDEN])
|
||||
)
|
||||
else:
|
||||
# subscribe to start channel
|
||||
self.nodes[end].triggers.append(start)
|
||||
self.nodes[end].channels.append(start)
|
||||
|
||||
def attach_branch(self, start: str, name: str, branch: Branch) -> None:
|
||||
def branch_writer(packets: list[Union[str, Send]]) -> Optional[ChannelWrite]:
|
||||
writes = [
|
||||
(
|
||||
ChannelWriteEntry(f"branch:{start}:{name}:{p}" if p != END else END)
|
||||
if not isinstance(p, Send)
|
||||
else p
|
||||
)
|
||||
for p in packets
|
||||
]
|
||||
return ChannelWrite(writes, tags=[TAG_HIDDEN])
|
||||
|
||||
# add hidden start node
|
||||
if start == START and start not in self.nodes:
|
||||
self.nodes[start] = Channel.subscribe_to(START, tags=[TAG_HIDDEN])
|
||||
|
||||
# attach branch writer
|
||||
self.nodes[start] |= branch.run(branch_writer)
|
||||
|
||||
# attach branch readers
|
||||
ends = branch.ends.values() if branch.ends else [node for node in self.nodes]
|
||||
for end in ends:
|
||||
if end != END:
|
||||
channel_name = f"branch:{start}:{name}:{end}"
|
||||
self.channels[channel_name] = EphemeralValue(Any)
|
||||
self.nodes[end].triggers.append(channel_name)
|
||||
self.nodes[end].channels.append(channel_name)
|
||||
|
||||
def get_graph(
|
||||
self,
|
||||
config: Optional[RunnableConfig] = None,
|
||||
*,
|
||||
xray: Union[int, bool] = False,
|
||||
) -> DrawableGraph:
|
||||
"""Returns a drawable representation of the computation graph."""
|
||||
graph = DrawableGraph()
|
||||
start_nodes: dict[str, RunnableGraphNode] = {
|
||||
START: graph.add_node(self.get_input_schema(config), START)
|
||||
}
|
||||
end_nodes: dict[str, RunnableGraphNode] = {
|
||||
END: graph.add_node(self.get_output_schema(config), END)
|
||||
}
|
||||
|
||||
for key, node in self.builder.nodes.items():
|
||||
if xray:
|
||||
subgraph = (
|
||||
node.get_graph(
|
||||
config=config,
|
||||
xray=xray - 1 if isinstance(xray, int) and xray > 0 else xray,
|
||||
)
|
||||
if isinstance(node, CompiledGraph)
|
||||
else node.get_graph(config=config)
|
||||
)
|
||||
subgraph.trim_first_node()
|
||||
subgraph.trim_last_node()
|
||||
if len(subgraph.nodes) > 1:
|
||||
end_nodes[key], start_nodes[key] = graph.extend(
|
||||
subgraph, prefix=key
|
||||
)
|
||||
else:
|
||||
n = graph.add_node(node, key)
|
||||
start_nodes[key] = n
|
||||
end_nodes[key] = n
|
||||
else:
|
||||
n = graph.add_node(node, key)
|
||||
start_nodes[key] = n
|
||||
end_nodes[key] = n
|
||||
for start, end in sorted(self.builder._all_edges):
|
||||
graph.add_edge(start_nodes[start], end_nodes[end])
|
||||
for start, branches in self.builder.branches.items():
|
||||
default_ends = {
|
||||
**{k: k for k in self.builder.nodes if k != start},
|
||||
END: END,
|
||||
}
|
||||
for _, branch in branches.items():
|
||||
if branch.ends is not None:
|
||||
ends = branch.ends
|
||||
elif branch.then is not None:
|
||||
ends = {k: k for k in default_ends if k not in (END, branch.then)}
|
||||
else:
|
||||
ends = default_ends
|
||||
for label, end in ends.items():
|
||||
graph.add_edge(
|
||||
start_nodes[start],
|
||||
end_nodes[end],
|
||||
label if label != end else None,
|
||||
conditional=True,
|
||||
)
|
||||
if branch.then is not None:
|
||||
graph.add_edge(start_nodes[end], end_nodes[branch.then])
|
||||
|
||||
return graph
|
||||
@@ -0,0 +1,139 @@
|
||||
import uuid
|
||||
from typing import Annotated, TypedDict, Union
|
||||
|
||||
from langchain_core.messages import (
|
||||
AnyMessage,
|
||||
MessageLikeRepresentation,
|
||||
convert_to_messages,
|
||||
message_chunk_to_message,
|
||||
)
|
||||
|
||||
from langgraph.graph.state import StateGraph
|
||||
|
||||
Messages = Union[list[MessageLikeRepresentation], MessageLikeRepresentation]
|
||||
|
||||
|
||||
def add_messages(left: Messages, right: Messages) -> Messages:
|
||||
"""Merges two lists of messages, updating existing messages by ID.
|
||||
|
||||
By default, this ensures the state is "append-only", unless the
|
||||
new message has the same ID as an existing message.
|
||||
|
||||
Args:
|
||||
left: The base list of messages.
|
||||
right: The list of messages (or single message) to merge
|
||||
into the base list.
|
||||
|
||||
Returns:
|
||||
A new list of messages with the messages from `right` merged into `left`.
|
||||
If a message in `right` has the same ID as a message in `left`, the
|
||||
message from `right` will replace the message from `left`.
|
||||
|
||||
Examples:
|
||||
```pycon
|
||||
>>> from langchain_core.messages import AIMessage, HumanMessage
|
||||
>>> msgs1 = [HumanMessage(content="Hello", id="1")]
|
||||
>>> msgs2 = [AIMessage(content="Hi there!", id="2")]
|
||||
>>> add_messages(msgs1, msgs2)
|
||||
[HumanMessage(content='Hello', id='1'), AIMessage(content='Hi there!', id='2')]
|
||||
|
||||
>>> msgs1 = [HumanMessage(content="Hello", id="1")]
|
||||
>>> msgs2 = [HumanMessage(content="Hello again", id="1")]
|
||||
>>> add_messages(msgs1, msgs2)
|
||||
[HumanMessage(content='Hello again', id='1')]
|
||||
|
||||
>>> from typing import Annotated
|
||||
>>> from typing_extensions import TypedDict
|
||||
>>> from langgraph.graph import StateGraph
|
||||
>>>
|
||||
>>> class State(TypedDict):
|
||||
... messages: Annotated[list, add_messages]
|
||||
...
|
||||
>>> builder = StateGraph(State)
|
||||
>>> builder.add_node("chatbot", lambda state: {"messages": [("assistant", "Hello")]})
|
||||
>>> builder.set_entry_point("chatbot")
|
||||
>>> builder.set_finish_point("chatbot")
|
||||
>>> graph = builder.compile()
|
||||
>>> graph.invoke({})
|
||||
{'messages': [AIMessage(content='Hello', id=...)]}
|
||||
```
|
||||
|
||||
"""
|
||||
# coerce to list
|
||||
if not isinstance(left, list):
|
||||
left = [left]
|
||||
if not isinstance(right, list):
|
||||
right = [right]
|
||||
# coerce to message
|
||||
left = [message_chunk_to_message(m) for m in convert_to_messages(left)]
|
||||
right = [message_chunk_to_message(m) for m in convert_to_messages(right)]
|
||||
# assign missing ids
|
||||
for m in left:
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
for m in right:
|
||||
if m.id is None:
|
||||
m.id = str(uuid.uuid4())
|
||||
# merge
|
||||
left_idx_by_id = {m.id: i for i, m in enumerate(left)}
|
||||
merged = left.copy()
|
||||
for m in right:
|
||||
if (existing_idx := left_idx_by_id.get(m.id)) is not None:
|
||||
merged[existing_idx] = m
|
||||
else:
|
||||
merged.append(m)
|
||||
return merged
|
||||
|
||||
|
||||
class MessageGraph(StateGraph):
|
||||
"""A StateGraph where every node receives a list of messages as input and returns one or more messages as output.
|
||||
|
||||
MessageGraph is a subclass of StateGraph whose entire state is a single, append-only* list of messages.
|
||||
Each node in a MessageGraph takes a list of messages as input and returns zero or more
|
||||
messages as output. The `add_messages` function is used to merge the output messages from each node
|
||||
into the existing list of messages in the graph's state.
|
||||
|
||||
Examples:
|
||||
```pycon
|
||||
>>> from langgraph.graph.message import MessageGraph
|
||||
...
|
||||
>>> builder = MessageGraph()
|
||||
>>> builder.add_node("chatbot", lambda state: [("assistant", "Hello!")])
|
||||
>>> builder.set_entry_point("chatbot")
|
||||
>>> builder.set_finish_point("chatbot")
|
||||
>>> builder.compile().invoke([("user", "Hi there.")])
|
||||
[HumanMessage(content="Hi there.", id='...'), AIMessage(content="Hello!", id='...')]
|
||||
|
||||
|
||||
>>> from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||
>>> from langgraph.graph.message import MessageGraph
|
||||
...
|
||||
>>> builder = MessageGraph()
|
||||
>>> builder.add_node(
|
||||
... "chatbot",
|
||||
... lambda state: [
|
||||
... AIMessage(
|
||||
... content="Hello!",
|
||||
... tool_calls=[{"name": "search", "id": "123", "args": {"query": "X"}}],
|
||||
... )
|
||||
... ],
|
||||
... )
|
||||
>>> builder.add_node(
|
||||
... "search", lambda state: [ToolMessage(content="Searching...", tool_call_id="123")]
|
||||
... )
|
||||
>>> builder.set_entry_point("chatbot")
|
||||
>>> builder.add_edge("chatbot", "search")
|
||||
>>> builder.set_finish_point("search")
|
||||
>>> builder.compile().invoke([HumanMessage(content="Hi there. Can you search for X?")])
|
||||
{'messages': [HumanMessage(content="Hi there. Can you search for X?", id='b8b7d8f4-7f4d-4f4d-9c1d-f8b8d8f4d9c1'),
|
||||
AIMessage(content="Hello!", id='f4d9c1d8-8d8f-4d9c-b8b7-d8f4f4d9c1d8'),
|
||||
ToolMessage(content="Searching...", id='d8f4f4d9-c1d8-4f4d-b8b7-d8f4f4d9c1d8', tool_call_id="123")]}
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(Annotated[list[AnyMessage], add_messages])
|
||||
|
||||
|
||||
class MessagesState(TypedDict):
|
||||
messages: Annotated[list[AnyMessage], add_messages]
|
||||
@@ -0,0 +1,510 @@
|
||||
import logging
|
||||
import typing
|
||||
import warnings
|
||||
from functools import partial
|
||||
from inspect import signature
|
||||
from typing import (
|
||||
Any,
|
||||
Optional,
|
||||
Sequence,
|
||||
Type,
|
||||
Union,
|
||||
get_origin,
|
||||
get_type_hints,
|
||||
overload,
|
||||
)
|
||||
|
||||
from langchain_core.pydantic_v1 import BaseModel
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langchain_core.runnables.base import RunnableLike
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.dynamic_barrier_value import DynamicBarrierValue, WaitForNames
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.named_barrier_value import NamedBarrierValue
|
||||
from langgraph.checkpoint import BaseCheckpointSaver
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import InvalidUpdateError
|
||||
from langgraph.graph.graph import END, START, Branch, CompiledGraph, Graph, Send
|
||||
from langgraph.managed.base import ManagedValue, is_managed_value
|
||||
from langgraph.pregel.read import ChannelRead, PregelNode
|
||||
from langgraph.pregel.types import All
|
||||
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite, ChannelWriteEntry
|
||||
from langgraph.utils import RunnableCallable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _warn_invalid_state_schema(schema: Union[Type[Any], Any]) -> None:
|
||||
if isinstance(schema, type):
|
||||
return
|
||||
if typing.get_args(schema):
|
||||
return
|
||||
warnings.warn(
|
||||
f"Invalid state_schema: {schema}. Expected a type or Annotated[type, reducer]. "
|
||||
"Please provide a valid schema to ensure correct updates.\n"
|
||||
" See: https://langchain-ai.github.io/langgraph/reference/graphs/#stategraph"
|
||||
)
|
||||
|
||||
|
||||
class StateGraph(Graph):
|
||||
"""A graph whose nodes communicate by reading and writing to a shared state.
|
||||
The signature of each node is State -> Partial<State>.
|
||||
|
||||
Each state key can optionally be annotated with a reducer function that
|
||||
will be used to aggregate the values of that key received from multiple nodes.
|
||||
The signature of a reducer function is (Value, Value) -> Value.
|
||||
|
||||
Args:
|
||||
state_schema (Type[Any]): The schema class that defines the state.
|
||||
config_schema (Optional[Type[Any]]): The schema class that defines the configuration.
|
||||
Use this to expose configurable parameters in your API.
|
||||
|
||||
|
||||
Examples:
|
||||
>>> from langchain_core.runnables import RunnableConfig
|
||||
>>> from typing_extensions import Annotated, TypedDict
|
||||
>>> from langgraph.checkpoint import MemorySaver
|
||||
>>> from langgraph.graph import StateGraph
|
||||
>>>
|
||||
>>> def reducer(a: list, b: int | None) -> int:
|
||||
... if b is not None:
|
||||
... return a + [b]
|
||||
... return a
|
||||
>>>
|
||||
>>> class State(TypedDict):
|
||||
... x: Annotated[list, reducer]
|
||||
>>>
|
||||
>>> class ConfigSchema(TypedDict):
|
||||
... r: float
|
||||
>>>
|
||||
>>> graph = StateGraph(State, config_schema=ConfigSchema)
|
||||
>>>
|
||||
>>> def node(state: State, config: RunnableConfig) -> dict:
|
||||
... r = config["configurable"].get("r", 1.0)
|
||||
... x = state["x"][-1]
|
||||
... next_value = x * r * (1 - x)
|
||||
... return {"x": next_value}
|
||||
>>>
|
||||
>>> graph.add_node("A", node)
|
||||
>>> graph.set_entry_point("A")
|
||||
>>> graph.set_finish_point("A")
|
||||
>>> compiled = graph.compile()
|
||||
>>>
|
||||
>>> print(compiled.config_specs)
|
||||
[ConfigurableFieldSpec(id='r', annotation=<class 'float'>, name=None, description=None, default=None, is_shared=False, dependencies=None)]
|
||||
>>>
|
||||
>>> step1 = compiled.invoke({"x": 0.5}, {"configurable": {"r": 3.0}})
|
||||
>>> print(step1)
|
||||
{'x': [0.5, 0.75]}"""
|
||||
|
||||
def __init__(
|
||||
self, state_schema: Type[Any], config_schema: Optional[Type[Any]] = None
|
||||
) -> None:
|
||||
super().__init__()
|
||||
_warn_invalid_state_schema(state_schema)
|
||||
self.schema = state_schema
|
||||
self.config_schema = config_schema
|
||||
self.channels, self.managed = _get_channels(state_schema)
|
||||
if any(isinstance(c, BinaryOperatorAggregate) for c in self.channels.values()):
|
||||
self.support_multiple_edges = True
|
||||
self.waiting_edges: set[tuple[tuple[str, ...], str]] = set()
|
||||
|
||||
@property
|
||||
def _all_edges(self) -> set[tuple[str, str]]:
|
||||
return self.edges | {
|
||||
(start, end) for starts, end in self.waiting_edges for start in starts
|
||||
}
|
||||
|
||||
@overload
|
||||
def add_node(self, node: RunnableLike) -> None:
|
||||
"""Adds a new node to the state graph.
|
||||
Will take the name of the function/runnable as the node name.
|
||||
|
||||
Args:
|
||||
node (RunnableLike): The function or runnable this node will run.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key is already being used as a state key.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
...
|
||||
|
||||
@overload
|
||||
def add_node(self, node: str, action: RunnableLike) -> None:
|
||||
"""Adds a new node to the state graph.
|
||||
|
||||
Args:
|
||||
node (str): The key of the node.
|
||||
action (RunnableLike): The action associated with the node.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key is already being used as a state key.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
...
|
||||
|
||||
def add_node(
|
||||
self, node: Union[str, RunnableLike], action: Optional[RunnableLike] = None
|
||||
) -> None:
|
||||
if not isinstance(node, str):
|
||||
action = node
|
||||
if isinstance(action, Runnable):
|
||||
node = action.name
|
||||
else:
|
||||
node = getattr(action, "__name__", action.__class__.__name__)
|
||||
if node is None:
|
||||
raise ValueError(
|
||||
"Node name must be provided if action is not a function"
|
||||
)
|
||||
if node in self.channels:
|
||||
raise ValueError(f"'{node}' is already being used as a state key")
|
||||
return super().add_node(node, action)
|
||||
|
||||
def add_edge(self, start_key: Union[str, list[str]], end_key: str) -> None:
|
||||
"""Adds a directed edge from the start node to the end node.
|
||||
|
||||
If the graph transitions to the start_key node, it will always transition to the end_key node next.
|
||||
|
||||
Args:
|
||||
start_key (Union[str, list[str]]): The key(s) of the start node(s) of the edge.
|
||||
end_key (str): The key of the end node of the edge.
|
||||
|
||||
Raises:
|
||||
ValueError: If the start key is 'END' or if the start key or end key is not present in the graph.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if isinstance(start_key, str):
|
||||
return super().add_edge(start_key, end_key)
|
||||
|
||||
if self.compiled:
|
||||
logger.warning(
|
||||
"Adding an edge to a graph that has already been compiled. This will "
|
||||
"not be reflected in the compiled graph."
|
||||
)
|
||||
for start in start_key:
|
||||
if start == END:
|
||||
raise ValueError("END cannot be a start node")
|
||||
if start not in self.nodes:
|
||||
raise ValueError(f"Need to add_node `{start}` first")
|
||||
if end_key == END:
|
||||
raise ValueError("END cannot be an end node")
|
||||
if end_key not in self.nodes:
|
||||
raise ValueError(f"Need to add_node `{end_key}` first")
|
||||
|
||||
self.waiting_edges.add((tuple(start_key), end_key))
|
||||
|
||||
def compile(
|
||||
self,
|
||||
checkpointer: Optional[BaseCheckpointSaver] = None,
|
||||
interrupt_before: Optional[Union[All, Sequence[str]]] = None,
|
||||
interrupt_after: Optional[Union[All, Sequence[str]]] = None,
|
||||
debug: bool = False,
|
||||
) -> CompiledGraph:
|
||||
"""Compiles the state graph into a `CompiledGraph` object.
|
||||
|
||||
The compiled graph implements the `Runnable` interface and can be invoked,
|
||||
streamed, batched, and run asynchronously.
|
||||
|
||||
Args:
|
||||
checkpointer (Optional[BaseCheckpointSaver]): An optional checkpoint saver object.
|
||||
This serves as a fully versioned "memory" for the graph, allowing
|
||||
the graph to be paused and resumed, and replayed from any point.
|
||||
interrupt_before (Optional[Sequence[str]]): An optional list of node names to interrupt before.
|
||||
interrupt_after (Optional[Sequence[str]]): An optional list of node names to interrupt after.
|
||||
debug (bool): A flag indicating whether to enable debug mode.
|
||||
|
||||
Returns:
|
||||
CompiledGraph: The compiled state graph.
|
||||
"""
|
||||
# assign default values
|
||||
interrupt_before = interrupt_before or []
|
||||
interrupt_after = interrupt_after or []
|
||||
|
||||
# validate the graph
|
||||
self.validate(
|
||||
interrupt=(
|
||||
(interrupt_before if interrupt_before != "*" else []) + interrupt_after
|
||||
if interrupt_after != "*"
|
||||
else []
|
||||
)
|
||||
)
|
||||
|
||||
# prepare output channels
|
||||
state_keys = list(self.channels)
|
||||
output_channels = state_keys[0] if state_keys == ["__root__"] else state_keys
|
||||
|
||||
compiled = CompiledStateGraph(
|
||||
builder=self,
|
||||
config_type=self.config_schema,
|
||||
nodes={},
|
||||
channels={**self.channels, START: EphemeralValue(self.schema)},
|
||||
input_channels=START,
|
||||
stream_mode="updates",
|
||||
output_channels=output_channels,
|
||||
stream_channels=output_channels,
|
||||
checkpointer=checkpointer,
|
||||
interrupt_before_nodes=interrupt_before,
|
||||
interrupt_after_nodes=interrupt_after,
|
||||
auto_validate=False,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
compiled.attach_node(START, None)
|
||||
for key, node in self.nodes.items():
|
||||
compiled.attach_node(key, node)
|
||||
|
||||
for start, end in self.edges:
|
||||
compiled.attach_edge(start, end)
|
||||
|
||||
for starts, end in self.waiting_edges:
|
||||
compiled.attach_edge(starts, end)
|
||||
|
||||
for start, branches in self.branches.items():
|
||||
for name, branch in branches.items():
|
||||
compiled.attach_branch(start, name, branch)
|
||||
|
||||
return compiled.validate()
|
||||
|
||||
|
||||
class CompiledStateGraph(CompiledGraph):
|
||||
builder: StateGraph
|
||||
|
||||
def get_input_schema(
|
||||
self, config: Optional[RunnableConfig] = None
|
||||
) -> type[BaseModel]:
|
||||
if isinstance(self.builder.schema, BaseModel):
|
||||
return self.builder.schema
|
||||
|
||||
return super().get_input_schema(config)
|
||||
|
||||
def get_output_schema(self, config: Optional[RunnableConfig] = None) -> BaseModel:
|
||||
if isinstance(self.builder.schema, BaseModel):
|
||||
return self.builder.schema
|
||||
|
||||
return super().get_output_schema(config)
|
||||
|
||||
def attach_node(self, key: str, node: Optional[Runnable]) -> None:
|
||||
state_keys = list(self.builder.channels)
|
||||
|
||||
def _get_state_key(input: dict, config: RunnableConfig, *, key: str) -> Any:
|
||||
if input is None:
|
||||
return SKIP_WRITE
|
||||
elif isinstance(input, dict):
|
||||
return input.get(key, SKIP_WRITE)
|
||||
elif get_type_hints(type(input)).get(key):
|
||||
value = getattr(input, key, SKIP_WRITE)
|
||||
return value if value is not None else SKIP_WRITE
|
||||
else:
|
||||
raise InvalidUpdateError(f"Expected dict, got {input}")
|
||||
|
||||
# state updaters
|
||||
state_write_entries = (
|
||||
[ChannelWriteEntry("__root__", skip_none=True)]
|
||||
if state_keys == ["__root__"]
|
||||
else [
|
||||
ChannelWriteEntry(
|
||||
key,
|
||||
mapper=RunnableCallable(
|
||||
_get_state_key, key=key, trace=False, recurse=False
|
||||
),
|
||||
)
|
||||
for key in state_keys
|
||||
]
|
||||
)
|
||||
|
||||
# add node and output channel
|
||||
if key == START:
|
||||
self.nodes[key] = PregelNode(
|
||||
tags=[TAG_HIDDEN],
|
||||
triggers=[START],
|
||||
channels=[START],
|
||||
writers=[
|
||||
ChannelWrite(
|
||||
state_write_entries,
|
||||
tags=[TAG_HIDDEN],
|
||||
require_at_least_one_of=state_keys,
|
||||
),
|
||||
],
|
||||
)
|
||||
else:
|
||||
self.channels[key] = EphemeralValue(Any, guard=False)
|
||||
self.nodes[key] = PregelNode(
|
||||
triggers=[],
|
||||
# read state keys and managed values
|
||||
channels=(
|
||||
state_keys
|
||||
if state_keys == ["__root__"]
|
||||
else ({chan: chan for chan in state_keys} | self.builder.managed)
|
||||
),
|
||||
# coerce state dict to schema class (eg. pydantic model)
|
||||
mapper=(
|
||||
None
|
||||
if state_keys == ["__root__"]
|
||||
else partial(_coerce_state, self.builder.schema)
|
||||
),
|
||||
writers=[
|
||||
# publish to this channel and state keys
|
||||
ChannelWrite(
|
||||
[ChannelWriteEntry(key, key)] + state_write_entries,
|
||||
tags=[TAG_HIDDEN],
|
||||
require_at_least_one_of=state_keys,
|
||||
),
|
||||
],
|
||||
).pipe(node)
|
||||
|
||||
def attach_edge(self, starts: Union[str, Sequence[str]], end: str) -> None:
|
||||
if isinstance(starts, str):
|
||||
if starts == START:
|
||||
channel_name = f"start:{end}"
|
||||
# register channel
|
||||
self.channels[channel_name] = EphemeralValue(Any)
|
||||
# subscribe to channel
|
||||
self.nodes[end].triggers.append(channel_name)
|
||||
# publish to channel
|
||||
self.nodes[START] |= ChannelWrite(
|
||||
[ChannelWriteEntry(channel_name, START)], tags=[TAG_HIDDEN]
|
||||
)
|
||||
elif end != END:
|
||||
# subscribe to start channel
|
||||
self.nodes[end].triggers.append(starts)
|
||||
elif end != END:
|
||||
channel_name = f"join:{'+'.join(starts)}:{end}"
|
||||
# register channel
|
||||
self.channels[channel_name] = NamedBarrierValue(str, set(starts))
|
||||
# subscribe to channel
|
||||
self.nodes[end].triggers.append(channel_name)
|
||||
# publish to channel
|
||||
for start in starts:
|
||||
self.nodes[start] |= ChannelWrite(
|
||||
[ChannelWriteEntry(channel_name, start)], tags=[TAG_HIDDEN]
|
||||
)
|
||||
|
||||
def attach_branch(self, start: str, name: str, branch: Branch) -> None:
|
||||
def branch_writer(packets: list[Union[str, Send]]) -> Optional[ChannelWrite]:
|
||||
if filtered := [p for p in packets if p != END]:
|
||||
writes = [
|
||||
ChannelWriteEntry(f"branch:{start}:{name}:{p}", start)
|
||||
if not isinstance(p, Send)
|
||||
else p
|
||||
for p in filtered
|
||||
]
|
||||
if branch.then and branch.then != END:
|
||||
writes.append(
|
||||
ChannelWriteEntry(
|
||||
f"branch:{start}:{name}:then",
|
||||
WaitForNames(
|
||||
{p.node if isinstance(p, Send) else p for p in filtered}
|
||||
),
|
||||
)
|
||||
)
|
||||
return ChannelWrite(writes, tags=[TAG_HIDDEN])
|
||||
|
||||
# attach branch publisher
|
||||
self.nodes[start] |= branch.run(branch_writer, _get_state_reader(self.builder))
|
||||
|
||||
# attach branch subscribers
|
||||
ends = (
|
||||
branch.ends.values()
|
||||
if branch.ends
|
||||
else [node for node in self.builder.nodes if node != branch.then]
|
||||
)
|
||||
for end in ends:
|
||||
if end != END:
|
||||
channel_name = f"branch:{start}:{name}:{end}"
|
||||
self.channels[channel_name] = EphemeralValue(Any)
|
||||
self.nodes[end].triggers.append(channel_name)
|
||||
|
||||
# attach then subscriber
|
||||
if branch.then and branch.then != END:
|
||||
channel_name = f"branch:{start}:{name}:then"
|
||||
self.channels[channel_name] = DynamicBarrierValue(str)
|
||||
self.nodes[branch.then].triggers.append(channel_name)
|
||||
for end in ends:
|
||||
if end != END:
|
||||
self.nodes[end] |= ChannelWrite(
|
||||
[ChannelWriteEntry(channel_name, end)], tags=[TAG_HIDDEN]
|
||||
)
|
||||
|
||||
|
||||
def _get_state_reader(graph: StateGraph) -> ChannelRead:
|
||||
state_keys = list(graph.channels)
|
||||
return partial(
|
||||
ChannelRead.do_read,
|
||||
channel=state_keys[0] if state_keys == ["__root__"] else state_keys,
|
||||
fresh=True,
|
||||
# coerce state dict to schema class (eg. pydantic model)
|
||||
mapper=(
|
||||
None if state_keys == ["__root__"] else partial(_coerce_state, graph.schema)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _coerce_state(schema: Type[Any], input: dict[str, Any]) -> dict[str, Any]:
|
||||
return schema(**input)
|
||||
|
||||
|
||||
def _get_channels(
|
||||
schema: Type[dict],
|
||||
) -> tuple[dict[str, BaseChannel], dict[str, Type[ManagedValue]]]:
|
||||
if not hasattr(schema, "__annotations__"):
|
||||
return {"__root__": _get_channel(schema, allow_managed=False)}, {}
|
||||
|
||||
all_keys = {
|
||||
name: _get_channel(typ)
|
||||
for name, typ in get_type_hints(schema, include_extras=True).items()
|
||||
if name != "__slots__"
|
||||
}
|
||||
return (
|
||||
{k: v for k, v in all_keys.items() if not is_managed_value(v)},
|
||||
{k: v for k, v in all_keys.items() if is_managed_value(v)},
|
||||
)
|
||||
|
||||
|
||||
def _get_channel(
|
||||
annotation: Any, *, allow_managed: bool = True
|
||||
) -> Union[BaseChannel, Type[ManagedValue]]:
|
||||
if manager := _is_field_managed_value(annotation):
|
||||
if allow_managed:
|
||||
return manager
|
||||
else:
|
||||
raise ValueError(f"This {annotation} not allowed in this position")
|
||||
elif channel := _is_field_binop(annotation):
|
||||
return channel
|
||||
return LastValue(annotation)
|
||||
|
||||
|
||||
def _is_field_binop(typ: Type[Any]) -> Optional[BinaryOperatorAggregate]:
|
||||
if hasattr(typ, "__metadata__"):
|
||||
meta = typ.__metadata__
|
||||
if len(meta) >= 1 and callable(meta[-1]):
|
||||
sig = signature(meta[0])
|
||||
params = list(sig.parameters.values())
|
||||
if len(params) == 2 and len(
|
||||
[
|
||||
p
|
||||
for p in params
|
||||
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
):
|
||||
return BinaryOperatorAggregate(typ, meta[0])
|
||||
return None
|
||||
|
||||
|
||||
def _is_field_managed_value(typ: Type[Any]) -> Optional[Type[ManagedValue]]:
|
||||
if hasattr(typ, "__metadata__"):
|
||||
meta = typ.__metadata__
|
||||
if len(meta) == 1:
|
||||
decoration = get_origin(meta[0]) or meta[0]
|
||||
if is_managed_value(decoration):
|
||||
return decoration
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,3 @@
|
||||
from langgraph.managed.is_last_step import IsLastStep
|
||||
|
||||
__all__ = ["IsLastStep"]
|
||||
@@ -0,0 +1,130 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AsyncExitStack, ExitStack, asynccontextmanager, contextmanager
|
||||
from inspect import isclass
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Generator,
|
||||
Generic,
|
||||
NamedTuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import Self, TypeGuard
|
||||
|
||||
from langgraph.pregel.types import PregelTaskDescription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.pregel import Pregel
|
||||
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class ManagedValue(ABC, Generic[V]):
|
||||
def __init__(self, config: RunnableConfig, graph: "Pregel") -> None:
|
||||
self.config = config
|
||||
self.graph = graph
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def enter(
|
||||
cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any
|
||||
) -> Generator[Self, None, None]:
|
||||
try:
|
||||
value = cls(config, graph, **kwargs)
|
||||
yield value
|
||||
finally:
|
||||
# because managed value and Pregel have reference to each other
|
||||
# let's make sure to break the reference on exit
|
||||
try:
|
||||
del value
|
||||
except UnboundLocalError:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def aenter(
|
||||
cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any
|
||||
) -> AsyncGenerator[Self, None]:
|
||||
try:
|
||||
value = cls(config, graph, **kwargs)
|
||||
yield value
|
||||
finally:
|
||||
# because managed value and Pregel have reference to each other
|
||||
# let's make sure to break the reference on exit
|
||||
try:
|
||||
del value
|
||||
except UnboundLocalError:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def __call__(self, step: int, task: PregelTaskDescription) -> V:
|
||||
...
|
||||
|
||||
|
||||
class ConfiguredManagedValue(NamedTuple):
|
||||
cls: Type[ManagedValue]
|
||||
kwargs: dict[str, Any]
|
||||
|
||||
|
||||
ManagedValueSpec = Union[Type[ManagedValue], ConfiguredManagedValue]
|
||||
|
||||
ManagedValueMapping = dict[str, ManagedValue]
|
||||
|
||||
|
||||
def is_managed_value(value: Any) -> TypeGuard[ManagedValueSpec]:
|
||||
return (isclass(value) and issubclass(value, ManagedValue)) or isinstance(
|
||||
value, ConfiguredManagedValue
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def ManagedValuesManager(
|
||||
values: dict[str, ManagedValueSpec],
|
||||
config: RunnableConfig,
|
||||
graph: "Pregel",
|
||||
) -> Generator[ManagedValueMapping, None, None]:
|
||||
if values:
|
||||
with ExitStack() as stack:
|
||||
yield {
|
||||
key: stack.enter_context(
|
||||
value.cls.enter(config, graph, **value.kwargs)
|
||||
if isinstance(value, ConfiguredManagedValue)
|
||||
else value.enter(config, graph)
|
||||
)
|
||||
for key, value in values.items()
|
||||
}
|
||||
else:
|
||||
yield {}
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def AsyncManagedValuesManager(
|
||||
values: dict[str, ManagedValueSpec],
|
||||
config: RunnableConfig,
|
||||
graph: "Pregel",
|
||||
) -> AsyncGenerator[ManagedValueMapping, None]:
|
||||
if values:
|
||||
async with AsyncExitStack() as stack:
|
||||
# create enter tasks with reference to spec
|
||||
tasks = {
|
||||
asyncio.create_task(
|
||||
stack.enter_async_context(
|
||||
value.cls.aenter(config, graph, **value.kwargs)
|
||||
if isinstance(value, ConfiguredManagedValue)
|
||||
else value.aenter(config, graph)
|
||||
)
|
||||
): key
|
||||
for key, value in values.items()
|
||||
}
|
||||
# wait for all enter tasks
|
||||
done, _ = await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
|
||||
# build mapping from spec to result
|
||||
yield {tasks[task]: task.result() for task in done}
|
||||
else:
|
||||
yield {}
|
||||
@@ -0,0 +1,103 @@
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
AsyncIterator,
|
||||
Callable,
|
||||
Dict,
|
||||
Generator,
|
||||
Generic,
|
||||
Iterator,
|
||||
Optional,
|
||||
Sequence,
|
||||
Union,
|
||||
)
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.manager import AsyncChannelsManager, ChannelsManager
|
||||
from langgraph.managed.base import ConfiguredManagedValue, ManagedValue, V
|
||||
from langgraph.pregel import Pregel
|
||||
from langgraph.pregel.io import read_channels
|
||||
from langgraph.pregel.types import PregelTaskDescription
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langgraph.pregel import Pregel
|
||||
|
||||
# Metadata filter can be a dict (static) or a function (dynamic) that takes a
|
||||
# RunnableConfig and returns a dict. Functions are used for filtering on
|
||||
# metadata values that are only available at runtime.
|
||||
MetadataFilter = Union[Dict[str, Any], Callable[[RunnableConfig], Dict[str, Any]]]
|
||||
|
||||
|
||||
class FewShotExamples(ManagedValue[Sequence[V]], Generic[V]):
|
||||
examples: list[V]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
graph: Pregel,
|
||||
k: int = 5,
|
||||
metadata_filter: Optional[MetadataFilter] = None,
|
||||
) -> None:
|
||||
super().__init__(config, graph)
|
||||
self.k = k
|
||||
self.metadata_filter = metadata_filter or {}
|
||||
|
||||
@classmethod
|
||||
def configure(
|
||||
cls, k: int = 5, metadata_filter: Optional[MetadataFilter] = None
|
||||
) -> ConfiguredManagedValue:
|
||||
return ConfiguredManagedValue(
|
||||
cls,
|
||||
{
|
||||
"k": k,
|
||||
"metadata_filter": metadata_filter,
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def metadata_filter_dict(self) -> Dict[str, Any]:
|
||||
if isinstance(self.metadata_filter, Callable):
|
||||
return self.metadata_filter(self.config)
|
||||
else:
|
||||
return self.metadata_filter
|
||||
|
||||
def iter(self, score: int = 1) -> Iterator[V]:
|
||||
for example in self.graph.checkpointer.list(
|
||||
None, filter={"score": score, **self.metadata_filter_dict}, limit=self.k
|
||||
):
|
||||
with ChannelsManager(self.graph.channels, example.checkpoint) as channels:
|
||||
yield read_channels(channels, self.graph.output_channels)
|
||||
|
||||
async def aiter(self, score: int = 1) -> AsyncIterator[V]:
|
||||
async for example in self.graph.checkpointer.alist(
|
||||
None, filter={"score": score, **self.metadata_filter_dict}, limit=self.k
|
||||
):
|
||||
async with AsyncChannelsManager(
|
||||
self.graph.channels, example.checkpoint
|
||||
) as channels:
|
||||
yield read_channels(channels, self.graph.output_channels)
|
||||
|
||||
@classmethod
|
||||
@contextmanager
|
||||
def enter(
|
||||
cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any
|
||||
) -> Generator[Self, None, None]:
|
||||
with super().enter(config, graph, **kwargs) as value:
|
||||
value.examples = list(value.iter())
|
||||
yield value
|
||||
|
||||
@classmethod
|
||||
@asynccontextmanager
|
||||
async def aenter(
|
||||
cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any
|
||||
) -> AsyncGenerator[Self, None]:
|
||||
async with super().aenter(config, graph, **kwargs) as value:
|
||||
value.examples = [e async for e in value.aiter()]
|
||||
yield value
|
||||
|
||||
def __call__(self, step: int, task: PregelTaskDescription) -> Sequence[V]:
|
||||
return self.examples
|
||||
@@ -0,0 +1,12 @@
|
||||
from typing import Annotated
|
||||
|
||||
from langgraph.managed.base import ManagedValue
|
||||
from langgraph.pregel.types import PregelExecutableTask
|
||||
|
||||
|
||||
class IsLastStepManager(ManagedValue[bool]):
|
||||
def __call__(self, step: int, task: PregelExecutableTask) -> bool:
|
||||
return step == self.config["recursion_limit"] - 1
|
||||
|
||||
|
||||
IsLastStep = Annotated[bool, IsLastStepManager]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""langgraph.prebuilt exposes a higher-level API for creating and executing agents and tools."""
|
||||
from langgraph.prebuilt import chat_agent_executor
|
||||
from langgraph.prebuilt.agent_executor import create_agent_executor
|
||||
from langgraph.prebuilt.chat_agent_executor import create_react_agent
|
||||
from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation
|
||||
from langgraph.prebuilt.tool_node import ToolNode, tools_condition
|
||||
from langgraph.prebuilt.tool_validator import ValidationNode
|
||||
|
||||
__all__ = [
|
||||
"create_agent_executor",
|
||||
"chat_agent_executor",
|
||||
"create_react_agent",
|
||||
"ToolExecutor",
|
||||
"ToolInvocation",
|
||||
"ToolNode",
|
||||
"tools_condition",
|
||||
"ValidationNode",
|
||||
]
|
||||
@@ -0,0 +1,180 @@
|
||||
import operator
|
||||
from typing import Annotated, Sequence, TypedDict, Union
|
||||
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
from langchain_core.messages import BaseMessage
|
||||
|
||||
from langgraph._api.deprecation import deprecated
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
from langgraph.prebuilt.tool_executor import ToolExecutor
|
||||
from langgraph.utils import RunnableCallable
|
||||
|
||||
|
||||
def _get_agent_state(input_schema=None):
|
||||
if input_schema is None:
|
||||
|
||||
class AgentState(TypedDict):
|
||||
# The input string
|
||||
input: str
|
||||
# The list of previous messages in the conversation
|
||||
chat_history: Sequence[BaseMessage]
|
||||
# The outcome of a given call to the agent
|
||||
# Needs `None` as a valid type, since this is what this will start as
|
||||
agent_outcome: Union[AgentAction, AgentFinish, None]
|
||||
# List of actions and corresponding observations
|
||||
# Here we annotate this with `operator.add` to indicate that operations to
|
||||
# this state should be ADDED to the existing values (not overwrite it)
|
||||
intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]
|
||||
|
||||
else:
|
||||
|
||||
class AgentState(input_schema):
|
||||
# The outcome of a given call to the agent
|
||||
# Needs `None` as a valid type, since this is what this will start as
|
||||
agent_outcome: Union[AgentAction, AgentFinish, None]
|
||||
# List of actions and corresponding observations
|
||||
# Here we annotate this with `operator.add` to indicate that operations to
|
||||
# this state should be ADDED to the existing values (not overwrite it)
|
||||
intermediate_steps: Annotated[list[tuple[AgentAction, str]], operator.add]
|
||||
|
||||
return AgentState
|
||||
|
||||
|
||||
@deprecated(
|
||||
"0.0.44",
|
||||
alternative="create_react_agent",
|
||||
example="""
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
|
||||
create_react_agent(...)
|
||||
""",
|
||||
)
|
||||
def create_agent_executor(
|
||||
agent_runnable, tools, input_schema=None
|
||||
) -> CompiledStateGraph:
|
||||
"""This is a helper function for creating a graph that works with LangChain Agents.
|
||||
|
||||
Args:
|
||||
agent_runnable (RunnableLike): The agent runnable.
|
||||
tools (list): A list of tools to be used by the agent.
|
||||
input_schema (dict, optional): The input schema for the agent. Defaults to None.
|
||||
|
||||
Returns:
|
||||
The `CompiledStateGraph` object.
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
# Since this is deprecated, you should use `create_react_agent` instead.
|
||||
# Example usage:
|
||||
from langgraph.prebuilt import create_react_agent
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langchain_community.tools.tavily_search import TavilySearchResults
|
||||
|
||||
tools = [TavilySearchResults(max_results=1)]
|
||||
model = ChatOpenAI()
|
||||
|
||||
app = create_react_agent(model, tools)
|
||||
|
||||
inputs = {"messages": [("user", "what is the weather in sf")]}
|
||||
for s in app.stream(inputs):
|
||||
print(list(s.values())[0])
|
||||
print("----")
|
||||
"""
|
||||
|
||||
if isinstance(tools, ToolExecutor):
|
||||
tool_executor = tools
|
||||
else:
|
||||
tool_executor = ToolExecutor(tools)
|
||||
|
||||
state = _get_agent_state(input_schema)
|
||||
|
||||
# Define logic that will be used to determine which conditional edge to go down
|
||||
|
||||
def should_continue(data):
|
||||
# If the agent outcome is an AgentFinish, then we return `exit` string
|
||||
# This will be used when setting up the graph to define the flow
|
||||
if isinstance(data["agent_outcome"], AgentFinish):
|
||||
return "end"
|
||||
# Otherwise, an AgentAction is returned
|
||||
# Here we return `continue` string
|
||||
# This will be used when setting up the graph to define the flow
|
||||
else:
|
||||
return "continue"
|
||||
|
||||
def run_agent(data, config):
|
||||
agent_outcome = agent_runnable.invoke(data, config)
|
||||
return {"agent_outcome": agent_outcome}
|
||||
|
||||
async def arun_agent(data, config):
|
||||
agent_outcome = await agent_runnable.ainvoke(data, config)
|
||||
return {"agent_outcome": agent_outcome}
|
||||
|
||||
# Define the function to execute tools
|
||||
def execute_tools(data, config):
|
||||
# Get the most recent agent_outcome - this is the key added in the `agent` above
|
||||
agent_action = data["agent_outcome"]
|
||||
if not isinstance(agent_action, list):
|
||||
agent_action = [agent_action]
|
||||
output = tool_executor.batch(agent_action, config, return_exceptions=True)
|
||||
return {
|
||||
"intermediate_steps": [
|
||||
(action, str(out)) for action, out in zip(agent_action, output)
|
||||
]
|
||||
}
|
||||
|
||||
async def aexecute_tools(data, config):
|
||||
# Get the most recent agent_outcome - this is the key added in the `agent` above
|
||||
agent_action = data["agent_outcome"]
|
||||
if not isinstance(agent_action, list):
|
||||
agent_action = [agent_action]
|
||||
output = await tool_executor.abatch(
|
||||
agent_action, config, return_exceptions=True
|
||||
)
|
||||
return {
|
||||
"intermediate_steps": [
|
||||
(action, str(out)) for action, out in zip(agent_action, output)
|
||||
]
|
||||
}
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(state)
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", RunnableCallable(run_agent, arun_agent))
|
||||
workflow.add_node("tools", RunnableCallable(execute_tools, aexecute_tools))
|
||||
|
||||
# Set the entrypoint as `agent`
|
||||
# This means that this node is the first one called
|
||||
workflow.set_entry_point("agent")
|
||||
|
||||
# We now add a conditional edge
|
||||
workflow.add_conditional_edges(
|
||||
# First, we define the start node. We use `agent`.
|
||||
# This means these are the edges taken after the `agent` node is called.
|
||||
"agent",
|
||||
# Next, we pass in the function that will determine which node is called next.
|
||||
should_continue,
|
||||
# Finally we pass in a mapping.
|
||||
# The keys are strings, and the values are other nodes.
|
||||
# END is a special node marking that the graph should finish.
|
||||
# What will happen is we will call `should_continue`, and then the output of that
|
||||
# will be matched against the keys in this mapping.
|
||||
# Based on which one it matches, that node will then be called.
|
||||
{
|
||||
# If `tools`, then we call the tool node.
|
||||
"continue": "tools",
|
||||
# Otherwise we finish.
|
||||
"end": END,
|
||||
},
|
||||
)
|
||||
|
||||
# We now add a normal edge from `tools` to `agent`.
|
||||
# This means that after `tools` is called, `agent` node is called next.
|
||||
workflow.add_edge("tools", "agent")
|
||||
|
||||
# Finally, we compile it!
|
||||
# This compiles it into a LangChain Runnable,
|
||||
# meaning you can use it as you would any other runnable
|
||||
return workflow.compile()
|
||||
@@ -0,0 +1,486 @@
|
||||
import json
|
||||
from typing import Annotated, Callable, Optional, Sequence, TypedDict, Union
|
||||
|
||||
from langchain_core.language_models import LanguageModelLike
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
FunctionMessage,
|
||||
SystemMessage,
|
||||
)
|
||||
from langchain_core.runnables import Runnable, RunnableConfig, RunnableLambda
|
||||
from langchain_core.tools import BaseTool
|
||||
from langchain_core.utils.function_calling import convert_to_openai_function
|
||||
|
||||
from langgraph._api.deprecation import deprecated
|
||||
from langgraph.checkpoint import BaseCheckpointSaver
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.graph.graph import CompiledGraph
|
||||
from langgraph.graph.message import add_messages
|
||||
from langgraph.managed import IsLastStep
|
||||
from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation
|
||||
from langgraph.prebuilt.tool_node import ToolNode
|
||||
|
||||
|
||||
# We create the AgentState that we will pass around
|
||||
# This simply involves a list of messages
|
||||
# We want steps to return messages to append to the list
|
||||
# So we annotate the messages attribute with operator.add
|
||||
class AgentState(TypedDict):
|
||||
"""The state of the agent."""
|
||||
|
||||
messages: Annotated[Sequence[BaseMessage], add_messages]
|
||||
|
||||
is_last_step: IsLastStep
|
||||
|
||||
|
||||
@deprecated("0.0.44", "create_react_agent")
|
||||
def create_function_calling_executor(
|
||||
model: LanguageModelLike, tools: Union[ToolExecutor, Sequence[BaseTool]]
|
||||
) -> CompiledGraph:
|
||||
"""Creates a graph that works with a chat model that utilizes function calling.
|
||||
|
||||
Examples:
|
||||
```pycon
|
||||
>>> # Since this is deprecated, you should use `create_react_agent` instead.
|
||||
>>> # Example usage:
|
||||
>>> from langgraph.prebuilt import create_react_agent
|
||||
>>> from langchain_openai import ChatOpenAI
|
||||
>>> from langchain_community.tools.tavily_search import TavilySearchResults
|
||||
>>>
|
||||
>>> tools = [TavilySearchResults(max_results=1)]
|
||||
>>> model = ChatOpenAI()
|
||||
>>>
|
||||
>>> app = create_react_agent(model, tools)
|
||||
>>>
|
||||
>>> inputs = {"messages": [("user", "what is the weather in sf")]}
|
||||
>>> for s in app.stream(inputs):
|
||||
... print(list(s.values())[0])
|
||||
... print("----")
|
||||
```
|
||||
"""
|
||||
if isinstance(tools, ToolExecutor):
|
||||
tool_executor = tools
|
||||
tool_classes = tools.tools
|
||||
else:
|
||||
tool_executor = ToolExecutor(tools)
|
||||
tool_classes = tools
|
||||
model = model.bind(functions=[convert_to_openai_function(t) for t in tool_classes])
|
||||
|
||||
# Define the function that determines whether to continue or not
|
||||
def should_continue(state: AgentState):
|
||||
messages = state["messages"]
|
||||
last_message = messages[-1]
|
||||
# If there is no function call, then we finish
|
||||
if "function_call" not in last_message.additional_kwargs:
|
||||
return "end"
|
||||
# Otherwise if there is, we continue
|
||||
else:
|
||||
return "continue"
|
||||
|
||||
# Define the function that calls the model
|
||||
def call_model(state: AgentState, config: RunnableConfig):
|
||||
messages = state["messages"]
|
||||
response = model.invoke(messages, config)
|
||||
# We return a list, because this will get added to the existing list
|
||||
return {"messages": [response]}
|
||||
|
||||
async def acall_model(state: AgentState, config: RunnableConfig):
|
||||
messages = state["messages"]
|
||||
response = await model.ainvoke(messages, config)
|
||||
# We return a list, because this will get added to the existing list
|
||||
return {"messages": [response]}
|
||||
|
||||
# Define the function to execute tools
|
||||
def _get_action(state: AgentState):
|
||||
messages = state["messages"]
|
||||
# Based on the continue condition
|
||||
# we know the last message involves a function call
|
||||
last_message = messages[-1]
|
||||
# We construct an AgentAction from the function_call
|
||||
return ToolInvocation(
|
||||
tool=last_message.additional_kwargs["function_call"]["name"],
|
||||
tool_input=json.loads(
|
||||
last_message.additional_kwargs["function_call"]["arguments"]
|
||||
),
|
||||
)
|
||||
|
||||
def call_tool(state: AgentState, config: RunnableConfig):
|
||||
action = _get_action(state)
|
||||
# We call the tool_executor and get back a response
|
||||
response = tool_executor.invoke(action, config)
|
||||
# We use the response to create a FunctionMessage
|
||||
function_message = FunctionMessage(content=str(response), name=action.tool)
|
||||
# We return a list, because this will get added to the existing list
|
||||
return {"messages": [function_message]}
|
||||
|
||||
async def acall_tool(state: AgentState, config: RunnableConfig):
|
||||
action = _get_action(state)
|
||||
# We call the tool_executor and get back a response
|
||||
response = await tool_executor.ainvoke(action, config)
|
||||
# We use the response to create a FunctionMessage
|
||||
function_message = FunctionMessage(content=str(response), name=action.tool)
|
||||
# We return a list, because this will get added to the existing list
|
||||
return {"messages": [function_message]}
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(AgentState)
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", RunnableLambda(call_model, acall_model))
|
||||
workflow.add_node("tools", RunnableLambda(call_tool, acall_tool))
|
||||
|
||||
# Set the entrypoint as `agent`
|
||||
# This means that this node is the first one called
|
||||
workflow.set_entry_point("agent")
|
||||
|
||||
# We now add a conditional edge
|
||||
workflow.add_conditional_edges(
|
||||
# First, we define the start node. We use `agent`.
|
||||
# This means these are the edges taken after the `agent` node is called.
|
||||
"agent",
|
||||
# Next, we pass in the function that will determine which node is called next.
|
||||
should_continue,
|
||||
# Finally we pass in a mapping.
|
||||
# The keys are strings, and the values are other nodes.
|
||||
# END is a special node marking that the graph should finish.
|
||||
# What will happen is we will call `should_continue`, and then the output of that
|
||||
# will be matched against the keys in this mapping.
|
||||
# Based on which one it matches, that node will then be called.
|
||||
{
|
||||
# If `tools`, then we call the tool node.
|
||||
"continue": "tools",
|
||||
# Otherwise we finish.
|
||||
"end": END,
|
||||
},
|
||||
)
|
||||
|
||||
# We now add a normal edge from `tools` to `agent`.
|
||||
# This means that after `tools` is called, `agent` node is called next.
|
||||
workflow.add_edge("tools", "agent")
|
||||
|
||||
# Finally, we compile it!
|
||||
# This compiles it into a LangChain Runnable,
|
||||
# meaning you can use it as you would any other runnable
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
def create_react_agent(
|
||||
model: LanguageModelLike,
|
||||
tools: Union[ToolExecutor, Sequence[BaseTool]],
|
||||
messages_modifier: Optional[Union[SystemMessage, str, Callable, Runnable]] = None,
|
||||
checkpointer: Optional[BaseCheckpointSaver] = None,
|
||||
interrupt_before: Optional[Sequence[str]] = None,
|
||||
interrupt_after: Optional[Sequence[str]] = None,
|
||||
debug: bool = False,
|
||||
) -> CompiledGraph:
|
||||
"""Creates a graph that works with a chat model that utilizes tool calling.
|
||||
|
||||
Args:
|
||||
model: The `LangChain` chat model that supports tool calling.
|
||||
tools: A list of tools or a ToolExecutor instance.
|
||||
messages_modifier: An optional
|
||||
messages modifier. This applies to messages BEFORE they are passed into the LLM.
|
||||
Can take a few different forms:
|
||||
- SystemMessage: this is added to the beginning of the list of messages.
|
||||
- str: This is converted to a SystemMessage and added to the beginning of the list of messages.
|
||||
- Callable: This function should take in a list of messages and the output is then passed to the language model.
|
||||
- Runnable: This runnable should take in a list of messages and the output is then passed to the language model.
|
||||
checkpointer: An optional checkpoint saver object. This is useful for persisting
|
||||
the state of the graph (e.g., as chat memory).
|
||||
interrupt_before: An optional list of node names to interrupt before.
|
||||
Should be one of the following: "agent", "tools".
|
||||
This is useful if you want to add a user confirmation or other interrupt before taking an action.
|
||||
interrupt_after: An optional list of node names to interrupt after.
|
||||
Should be one of the following: "agent", "tools".
|
||||
This is useful if you want to return directly or run additional processing on an output.
|
||||
debug: A flag indicating whether to enable debug mode.
|
||||
|
||||
Returns:
|
||||
A compiled LangChain runnable that can be used for chat interactions.
|
||||
|
||||
Examples:
|
||||
Use with a simple tool:
|
||||
|
||||
```pycon
|
||||
>>> from datetime import datetime
|
||||
>>> from langchain_core.tools import tool
|
||||
>>> from langchain_openai import ChatOpenAI
|
||||
>>> from langgraph.prebuilt import create_react_agent
|
||||
>>>
|
||||
>>> @tool
|
||||
... def check_weather(location: str, at_time: datetime | None = None) -> float:
|
||||
... '''Return the weather forecast for the specified location.'''
|
||||
... return f"It's always sunny in {location}"
|
||||
>>>
|
||||
>>> tools = [check_weather]
|
||||
>>> model = ChatOpenAI(model="gpt-4o")
|
||||
>>> graph = create_react_agent(model, tools=tools)
|
||||
>>> inputs = {"messages": [("user", "what is the weather in sf")]}
|
||||
>>> for s in graph.stream(inputs, stream_mode="values"):
|
||||
... message = s["messages"][-1]
|
||||
... if isinstance(message, tuple):
|
||||
... print(message)
|
||||
... else:
|
||||
... message.pretty_print()
|
||||
('user', 'what is the weather in sf')
|
||||
================================== Ai Message ==================================
|
||||
Tool Calls:
|
||||
check_weather (call_LUzFvKJRuaWQPeXvBOzwhQOu)
|
||||
Call ID: call_LUzFvKJRuaWQPeXvBOzwhQOu
|
||||
Args:
|
||||
location: San Francisco
|
||||
================================= Tool Message =================================
|
||||
Name: check_weather
|
||||
It's always sunny in San Francisco
|
||||
================================== Ai Message ==================================
|
||||
The weather in San Francisco is sunny.
|
||||
```
|
||||
Add a system prompt for the LLM:
|
||||
|
||||
```pycon
|
||||
>>> system_prompt = "You are a helpful bot named Fred."
|
||||
>>> graph = create_react_agent(model, tools, messages_modifier=system_prompt)
|
||||
>>> inputs = {"messages": [("user", "What's your name? And what's the weather in SF?")]}
|
||||
>>> for s in graph.stream(inputs, stream_mode="values"):
|
||||
... message = s["messages"][-1]
|
||||
... if isinstance(message, tuple):
|
||||
... print(message)
|
||||
... else:
|
||||
... message.pretty_print()
|
||||
('user', "What's your name? And what's the weather in SF?")
|
||||
================================== Ai Message ==================================
|
||||
Hi, my name is Fred. Let me check the weather in San Francisco for you.
|
||||
Tool Calls:
|
||||
check_weather (call_lqhj4O0hXYkW9eknB4S41EXk)
|
||||
Call ID: call_lqhj4O0hXYkW9eknB4S41EXk
|
||||
Args:
|
||||
location: San Francisco
|
||||
================================= Tool Message =================================
|
||||
Name: check_weather
|
||||
It's always sunny in San Francisco
|
||||
================================== Ai Message ==================================
|
||||
The weather in San Francisco is currently sunny. If you need any more details or have other questions, feel free to ask!
|
||||
```
|
||||
|
||||
Add a more complex prompt for the LLM:
|
||||
|
||||
```pycon
|
||||
>>> from langchain_core.prompts import ChatPromptTemplate
|
||||
>>> prompt = ChatPromptTemplate.from_messages([
|
||||
... ("system", "You are a helpful bot named Fred."),
|
||||
... ("placeholder", "{messages}"),
|
||||
... ("user", "Remember, always be polite!"),
|
||||
... ])
|
||||
>>> def modify_messages(messages: list):
|
||||
... # You can do more complex modifications here
|
||||
... return prompt.invoke({"messages": messages})
|
||||
>>>
|
||||
>>> app = create_react_agent(model, tools, messages_modifier=modify_messages)
|
||||
>>> inputs = {"messages": [("user", "What's your name? And what's the weather in SF?")]}
|
||||
>>> for s in graph.stream(inputs, stream_mode="values"):
|
||||
... message = s["messages"][-1]
|
||||
... if isinstance(message, tuple):
|
||||
... print(message)
|
||||
... else:
|
||||
... message.pretty_print()
|
||||
```
|
||||
|
||||
Add "chat memory" to the graph:
|
||||
|
||||
```pycon
|
||||
>>> from langgraph.checkpoint import MemorySaver
|
||||
>>> graph = create_react_agent(model, tools, checkpointer=MemorySaver())
|
||||
>>> config = {"configurable": {"thread_id": "thread-1"}}
|
||||
>>> def print_stream(graph, inputs, config):
|
||||
... for s in graph.stream(inputs, config, stream_mode="values"):
|
||||
... message = s["messages"][-1]
|
||||
... if isinstance(message, tuple):
|
||||
... print(message)
|
||||
... else:
|
||||
... message.pretty_print()
|
||||
>>> inputs = {"messages": [("user", "What's the weather in SF?")]}
|
||||
>>> print_stream(graph, inputs, config)
|
||||
>>> inputs2 = {"messages": [("user", "Cool, so then should i go biking today?")]}
|
||||
>>> print_stream(graph, inputs2, config)
|
||||
('user', "What's the weather in SF?")
|
||||
================================== Ai Message ==================================
|
||||
Tool Calls:
|
||||
check_weather (call_ChndaktJxpr6EMPEB5JfOFYc)
|
||||
Call ID: call_ChndaktJxpr6EMPEB5JfOFYc
|
||||
Args:
|
||||
location: San Francisco
|
||||
================================= Tool Message =================================
|
||||
Name: check_weather
|
||||
It's always sunny in San Francisco
|
||||
================================== Ai Message ==================================
|
||||
The weather in San Francisco is sunny. Enjoy your day!
|
||||
================================ Human Message =================================
|
||||
Cool, so then should i go biking today?
|
||||
================================== Ai Message ==================================
|
||||
Since the weather in San Francisco is sunny, it sounds like a great day for biking! Enjoy your ride!
|
||||
```
|
||||
|
||||
Add an interrupt to let the user confirm before taking an action:
|
||||
|
||||
```pycon
|
||||
>>> graph = create_react_agent(
|
||||
... model, tools, interrupt_before=["tools"], checkpointer=MemorySaver()
|
||||
>>> )
|
||||
>>> config = {"configurable": {"thread_id": "thread-1"}}
|
||||
>>> def print_stream(graph, inputs, config):
|
||||
... for s in graph.stream(inputs, config, stream_mode="values"):
|
||||
... message = s["messages"][-1]
|
||||
... if isinstance(message, tuple):
|
||||
... print(message)
|
||||
... else:
|
||||
... message.pretty_print()
|
||||
|
||||
>>> inputs = {"messages": [("user", "What's the weather in SF?")]}
|
||||
>>> print_stream(graph, inputs, config)
|
||||
>>> snapshot = graph.get_state(config)
|
||||
>>> print("Next step: ", snapshot.next)
|
||||
>>> print_stream(graph, None, config)
|
||||
```
|
||||
|
||||
Add a timeout for a given step:
|
||||
|
||||
```pycon
|
||||
>>> import time
|
||||
>>> @tool
|
||||
... def check_weather(location: str, at_time: datetime | None = None) -> float:
|
||||
... '''Return the weather forecast for the specified location.'''
|
||||
... time.sleep(2)
|
||||
... return f"It's always sunny in {location}"
|
||||
>>>
|
||||
>>> tools = [check_weather]
|
||||
>>> graph = create_react_agent(model, tools)
|
||||
>>> graph.step_timeout = 1 # Seconds
|
||||
>>> for s in graph.stream({"messages": [("user", "what is the weather in sf")]}):
|
||||
... print(s)
|
||||
TimeoutError: Timed out at step 2
|
||||
```
|
||||
"""
|
||||
|
||||
if isinstance(tools, ToolExecutor):
|
||||
tool_classes = tools.tools
|
||||
else:
|
||||
tool_classes = tools
|
||||
model = model.bind_tools(tool_classes)
|
||||
|
||||
# Define the function that determines whether to continue or not
|
||||
def should_continue(state: AgentState):
|
||||
messages = state["messages"]
|
||||
last_message = messages[-1]
|
||||
# If there is no function call, then we finish
|
||||
if not last_message.tool_calls:
|
||||
return "end"
|
||||
# Otherwise if there is, we continue
|
||||
else:
|
||||
return "continue"
|
||||
|
||||
# Add the message modifier, if exists
|
||||
if messages_modifier is None:
|
||||
model_runnable = model
|
||||
elif isinstance(messages_modifier, str):
|
||||
_system_message: BaseMessage = SystemMessage(content=messages_modifier)
|
||||
model_runnable = (lambda messages: [_system_message] + messages) | model
|
||||
elif isinstance(messages_modifier, SystemMessage):
|
||||
model_runnable = (lambda messages: [messages_modifier] + messages) | model
|
||||
elif isinstance(messages_modifier, (Callable, Runnable)):
|
||||
model_runnable = messages_modifier | model
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Got unexpected type for `messages_modifier`: {type(messages_modifier)}"
|
||||
)
|
||||
|
||||
# Define the function that calls the model
|
||||
def call_model(
|
||||
state: AgentState,
|
||||
config: RunnableConfig,
|
||||
):
|
||||
messages = state["messages"]
|
||||
response = model_runnable.invoke(messages, config)
|
||||
if state["is_last_step"] and response.tool_calls:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
id=response.id,
|
||||
content="Sorry, need more steps to process this request.",
|
||||
)
|
||||
]
|
||||
}
|
||||
# We return a list, because this will get added to the existing list
|
||||
return {"messages": [response]}
|
||||
|
||||
async def acall_model(state: AgentState, config: RunnableConfig):
|
||||
messages = state["messages"]
|
||||
response = await model_runnable.ainvoke(messages, config)
|
||||
if state["is_last_step"] and response.tool_calls:
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(
|
||||
id=response.id,
|
||||
content="Sorry, need more steps to process this request.",
|
||||
)
|
||||
]
|
||||
}
|
||||
# We return a list, because this will get added to the existing list
|
||||
return {"messages": [response]}
|
||||
|
||||
# Define a new graph
|
||||
workflow = StateGraph(AgentState)
|
||||
|
||||
# Define the two nodes we will cycle between
|
||||
workflow.add_node("agent", RunnableLambda(call_model, acall_model))
|
||||
workflow.add_node("tools", ToolNode(tools))
|
||||
|
||||
# Set the entrypoint as `agent`
|
||||
# This means that this node is the first one called
|
||||
workflow.set_entry_point("agent")
|
||||
|
||||
# We now add a conditional edge
|
||||
workflow.add_conditional_edges(
|
||||
# First, we define the start node. We use `agent`.
|
||||
# This means these are the edges taken after the `agent` node is called.
|
||||
"agent",
|
||||
# Next, we pass in the function that will determine which node is called next.
|
||||
should_continue,
|
||||
# Finally we pass in a mapping.
|
||||
# The keys are strings, and the values are other nodes.
|
||||
# END is a special node marking that the graph should finish.
|
||||
# What will happen is we will call `should_continue`, and then the output of that
|
||||
# will be matched against the keys in this mapping.
|
||||
# Based on which one it matches, that node will then be called.
|
||||
{
|
||||
# If `tools`, then we call the tool node.
|
||||
"continue": "tools",
|
||||
# Otherwise we finish.
|
||||
"end": END,
|
||||
},
|
||||
)
|
||||
|
||||
# We now add a normal edge from `tools` to `agent`.
|
||||
# This means that after `tools` is called, `agent` node is called next.
|
||||
workflow.add_edge("tools", "agent")
|
||||
|
||||
# Finally, we compile it!
|
||||
# This compiles it into a LangChain Runnable,
|
||||
# meaning you can use it as you would any other runnable
|
||||
return workflow.compile(
|
||||
checkpointer=checkpointer,
|
||||
interrupt_before=interrupt_before,
|
||||
interrupt_after=interrupt_after,
|
||||
debug=debug,
|
||||
)
|
||||
|
||||
|
||||
# Keep for backwards compatibility
|
||||
create_tool_calling_executor = create_react_agent
|
||||
|
||||
__all__ = [
|
||||
"create_react_agent",
|
||||
"create_tool_calling_executor",
|
||||
"create_function_calling_executor",
|
||||
"AgentState",
|
||||
]
|
||||
@@ -0,0 +1,126 @@
|
||||
from typing import Any, Callable, Sequence, Union
|
||||
|
||||
from langchain_core.load.serializable import Serializable
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.tools import BaseTool
|
||||
from langchain_core.tools import tool as create_tool
|
||||
|
||||
from langgraph.utils import RunnableCallable
|
||||
|
||||
INVALID_TOOL_MSG_TEMPLATE = (
|
||||
"{requested_tool_name} is not a valid tool, "
|
||||
"try one of [{available_tool_names_str}]."
|
||||
)
|
||||
|
||||
|
||||
class ToolInvocationInterface:
|
||||
"""Interface for invoking a tool.
|
||||
|
||||
Attributes:
|
||||
tool (str): The name of the tool to invoke.
|
||||
tool_input (Union[str, dict]): The input to pass to the tool.
|
||||
|
||||
"""
|
||||
|
||||
tool: str
|
||||
tool_input: Union[str, dict]
|
||||
|
||||
|
||||
class ToolInvocation(Serializable):
|
||||
"""Information about how to invoke a tool.
|
||||
|
||||
Attributes:
|
||||
tool (str): The name of the Tool to execute.
|
||||
tool_input (Union[str, dict]): The input to pass in to the Tool.
|
||||
|
||||
Examples:
|
||||
|
||||
invocation = ToolInvocation(
|
||||
tool="search",
|
||||
tool_input="What is the capital of France?"
|
||||
)
|
||||
"""
|
||||
|
||||
tool: str
|
||||
tool_input: Union[str, dict]
|
||||
|
||||
|
||||
class ToolExecutor(RunnableCallable):
|
||||
"""Executes a tool invocation.
|
||||
|
||||
Args:
|
||||
tools (Sequence[BaseTool]): A sequence of tools that can be invoked.
|
||||
invalid_tool_msg_template (str, optional): The template for the error message
|
||||
when an invalid tool is requested. Defaults to INVALID_TOOL_MSG_TEMPLATE.
|
||||
|
||||
Examples:
|
||||
|
||||
```pycon
|
||||
>>> from langchain_core.tools import tool
|
||||
>>> from langgraph.prebuilt.tool_executor import ToolExecutor, ToolInvocation
|
||||
...
|
||||
...
|
||||
>>> @tool
|
||||
... def search(query: str) -> str:
|
||||
... \"\"\"Search engine.\"\"\"
|
||||
... return f"Searching for: {query}"
|
||||
...
|
||||
...
|
||||
>>> tools = [search]
|
||||
>>> executor = ToolExecutor(tools)
|
||||
...
|
||||
>>> invocation = ToolInvocation(tool="search", tool_input="What is the capital of France?")
|
||||
>>> result = executor.invoke(invocation)
|
||||
>>> print(result)
|
||||
"Searching for: What is the capital of France?"
|
||||
```
|
||||
|
||||
```pycon
|
||||
>>> invocation = ToolInvocation(
|
||||
... tool="nonexistent", tool_input="What is the capital of France?"
|
||||
... )
|
||||
>>> result = executor.invoke(invocation)
|
||||
>>> print(result)
|
||||
"nonexistent is not a valid tool, try one of [search]."
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: Sequence[Union[BaseTool, Callable]],
|
||||
*,
|
||||
invalid_tool_msg_template: str = INVALID_TOOL_MSG_TEMPLATE,
|
||||
) -> None:
|
||||
super().__init__(self._execute, afunc=self._aexecute, trace=False)
|
||||
tools_ = [
|
||||
tool if isinstance(tool, BaseTool) else create_tool(tool) for tool in tools
|
||||
]
|
||||
self.tools = tools_
|
||||
self.tool_map = {t.name: t for t in tools}
|
||||
self.invalid_tool_msg_template = invalid_tool_msg_template
|
||||
|
||||
def _execute(
|
||||
self, tool_invocation: ToolInvocationInterface, config: RunnableConfig
|
||||
) -> Any:
|
||||
if tool_invocation.tool not in self.tool_map:
|
||||
return self.invalid_tool_msg_template.format(
|
||||
requested_tool_name=tool_invocation.tool,
|
||||
available_tool_names_str=", ".join([t.name for t in self.tools]),
|
||||
)
|
||||
else:
|
||||
tool = self.tool_map[tool_invocation.tool]
|
||||
output = tool.invoke(tool_invocation.tool_input, config)
|
||||
return output
|
||||
|
||||
async def _aexecute(
|
||||
self, tool_invocation: ToolInvocationInterface, config: RunnableConfig
|
||||
) -> Any:
|
||||
if tool_invocation.tool not in self.tool_map:
|
||||
return self.invalid_tool_msg_template.format(
|
||||
requested_tool_name=tool_invocation.tool,
|
||||
available_tool_names_str=", ".join([t.name for t in self.tools]),
|
||||
)
|
||||
else:
|
||||
tool = self.tool_map[tool_invocation.tool]
|
||||
output = await tool.ainvoke(tool_invocation.tool_input, config)
|
||||
return output
|
||||
@@ -0,0 +1,174 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any, Callable, Dict, Literal, Optional, Sequence, Union
|
||||
|
||||
from langchain_core.messages import AIMessage, AnyMessage, ToolCall, ToolMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.runnables.config import get_executor_for_config
|
||||
from langchain_core.tools import BaseTool
|
||||
from langchain_core.tools import tool as create_tool
|
||||
|
||||
from langgraph.utils import RunnableCallable
|
||||
|
||||
|
||||
def str_output(output: Any) -> str:
|
||||
if isinstance(output, str):
|
||||
return output
|
||||
else:
|
||||
try:
|
||||
return json.dumps(output)
|
||||
except Exception:
|
||||
return str(output)
|
||||
|
||||
|
||||
class ToolNode(RunnableCallable):
|
||||
"""A node that runs the tools requested in the last AIMessage. It can be used
|
||||
either in StateGraph with a "messages" key or in MessageGraph. If multiple
|
||||
tool calls are requested, they will be run in parallel. The output will be
|
||||
a list of ToolMessages, one for each tool call.
|
||||
|
||||
The `ToolNode` is roughly analogous to:
|
||||
|
||||
```python
|
||||
tools_by_name = {tool.name: tool for tool in tools}
|
||||
def tool_node(state: dict):
|
||||
result = []
|
||||
for tool_call in state["messages"][-1].tool_calls:
|
||||
tool = tools_by_name[tool_call["name"]]
|
||||
observation = tool.invoke(tool_call["args"])
|
||||
result.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))
|
||||
return {"messages": result}
|
||||
```
|
||||
|
||||
Important:
|
||||
- The state MUST contain a list of messages.
|
||||
- The last message MUST be an `AIMessage`.
|
||||
- The `AIMessage` MUST have `tool_calls` populated.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tools: Sequence[Union[BaseTool, Callable]],
|
||||
*,
|
||||
name: str = "tools",
|
||||
tags: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
super().__init__(self._func, self._afunc, name=name, tags=tags, trace=False)
|
||||
self.tools_by_name: Dict[str, BaseTool] = {}
|
||||
for tool_ in tools:
|
||||
if not isinstance(tool_, BaseTool):
|
||||
tool_ = create_tool(tool_)
|
||||
self.tools_by_name[tool_.name] = tool_
|
||||
|
||||
def _func(
|
||||
self, input: Union[list[AnyMessage], dict[str, Any]], config: RunnableConfig
|
||||
) -> Any:
|
||||
if isinstance(input, list):
|
||||
output_type = "list"
|
||||
message: AnyMessage = input[-1]
|
||||
elif messages := input.get("messages", []):
|
||||
output_type = "dict"
|
||||
message = messages[-1]
|
||||
else:
|
||||
raise ValueError("No message found in input")
|
||||
|
||||
if not isinstance(message, AIMessage):
|
||||
raise ValueError("Last message is not an AIMessage")
|
||||
|
||||
def run_one(call: ToolCall):
|
||||
output = self.tools_by_name[call["name"]].invoke(call["args"], config)
|
||||
return ToolMessage(
|
||||
content=str_output(output), name=call["name"], tool_call_id=call["id"]
|
||||
)
|
||||
|
||||
with get_executor_for_config(config) as executor:
|
||||
outputs = [*executor.map(run_one, message.tool_calls)]
|
||||
if output_type == "list":
|
||||
return outputs
|
||||
else:
|
||||
return {"messages": outputs}
|
||||
|
||||
async def _afunc(
|
||||
self, input: Union[list[AnyMessage], dict[str, Any]], config: RunnableConfig
|
||||
) -> Any:
|
||||
if isinstance(input, list):
|
||||
output_type = "list"
|
||||
message: AnyMessage = input[-1]
|
||||
elif messages := input.get("messages", []):
|
||||
output_type = "dict"
|
||||
message = messages[-1]
|
||||
else:
|
||||
raise ValueError("No message found in input")
|
||||
|
||||
if not isinstance(message, AIMessage):
|
||||
raise ValueError("Last message is not an AIMessage")
|
||||
|
||||
async def run_one(call: ToolCall):
|
||||
output = await self.tools_by_name[call["name"]].ainvoke(
|
||||
call["args"], config
|
||||
)
|
||||
return ToolMessage(
|
||||
content=str_output(output), name=call["name"], tool_call_id=call["id"]
|
||||
)
|
||||
|
||||
outputs = await asyncio.gather(*(run_one(call) for call in message.tool_calls))
|
||||
if output_type == "list":
|
||||
return outputs
|
||||
else:
|
||||
return {"messages": outputs}
|
||||
|
||||
|
||||
def tools_condition(
|
||||
state: Union[list[AnyMessage], dict[str, Any]],
|
||||
) -> Literal["tools", "__end__"]:
|
||||
"""Use in the conditional_edge to route to the ToolNode if the last message
|
||||
|
||||
has tool calls. Otherwise, route to the end.
|
||||
|
||||
Args:
|
||||
state (Union[list[AnyMessage], dict[str, Any]]): The state to check for
|
||||
tool calls. Must have a list of messages (MessageGraph) or have the
|
||||
"messages" key (StateGraph).
|
||||
|
||||
Returns:
|
||||
The next node to route to.
|
||||
|
||||
|
||||
Examples:
|
||||
Create a custom ReAct-style agent with tools.
|
||||
```pycon
|
||||
>>> from langchain_anthropic import ChatAnthropic
|
||||
>>> from langchain_core.tools import tool
|
||||
>>>
|
||||
>>> from langgraph.graph import MessageGraph
|
||||
>>> from langgraph.prebuilt import ToolNode, tools_condition
|
||||
>>>
|
||||
>>> @tool
|
||||
>>> def divide(a: float, b: float) -> int:
|
||||
>>> \"\"\"Return a / b.\"\"\"
|
||||
>>> return a / b
|
||||
>>>
|
||||
>>> llm = ChatAnthropic(model="claude-3-haiku-20240307")
|
||||
>>> tools = [divide]
|
||||
>>>
|
||||
>>> graph_builder = MessageGraph()
|
||||
>>> graph_builder.add_node("tools", ToolNode(tools))
|
||||
>>> graph_builder.add_node("chatbot", llm.bind_tools(tools))
|
||||
>>> graph_builder.add_edge("tools", "chatbot")
|
||||
>>> graph_builder.add_conditional_edges(
|
||||
... "chatbot", tools_condition
|
||||
... )
|
||||
>>> graph_builder.set_entry_point("chatbot")
|
||||
>>> graph = graph_builder.compile()
|
||||
>>> graph.invoke([("user", "What's 329993 divided by 13662?")])
|
||||
```
|
||||
"""
|
||||
if isinstance(state, list):
|
||||
ai_message = state[-1]
|
||||
elif messages := state.get("messages", []):
|
||||
ai_message = messages[-1]
|
||||
else:
|
||||
raise ValueError(f"No messages found in input state to tool_edge: {state}")
|
||||
if hasattr(ai_message, "tool_calls") and len(ai_message.tool_calls) > 0:
|
||||
return "tools"
|
||||
return "__end__"
|
||||
@@ -0,0 +1,230 @@
|
||||
"""This module provides a ValidationNode class that can be used to validate tool calls
|
||||
in a langchain graph. It applies a pydantic schema to tool_calls in the models' outputs,
|
||||
and returns a ToolMessage with the validated content. If the schema is not valid, it
|
||||
returns a ToolMessage with the error message. The ValidationNode can be used in a
|
||||
StateGraph with a "messages" key or in a MessageGraph. If multiple tool calls are
|
||||
requested, they will be run in parallel.
|
||||
"""
|
||||
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
Type,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
AnyMessage,
|
||||
ToolCall,
|
||||
ToolMessage,
|
||||
)
|
||||
from langchain_core.pydantic_v1 import BaseModel, ValidationError
|
||||
from langchain_core.runnables import (
|
||||
RunnableConfig,
|
||||
)
|
||||
from langchain_core.runnables.config import get_executor_for_config
|
||||
from langchain_core.tools import BaseTool, create_schema_from_function
|
||||
from pydantic import BaseModel as BaseModelV2
|
||||
from pydantic import ValidationError as ValidationErrorV2
|
||||
|
||||
from langgraph.utils import RunnableCallable
|
||||
|
||||
|
||||
def _default_format_error(
|
||||
error: BaseException, call: ToolCall, schema: Type[BaseModel]
|
||||
) -> str:
|
||||
"""Default error formatting function."""
|
||||
return f"{repr(error)}\n\nRespond after fixing all validation errors."
|
||||
|
||||
|
||||
class ValidationNode(RunnableCallable):
|
||||
"""A node that validates all tools requests from the last AIMessage.
|
||||
|
||||
It can be used either in StateGraph with a "messages" key or in MessageGraph.
|
||||
|
||||
!!! note
|
||||
|
||||
This node does not actually **run** the tools, it only validates the tool calls,
|
||||
which is useful for extraction and other use cases where you need to generate
|
||||
structured output that conforms to a complex schema without losing the original
|
||||
messages and tool IDs (for use in multi-turn conversations).
|
||||
|
||||
Args:
|
||||
schemas: A list of schemas to validate the tool calls with. These can be
|
||||
any of the following:
|
||||
- A pydantic BaseModel class
|
||||
- A BaseTool instance (the args_schema will be used)
|
||||
- A function (a schema will be created from the function signature)
|
||||
format_error: A function that takes an exception, a ToolCall, and a schema
|
||||
and returns a formatted error string. By default, it returns the
|
||||
exception repr and a message to respond after fixing validation errors.
|
||||
name: The name of the node.
|
||||
tags: A list of tags to add to the node.
|
||||
|
||||
Returns:
|
||||
(Union[Dict[str, List[ToolMessage]], Sequence[ToolMessage]]): A list of ToolMessages with the validated content or error messages.
|
||||
|
||||
Examples:
|
||||
Example usage for re-prompting the model to generate a valid response:
|
||||
>>> from typing import Literal
|
||||
...
|
||||
>>> from langchain_anthropic import ChatAnthropic
|
||||
>>> from langchain_core.pydantic_v1 import BaseModel, validator
|
||||
...
|
||||
>>> from langgraph.graph import END, START, MessageGraph
|
||||
>>> from langgraph.prebuilt import ValidationNode
|
||||
...
|
||||
...
|
||||
>>> class SelectNumber(BaseModel):
|
||||
... a: int
|
||||
...
|
||||
... @validator("a")
|
||||
... def a_must_be_meaningful(cls, v):
|
||||
... if v != 37:
|
||||
... raise ValueError("Only 37 is allowed")
|
||||
... return v
|
||||
...
|
||||
...
|
||||
>>> builder = MessageGraph()
|
||||
>>> llm = ChatAnthropic(model="claude-3-haiku-20240307").bind_tools([SelectNumber])
|
||||
>>> builder.add_node("model", llm)
|
||||
>>> builder.add_node("validation", ValidationNode([SelectNumber]))
|
||||
>>> builder.add_edge(START, "model")
|
||||
...
|
||||
...
|
||||
>>> def should_validate(state: list) -> Literal["validation", "__end__"]:
|
||||
... if state[-1].tool_calls:
|
||||
... return "validation"
|
||||
... return END
|
||||
...
|
||||
...
|
||||
>>> builder.add_conditional_edges("model", should_validate)
|
||||
...
|
||||
...
|
||||
>>> def should_reprompt(state: list) -> Literal["model", "__end__"]:
|
||||
... for msg in state[::-1]:
|
||||
... # None of the tool calls were errors
|
||||
... if msg.type == "ai":
|
||||
... return END
|
||||
... if msg.additional_kwargs.get("is_error"):
|
||||
... return "model"
|
||||
... return END
|
||||
...
|
||||
...
|
||||
>>> builder.add_conditional_edges("validation", should_reprompt)
|
||||
...
|
||||
...
|
||||
>>> graph = builder.compile()
|
||||
>>> res = graph.invoke(("user", "Select a number, any number"))
|
||||
>>> # Show the retry logic
|
||||
>>> for msg in res:
|
||||
... msg.pretty_print()
|
||||
================================ Human Message =================================
|
||||
Select a number, any number
|
||||
================================== Ai Message ==================================
|
||||
[{'id': 'toolu_01JSjT9Pq8hGmTgmMPc6KnvM', 'input': {'a': 42}, 'name': 'SelectNumber', 'type': 'tool_use'}]
|
||||
Tool Calls:
|
||||
SelectNumber (toolu_01JSjT9Pq8hGmTgmMPc6KnvM)
|
||||
Call ID: toolu_01JSjT9Pq8hGmTgmMPc6KnvM
|
||||
Args:
|
||||
a: 42
|
||||
================================= Tool Message =================================
|
||||
Name: SelectNumber
|
||||
ValidationError(model='SelectNumber', errors=[{'loc': ('a',), 'msg': 'Only 37 is allowed', 'type': 'value_error'}])
|
||||
Respond after fixing all validation errors.
|
||||
================================== Ai Message ==================================
|
||||
[{'id': 'toolu_01PkxSVxNxc5wqwCPW1FiSmV', 'input': {'a': 37}, 'name': 'SelectNumber', 'type': 'tool_use'}]
|
||||
Tool Calls:
|
||||
SelectNumber (toolu_01PkxSVxNxc5wqwCPW1FiSmV)
|
||||
Call ID: toolu_01PkxSVxNxc5wqwCPW1FiSmV
|
||||
Args:
|
||||
a: 37
|
||||
================================= Tool Message =================================
|
||||
Name: SelectNumber
|
||||
{"a": 37}
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schemas: Sequence[Union[BaseTool, Type[BaseModel], Callable]],
|
||||
*,
|
||||
format_error: Optional[
|
||||
Callable[[BaseException, ToolCall, Type[BaseModel]], str]
|
||||
] = None,
|
||||
name: str = "validation",
|
||||
tags: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
super().__init__(self._func, None, name=name, tags=tags, trace=False)
|
||||
self._format_error = format_error or _default_format_error
|
||||
self.schemas_by_name: Dict[str, Type[BaseModel]] = {}
|
||||
for schema in schemas:
|
||||
if isinstance(schema, BaseTool):
|
||||
if schema.args_schema is None:
|
||||
raise ValueError(
|
||||
f"Tool {schema.name} does not have an args_schema defined."
|
||||
)
|
||||
self.schemas_by_name[schema.name] = schema.args_schema
|
||||
elif isinstance(schema, type) and issubclass(
|
||||
schema, (BaseModel, BaseModelV2)
|
||||
):
|
||||
self.schemas_by_name[schema.__name__] = cast(Type[BaseModel], schema)
|
||||
elif callable(schema):
|
||||
base_model = create_schema_from_function("Validation", schema)
|
||||
self.schemas_by_name[schema.__name__] = base_model
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unsupported input to ValidationNode. Expected BaseModel, tool or function. Got: {type(schema)}."
|
||||
)
|
||||
|
||||
def _get_message(
|
||||
self, input: Union[list[AnyMessage], dict[str, Any]]
|
||||
) -> Tuple[str, AIMessage]:
|
||||
"""Extract the last AIMessage from the input."""
|
||||
if isinstance(input, list):
|
||||
output_type = "list"
|
||||
messages: list = input
|
||||
elif messages := input.get("messages", []):
|
||||
output_type = "dict"
|
||||
else:
|
||||
raise ValueError("No message found in input")
|
||||
message: AnyMessage = messages[-1]
|
||||
if not isinstance(message, AIMessage):
|
||||
raise ValueError("Last message is not an AIMessage")
|
||||
return output_type, message
|
||||
|
||||
def _func(
|
||||
self, input: Union[list[AnyMessage], dict[str, Any]], config: RunnableConfig
|
||||
) -> Any:
|
||||
"""Validate and run tool calls synchronously."""
|
||||
output_type, message = self._get_message(input)
|
||||
|
||||
def run_one(call: ToolCall):
|
||||
schema = self.schemas_by_name[call["name"]]
|
||||
try:
|
||||
output = schema.validate(call["args"])
|
||||
return ToolMessage(
|
||||
content=output.json(),
|
||||
name=call["name"],
|
||||
tool_call_id=cast(str, call["id"]),
|
||||
)
|
||||
except (ValidationError, ValidationErrorV2) as e:
|
||||
return ToolMessage(
|
||||
content=self._format_error(e, call, schema),
|
||||
name=call["name"],
|
||||
tool_call_id=cast(str, call["id"]),
|
||||
additional_kwargs={"is_error": True},
|
||||
)
|
||||
|
||||
with get_executor_for_config(config) as executor:
|
||||
outputs = [*executor.map(run_one, message.tool_calls)]
|
||||
if output_type == "list":
|
||||
return outputs
|
||||
else:
|
||||
return {"messages": outputs}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
import json
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pprint import pformat
|
||||
from typing import Any, Iterator, Literal, Mapping, Optional, Sequence, TypedDict, Union
|
||||
from uuid import UUID, uuid5
|
||||
|
||||
from langchain_core.runnables.config import RunnableConfig
|
||||
from langchain_core.utils.input import get_bolded_text, get_colored_text
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.checkpoint.base import CheckpointMetadata
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.pregel.io import read_channels
|
||||
from langgraph.pregel.types import PregelExecutableTask
|
||||
|
||||
|
||||
class TaskPayload(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
input: Any
|
||||
triggers: list[str]
|
||||
|
||||
|
||||
class TaskResultPayload(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
result: list[tuple[str, Any]]
|
||||
|
||||
|
||||
class CheckpointPayload(TypedDict):
|
||||
config: Optional[RunnableConfig]
|
||||
metadata: CheckpointMetadata
|
||||
values: dict[str, Any]
|
||||
|
||||
|
||||
class DebugOutputBase(TypedDict):
|
||||
timestamp: str
|
||||
step: int
|
||||
type: str
|
||||
payload: dict[str, Any]
|
||||
|
||||
|
||||
class DebugOutputTask(DebugOutputBase):
|
||||
type: Literal["task"]
|
||||
payload: TaskPayload
|
||||
|
||||
|
||||
class DebugOutputTaskResult(DebugOutputBase):
|
||||
type: Literal["task_result"]
|
||||
payload: TaskResultPayload
|
||||
|
||||
|
||||
class DebugOutputCheckpoint(DebugOutputBase):
|
||||
type: Literal["checkpoint"]
|
||||
payload: CheckpointPayload
|
||||
|
||||
|
||||
DebugOutput = Union[DebugOutputTask, DebugOutputTaskResult, DebugOutputCheckpoint]
|
||||
|
||||
|
||||
TASK_NAMESPACE = UUID("6ba7b831-9dad-11d1-80b4-00c04fd430c8")
|
||||
|
||||
|
||||
def map_debug_tasks(
|
||||
step: int, tasks: list[PregelExecutableTask]
|
||||
) -> Iterator[DebugOutputTask]:
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
for name, input, _, _, config, triggers in tasks:
|
||||
if config is not None and TAG_HIDDEN in config.get("tags", []):
|
||||
continue
|
||||
|
||||
yield {
|
||||
"type": "task",
|
||||
"timestamp": ts,
|
||||
"step": step,
|
||||
"payload": {
|
||||
"id": str(
|
||||
uuid5(TASK_NAMESPACE, json.dumps((name, step, config["metadata"])))
|
||||
),
|
||||
"name": name,
|
||||
"input": input,
|
||||
"triggers": triggers,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_debug_task_results(
|
||||
step: int,
|
||||
tasks: list[PregelExecutableTask],
|
||||
stream_channels_list: Sequence[str],
|
||||
) -> Iterator[DebugOutputTaskResult]:
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
for name, _, _, writes, config, _ in tasks:
|
||||
if config is not None and TAG_HIDDEN in config.get("tags", []):
|
||||
continue
|
||||
|
||||
yield {
|
||||
"type": "task_result",
|
||||
"timestamp": ts,
|
||||
"step": step,
|
||||
"payload": {
|
||||
"id": str(
|
||||
uuid5(TASK_NAMESPACE, json.dumps((name, step, config["metadata"])))
|
||||
),
|
||||
"name": name,
|
||||
"result": [w for w in writes if w[0] in stream_channels_list],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_debug_checkpoint(
|
||||
step: int,
|
||||
config: RunnableConfig,
|
||||
channels: Mapping[str, BaseChannel],
|
||||
stream_channels: Union[str, Sequence[str]],
|
||||
metadata: CheckpointMetadata,
|
||||
) -> Iterator[DebugOutputCheckpoint]:
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
yield {
|
||||
"type": "checkpoint",
|
||||
"timestamp": ts,
|
||||
"step": step,
|
||||
"payload": {
|
||||
"config": config,
|
||||
"values": read_channels(channels, stream_channels),
|
||||
"metadata": metadata,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def print_step_tasks(step: int, next_tasks: list[PregelExecutableTask]) -> None:
|
||||
n_tasks = len(next_tasks)
|
||||
print(
|
||||
f"{get_colored_text(f'[{step}:tasks]', color='blue')} "
|
||||
+ get_bolded_text(
|
||||
f"Starting step {step} with {n_tasks} task{'s' if n_tasks > 1 else ''}:\n"
|
||||
)
|
||||
+ "\n".join(
|
||||
f"- {get_colored_text(name, 'green')} -> {pformat(val)}"
|
||||
for name, val, _, _, _, _ in next_tasks
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_step_writes(
|
||||
step: int, writes: Sequence[tuple[str, Any]], whitelist: Sequence[str]
|
||||
) -> None:
|
||||
by_channel: dict[str, list[Any]] = defaultdict(list)
|
||||
for channel, value in writes:
|
||||
if channel in whitelist:
|
||||
by_channel[channel].append(value)
|
||||
print(
|
||||
f"{get_colored_text(f'[{step}:writes]', color='blue')} "
|
||||
+ get_bolded_text(
|
||||
f"Finished step {step} with writes to {len(by_channel)} channel{'s' if len(by_channel) > 1 else ''}:\n"
|
||||
)
|
||||
+ "\n".join(
|
||||
f"- {get_colored_text(name, 'yellow')} -> {', '.join(pformat(v) for v in vals)}"
|
||||
for name, vals in by_channel.items()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def print_step_checkpoint(
|
||||
step: int, channels: Mapping[str, BaseChannel], whitelist: Sequence[str]
|
||||
) -> None:
|
||||
print(
|
||||
f"{get_colored_text(f'[{step}:checkpoint]', color='blue')} "
|
||||
+ get_bolded_text(f"State at the end of step {step}:\n")
|
||||
+ pformat(read_channels(channels, whitelist), depth=3)
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any, Iterator, Mapping, Optional, Sequence, TypeVar, Union
|
||||
|
||||
from langchain_core.runnables.utils import AddableDict
|
||||
|
||||
from langgraph.channels.base import BaseChannel, EmptyChannelError
|
||||
from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.pregel.log import logger
|
||||
from langgraph.pregel.types import PregelExecutableTask
|
||||
|
||||
|
||||
def read_channel(
|
||||
channels: Mapping[str, BaseChannel],
|
||||
chan: str,
|
||||
*,
|
||||
catch: bool = True,
|
||||
return_exception: bool = False,
|
||||
) -> Any:
|
||||
try:
|
||||
return channels[chan].get()
|
||||
except EmptyChannelError as exc:
|
||||
if return_exception:
|
||||
return exc
|
||||
elif catch:
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def read_channels(
|
||||
channels: Mapping[str, BaseChannel],
|
||||
select: Union[list[str], str],
|
||||
*,
|
||||
skip_empty: bool = True,
|
||||
) -> Union[dict[str, Any], Any]:
|
||||
if isinstance(select, str):
|
||||
return read_channel(channels, select)
|
||||
else:
|
||||
values: dict[str, Any] = {}
|
||||
for k in select:
|
||||
try:
|
||||
values[k] = read_channel(channels, k, catch=not skip_empty)
|
||||
except EmptyChannelError:
|
||||
pass
|
||||
return values
|
||||
|
||||
|
||||
def map_input(
|
||||
input_channels: Union[str, Sequence[str]],
|
||||
chunk: Optional[Union[dict[str, Any], Any]],
|
||||
) -> Iterator[tuple[str, Any]]:
|
||||
"""Map input chunk to a sequence of pending writes in the form (channel, value)."""
|
||||
if chunk is None:
|
||||
return
|
||||
elif isinstance(input_channels, str):
|
||||
yield (input_channels, chunk)
|
||||
else:
|
||||
if not isinstance(chunk, dict):
|
||||
raise TypeError(f"Expected chunk to be a dict, got {type(chunk).__name__}")
|
||||
for k in chunk:
|
||||
if k in input_channels:
|
||||
yield (k, chunk[k])
|
||||
else:
|
||||
logger.warning(f"Input channel {k} not found in {input_channels}")
|
||||
|
||||
|
||||
class AddableValuesDict(AddableDict):
|
||||
def __add__(self, other: dict[str, Any]) -> "AddableValuesDict":
|
||||
return self | other
|
||||
|
||||
def __radd__(self, other: dict[str, Any]) -> "AddableValuesDict":
|
||||
return other | self
|
||||
|
||||
|
||||
def map_output_values(
|
||||
output_channels: Union[str, Sequence[str]],
|
||||
pending_writes: Sequence[tuple[str, Any]],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
) -> Iterator[Union[dict[str, Any], Any]]:
|
||||
"""Map pending writes (a sequence of tuples (channel, value)) to output chunk."""
|
||||
if isinstance(output_channels, str):
|
||||
if any(chan == output_channels for chan, _ in pending_writes):
|
||||
yield read_channel(channels, output_channels)
|
||||
else:
|
||||
if {c for c, _ in pending_writes if c in output_channels}:
|
||||
yield AddableValuesDict(read_channels(channels, output_channels))
|
||||
|
||||
|
||||
class AddableUpdatesDict(AddableDict):
|
||||
def __add__(self, other: dict[str, Any]) -> "AddableUpdatesDict":
|
||||
return [self, other]
|
||||
|
||||
def __radd__(self, other: dict[str, Any]) -> "AddableUpdatesDict":
|
||||
raise TypeError("AddableUpdatesDict does not support right-side addition")
|
||||
|
||||
|
||||
def map_output_updates(
|
||||
output_channels: Union[str, Sequence[str]],
|
||||
tasks: list[PregelExecutableTask],
|
||||
) -> Iterator[dict[str, Union[Any, dict[str, Any]]]]:
|
||||
"""Map pending writes (a sequence of tuples (channel, value)) to output chunk."""
|
||||
output_tasks = [
|
||||
t for t in tasks if not t.config or TAG_HIDDEN not in t.config.get("tags")
|
||||
]
|
||||
if isinstance(output_channels, str):
|
||||
if updated := [
|
||||
(node, value)
|
||||
for node, _, _, writes, _, _ in output_tasks
|
||||
for chan, value in writes
|
||||
if chan == output_channels
|
||||
]:
|
||||
grouped = defaultdict(list)
|
||||
for node, value in updated:
|
||||
grouped[node].append(value)
|
||||
for node, value in grouped.items():
|
||||
if len(value) == 1:
|
||||
grouped[node] = value[0]
|
||||
yield AddableUpdatesDict(grouped)
|
||||
else:
|
||||
if updated := [
|
||||
(
|
||||
node,
|
||||
{chan: value for chan, value in writes if chan in output_channels},
|
||||
)
|
||||
for node, _, _, writes, _, _ in output_tasks
|
||||
if any(chan in output_channels for chan, _ in writes)
|
||||
]:
|
||||
grouped = defaultdict(list)
|
||||
for node, value in updated:
|
||||
grouped[node].append(value)
|
||||
for node, value in grouped.items():
|
||||
if len(value) == 1:
|
||||
grouped[node] = value[0]
|
||||
yield AddableUpdatesDict(grouped)
|
||||
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def single(iter: Iterator[T]) -> Optional[T]:
|
||||
for item in iter:
|
||||
return item
|
||||
@@ -0,0 +1,3 @@
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("langgraph")
|
||||
@@ -0,0 +1,250 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Mapping, Optional, Sequence, Union
|
||||
|
||||
from langchain_core.pydantic_v1 import Field
|
||||
from langchain_core.runnables import (
|
||||
Runnable,
|
||||
RunnableConfig,
|
||||
RunnablePassthrough,
|
||||
RunnableSequence,
|
||||
RunnableSerializable,
|
||||
)
|
||||
from langchain_core.runnables.base import Other, RunnableBindingBase, coerce_to_runnable
|
||||
from langchain_core.runnables.config import merge_configs
|
||||
from langchain_core.runnables.utils import ConfigurableFieldSpec
|
||||
|
||||
from langgraph.constants import CONFIG_KEY_READ
|
||||
from langgraph.managed.base import ManagedValueSpec
|
||||
from langgraph.pregel.write import ChannelWrite
|
||||
from langgraph.utils import RunnableCallable
|
||||
|
||||
READ_TYPE = Callable[[str, bool], Union[Any, dict[str, Any]]]
|
||||
|
||||
|
||||
class ChannelRead(RunnableCallable):
|
||||
channel: Union[str, list[str]]
|
||||
|
||||
fresh: bool = False
|
||||
|
||||
mapper: Optional[Callable[[Any], Any]] = None
|
||||
|
||||
@property
|
||||
def config_specs(self) -> list[ConfigurableFieldSpec]:
|
||||
return [
|
||||
ConfigurableFieldSpec(
|
||||
id=CONFIG_KEY_READ,
|
||||
name=CONFIG_KEY_READ,
|
||||
description=None,
|
||||
default=None,
|
||||
annotation=None,
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channel: Union[str, list[str]],
|
||||
*,
|
||||
fresh: bool = False,
|
||||
mapper: Optional[Callable[[Any], Any]] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
super().__init__(func=self._read, afunc=self._aread, tags=tags, name=None)
|
||||
self.fresh = fresh
|
||||
self.mapper = mapper
|
||||
self.channel = channel
|
||||
|
||||
def get_name(
|
||||
self, suffix: Optional[str] = None, *, name: Optional[str] = None
|
||||
) -> str:
|
||||
if name:
|
||||
pass
|
||||
elif isinstance(self.channel, str):
|
||||
name = f"ChannelRead<{self.channel}>"
|
||||
else:
|
||||
name = f"ChannelRead<{','.join(self.channel)}>"
|
||||
return super().get_name(suffix, name=name)
|
||||
|
||||
def _read(self, _: Any, config: RunnableConfig) -> Any:
|
||||
return self.do_read(
|
||||
config, channel=self.channel, fresh=self.fresh, mapper=self.mapper
|
||||
)
|
||||
|
||||
async def _aread(self, _: Any, config: RunnableConfig) -> Any:
|
||||
return self.do_read(
|
||||
config, channel=self.channel, fresh=self.fresh, mapper=self.mapper
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def do_read(
|
||||
config: RunnableConfig,
|
||||
*,
|
||||
channel: Union[str, list[str]],
|
||||
fresh: bool = False,
|
||||
mapper: Optional[Callable[[Any], Any]] = None,
|
||||
) -> Any:
|
||||
try:
|
||||
read: READ_TYPE = config["configurable"][CONFIG_KEY_READ]
|
||||
except KeyError:
|
||||
raise RuntimeError(
|
||||
"Not configured with a read function"
|
||||
"Make sure to call in the context of a Pregel process"
|
||||
)
|
||||
if mapper:
|
||||
return mapper(read(channel, fresh))
|
||||
else:
|
||||
return read(channel, fresh)
|
||||
|
||||
|
||||
DEFAULT_BOUND: RunnablePassthrough = RunnablePassthrough()
|
||||
|
||||
|
||||
class PregelNode(RunnableBindingBase):
|
||||
channels: Union[list[str], Mapping[str, Union[str, ManagedValueSpec]]]
|
||||
|
||||
triggers: list[str] = Field(default_factory=list)
|
||||
|
||||
mapper: Optional[Callable[[Any], Any]] = None
|
||||
|
||||
writers: list[Runnable] = Field(default_factory=list)
|
||||
|
||||
bound: Runnable[Any, Any] = Field(default=DEFAULT_BOUND)
|
||||
|
||||
kwargs: Mapping[str, Any] = Field(default_factory=dict)
|
||||
|
||||
def get_writers(self) -> list[Runnable]:
|
||||
"""Get writers with optimizations applied."""
|
||||
writers = self.writers.copy()
|
||||
while (
|
||||
len(writers) > 1
|
||||
and isinstance(writers[-1], ChannelWrite)
|
||||
and isinstance(writers[-2], ChannelWrite)
|
||||
):
|
||||
# we can combine writes if they are consecutive
|
||||
# careful to not modify the original writers list or ChannelWrite
|
||||
writers[-2] = ChannelWrite(
|
||||
writes=writers[-2].writes + writers[-1].writes,
|
||||
tags=writers[-2].config["tags"] if writers[-2].config else None,
|
||||
require_at_least_one_of=writers[-2].require_at_least_one_of,
|
||||
)
|
||||
writers.pop()
|
||||
return writers
|
||||
|
||||
def get_node(self) -> Optional[Runnable[Any, Any]]:
|
||||
writers = self.get_writers()
|
||||
if self.bound is DEFAULT_BOUND and not writers:
|
||||
return None
|
||||
elif self.bound is DEFAULT_BOUND and len(writers) == 1:
|
||||
return writers[0]
|
||||
elif self.bound is DEFAULT_BOUND:
|
||||
return RunnableSequence(*writers)
|
||||
elif writers:
|
||||
return RunnableSequence(self.bound, *writers)
|
||||
else:
|
||||
return self.bound
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
channels: Union[list[str], Mapping[str, str]],
|
||||
triggers: Sequence[str],
|
||||
mapper: Optional[Callable[[Any], Any]] = None,
|
||||
writers: Optional[list[Runnable]] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
bound: Optional[Runnable[Any, Any]] = None,
|
||||
kwargs: Optional[Mapping[str, Any]] = None,
|
||||
config: Optional[RunnableConfig] = None,
|
||||
**other_kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
channels=channels,
|
||||
triggers=triggers,
|
||||
mapper=mapper,
|
||||
writers=writers or [],
|
||||
bound=bound or DEFAULT_BOUND,
|
||||
kwargs=kwargs or {},
|
||||
config=merge_configs(config, {"tags": tags or []}),
|
||||
**other_kwargs,
|
||||
)
|
||||
|
||||
def __repr_args__(self) -> Any:
|
||||
return [(k, v) for k, v in super().__repr_args__() if k != "bound"]
|
||||
|
||||
def join(self, channels: Sequence[str]) -> PregelNode:
|
||||
assert isinstance(channels, list) or isinstance(
|
||||
channels, tuple
|
||||
), "channels must be a list or tuple"
|
||||
assert isinstance(
|
||||
self.channels, dict
|
||||
), "all channels must be named when using .join()"
|
||||
return PregelNode(
|
||||
channels={
|
||||
**self.channels,
|
||||
**{chan: chan for chan in channels},
|
||||
},
|
||||
triggers=self.triggers,
|
||||
mapper=self.mapper,
|
||||
writers=self.writers,
|
||||
bound=self.bound,
|
||||
kwargs=self.kwargs,
|
||||
config=self.config,
|
||||
)
|
||||
|
||||
def __or__(
|
||||
self,
|
||||
other: Union[
|
||||
Runnable[Any, Other],
|
||||
Callable[[Any], Other],
|
||||
Mapping[str, Runnable[Any, Other] | Callable[[Any], Other]],
|
||||
],
|
||||
) -> PregelNode:
|
||||
if ChannelWrite.is_writer(other):
|
||||
return PregelNode(
|
||||
channels=self.channels,
|
||||
triggers=self.triggers,
|
||||
mapper=self.mapper,
|
||||
writers=[*self.writers, other],
|
||||
bound=self.bound,
|
||||
kwargs=self.kwargs,
|
||||
config=self.config,
|
||||
)
|
||||
elif self.bound is DEFAULT_BOUND:
|
||||
return PregelNode(
|
||||
channels=self.channels,
|
||||
triggers=self.triggers,
|
||||
mapper=self.mapper,
|
||||
writers=self.writers,
|
||||
bound=coerce_to_runnable(other),
|
||||
kwargs=self.kwargs,
|
||||
config=self.config,
|
||||
)
|
||||
else:
|
||||
return PregelNode(
|
||||
channels=self.channels,
|
||||
triggers=self.triggers,
|
||||
mapper=self.mapper,
|
||||
writers=self.writers,
|
||||
# delegate to __or__ in self.bound
|
||||
bound=self.bound | other,
|
||||
kwargs=self.kwargs,
|
||||
config=self.config,
|
||||
)
|
||||
|
||||
def pipe(
|
||||
self,
|
||||
*others: Runnable[Any, Other] | Callable[[Any], Other],
|
||||
name: Optional[str] = None,
|
||||
) -> RunnableSerializable[Any, Other]:
|
||||
for other in others:
|
||||
self = self | other
|
||||
return self
|
||||
|
||||
def __ror__(
|
||||
self,
|
||||
other: Union[
|
||||
Runnable[Other, Any],
|
||||
Callable[[Any], Other],
|
||||
Mapping[str, Union[Runnable[Other, Any], Callable[[Other], Any]]],
|
||||
],
|
||||
) -> RunnableSerializable:
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,148 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Callable, NamedTuple, Optional, Union
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
|
||||
from langgraph.pregel.types import PregelExecutableTask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def default_retry_on(exc: Exception) -> bool:
|
||||
if isinstance(exc, ConnectionError):
|
||||
return True
|
||||
if isinstance(
|
||||
exc,
|
||||
(
|
||||
ValueError,
|
||||
TypeError,
|
||||
ArithmeticError,
|
||||
ImportError,
|
||||
LookupError,
|
||||
NameError,
|
||||
SyntaxError,
|
||||
RuntimeError,
|
||||
ReferenceError,
|
||||
StopIteration,
|
||||
StopAsyncIteration,
|
||||
OSError,
|
||||
),
|
||||
):
|
||||
return False
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return 500 <= exc.response.status_code < 600
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
return 500 <= exc.response.status_code < 600 if exc.response else True
|
||||
return True
|
||||
|
||||
|
||||
class RetryPolicy(NamedTuple):
|
||||
initial_interval: float = 0.5
|
||||
"""Amount of time that must elapse before the first retry occurs. In seconds."""
|
||||
backoff_factor: float = 2.0
|
||||
"""Multiplier by which the interval increases after each retry."""
|
||||
max_interval: float = 128.0
|
||||
"""Maximum amount of time that may elapse between retries. In seconds."""
|
||||
max_attempts: int = 10
|
||||
"""Maximum number of attempts to make before giving up, including the first."""
|
||||
jitter: bool = True
|
||||
"""Whether to add random jitter to the interval between retries."""
|
||||
retry_on: Union[
|
||||
tuple[Exception, ...], Callable[[Exception], bool]
|
||||
] = default_retry_on
|
||||
"""List of exceptions that should trigger a retry, or a callable that returns True for exceptions that should trigger a retry."""
|
||||
|
||||
|
||||
def run_with_retry(
|
||||
task: PregelExecutableTask,
|
||||
retry_policy: Optional[RetryPolicy],
|
||||
) -> None:
|
||||
"""Run a task with retries."""
|
||||
interval = retry_policy.initial_interval if retry_policy else 0
|
||||
attempts = 0
|
||||
while True:
|
||||
try:
|
||||
# clear any writes from previous attempts
|
||||
task.writes.clear()
|
||||
# run the task
|
||||
task.proc.invoke(task.input, task.config)
|
||||
# if successful, end
|
||||
break
|
||||
except Exception as exc:
|
||||
if retry_policy is None:
|
||||
raise
|
||||
# increment attempts
|
||||
attempts += 1
|
||||
# check if we should retry
|
||||
if callable(retry_policy.retry_on):
|
||||
if not retry_policy.retry_on(exc):
|
||||
raise
|
||||
elif not isinstance(exc, retry_policy.retry_on):
|
||||
raise
|
||||
# check if we should give up
|
||||
if attempts >= retry_policy.max_attempts:
|
||||
raise
|
||||
# sleep before retrying
|
||||
interval = min(
|
||||
retry_policy.max_interval,
|
||||
interval * retry_policy.backoff_factor,
|
||||
)
|
||||
time.sleep(
|
||||
interval + random.uniform(0, 1) if retry_policy.jitter else interval
|
||||
)
|
||||
# log the retry
|
||||
logger.info(
|
||||
f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}"
|
||||
)
|
||||
|
||||
|
||||
async def arun_with_retry(
|
||||
task: PregelExecutableTask,
|
||||
retry_policy: Optional[RetryPolicy],
|
||||
stream: bool = False,
|
||||
) -> None:
|
||||
"""Run a task asynchronously with retries."""
|
||||
interval = retry_policy.initial_interval if retry_policy else 0
|
||||
attempts = 0
|
||||
while True:
|
||||
try:
|
||||
# clear any writes from previous attempts
|
||||
task.writes.clear()
|
||||
# run the task
|
||||
if stream:
|
||||
async for _ in task.proc.astream(task.input, task.config):
|
||||
pass
|
||||
else:
|
||||
await task.proc.ainvoke(task.input, task.config)
|
||||
# if successful, end
|
||||
break
|
||||
except Exception as exc:
|
||||
if retry_policy is None:
|
||||
raise
|
||||
# increment attempts
|
||||
attempts += 1
|
||||
# check if we should retry
|
||||
if callable(retry_policy.retry_on):
|
||||
if not retry_policy.retry_on(exc):
|
||||
raise
|
||||
elif not isinstance(exc, retry_policy.retry_on):
|
||||
raise
|
||||
# check if we should give up
|
||||
if attempts >= retry_policy.max_attempts:
|
||||
raise
|
||||
# sleep before retrying
|
||||
interval = min(
|
||||
retry_policy.max_interval,
|
||||
interval * retry_policy.backoff_factor,
|
||||
)
|
||||
await asyncio.sleep(
|
||||
interval + random.uniform(0, 1) if retry_policy.jitter else interval
|
||||
)
|
||||
# log the retry
|
||||
logger.info(
|
||||
f"Retrying task {task.name} after {interval:.2f} seconds (attempt {attempts}) after {exc.__class__.__name__} {exc}"
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
from collections import deque
|
||||
from typing import Any, Literal, NamedTuple, Optional, Union
|
||||
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import CheckpointMetadata
|
||||
|
||||
|
||||
class PregelTaskDescription(NamedTuple):
|
||||
name: str
|
||||
input: Any
|
||||
|
||||
|
||||
class PregelExecutableTask(NamedTuple):
|
||||
name: str
|
||||
input: Any
|
||||
proc: Runnable
|
||||
writes: deque[tuple[str, Any]]
|
||||
config: RunnableConfig
|
||||
triggers: list[str]
|
||||
|
||||
|
||||
class StateSnapshot(NamedTuple):
|
||||
values: Union[dict[str, Any], Any]
|
||||
"""Current values of channels"""
|
||||
next: tuple[str]
|
||||
"""Nodes to execute in the next step, if any"""
|
||||
config: RunnableConfig
|
||||
"""Config used to fetch this snapshot"""
|
||||
metadata: Optional[CheckpointMetadata]
|
||||
"""Metadata associated with this snapshot"""
|
||||
created_at: Optional[str]
|
||||
"""Timestamp of snapshot creation"""
|
||||
parent_config: Optional[RunnableConfig] = None
|
||||
"""Config used to fetch the parent snapshot, if any"""
|
||||
|
||||
|
||||
All = Literal["*"]
|
||||
@@ -0,0 +1,87 @@
|
||||
from typing import Mapping, Optional, Sequence, Union
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.constants import RESERVED
|
||||
from langgraph.pregel.read import PregelNode
|
||||
from langgraph.pregel.types import All
|
||||
|
||||
|
||||
def validate_graph(
|
||||
nodes: Mapping[str, PregelNode],
|
||||
channels: dict[str, BaseChannel],
|
||||
input_channels: Union[str, Sequence[str]],
|
||||
output_channels: Union[str, Sequence[str]],
|
||||
stream_channels: Optional[Union[str, Sequence[str]]],
|
||||
interrupt_after_nodes: Union[All, Sequence[str]],
|
||||
interrupt_before_nodes: Union[All, Sequence[str]],
|
||||
) -> None:
|
||||
for chan in channels:
|
||||
if chan in RESERVED:
|
||||
raise ValueError(f"Channel names {RESERVED} are reserved")
|
||||
|
||||
subscribed_channels = set[str]()
|
||||
for name, node in nodes.items():
|
||||
if name in RESERVED:
|
||||
raise ValueError(f"Node names {RESERVED} are reserved")
|
||||
if isinstance(node, PregelNode):
|
||||
subscribed_channels.update(node.triggers)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Invalid node type {type(node)}, expected Channel.subscribe_to()"
|
||||
)
|
||||
|
||||
for chan in subscribed_channels:
|
||||
if chan not in channels:
|
||||
raise ValueError(f"Subscribed channel '{chan}' not in 'channels'")
|
||||
|
||||
if isinstance(input_channels, str):
|
||||
if input_channels not in channels:
|
||||
raise ValueError(f"Input channel '{input_channels}' not in 'channels'")
|
||||
if input_channels not in subscribed_channels:
|
||||
raise ValueError(
|
||||
f"Input channel {input_channels} is not subscribed to by any node"
|
||||
)
|
||||
else:
|
||||
for chan in input_channels:
|
||||
if chan not in channels:
|
||||
raise ValueError(f"Input channel '{chan}' not in 'channels'")
|
||||
if all(chan not in subscribed_channels for chan in input_channels):
|
||||
raise ValueError(
|
||||
f"None of the input channels {input_channels} are subscribed to by any node"
|
||||
)
|
||||
|
||||
all_output_channels = set[str]()
|
||||
if isinstance(output_channels, str):
|
||||
all_output_channels.add(output_channels)
|
||||
else:
|
||||
all_output_channels.update(output_channels)
|
||||
if isinstance(stream_channels, str):
|
||||
all_output_channels.add(stream_channels)
|
||||
elif stream_channels is not None:
|
||||
all_output_channels.update(stream_channels)
|
||||
|
||||
for chan in all_output_channels:
|
||||
if chan not in channels:
|
||||
raise ValueError(f"Output channel '{chan}' not in 'channels'")
|
||||
|
||||
if interrupt_after_nodes != "*":
|
||||
for node in interrupt_after_nodes:
|
||||
if node not in nodes:
|
||||
raise ValueError(f"Node {node} not in nodes")
|
||||
if interrupt_before_nodes != "*":
|
||||
for node in interrupt_before_nodes:
|
||||
if node not in nodes:
|
||||
raise ValueError(f"Node {node} not in nodes")
|
||||
|
||||
|
||||
def validate_keys(
|
||||
keys: Optional[Union[str, Sequence[str]]],
|
||||
channels: Mapping[str, BaseChannel],
|
||||
) -> None:
|
||||
if isinstance(keys, str):
|
||||
if keys not in channels:
|
||||
raise ValueError(f"Key {keys} not in channels")
|
||||
elif keys is not None:
|
||||
for chan in keys:
|
||||
if chan not in channels:
|
||||
raise ValueError(f"Key {chan} not in channels")
|
||||
@@ -0,0 +1,181 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
List,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langchain_core.runnables.utils import ConfigurableFieldSpec
|
||||
|
||||
from langgraph.constants import CONFIG_KEY_SEND, TASKS, Send
|
||||
from langgraph.errors import InvalidUpdateError
|
||||
from langgraph.utils import RunnableCallable
|
||||
|
||||
TYPE_SEND = Callable[[Sequence[tuple[str, Any]]], None]
|
||||
R = TypeVar("R", bound=Runnable)
|
||||
|
||||
|
||||
SKIP_WRITE = object()
|
||||
PASSTHROUGH = object()
|
||||
|
||||
|
||||
class ChannelWriteEntry(NamedTuple):
|
||||
channel: str
|
||||
value: Any = PASSTHROUGH
|
||||
skip_none: bool = False
|
||||
mapper: Optional[Runnable] = None
|
||||
|
||||
|
||||
class ChannelWrite(RunnableCallable):
|
||||
writes: Sequence[Union[ChannelWriteEntry, Send]]
|
||||
"""
|
||||
Sequence of write entries, each of which is a tuple of:
|
||||
- channel name
|
||||
- runnable to map input, or None to use the input, or any other value to use instead
|
||||
- whether to skip writing if the mapped value is None
|
||||
"""
|
||||
require_at_least_one_of: Optional[Sequence[str]]
|
||||
"""
|
||||
If defined, at least one of these channels must be written to.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
writes: Sequence[Union[ChannelWriteEntry, Send]],
|
||||
*,
|
||||
tags: Optional[list[str]] = None,
|
||||
require_at_least_one_of: Optional[Sequence[str]] = None,
|
||||
):
|
||||
super().__init__(func=self._write, afunc=self._awrite, name=None, tags=tags)
|
||||
self.writes = writes
|
||||
self.require_at_least_one_of = require_at_least_one_of
|
||||
|
||||
def __repr_args__(self) -> Any:
|
||||
return [("writes", self.writes)]
|
||||
|
||||
def get_name(
|
||||
self, suffix: Optional[str] = None, *, name: Optional[str] = None
|
||||
) -> str:
|
||||
if not name:
|
||||
name = f"ChannelWrite<{','.join(w.channel if isinstance(w, ChannelWriteEntry) else w.node for w in self.writes)}>"
|
||||
return super().get_name(suffix, name=name)
|
||||
|
||||
@property
|
||||
def config_specs(self) -> list[ConfigurableFieldSpec]:
|
||||
return [
|
||||
ConfigurableFieldSpec(
|
||||
id=CONFIG_KEY_SEND,
|
||||
name=CONFIG_KEY_SEND,
|
||||
description=None,
|
||||
default=None,
|
||||
annotation=None,
|
||||
),
|
||||
]
|
||||
|
||||
def _write(self, input: Any, config: RunnableConfig) -> None:
|
||||
# split packets and entries
|
||||
writes = [(TASKS, packet) for packet in self.writes if isinstance(packet, Send)]
|
||||
entries = [
|
||||
write for write in self.writes if isinstance(write, ChannelWriteEntry)
|
||||
]
|
||||
for entry in entries:
|
||||
if entry.channel == TASKS:
|
||||
raise InvalidUpdateError("Cannot write to the reserved channel TASKS")
|
||||
# process entries into values
|
||||
values = [
|
||||
input if write.value is PASSTHROUGH else write.value for write in entries
|
||||
]
|
||||
values = [
|
||||
val if write.mapper is None else write.mapper.invoke(val, config)
|
||||
for val, write in zip(values, entries)
|
||||
]
|
||||
values = [
|
||||
(write.channel, val)
|
||||
for val, write in zip(values, entries)
|
||||
if not write.skip_none or val is not None
|
||||
]
|
||||
# write packets and values
|
||||
self.do_write(
|
||||
config,
|
||||
writes + values,
|
||||
self.require_at_least_one_of if input is not None else None,
|
||||
)
|
||||
return input
|
||||
|
||||
async def _awrite(self, input: Any, config: RunnableConfig) -> None:
|
||||
# split packets and entries
|
||||
writes = [(TASKS, packet) for packet in self.writes if isinstance(packet, Send)]
|
||||
entries = [
|
||||
write for write in self.writes if isinstance(write, ChannelWriteEntry)
|
||||
]
|
||||
for entry in entries:
|
||||
if entry.channel == TASKS:
|
||||
raise InvalidUpdateError("Cannot write to the reserved channel TASKS")
|
||||
# process entries into values
|
||||
values = [
|
||||
input if write.value is PASSTHROUGH else write.value for write in entries
|
||||
]
|
||||
values = await asyncio.gather(
|
||||
*(
|
||||
_mk_future(val)
|
||||
if write.mapper is None
|
||||
else write.mapper.ainvoke(val, config)
|
||||
for val, write in zip(values, entries)
|
||||
)
|
||||
)
|
||||
values = [
|
||||
(write.channel, val)
|
||||
for val, write in zip(values, entries)
|
||||
if not write.skip_none or val is not None
|
||||
]
|
||||
# write packets and values
|
||||
self.do_write(
|
||||
config,
|
||||
writes + values,
|
||||
self.require_at_least_one_of if input is not None else None,
|
||||
)
|
||||
return input
|
||||
|
||||
@staticmethod
|
||||
def do_write(
|
||||
config: RunnableConfig,
|
||||
values: List[Tuple[str, Any]],
|
||||
require_at_least_one_of: Optional[Sequence[str]] = None,
|
||||
) -> None:
|
||||
filtered = [(chan, val) for chan, val in values if val is not SKIP_WRITE]
|
||||
if require_at_least_one_of is not None:
|
||||
if not {chan for chan, _ in filtered} & set(require_at_least_one_of):
|
||||
raise InvalidUpdateError(
|
||||
f"Must write to at least one of {require_at_least_one_of}"
|
||||
)
|
||||
write: TYPE_SEND = config["configurable"][CONFIG_KEY_SEND]
|
||||
write(filtered)
|
||||
|
||||
@staticmethod
|
||||
def is_writer(runnable: Runnable) -> bool:
|
||||
return (
|
||||
isinstance(runnable, ChannelWrite)
|
||||
or getattr(runnable, "_is_channel_writer", False) is True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def register_writer(runnable: R) -> R:
|
||||
# using object.__setattr__ to work around objects that override __setattr__
|
||||
# eg. pydantic models and dataclasses
|
||||
object.__setattr__(runnable, "_is_channel_writer", True)
|
||||
return runnable
|
||||
|
||||
|
||||
def _mk_future(val: Any) -> asyncio.Future:
|
||||
fut = asyncio.Future()
|
||||
fut.set_result(val)
|
||||
return fut
|
||||
@@ -0,0 +1,17 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class SerializerProtocol(Protocol):
|
||||
"""Protocol for serialization and deserialization of objects.
|
||||
|
||||
- `dumps`: Serialize an object to bytes.
|
||||
- `loads`: Deserialize an object from bytes.
|
||||
|
||||
Valid implementations include the `pickle`, `json` and `orjson` modules.
|
||||
"""
|
||||
|
||||
def dumps(self, obj: Any) -> bytes:
|
||||
...
|
||||
|
||||
def loads(self, data: bytes) -> Any:
|
||||
...
|
||||
@@ -0,0 +1,103 @@
|
||||
import dataclasses
|
||||
import importlib
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.load.load import Reviver
|
||||
from langchain_core.load.serializable import Serializable
|
||||
|
||||
from langgraph.constants import Send
|
||||
from langgraph.serde.base import SerializerProtocol
|
||||
|
||||
LC_REVIVER = Reviver()
|
||||
|
||||
|
||||
class JsonPlusSerializer(SerializerProtocol):
|
||||
def _encode_constructor_args(
|
||||
self,
|
||||
constructor: type[Any],
|
||||
*,
|
||||
method: Optional[str] = None,
|
||||
args: Optional[list[Any]] = None,
|
||||
kwargs: Optional[dict[str, Any]] = None,
|
||||
):
|
||||
return {
|
||||
"lc": 2,
|
||||
"type": "constructor",
|
||||
"id": [*constructor.__module__.split("."), constructor.__name__],
|
||||
"method": method,
|
||||
"args": args if args is not None else [],
|
||||
"kwargs": kwargs if kwargs is not None else {},
|
||||
}
|
||||
|
||||
def _default(self, obj):
|
||||
if isinstance(obj, Serializable):
|
||||
return obj.to_json()
|
||||
elif hasattr(obj, "model_dump") and callable(obj.model_dump):
|
||||
return self._encode_constructor_args(obj.__class__, kwargs=obj.model_dump())
|
||||
elif hasattr(obj, "dict") and callable(obj.dict):
|
||||
return self._encode_constructor_args(obj.__class__, kwargs=obj.dict())
|
||||
elif isinstance(obj, UUID):
|
||||
return self._encode_constructor_args(UUID, args=[obj.hex])
|
||||
elif isinstance(obj, (set, frozenset)):
|
||||
return self._encode_constructor_args(type(obj), args=[list(obj)])
|
||||
elif isinstance(obj, datetime):
|
||||
return self._encode_constructor_args(
|
||||
datetime, method="fromisoformat", args=[obj.isoformat()]
|
||||
)
|
||||
elif isinstance(obj, timezone):
|
||||
return self._encode_constructor_args(timezone, args=obj.__getinitargs__())
|
||||
elif isinstance(obj, timedelta):
|
||||
return self._encode_constructor_args(
|
||||
timedelta, args=[obj.days, obj.seconds, obj.microseconds]
|
||||
)
|
||||
elif dataclasses.is_dataclass(obj):
|
||||
return self._encode_constructor_args(
|
||||
obj.__class__,
|
||||
kwargs={
|
||||
field.name: getattr(obj, field.name)
|
||||
for field in dataclasses.fields(obj)
|
||||
},
|
||||
)
|
||||
elif isinstance(obj, Enum):
|
||||
return self._encode_constructor_args(obj.__class__, args=[obj.value])
|
||||
elif isinstance(obj, Send):
|
||||
return self._encode_constructor_args(
|
||||
obj.__class__, kwargs={"node": obj.node, "arg": obj.arg}
|
||||
)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Object of type {obj.__class__.__name__} is not JSON serializable"
|
||||
)
|
||||
|
||||
def _reviver(self, value: dict[str, Any]) -> Any:
|
||||
if (
|
||||
value.get("lc", None) == 2
|
||||
and value.get("type", None) == "constructor"
|
||||
and value.get("id", None) is not None
|
||||
):
|
||||
# Get module and class name
|
||||
[*module, name] = value["id"]
|
||||
# Import module
|
||||
mod = importlib.import_module(".".join(module))
|
||||
# Import class
|
||||
cls = getattr(mod, name)
|
||||
# Instantiate class
|
||||
if value["method"] is not None:
|
||||
method = getattr(cls, value["method"])
|
||||
return method(*value["args"], **value["kwargs"])
|
||||
else:
|
||||
return cls(*value["args"], **value["kwargs"])
|
||||
|
||||
return LC_REVIVER(value)
|
||||
|
||||
def dumps(self, obj: Any) -> bytes:
|
||||
return json.dumps(obj, default=self._default, ensure_ascii=False).encode(
|
||||
"utf-8", "ignore"
|
||||
)
|
||||
|
||||
def loads(self, data: bytes) -> Any:
|
||||
return json.loads(data, object_hook=self._reviver)
|
||||
@@ -0,0 +1,209 @@
|
||||
import asyncio
|
||||
import enum
|
||||
import inspect
|
||||
import sys
|
||||
from contextvars import copy_context
|
||||
from functools import partial, wraps
|
||||
from typing import Any, AsyncIterator, Awaitable, Callable, Optional
|
||||
|
||||
from langchain_core.runnables.base import (
|
||||
Runnable,
|
||||
RunnableConfig,
|
||||
RunnableLambda,
|
||||
RunnableLike,
|
||||
RunnableParallel,
|
||||
)
|
||||
from langchain_core.runnables.config import (
|
||||
merge_configs,
|
||||
run_in_executor,
|
||||
var_child_runnable_config,
|
||||
)
|
||||
from langchain_core.runnables.graph import Edge, Graph, Node, is_uuid
|
||||
from langchain_core.runnables.utils import accepts_config
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
|
||||
# Before Python 3.11 native StrEnum is not available
|
||||
class StrEnum(str, enum.Enum):
|
||||
"""A string enum."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RunnableCallable(Runnable):
|
||||
"""A much simpler version of RunnableLambda that requires sync and async functions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: Callable[..., Optional[Runnable]],
|
||||
afunc: Optional[Callable[..., Awaitable[Optional[Runnable]]]] = None,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
tags: Optional[list[str]] = None,
|
||||
trace: bool = True,
|
||||
recurse: bool = True,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if name is not None:
|
||||
self.name = name
|
||||
elif func:
|
||||
try:
|
||||
if func.__name__ != "<lambda>":
|
||||
self.name = func.__name__
|
||||
except AttributeError:
|
||||
pass
|
||||
elif afunc:
|
||||
try:
|
||||
self.name = afunc.__name__
|
||||
except AttributeError:
|
||||
pass
|
||||
self.func = func
|
||||
self.afunc = afunc
|
||||
self.config: Optional[RunnableConfig] = {"tags": tags} if tags else None
|
||||
self.kwargs = kwargs
|
||||
self.trace = trace
|
||||
self.recurse = recurse
|
||||
|
||||
def __repr__(self) -> str:
|
||||
repr_args = {
|
||||
k: v
|
||||
for k, v in self.__dict__.items()
|
||||
if k not in {"name", "func", "afunc", "config", "kwargs", "trace"}
|
||||
}
|
||||
return f"{self.get_name()}({', '.join(f'{k}={v!r}' for k, v in repr_args.items())})"
|
||||
|
||||
def invoke(
|
||||
self, input: Any, config: Optional[RunnableConfig] = None, **kwargs: Any
|
||||
) -> Any:
|
||||
if self.func is None:
|
||||
raise TypeError(
|
||||
f'No synchronous function provided to "{self.name}".'
|
||||
"\nEither initialize with a synchronous function or invoke"
|
||||
" via the async API (ainvoke, astream, etc.)"
|
||||
)
|
||||
kwargs = {**self.kwargs, **kwargs}
|
||||
if self.trace:
|
||||
ret = self._call_with_config(
|
||||
self.func, input, merge_configs(self.config, config), **kwargs
|
||||
)
|
||||
else:
|
||||
config = merge_configs(self.config, config)
|
||||
context = copy_context()
|
||||
context.run(var_child_runnable_config.set, config)
|
||||
if accepts_config(self.func):
|
||||
kwargs["config"] = config
|
||||
ret = context.run(self.func, input, **kwargs)
|
||||
if isinstance(ret, Runnable) and self.recurse:
|
||||
return ret.invoke(input, config)
|
||||
return ret
|
||||
|
||||
async def ainvoke(
|
||||
self, input: Any, config: Optional[RunnableConfig] = None, **kwargs: Any
|
||||
) -> Any:
|
||||
if not self.afunc:
|
||||
return self.invoke(input, config)
|
||||
kwargs = {**self.kwargs, **kwargs}
|
||||
if self.trace:
|
||||
ret = await self._acall_with_config(
|
||||
self.afunc, input, merge_configs(self.config, config), **kwargs
|
||||
)
|
||||
else:
|
||||
config = merge_configs(self.config, config)
|
||||
context = copy_context()
|
||||
context.run(var_child_runnable_config.set, config)
|
||||
if accepts_config(self.afunc):
|
||||
kwargs["config"] = config
|
||||
if sys.version_info >= (3, 11):
|
||||
ret = await asyncio.create_task(
|
||||
self.afunc(input, **kwargs), context=context
|
||||
)
|
||||
else:
|
||||
ret = await self.afunc(input, **kwargs)
|
||||
if isinstance(ret, Runnable) and self.recurse:
|
||||
return await ret.ainvoke(input, config)
|
||||
return ret
|
||||
|
||||
|
||||
class DrawableGraph(Graph):
|
||||
def extend(
|
||||
self, graph: Graph, prefix: str = ""
|
||||
) -> tuple[Optional[Node], Optional[Node]]:
|
||||
if all(is_uuid(node.id) for node in graph.nodes.values()):
|
||||
super().extend(graph)
|
||||
return graph.first_node(), graph.last_node()
|
||||
|
||||
new_nodes = {
|
||||
f"{prefix}:{k}": Node(f"{prefix}:{k}", v.data)
|
||||
for k, v in graph.nodes.items()
|
||||
}
|
||||
new_edges = [
|
||||
Edge(
|
||||
f"{prefix}:{edge.source}",
|
||||
f"{prefix}:{edge.target}",
|
||||
edge.data,
|
||||
edge.conditional,
|
||||
)
|
||||
for edge in graph.edges
|
||||
]
|
||||
self.nodes.update(new_nodes)
|
||||
self.edges.extend(new_edges)
|
||||
first = graph.first_node()
|
||||
last = graph.last_node()
|
||||
return (
|
||||
Node(f"{prefix}:{first.id}", first.data) if first else None,
|
||||
Node(f"{prefix}:{last.id}", last.data) if last else None,
|
||||
)
|
||||
|
||||
|
||||
def is_async_callable(
|
||||
func: Any,
|
||||
) -> TypeGuard[Callable[..., Awaitable]]:
|
||||
"""Check if a function is async."""
|
||||
return (
|
||||
asyncio.iscoroutinefunction(func)
|
||||
or hasattr(func, "__call__")
|
||||
and asyncio.iscoroutinefunction(func.__call__)
|
||||
)
|
||||
|
||||
|
||||
def is_async_generator(
|
||||
func: Any,
|
||||
) -> TypeGuard[Callable[..., AsyncIterator]]:
|
||||
"""Check if a function is an async generator."""
|
||||
return (
|
||||
inspect.isasyncgenfunction(func)
|
||||
or hasattr(func, "__call__")
|
||||
and inspect.isasyncgenfunction(func.__call__)
|
||||
)
|
||||
|
||||
|
||||
def coerce_to_runnable(thing: RunnableLike, *, name: str, trace: bool) -> Runnable:
|
||||
"""Coerce a runnable-like object into a Runnable.
|
||||
|
||||
Args:
|
||||
thing: A runnable-like object.
|
||||
|
||||
Returns:
|
||||
A Runnable.
|
||||
"""
|
||||
if isinstance(thing, Runnable):
|
||||
return thing
|
||||
elif is_async_generator(thing) or inspect.isgeneratorfunction(thing):
|
||||
return RunnableLambda(thing, name=name)
|
||||
elif callable(thing):
|
||||
if is_async_callable(thing):
|
||||
return RunnableCallable(None, thing, name=name, trace=trace)
|
||||
else:
|
||||
return RunnableCallable(
|
||||
thing,
|
||||
wraps(thing)(partial(run_in_executor, None, thing)),
|
||||
name=name,
|
||||
trace=trace,
|
||||
)
|
||||
elif isinstance(thing, dict):
|
||||
return RunnableParallel(thing)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Expected a Runnable, callable or dict."
|
||||
f"Instead got an unsupported type: {type(thing)}"
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Main entrypoint into package."""
|
||||
|
||||
from importlib import metadata
|
||||
|
||||
try:
|
||||
__version__ = metadata.version(__package__)
|
||||
except metadata.PackageNotFoundError:
|
||||
# Case where package metadata is not available.
|
||||
__version__ = ""
|
||||
del metadata # optional, avoids polluting the results of dir(__package__)
|
||||
Generated
+4125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
|
||||
[installer]
|
||||
modern-installation = false
|
||||
@@ -0,0 +1,84 @@
|
||||
[tool.poetry]
|
||||
name = "langgraph"
|
||||
version = "0.0.69"
|
||||
description = "langgraph"
|
||||
authors = []
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://www.github.com/langchain-ai/langgraph"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.9.0,<4.0"
|
||||
langchain-core = ">=0.2,<0.3"
|
||||
|
||||
|
||||
[tool.poetry.group.test.dependencies]
|
||||
pytest = "^7.3.0"
|
||||
pytest-cov = "^4.0.0"
|
||||
pytest-dotenv = "^0.5.2"
|
||||
pytest-asyncio = "^0.20.3"
|
||||
pytest-mock = "^3.10.0"
|
||||
syrupy = "^4.0.2"
|
||||
httpx = "^0.26.0"
|
||||
pytest-watcher = "^0.4.1"
|
||||
langchain = ">=0.1.0"
|
||||
aiosqlite = "^0.19.0"
|
||||
grandalf = "^0.8"
|
||||
mypy = "^1.6.0"
|
||||
ruff = "^0.1.4"
|
||||
jupyter = "^1.0.0"
|
||||
langchainhub = "^0.1.14"
|
||||
langchain-openai = ">=0.1.2"
|
||||
langchain-anthropic = ">=0.1.8"
|
||||
dataclasses-json = "^0.6.7"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [ "E", "F", "I" ]
|
||||
lint.ignore = [ "E501" ]
|
||||
line-length = 88
|
||||
indent-width = 4
|
||||
extend-include = ["*.ipynb"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
skip-magic-trailing-comma = false
|
||||
line-ending = "auto"
|
||||
docstring-code-format = false
|
||||
docstring-code-line-length = "dynamic"
|
||||
|
||||
[tool.mypy]
|
||||
ignore_missing_imports = "True"
|
||||
disallow_untyped_defs = "True"
|
||||
exclude = ["notebooks", "examples", "example_data"]
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = ["tests/*"]
|
||||
|
||||
[tool.pytest-watcher]
|
||||
now = true
|
||||
delay = 0.1
|
||||
runner_args = ["-x", "--ff", "-vv", "--snapshot-update"]
|
||||
patterns = ["*.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
# --strict-markers will raise errors on unknown marks.
|
||||
# https://docs.pytest.org/en/7.1.x/how-to/mark.html#raising-errors-on-unknown-marks
|
||||
#
|
||||
# https://docs.pytest.org/en/7.1.x/reference/reference.html
|
||||
# --strict-config any warnings encountered while parsing the `pytest`
|
||||
# section of the configuration file raise errors.
|
||||
#
|
||||
# https://github.com/tophat/syrupy
|
||||
# --snapshot-warn-unused Prints a warning on unused snapshots rather than fail the test suite.
|
||||
addopts = "--full-trace --strict-markers --strict-config --durations=5 --snapshot-warn-unused"
|
||||
# Registering custom markers.
|
||||
# https://docs.pytest.org/en/7.1.x/example/markers.html#registering-markers
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,100 @@
|
||||
# serializer version: 1
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_custom_state_class
|
||||
'''
|
||||
+-----------+
|
||||
| __start__ |
|
||||
+-----------+
|
||||
*
|
||||
*
|
||||
*
|
||||
+---------------+
|
||||
| rewrite_query |
|
||||
+---------------+
|
||||
*** ...
|
||||
* .
|
||||
** ...
|
||||
+--------------+ .
|
||||
| analyzer_one | .
|
||||
+--------------+ .
|
||||
* .
|
||||
* .
|
||||
* .
|
||||
+---------------+ +---------------+
|
||||
| retriever_one | | retriever_two |
|
||||
+---------------+ +---------------+
|
||||
*** ***
|
||||
* *
|
||||
** **
|
||||
+----+
|
||||
| qa |
|
||||
+----+
|
||||
*
|
||||
*
|
||||
*
|
||||
+---------+
|
||||
| __end__ |
|
||||
+---------+
|
||||
'''
|
||||
# ---
|
||||
# name: test_in_one_fan_out_state_graph_waiting_edge_via_branch
|
||||
'''
|
||||
+-----------+
|
||||
| __start__ |
|
||||
+-----------+
|
||||
*
|
||||
*
|
||||
*
|
||||
+---------------+
|
||||
| rewrite_query |
|
||||
+---------------+
|
||||
*** ...
|
||||
* .
|
||||
** ...
|
||||
+--------------+ .
|
||||
| analyzer_one | .
|
||||
+--------------+ .
|
||||
* .
|
||||
* .
|
||||
* .
|
||||
+---------------+ +---------------+
|
||||
| retriever_one | | retriever_two |
|
||||
+---------------+ +---------------+
|
||||
*** ***
|
||||
* *
|
||||
** **
|
||||
+----+
|
||||
| qa |
|
||||
+----+
|
||||
*
|
||||
*
|
||||
*
|
||||
+---------+
|
||||
| __end__ |
|
||||
+---------+
|
||||
'''
|
||||
# ---
|
||||
# name: test_nested_graph
|
||||
'''
|
||||
+-----------+
|
||||
| __start__ |
|
||||
+-----------+
|
||||
*
|
||||
*
|
||||
*
|
||||
+-------+
|
||||
| inner |
|
||||
+-------+
|
||||
*
|
||||
*
|
||||
*
|
||||
+------+
|
||||
| side |
|
||||
+------+
|
||||
*
|
||||
*
|
||||
*
|
||||
+---------+
|
||||
| __end__ |
|
||||
+---------+
|
||||
'''
|
||||
# ---
|
||||
@@ -0,0 +1,6 @@
|
||||
class AnyStr(str):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
return isinstance(other, str)
|
||||
@@ -0,0 +1,76 @@
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.channels.manager import create_checkpoint
|
||||
from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver
|
||||
from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata, empty_checkpoint
|
||||
|
||||
|
||||
class TestAsyncSqliteSaver:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
self.sqlite_saver = AsyncSqliteSaver.from_conn_string(":memory:")
|
||||
|
||||
# objects for test setup
|
||||
self.config_1: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-1", "thread_ts": "1"}
|
||||
}
|
||||
self.config_2: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-2", "thread_ts": "2"}
|
||||
}
|
||||
|
||||
self.chkpnt_1: Checkpoint = empty_checkpoint()
|
||||
self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
|
||||
|
||||
self.metadata_1: CheckpointMetadata = {
|
||||
"source": "input",
|
||||
"step": 2,
|
||||
"writes": {},
|
||||
"score": 1,
|
||||
}
|
||||
self.metadata_2: CheckpointMetadata = {
|
||||
"source": "loop",
|
||||
"step": 1,
|
||||
"writes": {"foo": "bar"},
|
||||
"score": None,
|
||||
}
|
||||
|
||||
async def test_asearch(self):
|
||||
# set up test
|
||||
# save checkpoints
|
||||
await self.sqlite_saver.aput(self.config_1, self.chkpnt_1, self.metadata_1)
|
||||
await self.sqlite_saver.aput(self.config_2, self.chkpnt_2, self.metadata_2)
|
||||
|
||||
# call method / assertions
|
||||
query_1: CheckpointMetadata = {"source": "input"} # search by 1 key
|
||||
query_2: CheckpointMetadata = {
|
||||
"step": 1,
|
||||
"writes": {"foo": "bar"},
|
||||
} # search by multiple keys
|
||||
query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints
|
||||
query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match
|
||||
|
||||
async with self.sqlite_saver as sqlite_saver:
|
||||
search_results_1 = [
|
||||
c async for c in sqlite_saver.alist(None, filter=query_1)
|
||||
]
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == self.metadata_1
|
||||
|
||||
search_results_2 = [
|
||||
c async for c in sqlite_saver.alist(None, filter=query_2)
|
||||
]
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == self.metadata_2
|
||||
|
||||
search_results_3 = [
|
||||
c async for c in sqlite_saver.alist(None, filter=query_3)
|
||||
]
|
||||
assert len(search_results_3) == 2
|
||||
|
||||
search_results_4 = [
|
||||
c async for c in sqlite_saver.alist(None, filter=query_4)
|
||||
]
|
||||
assert len(search_results_4) == 0
|
||||
|
||||
# TODO: test before and limit params
|
||||
@@ -0,0 +1,104 @@
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.channels.manager import create_checkpoint
|
||||
from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata, empty_checkpoint
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
|
||||
class TestMemorySaver:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
self.memory_saver = MemorySaver()
|
||||
|
||||
# objects for test setup
|
||||
self.config_1: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-1", "thread_ts": "1"}
|
||||
}
|
||||
self.config_2: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-2", "thread_ts": "2"}
|
||||
}
|
||||
|
||||
self.chkpnt_1: Checkpoint = empty_checkpoint()
|
||||
self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
|
||||
|
||||
self.metadata_1: CheckpointMetadata = {
|
||||
"source": "input",
|
||||
"step": 2,
|
||||
"writes": {},
|
||||
"score": 1,
|
||||
}
|
||||
self.metadata_2: CheckpointMetadata = {
|
||||
"source": "loop",
|
||||
"step": 1,
|
||||
"writes": {"foo": "bar"},
|
||||
"score": None,
|
||||
}
|
||||
|
||||
async def test_search(self):
|
||||
# set up test
|
||||
# save checkpoints
|
||||
self.memory_saver.put(self.config_1, self.chkpnt_1, self.metadata_1)
|
||||
self.memory_saver.put(self.config_2, self.chkpnt_2, self.metadata_2)
|
||||
|
||||
# call method / assertions
|
||||
query_1: CheckpointMetadata = {"source": "input"} # search by 1 key
|
||||
query_2: CheckpointMetadata = {
|
||||
"step": 1,
|
||||
"writes": {"foo": "bar"},
|
||||
} # search by multiple keys
|
||||
query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints
|
||||
query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match
|
||||
|
||||
search_results_1 = list(self.memory_saver.list(None, filter=query_1))
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == self.metadata_1
|
||||
|
||||
search_results_2 = list(self.memory_saver.list(None, filter=query_2))
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == self.metadata_2
|
||||
|
||||
search_results_3 = list(self.memory_saver.list(None, filter=query_3))
|
||||
assert len(search_results_3) == 2
|
||||
|
||||
search_results_4 = list(self.memory_saver.list(None, filter=query_4))
|
||||
assert len(search_results_4) == 0
|
||||
|
||||
# TODO: test before and limit params
|
||||
|
||||
async def test_asearch(self):
|
||||
# set up test
|
||||
# save checkpoints
|
||||
self.memory_saver.put(self.config_1, self.chkpnt_1, self.metadata_1)
|
||||
self.memory_saver.put(self.config_2, self.chkpnt_2, self.metadata_2)
|
||||
|
||||
# call method / assertions
|
||||
query_1: CheckpointMetadata = {"source": "input"} # search by 1 key
|
||||
query_2: CheckpointMetadata = {
|
||||
"step": 1,
|
||||
"writes": {"foo": "bar"},
|
||||
} # search by multiple keys
|
||||
query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints
|
||||
query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match
|
||||
|
||||
search_results_1 = [
|
||||
c async for c in self.memory_saver.alist(None, filter=query_1)
|
||||
]
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == self.metadata_1
|
||||
|
||||
search_results_2 = [
|
||||
c async for c in self.memory_saver.alist(None, filter=query_2)
|
||||
]
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == self.metadata_2
|
||||
|
||||
search_results_3 = [
|
||||
c async for c in self.memory_saver.alist(None, filter=query_3)
|
||||
]
|
||||
assert len(search_results_3) == 2
|
||||
|
||||
search_results_4 = [
|
||||
c async for c in self.memory_saver.alist(None, filter=query_4)
|
||||
]
|
||||
assert len(search_results_4) == 0
|
||||
@@ -0,0 +1,125 @@
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.channels.manager import create_checkpoint
|
||||
from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata, empty_checkpoint
|
||||
from langgraph.checkpoint.sqlite import (
|
||||
_AIO_ERROR_MSG,
|
||||
SqliteSaver,
|
||||
_metadata_predicate,
|
||||
search_where,
|
||||
)
|
||||
|
||||
|
||||
class TestSqliteSaver:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self):
|
||||
self.sqlite_saver = SqliteSaver.from_conn_string(":memory:")
|
||||
|
||||
# objects for test setup
|
||||
self.config_1: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-1", "thread_ts": "1"}
|
||||
}
|
||||
self.config_2: RunnableConfig = {
|
||||
"configurable": {"thread_id": "thread-2", "thread_ts": "2"}
|
||||
}
|
||||
|
||||
self.chkpnt_1: Checkpoint = empty_checkpoint()
|
||||
self.chkpnt_2: Checkpoint = create_checkpoint(self.chkpnt_1, {}, 1)
|
||||
|
||||
self.metadata_1: CheckpointMetadata = {
|
||||
"source": "input",
|
||||
"step": 2,
|
||||
"writes": {},
|
||||
"score": 1,
|
||||
}
|
||||
self.metadata_2: CheckpointMetadata = {
|
||||
"source": "loop",
|
||||
"step": 1,
|
||||
"writes": {"foo": "bar"},
|
||||
"score": None,
|
||||
}
|
||||
self.metadata_3: CheckpointMetadata = {}
|
||||
|
||||
def test_search(self):
|
||||
# set up test
|
||||
# save checkpoints
|
||||
self.sqlite_saver.put(self.config_1, self.chkpnt_1, self.metadata_1)
|
||||
self.sqlite_saver.put(self.config_2, self.chkpnt_2, self.metadata_2)
|
||||
|
||||
# call method / assertions
|
||||
query_1: CheckpointMetadata = {"source": "input"} # search by 1 key
|
||||
query_2: CheckpointMetadata = {
|
||||
"step": 1,
|
||||
"writes": {"foo": "bar"},
|
||||
} # search by multiple keys
|
||||
query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints
|
||||
query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match
|
||||
|
||||
search_results_1 = list(self.sqlite_saver.list(None, filter=query_1))
|
||||
assert len(search_results_1) == 1
|
||||
assert search_results_1[0].metadata == self.metadata_1
|
||||
|
||||
search_results_2 = list(self.sqlite_saver.list(None, filter=query_2))
|
||||
assert len(search_results_2) == 1
|
||||
assert search_results_2[0].metadata == self.metadata_2
|
||||
|
||||
search_results_3 = list(self.sqlite_saver.list(None, filter=query_3))
|
||||
assert len(search_results_3) == 2
|
||||
|
||||
search_results_4 = list(self.sqlite_saver.list(None, filter=query_4))
|
||||
assert len(search_results_4) == 0
|
||||
|
||||
# TODO: test before and limit params
|
||||
|
||||
def test_search_where(self):
|
||||
# call method / assertions
|
||||
expected_predicate_1 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = ? AND json_extract(CAST(metadata AS TEXT), '$.step') = ? AND json_extract(CAST(metadata AS TEXT), '$.writes') = ? AND json_extract(CAST(metadata AS TEXT), '$.score') = ? AND thread_ts < ?"
|
||||
expected_param_values_1 = ["input", 2, "{}", 1, "1"]
|
||||
assert search_where(None, self.metadata_1, self.config_1) == (
|
||||
expected_predicate_1,
|
||||
expected_param_values_1,
|
||||
)
|
||||
|
||||
def test_metadata_predicate(self):
|
||||
# call method / assertions
|
||||
expected_predicate_1 = [
|
||||
"json_extract(CAST(metadata AS TEXT), '$.source') = ?",
|
||||
"json_extract(CAST(metadata AS TEXT), '$.step') = ?",
|
||||
"json_extract(CAST(metadata AS TEXT), '$.writes') = ?",
|
||||
"json_extract(CAST(metadata AS TEXT), '$.score') = ?",
|
||||
]
|
||||
expected_predicate_2 = [
|
||||
"json_extract(CAST(metadata AS TEXT), '$.source') = ?",
|
||||
"json_extract(CAST(metadata AS TEXT), '$.step') = ?",
|
||||
"json_extract(CAST(metadata AS TEXT), '$.writes') = ?",
|
||||
"json_extract(CAST(metadata AS TEXT), '$.score') IS ?",
|
||||
]
|
||||
expected_predicate_3 = []
|
||||
|
||||
expected_param_values_1 = ["input", 2, "{}", 1]
|
||||
expected_param_values_2 = ["loop", 1, '{"foo":"bar"}', None]
|
||||
expected_param_values_3 = []
|
||||
|
||||
assert _metadata_predicate(self.metadata_1) == (
|
||||
expected_predicate_1,
|
||||
expected_param_values_1,
|
||||
)
|
||||
assert _metadata_predicate(self.metadata_2) == (
|
||||
expected_predicate_2,
|
||||
expected_param_values_2,
|
||||
)
|
||||
assert _metadata_predicate(self.metadata_3) == (
|
||||
expected_predicate_3,
|
||||
expected_param_values_3,
|
||||
)
|
||||
|
||||
async def test_informative_async_errors(self):
|
||||
# call method / assertions
|
||||
with pytest.raises(NotImplementedError, match=_AIO_ERROR_MSG):
|
||||
await self.sqlite_saver.aget(self.config_1)
|
||||
with pytest.raises(NotImplementedError, match=_AIO_ERROR_MSG):
|
||||
await self.sqlite_saver.aget_tuple(self.config_1)
|
||||
with pytest.raises(NotImplementedError, match=_AIO_ERROR_MSG):
|
||||
async for _ in self.sqlite_saver.alist(self.config_1):
|
||||
pass
|
||||
@@ -0,0 +1,15 @@
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def deterministic_uuids(mocker: MockerFixture) -> MockerFixture:
|
||||
side_effect = (
|
||||
UUID(f"00000000-0000-4000-8000-{i:012}", version=4) for i in range(10000)
|
||||
)
|
||||
return mocker.patch("uuid.uuid4", side_effect=side_effect)
|
||||
|
||||
|
||||
pytest.register_assert_rewrite("tests.memory_assert")
|
||||
@@ -0,0 +1,114 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Any, Optional
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from langgraph.checkpoint.base import (
|
||||
Checkpoint,
|
||||
CheckpointMetadata,
|
||||
SerializerProtocol,
|
||||
copy_checkpoint,
|
||||
)
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
|
||||
class NoopSerializer(SerializerProtocol):
|
||||
def loads(self, data: bytes) -> Any:
|
||||
return data
|
||||
|
||||
def dumps(self, obj: Any) -> bytes:
|
||||
return obj
|
||||
|
||||
|
||||
class MemorySaverAssertImmutable(MemorySaver):
|
||||
serde = NoopSerializer()
|
||||
|
||||
storage_for_copies: defaultdict[str, dict[str, Checkpoint]]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
super().__init__(serde=serde)
|
||||
self.storage_for_copies = defaultdict(dict)
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: dict,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: Optional[CheckpointMetadata] = None,
|
||||
) -> None:
|
||||
# assert checkpoint hasn't been modified since last written
|
||||
thread_id = config["configurable"]["thread_id"]
|
||||
if saved := super().get(config):
|
||||
assert (
|
||||
self.serde.loads(self.storage_for_copies[thread_id][saved["id"]])
|
||||
== saved
|
||||
)
|
||||
self.storage_for_copies[thread_id][checkpoint["id"]] = self.serde.dumps(
|
||||
copy_checkpoint(checkpoint)
|
||||
)
|
||||
# call super to write checkpoint
|
||||
return super().put(config, checkpoint, metadata)
|
||||
|
||||
|
||||
class MemorySaverAssertCheckpointMetadata(MemorySaver):
|
||||
"""This custom checkpointer is for verifying that a run's configurable
|
||||
fields are merged with the previous checkpoint config for each step in
|
||||
the run. This is the desired behavior. Because the checkpointer's (a)put()
|
||||
method is called for each step, the implementation of this checkpointer
|
||||
should produce a side effect that can be asserted.
|
||||
"""
|
||||
|
||||
serde = NoopSerializer()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
serde: Optional[SerializerProtocol] = None,
|
||||
) -> None:
|
||||
super().__init__(serde=serde)
|
||||
|
||||
def put(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: Optional[CheckpointMetadata] = None,
|
||||
) -> None:
|
||||
"""The implementation of put() merges config["configurable"] (a run's
|
||||
configurable fields) with the metadata field. The state of the
|
||||
checkpoint metadata can be asserted to confirm that the run's
|
||||
configurable fields were merged with the previous checkpoint config.
|
||||
"""
|
||||
configurable = config["configurable"].copy()
|
||||
|
||||
# remove thread_ts to make testing simpler
|
||||
configurable.pop("thread_ts", None)
|
||||
|
||||
self.storage[config["configurable"]["thread_id"]].update(
|
||||
{
|
||||
checkpoint["id"]: (
|
||||
self.serde.dumps(checkpoint),
|
||||
# merge configurable fields and metadata
|
||||
self.serde.dumps({**configurable, **metadata}),
|
||||
)
|
||||
}
|
||||
)
|
||||
return {
|
||||
"configurable": {
|
||||
"thread_id": config["configurable"]["thread_id"],
|
||||
"thread_ts": checkpoint["id"],
|
||||
}
|
||||
}
|
||||
|
||||
async def aput(
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpoint: Checkpoint,
|
||||
metadata: CheckpointMetadata,
|
||||
) -> RunnableConfig:
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, self.put, config, checkpoint, metadata
|
||||
)
|
||||
@@ -0,0 +1,332 @@
|
||||
import operator
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from typing import AsyncGenerator, Generator, Sequence, Union
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.context import Context
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.topic import Topic
|
||||
from langgraph.errors import EmptyChannelError, InvalidUpdateError
|
||||
|
||||
|
||||
def test_last_value() -> None:
|
||||
with LastValue(int).from_checkpoint() as channel:
|
||||
assert channel.ValueType is int
|
||||
assert channel.UpdateType is int
|
||||
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.get()
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
channel.update([5, 6])
|
||||
|
||||
channel.update([3])
|
||||
assert channel.get() == 3
|
||||
channel.update([4])
|
||||
assert channel.get() == 4
|
||||
checkpoint = channel.checkpoint()
|
||||
with LastValue(int).from_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == 4
|
||||
|
||||
|
||||
async def test_last_value_async() -> None:
|
||||
async with LastValue(int).afrom_checkpoint() as channel:
|
||||
assert channel.ValueType is int
|
||||
assert channel.UpdateType is int
|
||||
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.get()
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
channel.update([5, 6])
|
||||
|
||||
channel.update([3])
|
||||
assert channel.get() == 3
|
||||
channel.update([4])
|
||||
assert channel.get() == 4
|
||||
checkpoint = channel.checkpoint()
|
||||
async with LastValue(int).afrom_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == 4
|
||||
|
||||
|
||||
def test_topic() -> None:
|
||||
with Topic(str).from_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
assert channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
assert channel.update([["c", "d"], "d"])
|
||||
assert channel.get() == ["c", "d", "d"]
|
||||
assert channel.update([])
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.get()
|
||||
assert not channel.update([]), "channel already empty"
|
||||
assert channel.update(["e"])
|
||||
assert channel.get() == ["e"]
|
||||
checkpoint = channel.checkpoint()
|
||||
with Topic(str).from_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == ["e"]
|
||||
with Topic(str).from_checkpoint(checkpoint) as channel_copy:
|
||||
channel_copy.update(["f"])
|
||||
assert channel_copy.get() == ["f"]
|
||||
assert channel.get() == ["e"]
|
||||
|
||||
|
||||
async def test_topic_async() -> None:
|
||||
async with Topic(str).afrom_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
assert channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
assert channel.update(["b", ["c", "d"], "d"])
|
||||
assert channel.get() == ["b", "c", "d", "d"]
|
||||
assert channel.update([])
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.get()
|
||||
assert not channel.update([]), "channel already empty"
|
||||
assert channel.update(["e"])
|
||||
assert channel.get() == ["e"]
|
||||
checkpoint = channel.checkpoint()
|
||||
async with Topic(str).afrom_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == ["e"]
|
||||
|
||||
|
||||
def test_topic_unique() -> None:
|
||||
with Topic(str, unique=True).from_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
assert channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
assert channel.update(["b", ["c", "d"], "d"])
|
||||
assert channel.get() == ["c", "d"], "de-dupes from current and previous steps"
|
||||
assert channel.update([])
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.get()
|
||||
assert not channel.update([]), "channel already empty"
|
||||
assert channel.update(["e"])
|
||||
assert channel.get() == ["e"]
|
||||
checkpoint = channel.checkpoint()
|
||||
with Topic(str, unique=True).from_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == ["e"]
|
||||
assert channel.update(["d", "f"])
|
||||
assert channel.get() == ["f"], "de-dupes from checkpoint"
|
||||
|
||||
|
||||
async def test_topic_unique_async() -> None:
|
||||
async with Topic(str, unique=True).afrom_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
assert channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
assert channel.update(["b", ["c", "d"], "d"])
|
||||
assert channel.get() == ["c", "d"], "de-dupes from current and previous steps"
|
||||
assert channel.update([])
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.get()
|
||||
assert not channel.update([]), "channel already empty"
|
||||
assert channel.update(["e"])
|
||||
assert channel.get() == ["e"]
|
||||
checkpoint = channel.checkpoint()
|
||||
async with Topic(str, unique=True).afrom_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == ["e"]
|
||||
assert channel.update(["d", "f"])
|
||||
assert channel.get() == ["f"], "de-dupes from checkpoint"
|
||||
|
||||
|
||||
def test_topic_accumulate() -> None:
|
||||
with Topic(str, accumulate=True).from_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
assert channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
assert channel.update(["b", ["c", "d"], "d"])
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d"]
|
||||
assert not channel.update([])
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d"]
|
||||
checkpoint = channel.checkpoint()
|
||||
with Topic(str, accumulate=True).from_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d"]
|
||||
assert channel.update(["e"])
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d", "e"]
|
||||
|
||||
|
||||
async def test_topic_accumulate_async() -> None:
|
||||
async with Topic(str, accumulate=True).afrom_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
assert channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
assert channel.update(["b", ["c", "d"], "d"])
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d"]
|
||||
assert not channel.update([])
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d"]
|
||||
checkpoint = channel.checkpoint()
|
||||
async with Topic(str, accumulate=True).afrom_checkpoint(checkpoint) as channel:
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d"]
|
||||
assert channel.update(["e"])
|
||||
assert channel.get() == ["a", "b", "b", "c", "d", "d", "e"]
|
||||
|
||||
|
||||
def test_topic_unique_accumulate() -> None:
|
||||
with Topic(str, unique=True, accumulate=True).from_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
assert channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
assert channel.update(["b", ["c", "d"], "d"])
|
||||
assert channel.get() == ["a", "b", "c", "d"]
|
||||
assert not channel.update(["c"]), "no new values"
|
||||
assert not channel.update([])
|
||||
assert channel.get() == ["a", "b", "c", "d"]
|
||||
checkpoint = channel.checkpoint()
|
||||
with Topic(str, unique=True, accumulate=True).from_checkpoint(
|
||||
checkpoint
|
||||
) as channel:
|
||||
assert channel.get() == ["a", "b", "c", "d"]
|
||||
assert channel.update(["d", "e"])
|
||||
assert channel.get() == ["a", "b", "c", "d", "e"]
|
||||
|
||||
|
||||
async def test_topic_unique_accumulate_async() -> None:
|
||||
async with Topic(str, unique=True, accumulate=True).afrom_checkpoint() as channel:
|
||||
assert channel.ValueType is Sequence[str]
|
||||
assert channel.UpdateType is Union[str, list[str]]
|
||||
|
||||
channel.update(["a", "b"])
|
||||
assert channel.get() == ["a", "b"]
|
||||
channel.update(["b", ["c", "d"], "d"])
|
||||
assert channel.get() == ["a", "b", "c", "d"]
|
||||
channel.update([])
|
||||
assert channel.get() == ["a", "b", "c", "d"]
|
||||
checkpoint = channel.checkpoint()
|
||||
async with Topic(str, unique=True, accumulate=True).afrom_checkpoint(
|
||||
checkpoint
|
||||
) as channel:
|
||||
assert channel.get() == ["a", "b", "c", "d"]
|
||||
channel.update(["d", "e"])
|
||||
assert channel.get() == ["a", "b", "c", "d", "e"]
|
||||
|
||||
|
||||
def test_binop() -> None:
|
||||
with BinaryOperatorAggregate(int, operator.add).from_checkpoint() as channel:
|
||||
assert channel.ValueType is int
|
||||
assert channel.UpdateType is int
|
||||
|
||||
assert channel.get() == 0
|
||||
|
||||
channel.update([1, 2, 3])
|
||||
assert channel.get() == 6
|
||||
channel.update([4])
|
||||
assert channel.get() == 10
|
||||
checkpoint = channel.checkpoint()
|
||||
with BinaryOperatorAggregate(int, operator.add).from_checkpoint(
|
||||
checkpoint
|
||||
) as channel:
|
||||
assert channel.get() == 10
|
||||
|
||||
|
||||
async def test_binop_async() -> None:
|
||||
async with BinaryOperatorAggregate(int, operator.add).afrom_checkpoint() as channel:
|
||||
assert channel.ValueType is int
|
||||
assert channel.UpdateType is int
|
||||
|
||||
assert channel.get() == 0
|
||||
|
||||
channel.update([1, 2, 3])
|
||||
assert channel.get() == 6
|
||||
channel.update([4])
|
||||
assert channel.get() == 10
|
||||
checkpoint = channel.checkpoint()
|
||||
async with BinaryOperatorAggregate(int, operator.add).afrom_checkpoint(
|
||||
checkpoint
|
||||
) as channel:
|
||||
assert channel.get() == 10
|
||||
|
||||
|
||||
def test_ctx_manager(mocker: MockerFixture) -> None:
|
||||
setup = mocker.Mock()
|
||||
cleanup = mocker.Mock()
|
||||
|
||||
@contextmanager
|
||||
def an_int() -> Generator[int, None, None]:
|
||||
setup()
|
||||
try:
|
||||
yield 5
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
with Context(an_int, None, int).from_checkpoint() as channel:
|
||||
assert setup.call_count == 1
|
||||
assert cleanup.call_count == 0
|
||||
|
||||
assert channel.ValueType is int
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
assert channel.UpdateType is None
|
||||
|
||||
assert channel.get() == 5
|
||||
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
channel.update([5]) # type: ignore
|
||||
|
||||
assert setup.call_count == 1
|
||||
assert cleanup.call_count == 1
|
||||
|
||||
|
||||
def test_ctx_manager_ctx(mocker: MockerFixture) -> None:
|
||||
with Context(httpx.Client).from_checkpoint() as channel:
|
||||
assert channel.ValueType is httpx.Client
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
assert channel.UpdateType is None
|
||||
|
||||
assert isinstance(channel.get(), httpx.Client)
|
||||
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
channel.update([5]) # type: ignore
|
||||
|
||||
with pytest.raises(EmptyChannelError):
|
||||
channel.checkpoint()
|
||||
|
||||
|
||||
async def test_ctx_manager_async(mocker: MockerFixture) -> None:
|
||||
setup = mocker.Mock()
|
||||
cleanup = mocker.Mock()
|
||||
|
||||
@contextmanager
|
||||
def an_int_sync() -> Generator[int, None, None]:
|
||||
try:
|
||||
yield 5
|
||||
finally:
|
||||
pass
|
||||
|
||||
@asynccontextmanager
|
||||
async def an_int() -> AsyncGenerator[int, None]:
|
||||
setup()
|
||||
try:
|
||||
yield 5
|
||||
finally:
|
||||
cleanup()
|
||||
|
||||
async with Context(an_int_sync, an_int, int).afrom_checkpoint() as channel:
|
||||
assert setup.call_count == 1
|
||||
assert cleanup.call_count == 0
|
||||
|
||||
assert channel.ValueType is int
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
assert channel.UpdateType is None
|
||||
|
||||
assert channel.get() == 5
|
||||
|
||||
with pytest.raises(InvalidUpdateError):
|
||||
channel.update([5]) # type: ignore
|
||||
|
||||
assert setup.call_count == 1
|
||||
assert cleanup.call_count == 1
|
||||
@@ -0,0 +1,18 @@
|
||||
from typing import Iterator
|
||||
|
||||
from langgraph.pregel.io import single
|
||||
|
||||
|
||||
def test_single() -> None:
|
||||
closed = False
|
||||
|
||||
def myiter() -> Iterator[int]:
|
||||
try:
|
||||
yield 1
|
||||
yield 2
|
||||
finally:
|
||||
nonlocal closed
|
||||
closed = True
|
||||
|
||||
assert single(myiter()) == 1
|
||||
assert closed
|
||||
@@ -0,0 +1,112 @@
|
||||
import dataclasses
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
import dataclasses_json
|
||||
from langchain_core.pydantic_v1 import BaseModel as LcBaseModel
|
||||
from langchain_core.runnables import RunnableMap
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph.serde.jsonplus import JsonPlusSerializer
|
||||
|
||||
|
||||
class MyPydantic(BaseModel):
|
||||
foo: str
|
||||
bar: int
|
||||
|
||||
|
||||
class MyFunnyPydantic(LcBaseModel):
|
||||
foo: str
|
||||
bar: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MyDataclass:
|
||||
foo: str
|
||||
bar: int
|
||||
|
||||
def something(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
if sys.version_info < (3, 10):
|
||||
|
||||
class MyDataclassWSlots(MyDataclass):
|
||||
pass
|
||||
|
||||
else:
|
||||
|
||||
@dataclasses.dataclass(slots=True)
|
||||
class MyDataclassWSlots:
|
||||
foo: str
|
||||
bar: int
|
||||
|
||||
def something(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class MyEnum(Enum):
|
||||
FOO = "foo"
|
||||
BAR = "bar"
|
||||
|
||||
|
||||
@dataclasses_json.dataclass_json
|
||||
@dataclasses.dataclass
|
||||
class Person:
|
||||
name: str
|
||||
|
||||
|
||||
def test_serde_jsonplus() -> None:
|
||||
uid = uuid.UUID(int=1)
|
||||
current_time = datetime(2024, 4, 19, 23, 4, 57, 51022, timezone.max)
|
||||
|
||||
to_serialize = {
|
||||
"uid": uid,
|
||||
"time": current_time,
|
||||
"my_slotted_class": MyDataclassWSlots("bar", 2),
|
||||
"my_dataclass": MyDataclass("foo", 1),
|
||||
"my_enum": MyEnum.FOO,
|
||||
"my_pydantic": MyPydantic(foo="foo", bar=1),
|
||||
"my_funny_pydantic": MyFunnyPydantic(foo="foo", bar=1),
|
||||
"person": Person(name="foo"),
|
||||
"a_bool": True,
|
||||
"a_none": None,
|
||||
"a_str": "foo",
|
||||
"a_str_nuc": "foo\u0000",
|
||||
"a_str_uc": "foo ⛰️",
|
||||
"a_str_ucuc": "foo \u26f0\ufe0f\u0000",
|
||||
"a_str_ucucuc": "foo \\u26f0\\ufe0f",
|
||||
"text": [
|
||||
"Hello\ud83d\ude00",
|
||||
"Python\ud83d\udc0d",
|
||||
"Surrogate\ud834\udd1e",
|
||||
"Example\ud83c\udf89",
|
||||
"String\ud83c\udfa7",
|
||||
"With\ud83c\udf08",
|
||||
"Surrogates\ud83d\ude0e",
|
||||
"Embedded\ud83d\udcbb",
|
||||
"In\ud83c\udf0e",
|
||||
"The\ud83d\udcd6",
|
||||
"Text\ud83d\udcac",
|
||||
"收花🙄·到",
|
||||
],
|
||||
"an_int": 1,
|
||||
"a_float": 1.1,
|
||||
"runnable_map": RunnableMap({}),
|
||||
}
|
||||
|
||||
serde = JsonPlusSerializer()
|
||||
|
||||
dumped = serde.dumps(to_serialize)
|
||||
|
||||
assert (
|
||||
dumped
|
||||
== b"""{"uid": {"lc": 2, "type": "constructor", "id": ["uuid", "UUID"], "method": null, "args": ["00000000000000000000000000000001"], "kwargs": {}}, "time": {"lc": 2, "type": "constructor", "id": ["datetime", "datetime"], "method": "fromisoformat", "args": ["2024-04-19T23:04:57.051022+23:59"], "kwargs": {}}, "my_slotted_class": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyDataclassWSlots"], "method": null, "args": [], "kwargs": {"foo": "bar", "bar": 2}}, "my_dataclass": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyDataclass"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "my_enum": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyEnum"], "method": null, "args": ["foo"], "kwargs": {}}, "my_pydantic": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyPydantic"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "my_funny_pydantic": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "MyFunnyPydantic"], "method": null, "args": [], "kwargs": {"foo": "foo", "bar": 1}}, "person": {"lc": 2, "type": "constructor", "id": ["tests", "test_jsonplus", "Person"], "method": null, "args": [], "kwargs": {"name": "foo"}}, "a_bool": true, "a_none": null, "a_str": "foo", "a_str_nuc": "foo\\u0000", "a_str_uc": "foo \xe2\x9b\xb0\xef\xb8\x8f", "a_str_ucuc": "foo \xe2\x9b\xb0\xef\xb8\x8f\\u0000", "a_str_ucucuc": "foo \\\\u26f0\\\\ufe0f", "text": ["Hello", "Python", "Surrogate", "Example", "String", "With", "Surrogates", "Embedded", "In", "The", "Text", "\xe6\x94\xb6\xe8\x8a\xb1\xf0\x9f\x99\x84\xc2\xb7\xe5\x88\xb0"], "an_int": 1, "a_float": 1.1, "runnable_map": {"lc": 1, "type": "constructor", "id": ["langchain", "schema", "runnable", "RunnableParallel"], "kwargs": {"steps__": {}}, "name": "RunnableParallel<>", "graph": {"nodes": [{"id": 0, "type": "schema", "data": "Parallel<>Input"}, {"id": 1, "type": "schema", "data": "Parallel<>Output"}], "edges": []}}}"""
|
||||
)
|
||||
|
||||
assert serde.loads(dumped) == {
|
||||
**to_serialize,
|
||||
"text": [v.encode("utf-8", "ignore").decode() for v in to_serialize["text"]],
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Type, Union
|
||||
|
||||
import pytest
|
||||
from langchain_core.callbacks import (
|
||||
CallbackManagerForLLMRun,
|
||||
)
|
||||
from langchain_core.language_models import (
|
||||
BaseChatModel,
|
||||
LanguageModelInput,
|
||||
)
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
SystemMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.pydantic_v1 import BaseModel
|
||||
from langchain_core.runnables import Runnable, RunnableLambda
|
||||
from langchain_core.tools import BaseTool
|
||||
from langchain_core.tools import tool as dec_tool
|
||||
from pydantic import BaseModel as BaseModelV2
|
||||
|
||||
from langgraph.prebuilt import ToolNode, ValidationNode, create_react_agent
|
||||
|
||||
|
||||
class FakeToolCallingModel(BaseChatModel):
|
||||
def _generate(
|
||||
self,
|
||||
messages: List[BaseMessage],
|
||||
stop: Optional[List[str]] = None,
|
||||
run_manager: Optional[CallbackManagerForLLMRun] = None,
|
||||
**kwargs: Any,
|
||||
) -> ChatResult:
|
||||
"""Top Level call"""
|
||||
messages_string = "-".join([m.content for m in messages])
|
||||
message = AIMessage(content=messages_string, id="0")
|
||||
return ChatResult(generations=[ChatGeneration(message=message)])
|
||||
|
||||
@property
|
||||
def _llm_type(self) -> str:
|
||||
return "fake-tool-call-model"
|
||||
|
||||
def bind_tools(
|
||||
self,
|
||||
tools: Sequence[Union[Dict[str, Any], Type[BaseModel], Callable, BaseTool]],
|
||||
**kwargs: Any,
|
||||
) -> Runnable[LanguageModelInput, BaseMessage]:
|
||||
if len(tools) > 0:
|
||||
raise ValueError("Not supported yet!")
|
||||
return self
|
||||
|
||||
|
||||
def test_no_modifier():
|
||||
model = FakeToolCallingModel()
|
||||
agent = create_react_agent(model, [])
|
||||
inputs = [HumanMessage("hi?")]
|
||||
response = agent.invoke({"messages": inputs})
|
||||
expected_response = {"messages": inputs + [AIMessage(content="hi?", id="0")]}
|
||||
assert response == expected_response
|
||||
|
||||
|
||||
def test_system_message_modifier():
|
||||
model = FakeToolCallingModel()
|
||||
messages_modifier = SystemMessage(content="Foo")
|
||||
agent = create_react_agent(model, [], messages_modifier=messages_modifier)
|
||||
inputs = [HumanMessage("hi?")]
|
||||
response = agent.invoke({"messages": inputs})
|
||||
expected_response = {"messages": inputs + [AIMessage(content="Foo-hi?", id="0")]}
|
||||
assert response == expected_response
|
||||
|
||||
|
||||
def test_system_message_string_modifier():
|
||||
model = FakeToolCallingModel()
|
||||
messages_modifier = "Foo"
|
||||
agent = create_react_agent(model, [], messages_modifier=messages_modifier)
|
||||
inputs = [HumanMessage("hi?")]
|
||||
response = agent.invoke({"messages": inputs})
|
||||
expected_response = {"messages": inputs + [AIMessage(content="Foo-hi?", id="0")]}
|
||||
assert response == expected_response
|
||||
|
||||
|
||||
def test_callable_modifier():
|
||||
model = FakeToolCallingModel()
|
||||
|
||||
def messages_modifier(messages):
|
||||
return [HumanMessage(content="Bar")]
|
||||
|
||||
agent = create_react_agent(model, [], messages_modifier=messages_modifier)
|
||||
inputs = [HumanMessage("hi?")]
|
||||
response = agent.invoke({"messages": inputs})
|
||||
expected_response = {"messages": inputs + [AIMessage(content="Bar", id="0")]}
|
||||
assert response == expected_response
|
||||
|
||||
|
||||
def test_runnable_modifier():
|
||||
model = FakeToolCallingModel()
|
||||
|
||||
messages_modifier = RunnableLambda(lambda x: [HumanMessage(content="Baz")])
|
||||
|
||||
agent = create_react_agent(model, [], messages_modifier=messages_modifier)
|
||||
inputs = [HumanMessage("hi?")]
|
||||
response = agent.invoke({"messages": inputs})
|
||||
expected_response = {"messages": inputs + [AIMessage(content="Baz", id="0")]}
|
||||
assert response == expected_response
|
||||
|
||||
|
||||
async def test_tool_node():
|
||||
def tool1(some_val: int, some_other_val: str) -> str:
|
||||
"""Tool 1 docstring."""
|
||||
return f"{some_val} - {some_other_val}"
|
||||
|
||||
async def tool2(some_val: int, some_other_val: str) -> str:
|
||||
"""Tool 2 docstring."""
|
||||
return f"tool2: {some_val} - {some_other_val}"
|
||||
|
||||
result = ToolNode([tool1]).invoke(
|
||||
{
|
||||
"messages": [
|
||||
AIMessage(
|
||||
"hi?",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "tool1",
|
||||
"args": {"some_val": 1, "some_other_val": "foo"},
|
||||
"id": "some 0",
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
tool_message: ToolMessage = result["messages"][-1]
|
||||
assert tool_message.type == "tool"
|
||||
assert tool_message.content == "1 - foo"
|
||||
assert tool_message.tool_call_id == "some 0"
|
||||
|
||||
result2 = await ToolNode([tool2]).ainvoke(
|
||||
{
|
||||
"messages": [
|
||||
AIMessage(
|
||||
"hi?",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "tool2",
|
||||
"args": {"some_val": 2, "some_other_val": "bar"},
|
||||
"id": "some 1",
|
||||
}
|
||||
],
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
tool_message: ToolMessage = result2["messages"][-1]
|
||||
assert tool_message.type == "tool"
|
||||
assert tool_message.content == "tool2: 2 - bar"
|
||||
|
||||
|
||||
def my_function(some_val: int, some_other_val: str) -> str:
|
||||
return f"{some_val} - {some_other_val}"
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
some_val: int
|
||||
some_other_val: str
|
||||
|
||||
|
||||
class MyModelV2(BaseModelV2):
|
||||
some_val: int
|
||||
some_other_val: str
|
||||
|
||||
|
||||
@dec_tool
|
||||
def my_tool(some_val: int, some_other_val: str) -> str:
|
||||
"""Cool."""
|
||||
return f"{some_val} - {some_other_val}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tool_schema",
|
||||
[
|
||||
my_function,
|
||||
MyModel,
|
||||
MyModelV2,
|
||||
my_tool,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("use_message_key", [True, False])
|
||||
async def test_validation_node(tool_schema: Any, use_message_key: bool):
|
||||
validation_node = ValidationNode([tool_schema])
|
||||
tool_name = getattr(tool_schema, "name", getattr(tool_schema, "__name__", None))
|
||||
inputs = [
|
||||
AIMessage(
|
||||
"hi?",
|
||||
tool_calls=[
|
||||
{
|
||||
"name": tool_name,
|
||||
"args": {"some_val": 1, "some_other_val": "foo"},
|
||||
"id": "some 0",
|
||||
},
|
||||
{
|
||||
"name": tool_name,
|
||||
# Wrong type for some_val
|
||||
"args": {"some_val": "bar", "some_other_val": "foo"},
|
||||
"id": "some 1",
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
if use_message_key:
|
||||
inputs = {"messages": inputs}
|
||||
result = await validation_node.ainvoke(inputs)
|
||||
if use_message_key:
|
||||
result = result["messages"]
|
||||
|
||||
def check_results(messages: list):
|
||||
assert len(messages) == 2
|
||||
assert all(m.type == "tool" for m in messages)
|
||||
assert not messages[0].additional_kwargs.get("is_error")
|
||||
assert messages[1].additional_kwargs.get("is_error")
|
||||
|
||||
check_results(result)
|
||||
result_sync = validation_node.invoke(inputs)
|
||||
if use_message_key:
|
||||
result_sync = result_sync["messages"]
|
||||
check_results(result_sync)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
from typing import Annotated as Annotated2
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from pydantic.v1 import BaseModel
|
||||
from typing_extensions import Annotated, TypedDict
|
||||
|
||||
from langgraph.graph.state import _warn_invalid_state_schema
|
||||
|
||||
|
||||
class State(BaseModel):
|
||||
foo: str
|
||||
bar: int
|
||||
|
||||
|
||||
class State2(TypedDict):
|
||||
foo: str
|
||||
bar: int
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[
|
||||
{"foo": "bar"},
|
||||
["hi", lambda x, y: x + y],
|
||||
State(foo="bar", bar=1),
|
||||
State2(foo="bar", bar=1),
|
||||
],
|
||||
)
|
||||
def test_warns_invalid_schema(schema: Any):
|
||||
with pytest.warns(UserWarning):
|
||||
_warn_invalid_state_schema(schema)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"schema",
|
||||
[
|
||||
Annotated[dict, lambda x, y: y],
|
||||
Annotated2[list, lambda x, y: y],
|
||||
dict,
|
||||
State,
|
||||
State2,
|
||||
],
|
||||
)
|
||||
def test_doesnt_warn_valid_schema(schema: Any):
|
||||
# Assert the function does not raise a warning
|
||||
with pytest.warns(None):
|
||||
_warn_invalid_state_schema(schema)
|
||||
@@ -0,0 +1,72 @@
|
||||
import functools
|
||||
|
||||
from langgraph.utils import is_async_callable, is_async_generator
|
||||
|
||||
|
||||
def test_is_async() -> None:
|
||||
async def func() -> None:
|
||||
pass
|
||||
|
||||
assert is_async_callable(func)
|
||||
wrapped_func = functools.wraps(func)(func)
|
||||
assert is_async_callable(wrapped_func)
|
||||
|
||||
def sync_func() -> None:
|
||||
pass
|
||||
|
||||
assert not is_async_callable(sync_func)
|
||||
wrapped_sync_func = functools.wraps(sync_func)(sync_func)
|
||||
assert not is_async_callable(wrapped_sync_func)
|
||||
|
||||
class AsyncFuncCallable:
|
||||
async def __call__(self) -> None:
|
||||
pass
|
||||
|
||||
runnable = AsyncFuncCallable()
|
||||
assert is_async_callable(runnable)
|
||||
wrapped_runnable = functools.wraps(runnable)(runnable)
|
||||
assert is_async_callable(wrapped_runnable)
|
||||
|
||||
class SyncFuncCallable:
|
||||
def __call__(self) -> None:
|
||||
pass
|
||||
|
||||
sync_runnable = SyncFuncCallable()
|
||||
assert not is_async_callable(sync_runnable)
|
||||
wrapped_sync_runnable = functools.wraps(sync_runnable)(sync_runnable)
|
||||
assert not is_async_callable(wrapped_sync_runnable)
|
||||
|
||||
|
||||
def test_is_generator() -> None:
|
||||
async def gen():
|
||||
yield
|
||||
|
||||
assert is_async_generator(gen)
|
||||
|
||||
wrapped_gen = functools.wraps(gen)(gen)
|
||||
assert is_async_generator(wrapped_gen)
|
||||
|
||||
def sync_gen():
|
||||
yield
|
||||
|
||||
assert not is_async_generator(sync_gen)
|
||||
wrapped_sync_gen = functools.wraps(sync_gen)(sync_gen)
|
||||
assert not is_async_generator(wrapped_sync_gen)
|
||||
|
||||
class AsyncGenCallable:
|
||||
async def __call__(self):
|
||||
yield
|
||||
|
||||
runnable = AsyncGenCallable()
|
||||
assert is_async_generator(runnable)
|
||||
wrapped_runnable = functools.wraps(runnable)(runnable)
|
||||
assert is_async_generator(wrapped_runnable)
|
||||
|
||||
class SyncGenCallable:
|
||||
def __call__(self):
|
||||
yield
|
||||
|
||||
sync_runnable = SyncGenCallable()
|
||||
assert not is_async_generator(sync_runnable)
|
||||
wrapped_sync_runnable = functools.wraps(sync_runnable)(sync_runnable)
|
||||
assert not is_async_generator(wrapped_sync_runnable)
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 LangChain, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/client.cjs');
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dist/client.cjs'
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export * from './dist/client.mjs'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dist/client.mjs'
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/index.cjs');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user