Compare commits

..
Author SHA1 Message Date
Parker J. Ruleandopen-swe[bot] 359d66fc96 fix(cli): normalize development versions in Docker tags
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-10-26 04:36:22 +00:00
25 changed files with 345 additions and 1189 deletions

No files matched your search

-33
View File
@@ -24,39 +24,6 @@ TEST=path/to/test.py make test
Other pytest arguments can also be supplied inside the `TEST` variable.
## Direction: LangGraph is a runtime
LangGraph is consolidating as the low-level runtime for agents and workflows:
graph execution, checkpointing, streaming, and interrupt/resume. Critical
production systems and agent harnesses run on it, so correctness and stability
come first. It is moving away from being a friendly SDK for building agents
directly; agent development belongs one layer up, on a harness — Deep Agents
(github.com/langchain-ai/deepagents, a separate repo) is the recommended one.
Optimize contributions for runtime correctness, reliability, performance, and
simplicity — not for the shortest agent example.
- One way to do each thing. A change never adds a way; removals are always
deliberate (see below). The number of ways only goes down. No aliases,
overloads, extra accepted types, implicit defaults, or customization hooks
for convenience: ergonomics alone never justify new public surface.
- The mandate applies to everything that implements runtime behavior, in
whichever repo it lives: the graph engine (`langgraph`, `langgraph-core`),
the checkpointers (`checkpoint*`), and the server (`langgraph-api`). The
client SDKs and CLI stay ergonomic but are thin, faithful clients, never a
second implementation of runtime behavior.
- Agent-level packages (`prebuilt`, `langgraph-supervisor`, `langgraph-swarm`,
`langgraph-cua`) are maintenance-only: fix bugs, don't add agent features.
New examples and docs that build agents should use Deep Agents.
- Remove deliberately, not opportunistically. Removing or changing public
behavior must be the stated purpose of the PR and go through the deprecation
process — replacement in place, removal version recorded, migration note.
Never drop a default or overload as a side effect of an unrelated change.
- Behavior changes land in Python and JS together (the implementations live in
separate repos; note in the PR when the other one needs a matching change).
- If a task asks for something this section rules out, say so and propose the
runtime-level or harness-level alternative instead of quietly implementing it.
## Libraries
The repository contains several Python and JavaScript/TypeScript libraries.
+1 -1
View File
@@ -1 +1 @@
__version__ = "0.4.39"
__version__ = "0.4.38"
+1
View File
@@ -1738,6 +1738,7 @@ def docker_tag(
api_version = _resolve_compatible_api_version(
api_version, base_image, f"{language}{version_distro_tag}"
)
api_version = re.sub(r"(?<=\d)dev(?=\d+$)", ".dev", api_version)
full_tag = f"{api_version}-{language}{version_distro_tag}"
elif "/langgraph-server" in base_image and version_distro_tag not in base_image:
return f"{base_image}-{language}{version_distro_tag}"
+125 -133
View File
@@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping, Sequence
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Generic, Protocol, TypeVar
from typing import Protocol, TypeVar
import click
import click.exceptions
@@ -26,6 +26,7 @@ from langgraph_cli.dependency_tracking import find_tracked_packages
from langgraph_cli.docker import build_docker_image, can_build_locally
from langgraph_cli.exec import CommandRunner, Runner, subp_exec
from langgraph_cli.host_backend import (
MAX_PAGE_SIZE,
ControlPlaneEndpoints,
HostBackendClient,
HostBackendError,
@@ -182,15 +183,12 @@ class Listener:
)
return cls(
identifier,
str(resource.get("compute_id") or ""),
str(resource.get("compute_id", "")),
tuple(str(namespace) for namespace in namespaces)
if isinstance(namespaces, list)
else (),
)
def cells(self) -> tuple[str | None, ...]:
return (self.id, self.compute_id, ", ".join(self.namespaces))
@dataclass(frozen=True, slots=True)
class Unplaced:
@@ -278,6 +276,8 @@ def _describe_listeners(listeners: Sequence[Listener]) -> str:
]
if len(listeners) > len(shown):
lines.append(f" ... and {len(listeners) - len(shown)} more")
if len(listeners) == MAX_PAGE_SIZE:
lines.append(f" (only the first {MAX_PAGE_SIZE} listeners were read)")
return "\n".join(lines)
@@ -499,9 +499,16 @@ def _source_of(resource: object) -> str | None:
def find_deployment_by_name(
client: HostBackendClient, name: str
) -> ExistingDeployment | None:
for resource in client.list_deployments(name=name, name_contains=name):
listed = client.list_deployments(name=name, name_contains=name, limit=MAX_PAGE_SIZE)
for resource in listed:
if resource.get("name") == name and resource.get("id"):
return ExistingDeployment(str(resource["id"]), _source_of(resource))
if len(listed) >= MAX_PAGE_SIZE:
raise click.ClickException(
"This workspace has more deployments than the CLI can search, so it "
f"cannot tell whether '{name}' already exists. Pass --deployment-id to "
"update an existing deployment."
)
return None
@@ -576,96 +583,103 @@ def _validate_prebuilt_image(
_get_emitter().info(f"Image is available for {_DEPLOYMENT_PLATFORM}")
Resource = Mapping[str, object]
RowT = TypeVar("RowT")
@dataclass(frozen=True, slots=True)
class Table(Generic[RowT]):
headers: tuple[str, ...]
cells: Callable[[RowT], tuple[str | None, ...]]
def render(self, rows: Sequence[RowT]) -> str:
grid = [
self.headers,
*(tuple(cell or "-" for cell in self.cells(row)) for row in rows),
]
widths = [max(map(len, column)) for column in zip(*grid, strict=True)]
rule = tuple("-" * width for width in widths)
return "\n".join(
" ".join(
cell.ljust(width) for cell, width in zip(line, widths, strict=True)
)
for line in (grid[0], rule, *grid[1:])
)
def _text(resource: Resource, key: str) -> str | None:
value = resource.get(key)
return str(value) if value else None
def _deployment_url(deployment: Resource) -> str | None:
def _extract_deployment_url(deployment: dict[str, object]) -> str:
source_config = deployment.get("source_config")
custom_url = (
source_config.get("custom_url") if isinstance(source_config, Mapping) else None
)
return custom_url if isinstance(custom_url, str) and custom_url else None
if isinstance(source_config, dict):
custom_url = source_config.get("custom_url")
if isinstance(custom_url, str) and custom_url:
return custom_url
return "-"
def _with_replaced_status(revisions: Sequence[Resource]) -> list[Resource]:
live_index = next(
def format_deployments_table(deployments: Sequence[dict[str, object]]) -> str:
headers = ("Deployment ID", "Deployment Name", "Deployment URL")
rows = [
(
index
for index, revision in enumerate(revisions)
if revision.get("status") == "DEPLOYED"
),
None,
)
return [
{**revision, "status": "REPLACED"}
if revision.get("status") == "DEPLOYED" and index != live_index
else revision
for index, revision in enumerate(revisions)
str(deployment.get("id", "-") or "-"),
str(deployment.get("name", "-") or "-"),
_extract_deployment_url(deployment),
)
for deployment in deployments
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def format_row(row: Sequence[str]) -> str:
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
@dataclass(frozen=True, slots=True)
class DeploymentRow:
id: str
name: str | None
url: str | None
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
lines.extend(format_row(row) for row in rows)
return "\n".join(lines)
@classmethod
def from_resource(cls, deployment: Resource) -> "DeploymentRow":
identifier = _text(deployment, "id")
if identifier is None:
raise HostBackendError(
"The control plane returned a deployment without an id."
def _extract_listener_namespaces(listener: dict[str, object]) -> str:
compute_config = listener.get("compute_config")
namespaces = (
compute_config.get("k8s_namespaces")
if isinstance(compute_config, dict)
else None
)
if isinstance(namespaces, list) and namespaces:
return ", ".join(str(namespace) for namespace in namespaces)
return "-"
def format_listeners_table(listeners: Sequence[dict[str, object]]) -> str:
headers = ("Listener ID", "Compute ID", "Namespaces")
rows = [
(
str(listener.get("id", "-") or "-"),
str(listener.get("compute_id", "-") or "-"),
_extract_listener_namespaces(listener),
)
for listener in listeners
]
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def format_row(row: Sequence[str]) -> str:
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
lines.extend(format_row(row) for row in rows)
return "\n".join(lines)
def format_revisions_table(revisions: Sequence[dict[str, object]]) -> str:
headers = ("Revision ID", "Status", "Created At")
latest_deployed_seen = False
rows = []
for revision in revisions:
status = str(revision.get("status", "-") or "-")
if status == "DEPLOYED":
if latest_deployed_seen:
status = "REPLACED"
else:
latest_deployed_seen = True
rows.append(
(
str(revision.get("id", "-") or "-"),
status,
str(revision.get("created_at", "-") or "-"),
)
return cls(identifier, _text(deployment, "name"), _deployment_url(deployment))
)
def cells(self) -> tuple[str | None, ...]:
return (self.id, self.name, self.url)
widths = [
max(len(headers[index]), *(len(row[index]) for row in rows))
for index in range(len(headers))
]
def as_json(self) -> dict[str, str | None]:
return {"id": self.id, "name": self.name}
def format_row(row: Sequence[str]) -> str:
return " ".join(value.ljust(widths[index]) for index, value in enumerate(row))
DEPLOYMENTS_TABLE = Table(
("Deployment ID", "Deployment Name", "Deployment URL"), DeploymentRow.cells
)
LISTENERS_TABLE = Table(("Listener ID", "Compute ID", "Namespaces"), Listener.cells)
REVISIONS_TABLE = Table(
("Revision ID", "Status", "Created At"),
lambda revision: (
_text(revision, "id"),
_text(revision, "status"),
_text(revision, "created_at"),
),
)
lines = [format_row(headers), format_row(tuple("-" * width for width in widths))]
lines.extend(format_row(row) for row in rows)
return "\n".join(lines)
def format_timestamp(ts) -> str:
@@ -827,6 +841,7 @@ def _find_deployment(
lambda c: c.list_deployments(
agent_id=selector.agent_id,
agent_environment=selector.environment,
limit=MAX_PAGE_SIZE,
),
)
if len(existing) > 1:
@@ -992,15 +1007,20 @@ def _print_deployment_result(
dashboard_label: str,
status_url: str | None = None,
) -> None:
"""Print final deployment status and raise on failure."""
em = _get_emitter()
deployment = client.get_deployment(deployment_id)
url = _deployment_url(deployment) if isinstance(deployment, Mapping) else None
dep_info = client.get_deployment(deployment_id)
custom_url = None
if isinstance(dep_info, dict):
sc = dep_info.get("source_config")
if isinstance(sc, dict):
custom_url = sc.get("custom_url")
if last_status == "DEPLOYED":
em.result(
"succeeded",
deployment_id=deployment_id,
url=url,
url=custom_url,
status_url=status_url,
)
elif last_status in ("BUILD_FAILED", "DEPLOY_FAILED", "CREATE_FAILED"):
@@ -1061,8 +1081,6 @@ def _docker_config_for_token(registry_host: str, token: str, username: str):
_UPLOAD_TIMEOUT_SECONDS = 300
_BYTES_PER_MIB = 1_048_576
# Byte range Cloud Storage signs into upload URLs, up to the 200 MB archive limit.
_GCS_SIZE_RANGE = f"0,{200 * _BYTES_PER_MIB}"
class _ProgressReader:
@@ -1088,24 +1106,12 @@ class _ProgressReader:
return self._file_size
def _upload_source_tarball(
signed_url: str,
file_path: str,
file_size: int,
upload_headers: dict[str, str] | None,
) -> None:
"""Upload tarball via signed PUT URL with progress display."""
def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None:
"""Upload tarball to GCS via signed PUT URL with progress display."""
import urllib.error
import urllib.request
em = _get_emitter()
# Control planes that return no `upload_headers` sign for the Cloud Storage
# size range. Content-Type is a fallback: without one, urllib sends a form
# content type, which breaks signatures that cover it.
headers = {
"Content-Type": "application/gzip",
**(upload_headers or {"X-Goog-Content-Length-Range": _GCS_SIZE_RANGE}),
}
with open(file_path, "rb") as f:
reader = _ProgressReader(f, file_size, em)
@@ -1113,7 +1119,11 @@ def _upload_source_tarball(
signed_url,
data=reader,
method="PUT",
headers={**headers, "Content-Length": str(file_size)},
headers={
"Content-Type": "application/gzip",
"Content-Length": str(file_size),
"X-Goog-Content-Length-Range": "0,209715200",
},
)
try:
urllib.request.urlopen(req, timeout=_UPLOAD_TIMEOUT_SECONDS)
@@ -1433,9 +1443,7 @@ def _run_remote_build(
step += 1
_log_deploy_step(step, "Uploading source")
_upload_source_tarball(
signed_url, archive_path, file_size, upload_data.get("upload_headers")
)
_upload_to_gcs(signed_url, archive_path, file_size)
step += 1
_log_deploy_step(step, "Triggering remote build")
@@ -2586,13 +2594,6 @@ def _deploy_cmd(
default="",
help="Only show deployments whose names contain this value.",
)
@click.option(
"--json",
"json_output",
is_flag=True,
default=False,
help="Print the deployments as a JSON array of {id, name} instead of a table.",
)
@deploy.command("list", help="[Beta] List LangSmith Deployments.")
def deploy_list(
api_key: str | None,
@@ -2600,13 +2601,11 @@ def deploy_list(
name_contains: str,
agent_id: str | None,
environment: str | None,
json_output: bool,
) -> None:
if agent_id is not None or environment is not None:
click.secho(
"Note: --agent-id and --agent-environment flags are in private beta",
fg="yellow",
err=True,
)
if agent_id is not None and not agent_id.strip():
raise click.UsageError("--agent-id must not be empty.")
@@ -2615,19 +2614,15 @@ def deploy_list(
filters["agent_id"] = agent_id
if environment is not None:
filters["agent_environment"] = environment
client = _create_host_backend_client(host_url, api_key, no_input=json_output)
resources = _call_host_backend_with_optional_tenant(
client = _create_host_backend_client(host_url, api_key)
deployments = _call_host_backend_with_optional_tenant(
client,
lambda c: c.list_deployments(name_contains=name_contains, **filters),
no_input=json_output,
)
deployments = [DeploymentRow.from_resource(resource) for resource in resources]
if json_output:
click.echo(json_mod.dumps([deployment.as_json() for deployment in deployments]))
elif not deployments:
if not deployments:
click.echo("No deployments found.")
else:
click.echo(DEPLOYMENTS_TABLE.render(deployments))
return
click.echo(format_deployments_table(deployments))
# ---------------------------------------------------------------------------
@@ -2670,14 +2665,11 @@ def deploy_listeners_list(
)
if json_output:
click.echo(json_mod.dumps(listeners))
elif not listeners:
return
if not listeners:
click.echo("No listeners found for this workspace.")
else:
click.echo(
LISTENERS_TABLE.render(
[Listener.from_resource(listener) for listener in listeners]
)
)
return
click.echo(format_listeners_table(listeners))
# ---------------------------------------------------------------------------
@@ -2720,7 +2712,7 @@ def deploy_revisions_list(
if not revisions:
click.echo(f"No revisions found for deployment {deployment_id}.")
return
click.echo(REVISIONS_TABLE.render(_with_replaced_status(revisions)))
click.echo(format_revisions_table(revisions))
# ---------------------------------------------------------------------------
+10 -38
View File
@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Literal
from urllib.parse import urlparse
@@ -221,51 +220,27 @@ class HostBackendClient:
*,
name: str | None = None,
name_contains: str | None = None,
limit: int | None = None,
agent_id: str | None = None,
agent_environment: str | None = None,
) -> list[dict[str, object]]:
) -> list[dict[str, Any]]:
given = (
("name", name),
("name_contains", name_contains),
("limit", limit),
("agent_id", agent_id),
("agent_environment", agent_environment),
)
filters = {key: value for key, value in given if value is not None}
return self._every_page("/v2/deployments", filters)
def _every_page(
self, path: str, filters: Mapping[str, object]
) -> list[dict[str, object]]:
resources: list[dict[str, object]] = []
seen_ids: set[object] = set()
while True:
page = _resources(
self._request(
"GET",
path,
params={
**filters,
"limit": MAX_PAGE_SIZE,
"offset": len(resources),
},
)
)
page_ids = {resource.get("id") for resource in page}
if page_ids and page_ids <= seen_ids:
raise HostBackendError(
f"GET {path} returned the same resources again: this control "
"plane ignores 'offset', so the CLI cannot read past the first page."
)
seen_ids |= page_ids
resources.extend(page)
if len(page) < MAX_PAGE_SIZE:
return resources
params = {key: value for key, value in given if value is not None}
return _resources(self._request("GET", "/v2/deployments", params=params))
def get_listener(self, listener_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/listeners/{listener_id}")
def list_listeners(self) -> list[dict[str, object]]:
return self._every_page("/v2/listeners", {})
def list_listeners(self) -> list[dict[str, Any]]:
return _resources(
self._request("GET", "/v2/listeners", params={"limit": MAX_PAGE_SIZE})
)
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
return self._request("GET", f"/v2/deployments/{deployment_id}")
@@ -283,13 +258,10 @@ class HostBackendClient:
)
def request_upload_url(self, deployment_id: str) -> dict[str, Any]:
"""Get a signed URL for uploading the source tarball."""
# Tells the control plane this client sends the returned
# `upload_headers` with its upload, which some storage requires.
"""Get a signed GCS URL for uploading the source tarball."""
return self._request(
"POST",
f"/v2/deployments/{deployment_id}/upload-url",
{"supports_upload_headers": True},
)
def update_deployment(
-297
View File
@@ -482,177 +482,6 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
assert result.output.strip() == "No deployments found."
def test_deploy_list_prints_an_aligned_table(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_deployments(self, name_contains: str = ""):
return [
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{"id": "dep-4567", "name": "beta-agent", "source_config": {}},
]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
"Deployment ID Deployment Name Deployment URL ",
"------------- --------------- -------------------------",
"dep-123 alpha https://alpha.example.com",
"dep-4567 beta-agent - ",
]
def _deploy_list_json(
monkeypatch, deployments: list[dict], *extra_args: str
) -> click.testing.Result:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_deployments(
self,
name_contains: str = "",
agent_id: str | None = None,
agent_environment: str | None = None,
):
return deployments
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
return CliRunner().invoke(
cli,
[
"deploy",
"list",
"--json",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
*extra_args,
],
)
def test_deploy_list_command_json(monkeypatch) -> None:
result = _deploy_list_json(
monkeypatch,
[
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{"id": "dep-456", "name": "beta", "source_config": {}},
],
)
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == [
{"id": "dep-123", "name": "alpha"},
{"id": "dep-456", "name": "beta"},
]
def test_deploy_list_json_never_prints_deployment_secrets(monkeypatch) -> None:
result = _deploy_list_json(
monkeypatch,
[
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": None},
"secrets": [{"name": "OPENAI_API_KEY", "value": "sk-plain-value"}],
}
],
)
assert result.exit_code == 0, result.output
assert "sk-plain-value" not in result.output
assert json.loads(result.stdout) == [{"id": "dep-123", "name": "alpha"}]
def test_deploy_list_command_json_no_results(monkeypatch) -> None:
result = _deploy_list_json(monkeypatch, [])
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == []
def test_deploy_list_rejects_a_deployment_without_an_id(monkeypatch) -> None:
result = _deploy_list_json(monkeypatch, [{"name": "alpha"}])
assert result.exit_code != 0
assert "deployment without an id" in result.output
def test_deploy_list_json_keeps_the_agent_note_off_stdout(monkeypatch) -> None:
result = _deploy_list_json(
monkeypatch,
[{"id": "dep-123", "name": "alpha"}],
"--agent-id",
"customer-support",
"--agent-environment",
"staging",
)
assert result.exit_code == 0, result.output
assert json.loads(result.stdout) == [{"id": "dep-123", "name": "alpha"}]
assert "private beta" in result.stderr
def test_deploy_list_json_never_prompts_for_a_workspace(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_deployments(self, name_contains: str = ""):
raise HostBackendError("requires workspace specification", status_code=403)
def set_tenant(self, tenant_id: str) -> None:
raise AssertionError("must not retry with a prompted tenant with --json")
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
prompt_calls = []
monkeypatch.setattr(
click, "prompt", lambda *a, **k: prompt_calls.append((a, k)) or "unreachable"
)
result = CliRunner().invoke(
cli,
[
"deploy",
"list",
"--json",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert prompt_calls == []
assert result.exit_code != 0
assert "LANGSMITH_TENANT_ID" in result.output
def test_deploy_listeners_list_command(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
@@ -735,84 +564,6 @@ def test_deploy_listeners_list_command_no_results(monkeypatch) -> None:
assert result.output.strip() == "No listeners found for this workspace."
def test_deploy_listeners_list_prints_an_aligned_table(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_listeners(self):
return [
{
"id": "listener-1",
"compute_id": "prod-cluster",
"compute_config": {"k8s_namespaces": ["agents"]},
},
{
"id": "listener-22",
"compute_id": "multi",
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
},
{"id": "listener-3", "compute_id": "", "compute_config": {}},
{
"id": "listener-4",
"compute_id": None,
"compute_config": {"k8s_namespaces": ["agents"]},
},
]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"listeners",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
"Listener ID Compute ID Namespaces ",
"----------- ------------ ----------------------",
"listener-1 prod-cluster agents ",
"listener-22 multi agents, agents-staging",
"listener-3 - - ",
"listener-4 - agents ",
]
def test_deploy_listeners_list_rejects_a_listener_without_an_id(monkeypatch) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_listeners(self):
return [{"compute_id": "prod-cluster"}]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"listeners",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
],
)
assert result.exit_code != 0
assert "listener without an id" in result.output
def test_deploy_listeners_list_command_json(monkeypatch) -> None:
runner = CliRunner()
@@ -1015,54 +766,6 @@ def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
assert result.output.strip() == "No revisions found for deployment dep-123."
def test_deploy_revisions_list_marks_older_deployed_revisions_as_replaced(
monkeypatch,
) -> None:
class FakeClient:
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
pass
def list_revisions(self, deployment_id: str, limit: int = 1):
return [
{
"id": "rev-3",
"status": "DEPLOYING",
"created_at": "2026-10-20T10:00:00Z",
},
{
"id": "rev-2",
"status": "DEPLOYED",
"created_at": "2026-10-19T10:00:00Z",
},
{"id": "rev-1", "status": "DEPLOYED", "created_at": None},
]
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
result = CliRunner().invoke(
cli,
[
"deploy",
"revisions",
"list",
"--api-key",
"test-key",
"--host-url",
"https://api.example.com",
"dep-123",
],
)
assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
"Revision ID Status Created At ",
"----------- --------- --------------------",
"rev-3 DEPLOYING 2026-10-20T10:00:00Z",
"rev-2 DEPLOYED 2026-10-19T10:00:00Z",
"rev-1 REPLACED - ",
]
def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
runner = CliRunner()
captured: dict[str, str] = {}
@@ -96,7 +96,6 @@ class ControlPlaneDouble:
existing_deployments: list[dict] = field(default_factory=list)
push_token_status: int = 200
push_token_extra: dict = field(default_factory=dict)
upload_url_extra: dict = field(default_factory=dict)
create_error: str | None = None
listeners: list[dict] = field(default_factory=list)
listeners_by_id: dict[str, dict] = field(default_factory=dict)
@@ -112,11 +111,7 @@ class ControlPlaneDouble:
def _respond(self, request: httpx.Request) -> httpx.Response:
method, path = request.method, request.url.path
if (method, path) == ("GET", "/v2/listeners"):
offset = int(request.url.params.get("offset", 0))
limit = int(request.url.params.get("limit", 20))
return httpx.Response(
200, json={"resources": self.listeners[offset : offset + limit]}
)
return httpx.Response(200, json={"resources": self.listeners})
if method == "GET" and path.startswith("/v2/listeners/"):
listener_id = path.rsplit("/", 1)[-1]
if not _looks_like_a_uuid(listener_id):
@@ -164,12 +159,7 @@ class ControlPlaneDouble:
)
if path.endswith("/upload-url"):
return httpx.Response(
200,
json={
"upload_url": SIGNED_UPLOAD_URL,
"object_path": OBJECT_PATH,
**self.upload_url_extra,
},
200, json={"upload_url": SIGNED_UPLOAD_URL, "object_path": OBJECT_PATH}
)
if method == "PATCH":
return httpx.Response(200, json={"tenant_id": "tenant-1"})
@@ -286,7 +276,7 @@ class DeployProject:
control_plane: ControlPlaneDouble
docker: DockerDouble
timeline: list[str]
uploads: list[tuple[str, str, int, dict[str, str] | None]]
uploads: list[tuple[str, str, int]]
def run(self, *args: str, host_url: str = CONTROL_PLANE_URL) -> Result:
return CliRunner().invoke(
@@ -323,21 +313,16 @@ def deploy_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeployPro
timeline: list[str] = []
control_plane = ControlPlaneDouble(timeline)
docker = DockerDouble(timeline)
uploads: list[tuple[str, str, int, dict[str, str] | None]] = []
uploads: list[tuple[str, str, int]] = []
@contextmanager
def fake_create_archive(config_path: Path, config: dict) -> Iterator[tuple]:
timeline.append("create_archive")
yield ARCHIVE
def fake_upload(
signed_url: str,
file_path: str,
file_size: int,
upload_headers: dict[str, str] | None,
) -> None:
def fake_upload(signed_url: str, file_path: str, file_size: int) -> None:
timeline.append("upload_archive")
uploads.append((signed_url, file_path, file_size, upload_headers))
uploads.append((signed_url, file_path, file_size))
monkeypatch.setattr(deploy_module, "_no_input", False)
monkeypatch.setattr(deploy_module, "_emitter", None)
@@ -355,7 +340,7 @@ def deploy_project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DeployPro
)
monkeypatch.setattr(deploy_module.platform, "machine", lambda: "x86_64")
monkeypatch.setattr(archive_module, "create_archive", fake_create_archive)
monkeypatch.setattr(deploy_module, "_upload_source_tarball", fake_upload)
monkeypatch.setattr(deploy_module, "_upload_to_gcs", fake_upload)
return DeployProject(control_plane, docker, timeline, uploads)
@@ -714,9 +699,6 @@ def test_deployment_not_created_by_the_cli_gets_an_actionable_error(
def test_remote_build_creates_an_internal_source_deployment_and_uploads_the_archive(
deploy_project: DeployProject,
) -> None:
upload_headers = {"Content-Type": "application/gzip", "x-ms-blob-type": "BlockBlob"}
deploy_project.control_plane.upload_url_extra = {"upload_headers": upload_headers}
result = deploy_project.run("--remote", "--install-command", "yarn install")
assert result.exit_code == 0, result.output
@@ -731,12 +713,7 @@ def test_remote_build_creates_an_internal_source_deployment_and_uploads_the_arch
assert deploy_project.control_plane.bodies[CREATE_DEPLOYMENT]["source"] == (
"internal_source"
)
assert deploy_project.control_plane.bodies[_upload_url(CREATED_ID)] == {
"supports_upload_headers": True
}
assert deploy_project.uploads == [
(SIGNED_UPLOAD_URL, ARCHIVE[0], ARCHIVE[1], upload_headers)
]
assert deploy_project.uploads == [(SIGNED_UPLOAD_URL, ARCHIVE[0], ARCHIVE[1])]
assert deploy_project.control_plane.bodies[_patch(CREATED_ID)] == {
"revision_source": "internal_source",
"source_revision_config": {
@@ -1311,16 +1288,14 @@ def test_listener_flags_without_push_to_make_no_call_at_all(
assert deploy_project.timeline == []
def test_listeners_beyond_the_first_page_are_counted(
deploy_project: DeployProject,
) -> None:
def test_a_truncated_listener_page_says_so(deploy_project: DeployProject) -> None:
deploy_project.control_plane.listeners = [
{
"id": str(uuid.UUID(int=index)),
"compute_id": "cluster",
"compute_config": {"k8s_namespaces": ["agents"]},
}
for index in range(101)
for index in range(100)
]
result = deploy_project.run(
@@ -1328,8 +1303,7 @@ def test_listeners_beyond_the_first_page_are_counted(
)
assert result.exit_code != 0
assert "... and 91 more" in result.output
assert "first 100" not in result.output
assert "first 100" in result.output
def test_a_managed_build_in_a_listener_workspace_points_at_push_to(
+25 -7
View File
@@ -3333,20 +3333,37 @@ def test_docker_tag_with_api_version(in_config: bool):
@pytest.mark.parametrize("in_config", [False, True])
@pytest.mark.parametrize("version", ["0.9.0rc1", "0.9.0.dev1"])
def test_docker_tag_with_prerelease_api_version(version: str, in_config: bool):
@pytest.mark.parametrize(
"runtime,image", [("python", "langgraph-api"), ("node", "langgraphjs-api")]
)
@pytest.mark.parametrize(
"version,expected",
[
("0.9.0rc1", "0.9.0rc1"),
("0.9.0.dev1", "0.9.0.dev1"),
("0.18.0dev11", "0.18.0.dev11"),
],
)
def test_docker_tag_with_prerelease_api_version(
version: str, expected: str, in_config: bool, runtime: str, image: str
):
"""Test docker_tag with prerelease and dev api_version values."""
config = validate_config(
{
"python_version": "3.11",
f"{runtime}_version": "3.12" if runtime == "python" else "20",
"dependencies": ["."],
"graphs": {"agent": "./agent.py:graph"},
"graphs": {
"agent": "./agent.py:graph"
if runtime == "python"
else "./agent.js:graph"
},
"api_version": version if in_config else None,
}
)
tag = docker_tag(config, api_version=version if not in_config else None)
assert tag == f"langchain/langgraph-api:{version}-py3.11"
suffix = "py3.12" if runtime == "python" else "node20"
assert tag == f"langchain/{image}:{expected}-{suffix}"
def test_docker_tag_with_compatible_api_version_promotes_to_latest_patch():
@@ -3378,7 +3395,8 @@ def test_docker_tag_with_compatible_api_version_promotes_to_latest_patch():
assert tag == "langchain/langgraph-api:0.11.1-py3.12-wolfi"
def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc():
@pytest.mark.parametrize("dev_version", ["0.11.0.dev5", "0.11.0dev5"])
def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc(dev_version: str):
config = validate_config(
{
"python_version": "3.12",
@@ -3391,7 +3409,7 @@ def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc():
with patch(
"langgraph_cli.config._get_pypi_versions",
return_value=[
"0.11.0.dev5",
dev_version,
"0.11.0.dev6",
"0.11.0.dev7",
],
@@ -75,7 +75,6 @@ def test_agent_create(deployment_api, tmp_path, monkeypatch):
"agent_id": "customer-support",
"agent_environment": "staging",
"limit": "100",
"offset": "0",
}
payload = json.loads(requests[1].content)
assert payload["agent"] == {
+10 -110
View File
@@ -33,7 +33,6 @@ from langgraph_cli.deploy import (
_Emitter,
_env_without_deployment_name,
_parse_env_from_config,
_print_deployment_result,
_resolve_env_path,
_resolve_pushed_image_digest,
_resource_spec,
@@ -537,39 +536,6 @@ class TestEmitterTextMode:
assert "URL: https://app.test" in lines
@pytest.mark.parametrize(
("deployment", "expected_url_lines"),
[
pytest.param(
{
"id": "dep-1",
"source_config": {"custom_url": "https://agent.example.com"},
},
["URL: https://agent.example.com"],
id="custom_url_is_printed",
),
pytest.param(
{"id": "dep-1", "source_config": {}}, [], id="no_custom_url_no_line"
),
],
)
def test_a_deployed_result_prints_the_deployment_url(
capsys, monkeypatch, deployment, expected_url_lines
):
class FakeClient:
def get_deployment(self, deployment_id: str) -> dict:
return deployment
monkeypatch.setattr(deploy_mod, "_emitter", None)
_print_deployment_result(
FakeClient(), "dep-1", "DEPLOYED", dashboard_label="Deployment dashboard"
)
lines = [line.strip() for line in capsys.readouterr().out.splitlines()]
assert [line for line in lines if line.startswith("URL:")] == expected_url_lines
# ---------------------------------------------------------------------------
# --no-input guard on _create_host_backend_client
# ---------------------------------------------------------------------------
@@ -1244,7 +1210,6 @@ def test_finding_a_deployment_by_name_narrows_the_search_for_every_server_versio
"name": "agent",
"name_contains": "agent",
"limit": "100",
"offset": "0",
}
assert found == ExistingDeployment("dep-1", "github")
@@ -1272,29 +1237,20 @@ def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deplo
assert find_deployment_by_name(client, "brand-new-agent") is None
def test_a_deployment_beyond_the_first_page_is_found_by_name():
deployments = [
*(
{"id": f"dep-{index}", "name": f"other-agent-{index}"}
for index in range(100)
),
{"id": "dep-target", "name": "agent", "source": "external_docker"},
def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free():
page = [
{"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100)
]
def handler(req: httpx.Request) -> httpx.Response:
offset = int(req.url.params["offset"])
limit = int(req.url.params["limit"])
return httpx.Response(
200, json={"resources": deployments[offset : offset + limit]}
)
client = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
"https://api.example.com",
"key",
transport=httpx.MockTransport(
lambda req: httpx.Response(200, json={"resources": page})
),
)
assert find_deployment_by_name(client, "agent") == ExistingDeployment(
"dep-target", "external_docker"
)
with pytest.raises(click.ClickException, match="--deployment-id"):
find_deployment_by_name(client, "brand-new-agent")
def test_a_partial_page_without_a_match_means_the_name_is_free():
@@ -1458,59 +1414,3 @@ class TestPlacementOnAKnownListener:
def test_refuses_and_names_the_namespaces(self, request_, listener, message):
with pytest.raises(click.UsageError, match=message):
request_.on(listener)
DEFAULT_UPLOAD_HEADERS = {
"content-type": "application/gzip",
"content-length": "7",
"x-goog-content-length-range": "0,209715200",
}
@pytest.mark.parametrize(
("upload_headers", "expected"),
[
pytest.param(None, DEFAULT_UPLOAD_HEADERS, id="missing_sends_defaults"),
pytest.param({}, DEFAULT_UPLOAD_HEADERS, id="empty_sends_defaults"),
pytest.param(
{
"content-type": "application/x-gzip",
"x-ms-blob-type": "BlockBlob",
"content-length": "1",
},
{
"content-type": "application/x-gzip",
"x-ms-blob-type": "BlockBlob",
"content-length": "7",
},
id="returned_headers_replace_defaults",
),
pytest.param(
{"x-ms-blob-type": "BlockBlob"},
{
"content-type": "application/gzip",
"x-ms-blob-type": "BlockBlob",
"content-length": "7",
},
id="returned_headers_keep_a_content_type",
),
],
)
def test_source_upload_sends_the_headers_the_signed_url_requires(
tmp_path, mocker, upload_headers, expected
):
archive = tmp_path / "source.tar.gz"
archive.write_bytes(b"tarball")
sent = []
mocker.patch(
"langgraph_cli.deploy._get_emitter", return_value=MagicMock(json_mode=True)
)
mocker.patch(
"urllib.request.urlopen", side_effect=lambda req, timeout: sent.append(req)
)
deploy_mod._upload_source_tarball(
"https://storage.example/signed", str(archive), 7, upload_headers
)
assert {name.lower(): value for name, value in sent[0].header_items()} == expected
+9 -72
View File
@@ -575,7 +575,7 @@ def test_list_listeners_asks_for_a_full_page():
)
assert c.list_listeners() == [{"id": "listener-1"}]
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100&offset=0"
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100"
@pytest.mark.parametrize(
@@ -602,30 +602,29 @@ def test_is_cloud_recognises_the_managed_control_plane(control_plane_url, expect
assert endpoints.is_cloud is expected
FIRST_PAGE = {"limit": "100", "offset": "0"}
@pytest.mark.parametrize(
("call", "expected_params"),
[
pytest.param(
lambda c: c.list_deployments(name="agent"),
{"name": "agent", **FIRST_PAGE},
{"name": "agent"},
id="exact_name_filters_server_side",
),
pytest.param(
lambda c: c.list_deployments(name_contains="age"),
{"name_contains": "age", **FIRST_PAGE},
{"name_contains": "age"},
id="substring_search_keeps_its_own_parameter",
),
pytest.param(
lambda c: c.list_deployments(),
FIRST_PAGE,
id="no_filter_sends_only_the_page",
{},
id="no_filter_sends_no_parameters",
),
pytest.param(
lambda c: c.list_deployments(name="agent", name_contains="agent"),
{"name": "agent", "name_contains": "agent", **FIRST_PAGE},
lambda c: c.list_deployments(
name="agent", name_contains="agent", limit=100
),
{"name": "agent", "name_contains": "agent", "limit": "100"},
id="both_filters_travel_together_for_older_servers",
),
],
@@ -646,68 +645,6 @@ def test_list_deployments_sends_one_name_filter(call, expected_params):
assert seen == expected_params
@pytest.mark.parametrize(
"list_every",
[
pytest.param(HostBackendClient.list_deployments, id="deployments"),
pytest.param(HostBackendClient.list_listeners, id="listeners"),
],
)
def test_list_endpoints_read_every_page(list_every):
resources = [{"id": f"resource-{index}"} for index in range(101)]
offsets: list[str] = []
def handler(req: httpx.Request) -> httpx.Response:
offset = int(req.url.params["offset"])
limit = int(req.url.params["limit"])
offsets.append(req.url.params["offset"])
return httpx.Response(
200, json={"resources": resources[offset : offset + limit]}
)
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
assert list_every(c) == resources
assert offsets == ["0", "100"]
FIRST_PAGE_OF_DEPLOYMENTS = [
{"id": f"dep-{index}", "status": "DEPLOYED"} for index in range(100)
]
@pytest.mark.parametrize(
"repeated_page",
[
pytest.param(FIRST_PAGE_OF_DEPLOYMENTS, id="same_page_again"),
pytest.param(
[
{"id": "dep-99", "status": "DEPLOYING"},
*FIRST_PAGE_OF_DEPLOYMENTS[:99],
],
id="same_deployments_reordered_and_updated",
),
],
)
def test_list_deployments_fails_when_the_server_ignores_the_offset(repeated_page):
pages = iter([FIRST_PAGE_OF_DEPLOYMENTS, repeated_page])
def handler(req: httpx.Request) -> httpx.Response:
page = next(pages, None)
if page is None:
return httpx.Response(500, json={"detail": "the guard let paging run on"})
return httpx.Response(200, json={"resources": page})
c = HostBackendClient(
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
)
with pytest.raises(HostBackendError, match="ignores 'offset'"):
c.list_deployments()
@pytest.mark.parametrize(
("body", "expected"),
[
+98 -4
View File
@@ -1,6 +1,11 @@
from unittest.mock import patch
from langgraph_cli.deploy import Table
from langgraph_cli.deploy import (
_extract_deployment_url,
format_deployments_table,
format_listeners_table,
format_revisions_table,
)
from langgraph_cli.util import clean_empty_lines, warn_non_wolfi_distro
@@ -189,7 +194,96 @@ def test_warn_non_wolfi_distro_does_not_modify_config():
assert config_copy == original_config # Config should remain unchanged
def test_a_table_without_rows_still_renders_its_headers():
table = Table(("Name", "Status"), lambda resource: (None, None))
def test_extract_deployment_url_uses_custom_url():
deployment = {"source_config": {"custom_url": "https://example.com/custom"}}
assert _extract_deployment_url(deployment) == "https://example.com/custom"
assert table.render([]).splitlines() == ["Name Status", "---- ------"]
def test_extract_deployment_url_defaults_to_dash():
assert _extract_deployment_url({"id": "dep-123"}) == "-"
def test_format_deployments_table():
output = format_deployments_table(
[
{
"id": "dep-123",
"name": "alpha",
"source_config": {"custom_url": "https://alpha.example.com"},
},
{
"id": "dep-456",
"name": "beta",
"url": "https://beta.example.com",
},
]
)
assert "Deployment ID" in output
assert "Deployment Name" in output
assert "Deployment URL" in output
assert "dep-123" in output
assert "alpha" in output
assert "https://alpha.example.com" in output
assert "dep-456" in output
def test_format_revisions_table():
output = format_revisions_table(
[
{
"id": "rev-123",
"status": "DEPLOYED",
"created_at": "2023-11-09T10:00:00Z",
},
{
"id": "rev-456",
"status": "CREATING",
"created_at": "2023-11-07T05:31:56Z",
},
{
"id": "rev-789",
"status": "DEPLOYED",
"created_at": "2023-11-08T10:00:00Z",
},
]
)
assert "Revision ID" in output
assert "Status" in output
assert "Created At" in output
assert "rev-123" in output
assert "CREATING" in output
assert "2023-11-07T05:31:56Z" in output
assert "rev-456" in output
assert "rev-789" in output
assert "REPLACED" in output
def test_format_listeners_table():
output = format_listeners_table(
[
{
"id": "listener-1",
"compute_id": "prod-cluster",
"compute_config": {"k8s_namespaces": ["agents"]},
},
{
"id": "listener-2",
"compute_id": "multi-cluster",
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
},
{
"id": "listener-3",
"compute_id": "broken-cluster",
},
]
)
assert "Listener ID" in output
assert "Compute ID" in output
assert "Namespaces" in output
assert "listener-1" in output
assert "prod-cluster" in output
assert "agents" in output
assert "listener-2" in output
assert "agents, agents-staging" in output
assert "listener-3" in output
assert "broken-cluster" in output
@@ -347,7 +347,6 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
recursion_limit=DEFAULT_RECURSION_LIMIT,
configurable={},
)
inherited_tags: set[str] = set()
if var_config := var_child_runnable_config.get():
empty.update(
{
@@ -356,7 +355,6 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
if _is_not_empty(v)
},
)
inherited_tags.update(empty["tags"])
# An explicit config that supplies its own checkpoint coordinate (a
# thread_id, or any checkpoint_ns/checkpoint_id/checkpoint_map) is addressing
# its own checkpoint lineage, so drop the inherited ambient configurable
@@ -409,14 +407,12 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
elif k == "tags":
# Concatenate tags across configs so values bound via
# with_config(...) are preserved when later configs
# supply additional tags. Matches merge_configs. Tags the
# ambient config already has are skipped: a subgraph gets
# its parent's config both explicitly and as the ambient
# one, so they would double at every nesting level.
# supply additional tags. Matches merge_configs.
existing_tags: list[str] | None = empty.get("tags")
new_tags = [t for t in cast(list, v) if t not in inherited_tags]
empty["tags"] = (
[*existing_tags, *new_tags] if existing_tags else new_tags
[*existing_tags, *cast(list, v)]
if existing_tags
else list(cast(list, v))
)
else:
empty[k] = v # type: ignore[literal-required]
+40 -46
View File
@@ -657,11 +657,48 @@ class PregelLoop:
# check if iteration limit is reached
if self.step > self.stop:
self.status = "out_of_steps"
# stream the checkpoint the last step saved, as a tick that runs would
self._prepare_tasks_and_emit_checkpoint()
return False
self._prepare_tasks_and_emit_checkpoint()
# prepare next tasks
self.tasks = prepare_next_tasks(
self.checkpoint,
self.checkpoint_pending_writes,
self.nodes,
self.channels,
self.managed,
self.config,
self.step,
self.stop,
for_execution=True,
manager=self.manager,
store=self.store,
checkpointer=self.checkpointer,
trigger_to_nodes=self.trigger_to_nodes,
updated_channels=self.updated_channels,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
)
# produce debug output
if self._checkpointer_put_after_previous is not None:
self._emit(
"checkpoints",
map_debug_checkpoint,
{
**self.checkpoint_config,
CONF: {
**self.checkpoint_config[CONF],
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
},
self.channels,
self.stream_keys,
self.checkpoint_metadata,
self.tasks.values(),
self.checkpoint_pending_writes,
self.prev_checkpoint_config,
self.output_keys,
)
# if no more tasks, we're done
if not self.tasks:
@@ -769,53 +806,10 @@ class PregelLoop:
self.checkpoint, self.interrupt_after, self.tasks.values()
):
self.status = "interrupt_after"
# the next tick, which streams this checkpoint, never runs
self._prepare_tasks_and_emit_checkpoint()
raise GraphInterrupt()
# unset resuming flag
self.config[CONF].pop(CONFIG_KEY_RESUMING, None)
def _prepare_tasks_and_emit_checkpoint(self) -> None:
self.tasks = prepare_next_tasks(
self.checkpoint,
self.checkpoint_pending_writes,
self.nodes,
self.channels,
self.managed,
self.config,
self.step,
self.stop,
for_execution=True,
manager=self.manager,
store=self.store,
checkpointer=self.checkpointer,
trigger_to_nodes=self.trigger_to_nodes,
updated_channels=self.updated_channels,
retry_policy=self.retry_policy,
cache_policy=self.cache_policy,
)
# produce debug output
if self._checkpointer_put_after_previous is not None:
self._emit(
"checkpoints",
map_debug_checkpoint,
{
**self.checkpoint_config,
CONF: {
**self.checkpoint_config[CONF],
CONFIG_KEY_CHECKPOINT_ID: self.checkpoint["id"],
},
},
self.channels,
self.stream_keys,
self.checkpoint_metadata,
self.tasks.values(),
self.checkpoint_pending_writes,
self.prev_checkpoint_config,
self.output_keys,
)
def match_cached_writes(self) -> Sequence[PregelExecutableTask]:
raise NotImplementedError
-62
View File
@@ -4410,52 +4410,6 @@ def test_debug_retry(sync_checkpointer: BaseCheckpointSaver):
assert stream_parent_conf == history_parent_conf
def test_interrupt_after_streams_the_checkpoint_it_pauses_on(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("one", lambda state: {"x": 1})
.add_node("two", lambda state: {"x": 2})
.add_edge(START, "one")
.add_edge("one", "two")
.compile(checkpointer=sync_checkpointer, interrupt_after=["one"])
)
config = {"configurable": {"thread_id": "1"}}
checkpoints = list(graph.stream({"x": 0}, config, stream_mode="checkpoints"))
state = graph.get_state(config)
assert checkpoints[-1]["config"] == state.config
assert checkpoints[-1]["next"] == ["two"]
def test_recursion_limit_streams_the_last_checkpoint_saved(
sync_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("loop", lambda state: {"x": state["x"] + 1})
.add_edge(START, "loop")
.add_edge("loop", "loop")
.compile(checkpointer=sync_checkpointer)
)
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 3}
checkpoints = []
with pytest.raises(GraphRecursionError):
for chunk in graph.stream({"x": 0}, config, stream_mode="checkpoints"):
checkpoints.append(chunk)
assert checkpoints[-1]["config"] == graph.get_state(config).config
def test_debug_subgraphs(
sync_checkpointer: BaseCheckpointSaver, durability: Durability
):
@@ -6926,22 +6880,6 @@ def test_named_tasks_functional() -> None:
]
def test_invoke_tags_reach_a_node_three_subgraphs_deep_once() -> None:
class State(TypedDict):
copies: int
def leaf(state: State, config: RunnableConfig) -> State:
return {"copies": config["tags"].count("my-tag")}
graph = StateGraph(State).add_node("leaf", leaf).add_edge(START, "leaf").compile()
for _ in range(3):
graph = (
StateGraph(State).add_node("sub", graph).add_edge(START, "sub").compile()
)
assert graph.invoke({"copies": 0}, {"tags": ["my-tag"]}) == {"copies": 1}
def test_tags_stream_mode_messages() -> None:
model = GenericFakeChatModel(messages=iter(["foo"]), tags=["meow"])
graph = (
-49
View File
@@ -5875,55 +5875,6 @@ async def test_debug_retry(async_checkpointer: BaseCheckpointSaver):
assert stream_parent_conf == history_parent_conf
async def test_interrupt_after_streams_the_checkpoint_it_pauses_on(
async_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("one", lambda state: {"x": 1})
.add_node("two", lambda state: {"x": 2})
.add_edge(START, "one")
.add_edge("one", "two")
.compile(checkpointer=async_checkpointer, interrupt_after=["one"])
)
config = {"configurable": {"thread_id": "1"}}
checkpoints = [
chunk
async for chunk in graph.astream({"x": 0}, config, stream_mode="checkpoints")
]
state = await graph.aget_state(config)
assert checkpoints[-1]["config"] == state.config
assert checkpoints[-1]["next"] == ["two"]
async def test_recursion_limit_streams_the_last_checkpoint_saved(
async_checkpointer: BaseCheckpointSaver,
) -> None:
class State(TypedDict):
x: int
graph = (
StateGraph(State)
.add_node("loop", lambda state: {"x": state["x"] + 1})
.add_edge(START, "loop")
.add_edge("loop", "loop")
.compile(checkpointer=async_checkpointer)
)
config = {"configurable": {"thread_id": "1"}, "recursion_limit": 3}
checkpoints = []
with pytest.raises(GraphRecursionError):
async for chunk in graph.astream({"x": 0}, config, stream_mode="checkpoints"):
checkpoints.append(chunk)
assert checkpoints[-1]["config"] == (await graph.aget_state(config)).config
async def test_debug_subgraphs(
async_checkpointer: BaseCheckpointSaver, durability: Durability
):
-9
View File
@@ -799,15 +799,6 @@ def test_ensure_config_tags_concat_preserves_order_and_duplicates() -> None:
assert merged["tags"] == ["shared", "alpha", "shared", "beta"]
def test_ensure_config_skips_tags_inherited_from_ambient() -> None:
token = var_child_runnable_config.set({"tags": ["inherited"]})
try:
merged = ensure_config({"tags": ["inherited", "own"]})
finally:
var_child_runnable_config.reset(token)
assert merged["tags"] == ["inherited", "own"]
def test_ensure_config_merges_callbacks_across_configs() -> None:
a_cb = _TrackingCB("a")
b_cb = _TrackingCB("b")
+2 -2
View File
@@ -179,7 +179,7 @@ class HttpClient:
return await self.request_reconnect(
loc,
"GET",
headers=headers,
headers=request_headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
@@ -217,7 +217,7 @@ class HttpClient:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id:
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
-10
View File
@@ -99,7 +99,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> AsyncIterator[StreamPart]: ...
@@ -132,7 +131,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> AsyncIterator[StreamPartV2]: ...
@@ -149,7 +147,6 @@ class RunsClient:
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -163,7 +160,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> AsyncIterator[StreamPart]: ...
@@ -180,7 +176,6 @@ class RunsClient:
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
checkpoint_during: bool | None = None,
interrupt_before: All | Sequence[str] | None = None,
interrupt_after: All | Sequence[str] | None = None,
@@ -194,7 +189,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> AsyncIterator[StreamPartV2]: ...
@@ -391,7 +385,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
@overload
@@ -421,7 +414,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
async def create(
@@ -655,7 +647,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
@overload
@@ -682,7 +673,6 @@ class RunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
async def wait(
+3 -5
View File
@@ -179,7 +179,7 @@ class SyncHttpClient:
return self.request_reconnect(
loc,
"GET",
headers=headers,
headers=request_headers,
# don't pass on_response so it's only called once
reconnect_limit=reconnect_limit - 1,
)
@@ -219,7 +219,7 @@ class SyncHttpClient:
current_headers = dict(
request_headers if reconnect_path is None else reconnect_headers
)
if last_event_id:
if last_event_id is not None:
current_headers["Last-Event-ID"] = last_event_id
current_method = method if reconnect_path is None else "GET"
@@ -286,9 +286,7 @@ class SyncHttpClient:
break
def _encode_json(json: Any) -> tuple[dict[str, str], bytes | None]:
if json is None:
return {}, None
def _encode_json(json: Any) -> tuple[dict[str, str], bytes]:
body = orjson.dumps(
json,
_orjson_default,
+2 -21
View File
@@ -80,7 +80,6 @@ class SyncRunsClient:
command: Command | None = None,
stream_mode: StreamMode | Sequence[StreamMode] = "values",
stream_subgraphs: bool = False,
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
@@ -99,7 +98,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> Iterator[StreamPart]: ...
@@ -113,7 +111,6 @@ class SyncRunsClient:
command: Command | None = None,
stream_mode: StreamMode | Sequence[StreamMode] = "values",
stream_subgraphs: bool = False,
stream_resumable: bool = False,
metadata: Mapping[str, Any] | None = None,
config: Config | None = None,
context: Context | None = None,
@@ -132,7 +129,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> Iterator[StreamPartV2]: ...
@@ -163,7 +159,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v1"] = "v1",
) -> Iterator[StreamPart]: ...
@@ -194,7 +189,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
version: Literal["v2"],
) -> Iterator[StreamPartV2]: ...
@@ -386,7 +380,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
@overload
@@ -416,7 +409,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> Run: ...
def create(
@@ -650,7 +642,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
@overload
@@ -677,7 +668,6 @@ class SyncRunsClient:
headers: Mapping[str, str] | None = None,
params: QueryParamTypes | None = None,
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
durability: Durability | None = None,
) -> builtins.list[dict] | dict[str, Any]: ...
def wait(
@@ -825,6 +815,7 @@ class SyncRunsClient:
"checkpoint_during": checkpoint_during,
"on_completion": on_completion,
"after_seconds": after_seconds,
"raise_error": raise_error,
"durability": durability,
"langsmith_tracer": langsmith_tracing,
}
@@ -839,7 +830,7 @@ class SyncRunsClient:
if thread_id is not None
else "/runs/wait"
)
response = self.http.request_reconnect(
return self.http.request_reconnect(
endpoint,
"POST",
json={k: v for k, v in payload.items() if v is not None},
@@ -847,16 +838,6 @@ class SyncRunsClient:
headers=headers,
on_response=on_response if on_run_created else None,
)
if (
raise_error
and isinstance(response, dict)
and "__error__" in response
and isinstance(response["__error__"], dict)
):
raise Exception(
f"{response['__error__'].get('error')}: {response['__error__'].get('message')}"
)
return response
def list(
self,
+4 -15
View File
@@ -79,27 +79,18 @@ class SSEDecoder:
def __init__(self) -> None:
self._event = ""
self._data = bytearray()
self._last_event_id: str | None = None
self._last_event_id = ""
self._retry: int | None = None
self._at_stream_start = True
@property
def last_event_id(self) -> str | None:
"""Return the last event identifier that was seen.
"""Return the last event identifier that was seen."""
`None` until the stream sends an `id` field, and `""` after an empty
one, which resets the reconnect cursor.
"""
return self._last_event_id
return self._last_event_id or None
def decode(self, line: bytes) -> StreamPart | None:
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation
if self._at_stream_start:
self._at_stream_start = False
line = line.removeprefix(b"\xef\xbb\xbf")
if not line:
if (
not self._event
@@ -112,7 +103,7 @@ class SSEDecoder:
sse = StreamPart(
event=self._event,
data=orjson.loads(self._data) if self._data else None, # ty: ignore[invalid-argument-type]
id=self._last_event_id or None,
id=self.last_event_id,
)
# NOTE: as per the SSE spec, do not reset last_event_id.
@@ -133,8 +124,6 @@ class SSEDecoder:
if fieldname == b"event":
self._event = value.decode()
elif fieldname == b"data":
if self._data:
self._data.extend(b"\n")
self._data.extend(value)
elif fieldname == b"id":
if b"\0" in value:
-44
View File
@@ -1,9 +1,7 @@
from __future__ import annotations
import ast
import inspect
import re
from pathlib import Path
import pytest
@@ -130,45 +128,3 @@ def test_sync_api_matches_async(async_cls, sync_cls):
f"Return annotation mismatch for {async_cls.__name__}.{name}: "
f"async={a_ret}, sync={s_ret}"
)
# Stateless overloads (`thread_id: None`) leave out the thread-only parameters,
# and stateful ones (`thread_id: str`) leave out `on_completion`, which only
# applies to stateless runs.
_RUN_OVERLOAD_OMISSIONS = {
"None": {"checkpoint", "checkpoint_id", "multitask_strategy"},
"str": {"on_completion"},
}
@pytest.mark.parametrize("client_cls", [RunsClient, SyncRunsClient])
def test_run_overloads_accept_implementation_params(client_cls) -> None:
tree = ast.parse(Path(inspect.getfile(client_cls)).read_text())
cls = next(
node
for node in tree.body
if isinstance(node, ast.ClassDef) and node.name == client_cls.__name__
)
defs = [
node
for node in cls.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name in {"stream", "create", "wait"}
]
impl_params = {
node.name: {arg.arg for arg in node.args.kwonlyargs}
for node in defs
if not node.decorator_list
}
gaps = []
for stub in defs:
if not stub.decorator_list:
continue
thread_id = next(arg for arg in stub.args.args if arg.arg == "thread_id")
assert thread_id.annotation is not None
kind = ast.unparse(thread_id.annotation)
expected = impl_params[stub.name] - _RUN_OVERLOAD_OMISSIONS[kind]
if missing := expected - {arg.arg for arg in stub.args.kwonlyargs}:
gaps.append(f"{stub.name}(thread_id: {kind}) line {stub.lineno}: {missing}")
assert not gaps, gaps
-66
View File
@@ -5,7 +5,6 @@ from pathlib import Path
from typing import Any
import httpx
import orjson
import pytest
from typing_extensions import assert_type
@@ -111,25 +110,6 @@ def test_stream_sse():
assert len(parts) == 79
def test_sse_decoder_joins_data_lines_with_a_newline():
decoder = SSEDecoder()
for line in (b"data: [1,", b"data: 2]"):
decoder.decode(line)
assert decoder.decode(b"") == StreamPart(event="", data=[1, 2], id=None) # ty: ignore[invalid-argument-type]
for line in (b"data: [1", b"data: 0]"):
decoder.decode(line)
with pytest.raises(orjson.JSONDecodeError):
decoder.decode(b"")
def test_sse_decoder_strips_a_leading_bom():
decoder = SSEDecoder()
for line in (b"\xef\xbb\xbfevent: values", b"data: 1"):
decoder.decode(line)
assert decoder.decode(b"") == StreamPart(event="values", data=1, id=None) # ty: ignore[invalid-argument-type]
# --- HTTP client streaming ---
@@ -312,52 +292,6 @@ async def test_http_client_stream_recovers_after_disconnect():
]
def _drop_after_an_empty_id(stream_cls: type, reconnects: list[httpx.Request]):
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream", "Location": "/reconnect"},
stream=stream_cls(
[
b"id: 1\nevent: values\ndata: 1\n\n",
b"id:\nevent: values\ndata: 2\n\n",
],
httpx.RemoteProtocolError("incomplete chunked read"),
),
)
reconnects.append(request)
return httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
stream=stream_cls([b"event: end\ndata: null\n\n"]),
)
return handler
def test_sync_stream_reconnects_without_a_cursor_after_an_empty_id():
reconnects: list[httpx.Request] = []
transport = httpx.MockTransport(_drop_after_an_empty_id(ListByteStream, reconnects))
with httpx.Client(transport=transport, base_url="https://example.com") as client:
list(SyncHttpClient(client).stream("/stream", "POST", json={}))
assert "last-event-id" not in reconnects[0].headers
async def test_async_stream_reconnects_without_a_cursor_after_an_empty_id():
reconnects: list[httpx.Request] = []
transport = httpx.MockTransport(
_drop_after_an_empty_id(AsyncListByteStream, reconnects)
)
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
[part async for part in HttpClient(client).stream("/stream", "POST", json={})]
assert "last-event-id" not in reconnects[0].headers
# --- _sse_to_v2_dict conversion ---
-119
View File
@@ -1,119 +0,0 @@
from __future__ import annotations
import json
from collections.abc import AsyncIterator, Iterator
import httpx
import pytest
from langgraph_sdk.client import (
HttpClient,
RunsClient,
SyncHttpClient,
SyncRunsClient,
)
class _BodyDropsMidRead(httpx.SyncByteStream, httpx.AsyncByteStream):
def __iter__(self) -> Iterator[bytes]:
yield b'{"partial'
raise httpx.ReadError("connection dropped")
async def __aiter__(self) -> AsyncIterator[bytes]:
yield b'{"partial'
raise httpx.ReadError("connection dropped")
def _wait_handler(requests: list[httpx.Request]):
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.method == "POST":
return httpx.Response(
200,
headers={"Location": "/threads/t1/runs/r1/join"},
stream=_BodyDropsMidRead(),
)
return httpx.Response(200, json={"done": True})
return handler
def _assert_bodyless_get(request: httpx.Request) -> None:
assert request.method == "GET"
assert request.url.path == "/threads/t1/runs/r1/join"
assert "content-length" not in request.headers, request.headers
assert "content-type" not in request.headers, request.headers
assert request.read() == b""
def test_sync_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
with httpx.Client(transport=transport, base_url="https://example.com") as client:
runs = SyncRunsClient(SyncHttpClient(client))
with pytest.warns(UserWarning, match="attempting reconnect"):
result = runs.wait("t1", "agent", input={"question": "hi"})
assert result == {"done": True}
_assert_bodyless_get(requests[1])
async def test_async_wait_reconnects_with_a_bodyless_get():
requests: list[httpx.Request] = []
transport = httpx.MockTransport(_wait_handler(requests))
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
runs = RunsClient(HttpClient(client))
with pytest.warns(UserWarning, match="attempting reconnect"):
result = await runs.wait("t1", "agent", input={"question": "hi"})
assert result == {"done": True}
_assert_bodyless_get(requests[1])
def test_sync_wait_raises_the_run_error():
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(
200, json={"__error__": {"error": "RuntimeError", "message": "boom"}}
)
transport = httpx.MockTransport(handler)
with httpx.Client(transport=transport, base_url="https://example.com") as client:
runs = SyncRunsClient(SyncHttpClient(client))
with pytest.raises(Exception, match="RuntimeError: boom"):
runs.wait("t1", "agent", input={}, raise_error=True)
assert "raise_error" not in json.loads(requests[0].content)
@pytest.mark.parametrize("asynchronous", [False, True])
async def test_wait_returns_the_run_error_without_raise_error(asynchronous):
requests: list[httpx.Request] = []
run_error = {"__error__": {"error": "RuntimeError", "message": "boom"}}
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json=run_error)
transport = httpx.MockTransport(handler)
if asynchronous:
async with httpx.AsyncClient(
transport=transport, base_url="https://example.com"
) as client:
result = await RunsClient(HttpClient(client)).wait(
"t1", "agent", input={}, raise_error=False
)
else:
with httpx.Client(
transport=transport, base_url="https://example.com"
) as client:
result = SyncRunsClient(SyncHttpClient(client)).wait(
"t1", "agent", input={}, raise_error=False
)
assert result == run_error
assert "raise_error" not in json.loads(requests[0].content)