mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-09 09:55:13 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
576fa6db20 | ||
|
|
e65c221ada | ||
|
|
04076c4bb5 |
@@ -328,9 +328,6 @@ async def _run(
|
||||
store: weakref.ReferenceType[BaseStore],
|
||||
) -> None:
|
||||
while item := await aqueue.get():
|
||||
# don't run batch if the future is done (e.g. cancelled)
|
||||
if item[0].done():
|
||||
continue
|
||||
# check if store is still alive
|
||||
if s := store():
|
||||
try:
|
||||
@@ -338,9 +335,6 @@ async def _run(
|
||||
items = [item]
|
||||
try:
|
||||
while item := aqueue.get_nowait():
|
||||
# don't insert if the future is done (e.g. cancelled)
|
||||
if item[0].done():
|
||||
continue
|
||||
items.append(item)
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.18"
|
||||
__version__ = "0.4.15"
|
||||
|
||||
+87
-256
@@ -13,7 +13,6 @@ import tempfile
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
@@ -27,7 +26,6 @@ from langgraph_cli.config import Config
|
||||
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
|
||||
from langgraph_cli.docker import DockerCapabilities
|
||||
from langgraph_cli.exec import Runner, subp_exec
|
||||
from langgraph_cli.helpers import format_log_entry, level_fg, resolve_deployment_id
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
from langgraph_cli.progress import Progress
|
||||
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
|
||||
@@ -298,17 +296,6 @@ OPT_HOST_API_KEY = click.option(
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
OPT_HOST_DEPLOYMENT_NAME = click.option(
|
||||
"--name",
|
||||
envvar=_DEPLOYMENT_NAME_ENV,
|
||||
help=(
|
||||
"Deployment name. Can also be set via LANGSMITH_DEPLOYMENT_NAME "
|
||||
"environment variable or .env file. Defaults to current directory name "
|
||||
"if --deployment-id is not provided."
|
||||
),
|
||||
)
|
||||
|
||||
OPT_HOST_URL = click.option(
|
||||
"--host-url",
|
||||
envvar="LANGGRAPH_HOST_URL",
|
||||
@@ -697,7 +684,15 @@ def _deploy_base_options(
|
||||
def _apply(target: Callable) -> Callable:
|
||||
decorators = [
|
||||
OPT_HOST_API_KEY,
|
||||
OPT_HOST_DEPLOYMENT_NAME,
|
||||
click.option(
|
||||
"--name",
|
||||
envvar="LANGSMITH_DEPLOYMENT_NAME",
|
||||
help=(
|
||||
"Deployment name. Can also be set via LANGSMITH_DEPLOYMENT_NAME "
|
||||
"environment variable or .env file. Defaults to current directory name "
|
||||
"if --deployment-id is not provided."
|
||||
),
|
||||
),
|
||||
click.option(
|
||||
"--deployment-id",
|
||||
help=(
|
||||
@@ -721,13 +716,7 @@ def _deploy_base_options(
|
||||
OPT_VERBOSE,
|
||||
OPT_HOST_URL,
|
||||
click.option("--image-name", hidden=True),
|
||||
click.option(
|
||||
"--tag",
|
||||
"-t",
|
||||
default="latest",
|
||||
show_default=True,
|
||||
help="Tag to use for the pushed deployment image.",
|
||||
),
|
||||
click.option("--image-tag", default="latest", hidden=True),
|
||||
click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
@@ -764,12 +753,12 @@ def _deploy_base_options(
|
||||
@cli.group(
|
||||
cls=DeployGroup,
|
||||
help=(
|
||||
"[Beta] Build and deploy a LangGraph image to LangSmith Deployment.\n\n"
|
||||
"[Beta] Build and deploy a LangGraph image to LangSmith Deployments.\n\n"
|
||||
"This command is in beta and under active development. "
|
||||
"Expect frequent updates and improvements.\n\n"
|
||||
"Run from the root of your LangGraph project (where langgraph.json "
|
||||
"is located). This command also accepts build flags (--base-image, "
|
||||
"--config, --pull, etc.). See 'langgraph build --help' for details."
|
||||
"--pull, etc.). See 'langgraph build --help' for details."
|
||||
),
|
||||
context_settings=dict(ignore_unknown_options=True, allow_extra_args=True),
|
||||
invoke_without_command=True, # allow `deploy` click group to execute without command
|
||||
@@ -801,7 +790,7 @@ def _deploy(
|
||||
deployment_type: str,
|
||||
name: str | None,
|
||||
image_name: str | None,
|
||||
tag: str,
|
||||
image_tag: str,
|
||||
base_image: str | None,
|
||||
install_command: str | None,
|
||||
build_command: str | None,
|
||||
@@ -818,6 +807,15 @@ def _deploy(
|
||||
|
||||
env_vars = _parse_env_from_config(config_json, config)
|
||||
|
||||
if not api_key:
|
||||
for key_name in _API_KEY_ENV_NAMES:
|
||||
val = env_vars.get(key_name) or os.environ.get(key_name)
|
||||
if val:
|
||||
api_key = val
|
||||
break
|
||||
if not api_key:
|
||||
api_key = click.prompt("Host API key", hide_input=True)
|
||||
|
||||
if not deployment_id and not name:
|
||||
name = env_vars.get(_DEPLOYMENT_NAME_ENV)
|
||||
if not deployment_id and not name:
|
||||
@@ -854,21 +852,55 @@ def _deploy(
|
||||
def log_step(message: str) -> None:
|
||||
click.secho(message, fg="cyan")
|
||||
|
||||
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
|
||||
client = HostBackendClient(host_url, api_key)
|
||||
step = 1
|
||||
needs_creation = False
|
||||
|
||||
if deployment_id:
|
||||
log_step(f"{step}. Using deployment {deployment_id}")
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.get_deployment(deployment_id)
|
||||
)
|
||||
try:
|
||||
client.get_deployment(deployment_id)
|
||||
except HostBackendError as err:
|
||||
if (
|
||||
err.status_code == 403
|
||||
and "requires workspace specification" in err.message
|
||||
):
|
||||
click.secho(
|
||||
"Your API key is org-scoped and requires a workspace ID.",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
"Find your workspace ID in LangSmith under Settings > Workspaces.",
|
||||
fg="yellow",
|
||||
)
|
||||
tenant_id = click.prompt("Workspace ID")
|
||||
client = HostBackendClient(host_url, api_key, tenant_id=tenant_id)
|
||||
client.get_deployment(deployment_id)
|
||||
else:
|
||||
raise
|
||||
step += 1
|
||||
else:
|
||||
log_step(f"{step}. Looking up deployment '{name}'")
|
||||
existing = _call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments(name_contains=name)
|
||||
)
|
||||
try:
|
||||
existing = client.list_deployments(name_contains=name)
|
||||
except HostBackendError as err:
|
||||
if (
|
||||
err.status_code == 403
|
||||
and "requires workspace specification" in err.message
|
||||
):
|
||||
click.secho(
|
||||
"Your API key is org-scoped and requires a workspace ID.",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
"Find your workspace ID in LangSmith under Settings > Workspaces.",
|
||||
fg="yellow",
|
||||
)
|
||||
tenant_id = click.prompt("Workspace ID")
|
||||
client = HostBackendClient(host_url, api_key, tenant_id=tenant_id)
|
||||
existing = client.list_deployments(name_contains=name)
|
||||
else:
|
||||
raise
|
||||
found_id = None
|
||||
if isinstance(existing, dict):
|
||||
for dep in existing.get("resources", []):
|
||||
@@ -982,7 +1014,7 @@ def _deploy(
|
||||
normalized_registry = normalized_registry.split("//", 1)[1]
|
||||
repo_seed = image_name or name or config.parent.name
|
||||
repo_name = _normalize_image_name(repo_seed)
|
||||
tag_value = _normalize_image_tag(tag)
|
||||
tag_value = _normalize_image_tag(image_tag)
|
||||
remote_image = f"{normalized_registry}/{repo_name}:{tag_value}"
|
||||
|
||||
registry_host = normalized_registry.split("/")[0]
|
||||
@@ -1122,7 +1154,7 @@ def _deploy(
|
||||
)
|
||||
else:
|
||||
click.secho(
|
||||
" Check status in the LangSmith Deployment dashboard.",
|
||||
" Check status in the LangSmith Deployments dashboard.",
|
||||
fg="yellow",
|
||||
)
|
||||
|
||||
@@ -1146,11 +1178,7 @@ def _create_host_backend_client(
|
||||
resolved_api_key = val
|
||||
break
|
||||
if not resolved_api_key:
|
||||
click.secho(
|
||||
"No LangSmith API key found. Create one at Settings > API Keys in LangSmith.",
|
||||
fg="yellow",
|
||||
)
|
||||
resolved_api_key = click.prompt("Enter LangSmith API key", hide_input=True)
|
||||
resolved_api_key = click.prompt("Host API key", hide_input=True)
|
||||
return HostBackendClient(host_url, resolved_api_key)
|
||||
|
||||
|
||||
@@ -1158,49 +1186,24 @@ def _call_host_backend_with_optional_tenant(
|
||||
client: HostBackendClient,
|
||||
operation: Callable[[HostBackendClient], object],
|
||||
) -> object:
|
||||
"""Run *operation*, prompting for a workspace ID on org-scoped 403s.
|
||||
|
||||
On success the original *client* is returned as-is. If the user is
|
||||
prompted for a workspace ID, the tenant header is set on *client*
|
||||
in-place so all subsequent calls through the same instance are
|
||||
tenant-aware.
|
||||
"""
|
||||
prompted_for_tenant = False
|
||||
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
return operation(client)
|
||||
except HostBackendError as err:
|
||||
if err.status_code == 403 and "requires workspace specification" in err.message:
|
||||
click.secho(
|
||||
"Your API key is org-scoped and requires a workspace ID.",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
"Find your workspace ID in LangSmith under Settings > Workspaces.",
|
||||
fg="yellow",
|
||||
)
|
||||
tenant_id = click.prompt("Workspace ID")
|
||||
client = HostBackendClient(
|
||||
client._base_url, client._api_key, tenant_id=tenant_id
|
||||
)
|
||||
return operation(client)
|
||||
except HostBackendError as err:
|
||||
if (
|
||||
not prompted_for_tenant
|
||||
and err.status_code == 403
|
||||
and "requires workspace specification" in err.message
|
||||
):
|
||||
click.secho(
|
||||
"Your API key is org-scoped and requires a workspace ID.",
|
||||
fg="yellow",
|
||||
)
|
||||
click.secho(
|
||||
"Find your workspace ID in LangSmith under Settings > Workspaces.",
|
||||
fg="yellow",
|
||||
)
|
||||
client._client.headers["X-Tenant-ID"] = click.prompt("Workspace ID")
|
||||
prompted_for_tenant = True
|
||||
continue
|
||||
if err.status_code == 403 and "not enabled" in err.message.lower():
|
||||
from urllib.parse import urlparse
|
||||
|
||||
smith_host = "smith.langchain.com"
|
||||
parsed = urlparse(client._base_url)
|
||||
if (parsed.hostname or "").startswith("eu."):
|
||||
smith_host = "eu.smith.langchain.com"
|
||||
raise HostBackendError(
|
||||
"LangSmith Deployment is not enabled for this organization. "
|
||||
f"Enable it at https://{smith_host}/host/deployments"
|
||||
" (ensure this matches the organization for your API key).",
|
||||
status_code=403,
|
||||
) from None
|
||||
raise
|
||||
raise
|
||||
|
||||
|
||||
@OPT_HOST_API_KEY
|
||||
@@ -1215,7 +1218,9 @@ def deploy_list(api_key: str | None, host_url: str | None, name_contains: str) -
|
||||
client = _create_host_backend_client(host_url, api_key)
|
||||
response = _call_host_backend_with_optional_tenant(
|
||||
client,
|
||||
lambda c: c.list_deployments(name_contains=name_contains),
|
||||
lambda current_client: current_client.list_deployments(
|
||||
name_contains=name_contains
|
||||
),
|
||||
)
|
||||
resources = response.get("resources", []) if isinstance(response, dict) else []
|
||||
deployments = [item for item in resources if isinstance(item, dict)]
|
||||
@@ -1258,7 +1263,7 @@ def deploy_delete(
|
||||
client = _create_host_backend_client(host_url, api_key)
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client,
|
||||
lambda c: c.delete_deployment(deployment_id),
|
||||
lambda current_client: current_client.delete_deployment(deployment_id),
|
||||
)
|
||||
click.secho(f"Deleted deployment {deployment_id}.", fg="green")
|
||||
|
||||
@@ -1289,180 +1294,6 @@ def _normalize_image_tag(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
@OPT_HOST_API_KEY
|
||||
@OPT_HOST_DEPLOYMENT_NAME
|
||||
@click.option(
|
||||
"--deployment-id",
|
||||
help="Deployment ID. If omitted, --name is used to find the deployment.",
|
||||
)
|
||||
@click.option(
|
||||
"--type",
|
||||
"log_type",
|
||||
type=click.Choice(["deploy", "build"]),
|
||||
default="deploy",
|
||||
show_default=True,
|
||||
help=(
|
||||
"Log stream to fetch: 'deploy' shows agent server runtime logs; "
|
||||
"'build' shows build logs (for deployments built remotely)."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--revision-id",
|
||||
help="Specific revision ID. For build logs, defaults to latest revision.",
|
||||
)
|
||||
@click.option(
|
||||
"--level",
|
||||
type=click.Choice(
|
||||
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
|
||||
),
|
||||
help="Filter by log level.",
|
||||
)
|
||||
@click.option(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=100,
|
||||
show_default=True,
|
||||
help="Max log entries to fetch.",
|
||||
)
|
||||
@click.option(
|
||||
"--query",
|
||||
"-q",
|
||||
help="Search string filter.",
|
||||
)
|
||||
@click.option(
|
||||
"--start-time",
|
||||
help="ISO8601 start time (e.g. 2026-03-08T00:00:00Z).",
|
||||
)
|
||||
@click.option(
|
||||
"--end-time",
|
||||
help="ISO8601 end time. (e.g. 2026-03-08T00:00:00Z)",
|
||||
)
|
||||
@click.option(
|
||||
"--follow",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Continuously poll for new logs.",
|
||||
)
|
||||
@OPT_HOST_URL
|
||||
@deploy.command(
|
||||
"logs",
|
||||
help=(
|
||||
"[Beta] Fetch LangSmith Deployment logs. Use 'deploy' for agent runtime "
|
||||
"logs, or 'build' for remote build logs."
|
||||
),
|
||||
)
|
||||
@log_command
|
||||
def deploy_logs(
|
||||
api_key: str | None,
|
||||
name: str | None,
|
||||
deployment_id: str | None,
|
||||
log_type: str,
|
||||
revision_id: str | None,
|
||||
level: str | None,
|
||||
limit: int,
|
||||
query: str | None,
|
||||
start_time: str | None,
|
||||
end_time: str | None,
|
||||
follow: bool,
|
||||
host_url: str,
|
||||
):
|
||||
env_vars = _parse_env_from_config({}, pathlib.Path.cwd() / DEFAULT_CONFIG)
|
||||
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
|
||||
if not deployment_id and not name:
|
||||
name = env_vars.get(_DEPLOYMENT_NAME_ENV)
|
||||
dep_id = _call_host_backend_with_optional_tenant(
|
||||
client, lambda c: resolve_deployment_id(c, deployment_id, name)
|
||||
)
|
||||
|
||||
if log_type == "build" and not revision_id:
|
||||
revisions_resp = client.list_revisions(dep_id, limit=1)
|
||||
resources = (
|
||||
revisions_resp.get("resources", [])
|
||||
if isinstance(revisions_resp, dict)
|
||||
else []
|
||||
)
|
||||
if not resources:
|
||||
raise click.ClickException(
|
||||
"No revisions found for this deployment. Cannot fetch build logs."
|
||||
)
|
||||
revision_id = str(resources[0]["id"])
|
||||
click.secho(f"Using latest revision: {revision_id}", fg="cyan")
|
||||
|
||||
payload: dict = {"limit": limit, "order": "desc"}
|
||||
if level:
|
||||
payload["level"] = level.upper()
|
||||
if query:
|
||||
payload["query"] = query
|
||||
if start_time:
|
||||
payload["start_time"] = start_time
|
||||
if end_time:
|
||||
payload["end_time"] = end_time
|
||||
|
||||
def _fetch(request_payload: dict) -> list[dict]:
|
||||
if log_type == "build":
|
||||
resp = client.get_build_logs(dep_id, revision_id, request_payload)
|
||||
else:
|
||||
resp = client.get_deploy_logs(dep_id, request_payload, revision_id)
|
||||
|
||||
if isinstance(resp, dict):
|
||||
return resp.get("logs", [])
|
||||
return []
|
||||
|
||||
def _print_entries(entries: list[dict], *, reverse: bool = False) -> None:
|
||||
iterable = reversed(entries) if reverse else entries
|
||||
for entry in iterable:
|
||||
line = format_log_entry(entry)
|
||||
fg = level_fg(entry.get("level", ""))
|
||||
click.secho(line, fg=fg)
|
||||
|
||||
def _fetch_and_print(request_payload: dict, *, reverse: bool = False) -> list[dict]:
|
||||
entries = _fetch(request_payload)
|
||||
_print_entries(entries, reverse=reverse)
|
||||
return entries
|
||||
|
||||
def _fetch_and_print_new(request_payload: dict, seen_ids: set[str]) -> list[dict]:
|
||||
entries = _fetch(request_payload)
|
||||
new = [e for e in entries if e.get("id", "") not in seen_ids]
|
||||
if new:
|
||||
_print_entries(new)
|
||||
seen_ids.update(e.get("id", "") for e in new)
|
||||
return new
|
||||
|
||||
# initial log fetch will be newest -> oldest, so we need to reverse
|
||||
entries = _fetch_and_print(payload, reverse=True)
|
||||
|
||||
if not follow:
|
||||
if not entries:
|
||||
click.secho("No log entries found.", fg="yellow")
|
||||
return
|
||||
|
||||
payload["order"] = "asc"
|
||||
seen_ids: set[str] = {e.get("id", "") for e in entries if e.get("id")}
|
||||
|
||||
def _update_start_time(ts) -> None:
|
||||
if ts is None:
|
||||
return
|
||||
if isinstance(ts, (int, float)):
|
||||
dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
|
||||
payload["start_time"] = dt.isoformat()
|
||||
else:
|
||||
payload["start_time"] = str(ts)
|
||||
|
||||
if entries:
|
||||
# entries are in descending order here, so index 0 is the newest log
|
||||
_update_start_time(entries[0].get("timestamp"))
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(2)
|
||||
new_entries = _fetch_and_print_new(payload, seen_ids)
|
||||
if new_entries:
|
||||
_update_start_time(new_entries[-1].get("timestamp"))
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nStopped.")
|
||||
|
||||
|
||||
def _get_docker_ignore_content() -> str:
|
||||
"""Return the content of a .dockerignore file.
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Helpers for the ``langgraph logs`` CLI command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import click
|
||||
|
||||
from langgraph_cli.host_backend import HostBackendClient
|
||||
|
||||
|
||||
def resolve_deployment_id(
|
||||
client: HostBackendClient,
|
||||
deployment_id: str | None,
|
||||
name: str | None,
|
||||
) -> str:
|
||||
"""Resolve a deployment ID from --deployment-id or --name."""
|
||||
if deployment_id:
|
||||
return deployment_id
|
||||
if not name:
|
||||
raise click.UsageError("Either --deployment-id or --name is required.")
|
||||
existing = client.list_deployments(name_contains=name)
|
||||
if isinstance(existing, dict):
|
||||
for dep in existing.get("resources", []):
|
||||
if isinstance(dep, dict) and dep.get("name") == name:
|
||||
found_id = dep.get("id")
|
||||
if found_id:
|
||||
return str(found_id)
|
||||
raise click.ClickException(f"Deployment '{name}' not found.")
|
||||
|
||||
|
||||
def format_timestamp(ts) -> str:
|
||||
"""Convert a timestamp (epoch ms or string) to a readable string."""
|
||||
if isinstance(ts, (int, float)):
|
||||
dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(ts) if ts else ""
|
||||
|
||||
|
||||
def format_log_entry(entry: dict) -> str:
|
||||
"""Format a single log entry for display."""
|
||||
ts = format_timestamp(entry.get("timestamp", ""))
|
||||
level = entry.get("level", "")
|
||||
message = entry.get("message", "")
|
||||
if ts and level:
|
||||
return f"[{ts}] [{level}] {message}"
|
||||
elif ts:
|
||||
return f"[{ts}] {message}"
|
||||
return message
|
||||
|
||||
|
||||
def level_fg(level: str) -> str | None:
|
||||
"""Return click color for a log level."""
|
||||
level_upper = level.upper() if level else ""
|
||||
if level_upper in {"ERROR", "CRITICAL"}:
|
||||
return "red"
|
||||
if level_upper == "WARNING":
|
||||
return "yellow"
|
||||
return None
|
||||
@@ -19,12 +19,7 @@ class HostBackendError(click.ClickException):
|
||||
class HostBackendClient:
|
||||
"""Minimal JSON HTTP client for the host backend deployment service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
def __init__(self, base_url: str, api_key: str, tenant_id: str | None = None):
|
||||
if not base_url:
|
||||
raise click.UsageError("Host backend URL is required")
|
||||
transport = httpx.HTTPTransport(retries=3)
|
||||
@@ -35,6 +30,7 @@ class HostBackendClient:
|
||||
if tenant_id:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
@@ -120,24 +116,3 @@ class HostBackendClient:
|
||||
"GET",
|
||||
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
|
||||
)
|
||||
|
||||
def get_build_logs(
|
||||
self, project_id: str, revision_id: str, payload: dict[str, Any]
|
||||
) -> Any:
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/v1/projects/{project_id}/revisions/{revision_id}/build_logs",
|
||||
payload,
|
||||
)
|
||||
|
||||
def get_deploy_logs(
|
||||
self,
|
||||
project_id: str,
|
||||
payload: dict[str, Any],
|
||||
revision_id: str | None = None,
|
||||
) -> Any:
|
||||
if revision_id:
|
||||
path = f"/v1/projects/{project_id}/revisions/{revision_id}/deploy_logs"
|
||||
else:
|
||||
path = f"/v1/projects/{project_id}/deploy_logs"
|
||||
return self._request("POST", path, payload)
|
||||
|
||||
@@ -8,20 +8,31 @@ from zipfile import ZipFile
|
||||
import click
|
||||
|
||||
TEMPLATES: dict[str, dict[str, str]] = {
|
||||
"Deep Agent": {
|
||||
"description": "An opinionated deployment template for a Deep Agent.",
|
||||
"python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Agent": {
|
||||
"description": "A simple agent that can be flexibly extended to many tools.",
|
||||
"python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip",
|
||||
},
|
||||
"New LangGraph Project": {
|
||||
"description": "A simple, minimal chatbot with memory.",
|
||||
"python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip",
|
||||
},
|
||||
"ReAct Agent": {
|
||||
"description": "A simple agent that can be flexibly extended to many tools.",
|
||||
"python": "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Memory Agent": {
|
||||
"description": "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
|
||||
"python": "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Retrieval Agent": {
|
||||
"description": "An agent that includes a retrieval-based question-answering system.",
|
||||
"python": "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
"Data-enrichment Agent": {
|
||||
"description": "An agent that performs web searches and organizes its findings into a structured format.",
|
||||
"python": "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
|
||||
"js": "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
}
|
||||
|
||||
# Generate TEMPLATE_IDS programmatically
|
||||
@@ -67,25 +78,19 @@ def _choose_template() -> str:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
|
||||
template_info = TEMPLATES[selected_template]
|
||||
available_langs = [lang for lang in ("python", "js") if lang in template_info]
|
||||
|
||||
# Prompt the user to choose between Python or JS/TS version
|
||||
click.secho(
|
||||
f"\nYou selected: {selected_template} - {template_info['description']}",
|
||||
f"\nYou selected: {selected_template} - {TEMPLATES[selected_template]['description']}",
|
||||
fg="green",
|
||||
)
|
||||
|
||||
if len(available_langs) == 1:
|
||||
return template_info[available_langs[0]]
|
||||
|
||||
version_choice: int = click.prompt(
|
||||
"Choose language (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return template_info["python"]
|
||||
return TEMPLATES[selected_template]["python"]
|
||||
elif version_choice == 2:
|
||||
return template_info["js"]
|
||||
return TEMPLATES[selected_template]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return _choose_template()
|
||||
@@ -125,6 +130,37 @@ def _download_repo_with_requests(repo_url: str, path: str) -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _get_template_url(template_name: str) -> str | None:
|
||||
"""
|
||||
Retrieves the template URL based on the provided template name.
|
||||
|
||||
Args:
|
||||
template_name: The name of the template.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The URL of the template if found, else None.
|
||||
"""
|
||||
if template_name in TEMPLATES:
|
||||
click.secho(f"Template selected: {template_name}", fg="green")
|
||||
version_choice: int = click.prompt(
|
||||
"Choose version (1 for Python 🐍, 2 for JS/TS 🌐)", type=int
|
||||
)
|
||||
|
||||
if version_choice == 1:
|
||||
return TEMPLATES[template_name]["python"]
|
||||
elif version_choice == 2:
|
||||
return TEMPLATES[template_name]["js"]
|
||||
else:
|
||||
click.secho("❌ Invalid choice. Please try again.", fg="red")
|
||||
return None
|
||||
else:
|
||||
click.secho(
|
||||
f"Template '{template_name}' not found. Please select from the available options.",
|
||||
fg="red",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def create_new(path: str | None, template: str | None) -> None:
|
||||
"""Create a new LangGraph project at the specified PATH using the chosen TEMPLATE.
|
||||
|
||||
|
||||
@@ -3,17 +3,14 @@ import json
|
||||
import os
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.cli import (
|
||||
_call_host_backend_with_optional_tenant,
|
||||
_docker_config_for_token,
|
||||
_normalize_image_name,
|
||||
_normalize_image_tag,
|
||||
_parse_env_from_config,
|
||||
)
|
||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||
|
||||
|
||||
class TestDockerConfigForToken:
|
||||
@@ -135,92 +132,3 @@ class TestParseEnvFromConfig:
|
||||
assert result["GOOD"] == "value"
|
||||
# EMPTY= gives empty string, not None, so it should be present
|
||||
assert result["EMPTY"] == ""
|
||||
|
||||
|
||||
class TestCallHostBackendWithOptionalTenant:
|
||||
def _make_client(self, handler):
|
||||
c = HostBackendClient("https://api.example.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
return c
|
||||
|
||||
def _make_eu_client(self, handler):
|
||||
c = HostBackendClient("https://eu.api.host.langchain.com", "test-key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://eu.api.host.langchain.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
return c
|
||||
|
||||
def test_success_passes_through(self):
|
||||
client = self._make_client(lambda req: httpx.Response(200, json={"ok": True}))
|
||||
result = _call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
assert result == {"ok": True}
|
||||
|
||||
def test_403_not_enabled_gives_actionable_error(self):
|
||||
detail = (
|
||||
'{"detail":"LangSmith Deployment is not enabled for this organization"}'
|
||||
)
|
||||
client = self._make_client(lambda req: httpx.Response(403, text=detail))
|
||||
with pytest.raises(HostBackendError, match="not enabled") as exc_info:
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "smith.langchain.com" in exc_info.value.message
|
||||
|
||||
def test_403_not_enabled_eu_url(self):
|
||||
detail = (
|
||||
'{"detail":"LangSmith Deployment is not enabled for this organization"}'
|
||||
)
|
||||
client = self._make_eu_client(lambda req: httpx.Response(403, text=detail))
|
||||
with pytest.raises(HostBackendError, match="not enabled") as exc_info:
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
assert "eu.smith.langchain.com" in exc_info.value.message
|
||||
|
||||
def test_workspace_retry_then_not_enabled_gives_actionable_error(self, monkeypatch):
|
||||
requires_workspace = '{"detail":"requires workspace specification"}'
|
||||
not_enabled = (
|
||||
'{"detail":"LangSmith Deployment is not enabled for this organization"}'
|
||||
)
|
||||
seen_tenant_ids = []
|
||||
|
||||
def handler(req):
|
||||
seen_tenant_ids.append(req.headers.get("X-Tenant-ID"))
|
||||
if len(seen_tenant_ids) == 1:
|
||||
return httpx.Response(403, text=requires_workspace)
|
||||
if len(seen_tenant_ids) == 2:
|
||||
return httpx.Response(403, text=not_enabled)
|
||||
raise AssertionError("unexpected extra request")
|
||||
|
||||
monkeypatch.setattr(click, "prompt", lambda _text: "workspace-123")
|
||||
client = self._make_client(handler)
|
||||
|
||||
with pytest.raises(HostBackendError, match="not enabled") as exc_info:
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "smith.langchain.com" in exc_info.value.message
|
||||
assert seen_tenant_ids == [None, "workspace-123"]
|
||||
assert client._client.headers["X-Tenant-ID"] == "workspace-123"
|
||||
|
||||
def test_other_403_re_raises_original(self):
|
||||
client = self._make_client(
|
||||
lambda req: httpx.Response(403, text='{"detail":"some other error"}')
|
||||
)
|
||||
with pytest.raises(HostBackendError, match="some other error"):
|
||||
_call_host_backend_with_optional_tenant(
|
||||
client, lambda c: c.list_deployments()
|
||||
)
|
||||
|
||||
@@ -182,41 +182,3 @@ def test_list_revisions(client):
|
||||
def test_get_revision(client):
|
||||
result = client.get_revision("dep-123", "rev-456")
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_get_build_logs(client):
|
||||
result = client.get_build_logs("proj-1", "rev-1", {"limit": 10})
|
||||
assert result == {"ok": True}
|
||||
|
||||
|
||||
def test_get_deploy_logs_all_revisions():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
|
||||
assert "/revisions/" not in str(req.url)
|
||||
return httpx.Response(200, json={"logs": [{"message": "running"}]})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
result = c.get_deploy_logs("proj-1", {"limit": 10})
|
||||
assert result == {"logs": [{"message": "running"}]}
|
||||
|
||||
|
||||
def test_get_deploy_logs_specific_revision():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
|
||||
return httpx.Response(200, json={"logs": []})
|
||||
|
||||
c = HostBackendClient("https://api.example.com", "key")
|
||||
c._client = httpx.Client(
|
||||
base_url="https://api.example.com",
|
||||
transport=httpx.MockTransport(handler),
|
||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
||||
timeout=30,
|
||||
)
|
||||
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
|
||||
assert result == {"logs": []}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
from langgraph_cli.helpers import format_log_entry, format_timestamp, level_fg
|
||||
|
||||
|
||||
class TestFormatTimestamp:
|
||||
def test_epoch_ms(self):
|
||||
assert format_timestamp(1773119644012) == "2026-03-10 05:14:04"
|
||||
|
||||
def test_string_passthrough(self):
|
||||
assert format_timestamp("2026-03-08T00:00:00Z") == "2026-03-08T00:00:00Z"
|
||||
|
||||
def test_empty(self):
|
||||
assert format_timestamp("") == ""
|
||||
|
||||
def test_none(self):
|
||||
assert format_timestamp(None) == ""
|
||||
|
||||
|
||||
class TestFormatLogEntry:
|
||||
def test_full_entry_epoch(self):
|
||||
entry = {"timestamp": 1773119644012, "level": "ERROR", "message": "boom"}
|
||||
result = format_log_entry(entry)
|
||||
assert result == "[2026-03-10 05:14:04] [ERROR] boom"
|
||||
|
||||
def test_full_entry_string(self):
|
||||
entry = {
|
||||
"timestamp": "2026-03-08T12:00:00Z",
|
||||
"level": "ERROR",
|
||||
"message": "boom",
|
||||
}
|
||||
assert format_log_entry(entry) == "[2026-03-08T12:00:00Z] [ERROR] boom"
|
||||
|
||||
def test_no_level(self):
|
||||
entry = {"timestamp": "2026-03-08T12:00:00Z", "message": "hello"}
|
||||
assert format_log_entry(entry) == "[2026-03-08T12:00:00Z] hello"
|
||||
|
||||
def test_no_timestamp(self):
|
||||
entry = {"message": "bare message"}
|
||||
assert format_log_entry(entry) == "bare message"
|
||||
|
||||
def test_empty_entry(self):
|
||||
assert format_log_entry({}) == ""
|
||||
|
||||
|
||||
class TestLevelFg:
|
||||
def test_error(self):
|
||||
assert level_fg("ERROR") == "red"
|
||||
|
||||
def test_error_lowercase(self):
|
||||
assert level_fg("error") == "red"
|
||||
|
||||
def test_warning(self):
|
||||
assert level_fg("WARNING") == "yellow"
|
||||
|
||||
def test_info_returns_none(self):
|
||||
assert level_fg("INFO") is None
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
assert level_fg("") is None
|
||||
@@ -0,0 +1,67 @@
|
||||
# RESUME Writes Stripping: Complete Flow Reference
|
||||
|
||||
## Legend
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| **Level** | P = Parent, S = Subgraph |
|
||||
| **`is_replaying`** | `CONFIG_KEY_CHECKPOINT_ID` key exists in `config[CONF]` (line 249) |
|
||||
| **`__enter__` via** | Which branch loads the checkpoint: **ckpt_id** (explicit checkpoint_id in checkpoint_config), **replay_state** (parent's ReplayState), **latest** (fetch most recent) |
|
||||
| **`RESUMING`** | Value of `CONFIG_KEY_RESUMING` in configurable (set by parent for subgraphs, absent for outer graph) |
|
||||
| **`is_resuming`** | Computed at line 633 — controls whether to "proceed past previous checkpoint" |
|
||||
| **`in_map`** | `replaying_from_checkpoint_map` — subgraph's ns found in checkpoint_map |
|
||||
| **Strip?** | Are RESUME pending writes stripped? (line 662-671) |
|
||||
|
||||
## Setup
|
||||
|
||||
```
|
||||
Parent: START → executor (subgraph, checkpointer=True) → END
|
||||
Subgraph: START → step_a → ask_1 (interrupt) → ask_2 (interrupt) → END
|
||||
```
|
||||
|
||||
## The Table
|
||||
|
||||
| # | Scenario | Level | User call | `__enter__` via | `is_replaying` | `RESUMING` | `is_resuming` | `in_map` | Strip? | Why correct |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | **Fresh run** | P | `invoke({"v":[]}, cfg)` | latest (None) | False | _(absent)_ | False | — | N/A | No checkpoint yet, no writes to strip |
|
||||
| 1 | | S | _(Send from parent)_ | latest (None) | True¹ | False | False | False | N/A | No checkpoint yet |
|
||||
| 2 | **Resume single interrupt** | P | `invoke(Cmd(resume="a"), cfg)` | latest | False | _(absent)_ | True | — | No | Resuming — keep RESUME writes for interrupt() to return answer |
|
||||
| 2 | | S | _(Send)_ | latest | True¹ | True | True | False | No | `RESUMING=True` → keep. interrupt() returns "a", node completes |
|
||||
| 3 | **Resume 1st of 2 interrupts** | P | `invoke(Cmd(resume="a1"), cfg)` | latest | False | _(absent)_ | True | — | No | Keep RESUME writes — ask_1's answer must survive |
|
||||
| 3 | | S | _(Send)_ | latest | True¹ | True | True | False | **No** | ask_1 gets "a1" from RESUME write. ask_2 has no RESUME write → interrupt() re-fires. Correct. |
|
||||
| 4 | **Replay parent ckpt** (parent was mid-subgraph) | P | `invoke(None, parent_hist_cfg)` | ckpt_id | True | _(absent)_ | True | — | **Yes** | Replaying — strip stale RESUME writes so interrupts re-fire |
|
||||
| 4 | | S | _(Send)_ | replay_state² | True¹ | _(popped)_³ | False | False | **Yes** | `is_replaying=T`, `RESUMING` absent → strip. Subgraph replays cleanly |
|
||||
| 5 | **Time-travel to subgraph ckpt** (THE BUG) | P | `invoke(None, sub_cfg)` | ckpt_id⁴ | True | _(absent)_ | True | — | **Yes** | Parent replays from historical checkpoint |
|
||||
| 5 | | S | _(Send)_ | **ckpt_id**⁵ | True¹ | **True** | **True** | **True** | **Yes** ✨ | `in_map=True` overrides `RESUMING=True` → force strip. THE FIX. |
|
||||
| 5 | | S _(without fix)_ | _(Send)_ | ckpt_id⁵ | True¹ | **True** | **True** | _(no check)_ | **No** ❌ | BUG: `RESUMING=True` prevents strip → stale RESUME values → interrupt() doesn't re-fire |
|
||||
| 6 | **Fork from subgraph ckpt** | P | `invoke(None, update_state(sub_cfg,...))` | ckpt_id | True | _(absent)_ | True | — | **Yes** | Same as case 5 — fork creates new ckpt, but checkpoint_map still resolves |
|
||||
| 6 | | S | _(Send)_ | ckpt_id⁵ | True¹ | True | True | **True** | **Yes** ✨ | Same fix applies |
|
||||
| 7 | **Resume after case 5 re-interrupts** | P | `invoke(Cmd(resume="a2"), cfg)` | latest | False | _(absent)_ | True | — | No | Normal resume — keep RESUME writes |
|
||||
| 7 | | S | _(Send)_ | latest | True¹ | True | True | False⁶ | **No** | ask_2 gets "a2" from fresh RESUME write. Correct. |
|
||||
|
||||
## Footnotes
|
||||
|
||||
**¹** `is_replaying` is always `True` for subgraphs on tick 1 because `_algo.py` sets `CONFIG_KEY_CHECKPOINT_ID: None` — the key exists (even with `None` value), so `key in dict` is `True`. After tick 1, line 563 sets `is_replaying = False`.
|
||||
|
||||
**²** `replay_state` branch: parent passed `CONFIG_KEY_REPLAY_STATE = ReplayState(parent_ckpt_id)`. The subgraph uses `replay_state.get_checkpoint()` which does `checkpointer.list(before=parent_ckpt_id, limit=1)` to find the subgraph's checkpoint from before the replay point.
|
||||
|
||||
**³** The `replay_state` branch in `__enter__` (line 1158) explicitly pops `CONFIG_KEY_RESUMING` from config. This makes `is_resuming = False` in `_first()` because for nested graphs the fallback (`self.input is None or input_is_command`) is False (input is a Send arg).
|
||||
|
||||
**⁴** Parent `__init__` clears `checkpoint_ns → ""` and `checkpoint_id → None` (line 273-277), then resolves `""` from checkpoint_map → gets `parent_checkpoint_id` onto `checkpoint_config` (line 278-290).
|
||||
|
||||
**⁵** Subgraph `__init__` resolves its namespace (e.g. `"executor:task_id"`) from checkpoint_map → gets `subgraph_checkpoint_id` onto `checkpoint_config`. This is why the new first branch in `__enter__` (line 1141) fires — `checkpoint_config` has a truthy `checkpoint_id`.
|
||||
|
||||
**⁶** After case 5 completes/re-interrupts and user resumes, the config is a normal thread config with no checkpoint_map entry for the subgraph. `in_map` is False, so normal resume logic applies.
|
||||
|
||||
## The core tension (case 5)
|
||||
|
||||
The parent **can't distinguish** these cases when propagating flags to subgraphs:
|
||||
|
||||
| Parent sees | What's actually happening | Subgraph should strip RESUME? |
|
||||
|---|---|---|
|
||||
| `input=None`, has checkpoint | Resume after interrupt | Yes (replaying) |
|
||||
| `input=None`, has checkpoint | Resume after interrupt | No (resuming) |
|
||||
| `input=Command(resume=...)` | Active resume | No (resuming) |
|
||||
| `input=None`, has checkpoint | Time-travel to subgraph | Yes (replaying) |
|
||||
|
||||
The **only** distinguishing signal at the subgraph level is whether its namespace appears in `checkpoint_map`.
|
||||
@@ -5,6 +5,7 @@ from collections.abc import AsyncIterator, Iterator, Sequence
|
||||
from dataclasses import asdict
|
||||
from typing import (
|
||||
Any,
|
||||
Generic,
|
||||
Literal,
|
||||
cast,
|
||||
overload,
|
||||
@@ -31,7 +32,6 @@ from langgraph_sdk.client import (
|
||||
)
|
||||
from langgraph_sdk.schema import (
|
||||
Checkpoint,
|
||||
Context,
|
||||
QueryParamTypes,
|
||||
ThreadState,
|
||||
)
|
||||
@@ -66,6 +66,7 @@ from langgraph.types import (
|
||||
StreamMode,
|
||||
StreamPart,
|
||||
)
|
||||
from langgraph.typing import ContextT, InputT, OutputT, StateT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -109,7 +110,10 @@ class RemoteException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RemoteGraph(PregelProtocol):
|
||||
class RemoteGraph(
|
||||
PregelProtocol[StateT, ContextT, InputT, OutputT],
|
||||
Generic[StateT, ContextT, InputT, OutputT],
|
||||
):
|
||||
"""The `RemoteGraph` class is a client implementation for calling remote
|
||||
APIs that implement the LangGraph Server API specification.
|
||||
|
||||
@@ -689,10 +693,10 @@ class RemoteGraph(PregelProtocol):
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: StreamMode | list[StreamMode] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -701,15 +705,15 @@ class RemoteGraph(PregelProtocol):
|
||||
params: QueryParamTypes | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> Iterator[StreamPart]: ...
|
||||
) -> Iterator[StreamPart[StateT, OutputT]]: ...
|
||||
|
||||
@overload
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: StreamMode | list[StreamMode] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -722,10 +726,10 @@ class RemoteGraph(PregelProtocol):
|
||||
|
||||
def stream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: StreamMode | list[StreamMode] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -738,7 +742,7 @@ class RemoteGraph(PregelProtocol):
|
||||
"""Create a run and stream the results.
|
||||
|
||||
This method calls `POST /threads/{thread_id}/runs/stream` if a `thread_id`
|
||||
is specified in the `configurable` field of the config or
|
||||
is speciffed in the `configurable` field of the config or
|
||||
`POST /runs/stream` otherwise.
|
||||
|
||||
Args:
|
||||
@@ -844,10 +848,10 @@ class RemoteGraph(PregelProtocol):
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: StreamMode | list[StreamMode] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -856,15 +860,15 @@ class RemoteGraph(PregelProtocol):
|
||||
params: QueryParamTypes | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[StreamPart]: ...
|
||||
) -> AsyncIterator[StreamPart[StateT, OutputT]]: ...
|
||||
|
||||
@overload
|
||||
def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: StreamMode | list[StreamMode] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -877,10 +881,10 @@ class RemoteGraph(PregelProtocol):
|
||||
|
||||
async def astream(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
stream_mode: StreamMode | list[StreamMode] | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
@@ -893,7 +897,7 @@ class RemoteGraph(PregelProtocol):
|
||||
"""Create a run and stream the results.
|
||||
|
||||
This method calls `POST /threads/{thread_id}/runs/stream` if a `thread_id`
|
||||
is specified in the `configurable` field of the config or
|
||||
is speciffed in the `configurable` field of the config or
|
||||
`POST /runs/stream` otherwise.
|
||||
|
||||
Args:
|
||||
@@ -1015,25 +1019,25 @@ class RemoteGraph(PregelProtocol):
|
||||
@overload
|
||||
def invoke(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> GraphOutput[dict[str, Any]]: ...
|
||||
) -> GraphOutput[OutputT]: ...
|
||||
|
||||
@overload
|
||||
def invoke(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
@@ -1044,10 +1048,10 @@ class RemoteGraph(PregelProtocol):
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
@@ -1097,25 +1101,25 @@ class RemoteGraph(PregelProtocol):
|
||||
@overload
|
||||
async def ainvoke(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
version: Literal["v2"],
|
||||
**kwargs: Any,
|
||||
) -> GraphOutput[dict[str, Any]]: ...
|
||||
) -> GraphOutput[OutputT]: ...
|
||||
|
||||
@overload
|
||||
async def ainvoke(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
@@ -1126,10 +1130,10 @@ class RemoteGraph(PregelProtocol):
|
||||
|
||||
async def ainvoke(
|
||||
self,
|
||||
input: dict[str, Any] | Any,
|
||||
input: InputT | Command | None,
|
||||
config: RunnableConfig | None = None,
|
||||
*,
|
||||
context: Context | None = None,
|
||||
context: ContextT | None = None,
|
||||
interrupt_before: All | Sequence[str] | None = None,
|
||||
interrupt_after: All | Sequence[str] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.1.2"
|
||||
version = "1.1.1"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -1129,7 +1129,7 @@ _OutputT = TypeVar("_OutputT")
|
||||
_StateT = TypeVar("_StateT")
|
||||
|
||||
|
||||
def _check_type_narrowing(part: StreamPart[_StateT, _OutputT]) -> None:
|
||||
def _check_type_narrowing(part: StreamPart[_OutputT, _StateT]) -> None:
|
||||
"""Compile-time type narrowing checks — never called at runtime."""
|
||||
if part["type"] == "values":
|
||||
assert_type(part, ValuesStreamPart[_OutputT])
|
||||
|
||||
@@ -8,7 +8,8 @@ from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.graph import StateGraph
|
||||
from langgraph.types import Command
|
||||
from langgraph.pregel.remote import RemoteGraph
|
||||
from langgraph.types import Command, GraphOutput, StreamPart
|
||||
|
||||
|
||||
def test_typed_dict_state() -> None:
|
||||
@@ -159,3 +160,75 @@ def test_add_node_with_explicit_input_schema() -> None:
|
||||
# because it violates the principles of contravariance
|
||||
workflow.add_node("a_narrow", a, input_schema=ANarrow) # type: ignore[arg-type]
|
||||
workflow.add_node("b_narrow", b, input_schema=BNarrow) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@pytest.mark.skip("Purely for type checking")
|
||||
def test_remote_graph_generics_typed_dict() -> None:
|
||||
"""RemoteGraph parameterized with TypedDict should propagate types."""
|
||||
|
||||
class MyState(TypedDict):
|
||||
messages: list[str]
|
||||
|
||||
rg: RemoteGraph[MyState, None, MyState, MyState] = RemoteGraph(
|
||||
"test", url="http://localhost:8123"
|
||||
)
|
||||
|
||||
# v2 invoke should return GraphOutput[MyState]
|
||||
result: GraphOutput[MyState] = rg.invoke({"messages": ["hi"]}, version="v2")
|
||||
_val: MyState = result.value
|
||||
|
||||
# v1 invoke should return dict[str, Any] | Any
|
||||
_v1_result: dict[str, Any] | Any = rg.invoke({"messages": ["hi"]})
|
||||
|
||||
# v2 stream should yield StreamPart[MyState, MyState]
|
||||
for part in rg.stream({"messages": ["hi"]}, version="v2"):
|
||||
_part: StreamPart[MyState, MyState] = part
|
||||
|
||||
# input should accept the state type
|
||||
rg.invoke({"messages": ["hi"]}, version="v2")
|
||||
|
||||
# input should also accept Command
|
||||
rg.invoke(Command(), version="v2")
|
||||
|
||||
# input should also accept None
|
||||
rg.invoke(None, version="v2")
|
||||
|
||||
|
||||
@pytest.mark.skip("Purely for type checking")
|
||||
def test_remote_graph_generics_pydantic() -> None:
|
||||
"""RemoteGraph parameterized with Pydantic model should propagate types."""
|
||||
|
||||
class PydanticState(BaseModel):
|
||||
messages: list[str]
|
||||
|
||||
rg: RemoteGraph[PydanticState, None, PydanticState, PydanticState] = RemoteGraph(
|
||||
"test", url="http://localhost:8123"
|
||||
)
|
||||
|
||||
result: GraphOutput[PydanticState] = rg.invoke(
|
||||
PydanticState(messages=["hi"]), version="v2"
|
||||
)
|
||||
_val: PydanticState = result.value
|
||||
|
||||
|
||||
@pytest.mark.skip("Purely for type checking")
|
||||
def test_remote_graph_separate_input_output() -> None:
|
||||
"""RemoteGraph with different input/output schemas."""
|
||||
|
||||
class InputState(TypedDict):
|
||||
query: str
|
||||
|
||||
class OutputState(TypedDict):
|
||||
answer: str
|
||||
|
||||
class FullState(InputState, OutputState): ...
|
||||
|
||||
rg: RemoteGraph[FullState, None, InputState, OutputState] = RemoteGraph(
|
||||
"test", url="http://localhost:8123"
|
||||
)
|
||||
|
||||
result: GraphOutput[OutputState] = rg.invoke({"query": "hi"}, version="v2")
|
||||
_val: OutputState = result.value
|
||||
|
||||
# wrong input type should fail type checking
|
||||
rg.invoke({"answer": "wrong"}, version="v2") # type: ignore[call-overload]
|
||||
|
||||
Generated
+1
-1
@@ -1367,7 +1367,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.2"
|
||||
version = "1.1.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Generated
+1
-1
@@ -268,7 +268,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.2"
|
||||
version = "1.1.1"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Generated
+1
-1
@@ -265,7 +265,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.2"
|
||||
version = "1.1.1"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Reference in New Issue
Block a user