mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-11-10 07:57:43 +01:00
Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b4275c6d0 | ||
|
|
e5e37b5d70 | ||
|
|
22025bbdbe | ||
|
|
5847733bcb | ||
|
|
17335c9782 | ||
|
|
cf35b4a3a1 | ||
|
|
60cbb4af56 | ||
|
|
2643345bfc | ||
|
|
f35dc299b3 | ||
|
|
9412cb7aef | ||
|
|
302a4d4caf | ||
|
|
cf260dac7f | ||
|
|
a4d4187e82 | ||
|
|
572e83cf6e | ||
|
|
6ea1751a93 | ||
|
|
22ac52e278 | ||
|
|
dbc60dd112 | ||
|
|
b1baa6e0c3 | ||
|
|
25ccdc8091 |
No files matched your search
@@ -115,21 +115,16 @@ jobs:
|
||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||
timeout --kill-after=30 300 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
|
||||
echo "Finished starting up langgraph-test-h"
|
||||
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
|
||||
if [ "$LANGGRAPH_VERSION" != "1.2.6" ]; then
|
||||
echo "LANGGRAPH_VERSION != 1.2.6; $LANGGRAPH_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
|
||||
if [ "$LANGCHAIN_OPENAI_VERSION" != "1.1.14" ]; then
|
||||
echo "LANGCHAIN_OPENAI_VERSION != 1.1.14; $LANGCHAIN_OPENAI_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
LANGCHAIN_ANTHROPIC_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-anthropic'); print(v);")
|
||||
if [ "$LANGCHAIN_ANTHROPIC_VERSION" != "1.4.6" ]; then
|
||||
echo "LANGCHAIN_ANTHROPIC_VERSION != 1.4.6; $LANGCHAIN_ANTHROPIC_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
pins=$(grep -oE '"[a-z0-9-]+==[^"]+"' pyproject.toml | tr -d '"')
|
||||
[ -n "$pins" ] || { echo "no == pins found in pyproject.toml"; exit 1; }
|
||||
for pin in $pins; do
|
||||
pkg=${pin%%==*}
|
||||
installed=$(docker run --rm --entrypoint "" langgraph-test-h python -c "from importlib.metadata import version; print(version('$pkg'))")
|
||||
if [ "$installed" != "${pin#*==}" ]; then
|
||||
echo "$pkg $installed != pinned ${pin#*==}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build and test prerelease reqs fail service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
|
||||
Generated
+1242
-666
File diff suppressed because it is too large.
Load diff
Generated
+1387
-715
File diff suppressed because it is too large.
Load diff
Generated
+1308
-624
File diff suppressed because it is too large.
Load diff
Generated
+1583
-750
File diff suppressed because it is too large.
Load diff
@@ -86,6 +86,71 @@ langgraph dockerfile SAVE_PATH [OPTIONS]
|
||||
-c, --config FILE Config file path
|
||||
```
|
||||
|
||||
### `langgraph deploy metrics`
|
||||
|
||||
Fetch deployment monitoring time series as JSON through the LangSmith control plane.
|
||||
Use `langgraph deploy list` to find the deployment ID. Authentication uses the same
|
||||
API key and workspace configuration as the other deployment commands.
|
||||
|
||||
```bash
|
||||
langgraph deploy metrics DEPLOYMENT_ID \
|
||||
--start-time 2026-10-28T00:00:00Z \
|
||||
--end-time 2026-10-28T01:00:00Z \
|
||||
--metrics cpu_usage,memory_usage,worker_count
|
||||
```
|
||||
|
||||
`--metrics` accepts a comma-separated list of these names (also listed in
|
||||
`langgraph deploy metrics --help`):
|
||||
|
||||
- Resource usage: `memory_usage`, `cpu_usage`, `disk_usage`
|
||||
- Counts: `restart_count`, `replica_count`, `worker_count`, `lg_run_count`
|
||||
- HTTP traffic: `responses_per_second`, `error_responses_per_second`, `p95_latency`
|
||||
- Queue: `run_queue_wait_time`
|
||||
|
||||
These are exact metric names, not free-text search terms. Availability depends on
|
||||
the deployment and platform; a supported metric may have no data. Use
|
||||
`lg_run_count` for deployment runs, not the UI schema's placeholder `run_count`.
|
||||
|
||||
Omit `--metrics` to fetch all available metrics. Time bounds default to the last
|
||||
15 minutes, and the backend picks the resolution from the time
|
||||
range. This command never prompts; for an organization-scoped key, set
|
||||
`LANGSMITH_TENANT_ID` to the workspace ID.
|
||||
|
||||
Default output is compact JSON grouped by section title, chart title, and series
|
||||
name. Each series contains its real `project_metric` as `metric`, inferred `unit`
|
||||
(`percent`, `count`, or `null`), `latest`, `min`, `max`, `avg`, and
|
||||
`points: [[iso_timestamp, value], ...]`. Values are rounded to three decimals;
|
||||
statistics use unrounded finite values and ignore missing samples. `latest` is
|
||||
the latest non-missing value. Missing samples remain `null` gaps, and series with
|
||||
no values have null statistics. Empty sections are marked `{"_empty":true}`.
|
||||
|
||||
The top level includes `deployment_id`, `start_time`, `end_time`, and
|
||||
`step_seconds`. Bounds always describe the requested window, not the observed
|
||||
point bounds. The CLI resolves omitted bounds once: end defaults to now (UTC),
|
||||
and start defaults to 15 minutes before the resolved end. With only `--end-time`,
|
||||
start is 15 minutes before that value; with only `--start-time`, end is now. Both
|
||||
values are sent explicitly to the backend and output, even when there are no points. `step_seconds` is the most common gap between consecutive
|
||||
timestamps within series, with ties broken by the smallest gap. Whole seconds
|
||||
are integers; fractional seconds are preserved. It is `null` only when every
|
||||
series has fewer than two timestamps. UI metadata and repeated series IDs are
|
||||
omitted. The host-backend response itself is unchanged.
|
||||
|
||||
```bash
|
||||
langgraph deploy metrics DEPLOYMENT_ID --summary
|
||||
langgraph deploy metrics DEPLOYMENT_ID --raw
|
||||
```
|
||||
|
||||
`--summary` returns the same shape without `points`. `--raw` returns the original
|
||||
UI chart schema without reshaping or rounding. These flags are mutually exclusive.
|
||||
Neither mode returns traces.
|
||||
|
||||
Size comparison using the realistic synthetic unit-test fixture (compact JSON
|
||||
serialization for all modes; not a live deployment benchmark):
|
||||
|
||||
| Before: raw UI schema | After: default | After: summary |
|
||||
| --- | --- | --- |
|
||||
| 4,302 bytes | 1,226 bytes (72% smaller) | 806 bytes (81% smaller) |
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI uses a `langgraph.json` configuration file with these key settings:
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.40"
|
||||
__version__ = "0.4.42"
|
||||
@@ -11,8 +11,9 @@ import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Generic, Protocol, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
import click
|
||||
import click.exceptions
|
||||
@@ -23,6 +24,7 @@ from langgraph_cli.analytics import log_command
|
||||
from langgraph_cli.config import Config
|
||||
from langgraph_cli.constants import DEFAULT_CONFIG
|
||||
from langgraph_cli.dependency_tracking import find_tracked_packages
|
||||
from langgraph_cli.deployment_metrics import compact_deployment_metrics
|
||||
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 (
|
||||
@@ -2634,6 +2636,81 @@ def deploy_list(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@OPT_HOST_API_KEY
|
||||
@OPT_HOST_URL
|
||||
@click.argument("deployment_id", type=click.UUID)
|
||||
@click.option(
|
||||
"--start-time",
|
||||
help="ISO8601 start time. Defaults to 15 minutes before --end-time.",
|
||||
)
|
||||
@click.option("--end-time", help="ISO8601 end time. Defaults to now.")
|
||||
@click.option(
|
||||
"--metrics",
|
||||
help=(
|
||||
"Comma-separated metric names. Defaults to all metrics. Supported names: "
|
||||
"memory_usage, cpu_usage, disk_usage, restart_count, replica_count, "
|
||||
"worker_count, lg_run_count, responses_per_second, "
|
||||
"error_responses_per_second, p95_latency, run_queue_wait_time. "
|
||||
"Availability depends on the deployment and platform."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--summary", is_flag=True, help="Omit points and print per-series statistics only."
|
||||
)
|
||||
@click.option(
|
||||
"--raw", is_flag=True, help="Print the original host-backend chart schema."
|
||||
)
|
||||
@deploy.command(
|
||||
"metrics", help="[Beta] Fetch deployment monitoring time series as compact JSON."
|
||||
)
|
||||
def deploy_metrics(
|
||||
api_key: str | None,
|
||||
host_url: str | None,
|
||||
deployment_id: UUID,
|
||||
start_time: str | None,
|
||||
end_time: str | None,
|
||||
metrics: str | None,
|
||||
summary: bool,
|
||||
raw: bool,
|
||||
) -> None:
|
||||
if summary and raw:
|
||||
raise click.UsageError("--summary and --raw are mutually exclusive.")
|
||||
end_time = end_time or datetime.now(timezone.utc).isoformat()
|
||||
if start_time is None:
|
||||
try:
|
||||
end = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise click.BadParameter(
|
||||
"Expected an ISO8601 timestamp.", param_hint="--end-time"
|
||||
) from exc
|
||||
end = end.replace(tzinfo=end.tzinfo or timezone.utc).astimezone(timezone.utc)
|
||||
start_time = (end - timedelta(minutes=15)).isoformat()
|
||||
params = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"metrics": metrics,
|
||||
}.items()
|
||||
if value is not None
|
||||
}
|
||||
client = _create_host_backend_client(host_url, api_key, no_input=True)
|
||||
result = _call_host_backend_with_optional_tenant(
|
||||
client,
|
||||
lambda c: c.get_deployment_metrics(str(deployment_id), params),
|
||||
no_input=True,
|
||||
)
|
||||
if not raw:
|
||||
result = compact_deployment_metrics(
|
||||
result,
|
||||
str(deployment_id),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
summary=summary,
|
||||
)
|
||||
click.echo(json_mod.dumps(result, separators=(",", ":")))
|
||||
|
||||
|
||||
@deploy.group(
|
||||
"listeners",
|
||||
cls=NestedHelpGroup,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Compact deployment chart responses for command-line consumers."""
|
||||
|
||||
import math
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from statistics import fmean
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _timestamp(value: str) -> datetime:
|
||||
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return timestamp.replace(tzinfo=timestamp.tzinfo or timezone.utc).astimezone(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
|
||||
def _iso(value: datetime) -> str:
|
||||
return value.isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _unit(title: str, metric: str | None) -> str | None:
|
||||
if "%" in title:
|
||||
return "percent"
|
||||
if metric in {
|
||||
"restart_count",
|
||||
"replica_count",
|
||||
"worker_count",
|
||||
"lg_run_count",
|
||||
"run_count",
|
||||
}:
|
||||
return "count"
|
||||
return None
|
||||
|
||||
|
||||
def compact_deployment_metrics(
|
||||
response: dict[str, Any],
|
||||
deployment_id: str,
|
||||
*,
|
||||
start_time: str,
|
||||
end_time: str,
|
||||
summary: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Reshape UI charts without mutating the response; step is in seconds."""
|
||||
sections = {}
|
||||
spacings: Counter[float] = Counter()
|
||||
source_sections = list(response.get("sub_sections") or [])
|
||||
if response.get("charts"):
|
||||
source_sections.insert(0, response)
|
||||
for section in source_sections:
|
||||
charts = {}
|
||||
for chart in section.get("charts") or []:
|
||||
by_series = defaultdict(list)
|
||||
for point in chart.get("data") or []:
|
||||
timestamp = _timestamp(point["timestamp"])
|
||||
value = point.get("value")
|
||||
if value is not None and not math.isfinite(value):
|
||||
value = None
|
||||
by_series[point["series_id"]].append((timestamp, value))
|
||||
series_output = {}
|
||||
for series in chart.get("series") or []:
|
||||
points = sorted(by_series[series["id"]], key=lambda point: point[0])
|
||||
times = sorted({timestamp for timestamp, _ in points})
|
||||
spacings.update(
|
||||
(b - a).total_seconds()
|
||||
for a, b in zip(times, times[1:], strict=False)
|
||||
)
|
||||
values = [value for _, value in points if value is not None]
|
||||
metric = series.get("project_metric")
|
||||
stats = {
|
||||
"metric": metric,
|
||||
"unit": _unit(chart["title"], metric),
|
||||
"latest": round(values[-1], 3) if values else None,
|
||||
"min": round(min(values), 3) if values else None,
|
||||
"max": round(max(values), 3) if values else None,
|
||||
"avg": round(fmean(values), 3) if values else None,
|
||||
}
|
||||
if not summary:
|
||||
stats["points"] = [
|
||||
[
|
||||
_iso(timestamp),
|
||||
round(value, 3) if value is not None else None,
|
||||
]
|
||||
for timestamp, value in points
|
||||
]
|
||||
series_output[series["name"]] = stats
|
||||
charts[chart["title"]] = series_output or {"_empty": True}
|
||||
sections[section["title"]] = charts or {"_empty": True}
|
||||
step = min(spacings, key=lambda gap: (-spacings[gap], gap)) if spacings else None
|
||||
return {
|
||||
"deployment_id": deployment_id,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"step_seconds": int(step) if step is not None and step.is_integer() else step,
|
||||
"sections": sections or {"_empty": True},
|
||||
}
|
||||
@@ -362,6 +362,13 @@ class HostBackendClient:
|
||||
f"/v2/deployments/{deployment_id}/revisions/{revision_id}",
|
||||
)
|
||||
|
||||
def get_deployment_metrics(
|
||||
self, deployment_id: str, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return self._request(
|
||||
"GET", f"/v2/deployments/{deployment_id}/metrics", params=params
|
||||
)
|
||||
|
||||
def get_build_logs(
|
||||
self, project_id: str, revision_id: str, payload: dict[str, Any]
|
||||
) -> Any:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import langgraph_cli.deploy as deploy_module
|
||||
from langgraph_cli.cli import cli
|
||||
from langgraph_cli.deployment_metrics import compact_deployment_metrics
|
||||
from langgraph_cli.host_backend import HostBackendClient
|
||||
|
||||
DEPLOYMENT_ID = "019a0000-0000-7000-8000-000000000001"
|
||||
|
||||
|
||||
def invoke(monkeypatch, args, handler, input=None):
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
tenant_id="workspace-id",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
monkeypatch.setattr(deploy_module, "HostBackendClient", lambda *a, **kw: client)
|
||||
return CliRunner().invoke(
|
||||
cli,
|
||||
["deploy", *args, "--api-key", "key"],
|
||||
input=input,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bounds", [[], ["start_time"], ["end_time"], ["start_time", "end_time"]]
|
||||
)
|
||||
@pytest.mark.parametrize("mode", [None, "--summary", "--raw"])
|
||||
def test_metrics(monkeypatch, bounds, mode, deployment_metrics_response):
|
||||
charts = deployment_metrics_response
|
||||
|
||||
class FrozenDatetime(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return cls(2026, 10, 28, 23, 2, 33, tzinfo=timezone.utc)
|
||||
|
||||
monkeypatch.setattr(deploy_module, "datetime", FrozenDatetime)
|
||||
explicit = {
|
||||
"start_time": "2026-10-27T22:32:33Z",
|
||||
"end_time": "2026-10-28T21:32:33Z",
|
||||
}
|
||||
options = {key: explicit[key] for key in bounds}
|
||||
if bounds:
|
||||
options["metrics"] = "cpu_usage,memory_usage"
|
||||
params = {
|
||||
"start_time": (
|
||||
"2026-10-28T21:17:33+00:00"
|
||||
if "end_time" in bounds
|
||||
else "2026-10-28T22:47:33+00:00"
|
||||
),
|
||||
"end_time": "2026-10-28T23:02:33+00:00",
|
||||
**options,
|
||||
}
|
||||
|
||||
def handler(request):
|
||||
assert request.method == "GET"
|
||||
assert request.url.path == f"/v2/deployments/{DEPLOYMENT_ID}/metrics"
|
||||
assert dict(request.url.params) == params
|
||||
assert request.headers["x-tenant-id"] == "workspace-id"
|
||||
assert request.headers["x-api-key"] == "key"
|
||||
return httpx.Response(200, json=charts)
|
||||
|
||||
args = ["metrics", DEPLOYMENT_ID]
|
||||
for key, value in options.items():
|
||||
args.extend([f"--{key.replace('_', '-')}", value])
|
||||
if mode:
|
||||
args.append(mode)
|
||||
result = invoke(monkeypatch, args, handler)
|
||||
assert result.exit_code == 0, result.output
|
||||
expected = (
|
||||
charts
|
||||
if mode == "--raw"
|
||||
else compact_deployment_metrics(
|
||||
charts,
|
||||
DEPLOYMENT_ID,
|
||||
start_time=params.get("start_time"),
|
||||
end_time=params.get("end_time"),
|
||||
summary=mode == "--summary",
|
||||
)
|
||||
)
|
||||
assert json.loads(result.output) == expected
|
||||
if mode != "--raw":
|
||||
assert expected["start_time"] == params["start_time"]
|
||||
assert expected["end_time"] == params["end_time"]
|
||||
assert result.output.strip() == json.dumps(expected, separators=(",", ":"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["metrics", DEPLOYMENT_ID, "--step-seconds", "60"],
|
||||
["metrics", "invalid-id"],
|
||||
["metrics", DEPLOYMENT_ID, "--summary", "--raw"],
|
||||
],
|
||||
)
|
||||
def test_invalid_options_do_not_send_requests(monkeypatch, args):
|
||||
def handler(request):
|
||||
pytest.fail("Invalid options must not send a request")
|
||||
|
||||
result = invoke(monkeypatch, args, handler)
|
||||
assert result.exit_code == 2, result.output
|
||||
|
||||
|
||||
def test_end_only_with_timezone_offset(monkeypatch):
|
||||
def handler(request):
|
||||
assert request.url.params["end_time"] == "2026-10-28T01:05:00+02:00"
|
||||
assert request.url.params["start_time"] == "2026-10-27T22:50:00+00:00"
|
||||
return httpx.Response(200, json={"charts": [], "sub_sections": []})
|
||||
|
||||
result = invoke(
|
||||
monkeypatch,
|
||||
["metrics", DEPLOYMENT_ID, "--end-time", "2026-10-28T01:05:00+02:00"],
|
||||
handler,
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
output = json.loads(result.output)
|
||||
assert output["start_time"] == "2026-10-27T22:50:00+00:00"
|
||||
assert output["end_time"] == "2026-10-28T01:05:00+02:00"
|
||||
|
||||
|
||||
def test_invalid_end_only_does_not_send_request(monkeypatch):
|
||||
def handler(request):
|
||||
pytest.fail("Invalid end time must not send a request")
|
||||
|
||||
result = invoke(
|
||||
monkeypatch, ["metrics", DEPLOYMENT_ID, "--end-time", "not-a-date"], handler
|
||||
)
|
||||
assert result.exit_code == 2, result.output
|
||||
assert "--end-time" in result.output
|
||||
assert "ISO8601" in result.output
|
||||
|
||||
|
||||
def test_metrics_help_lists_supported_filters():
|
||||
result = CliRunner().invoke(cli, ["deploy", "metrics", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
for metric in (
|
||||
"memory_usage",
|
||||
"cpu_usage",
|
||||
"disk_usage",
|
||||
"restart_count",
|
||||
"replica_count",
|
||||
"worker_count",
|
||||
"lg_run_count",
|
||||
"responses_per_second",
|
||||
"error_responses_per_second",
|
||||
"p95_latency",
|
||||
"run_queue_wait_time",
|
||||
):
|
||||
assert metric in result.output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [403, 422, 429])
|
||||
def test_backend_errors(monkeypatch, status):
|
||||
def handler(request):
|
||||
return httpx.Response(status, json={"detail": "Operation rejected"})
|
||||
|
||||
args = ["metrics", DEPLOYMENT_ID]
|
||||
result = invoke(monkeypatch, args, handler)
|
||||
assert result.exit_code == 1
|
||||
assert "Operation rejected" in result.output
|
||||
@@ -1,9 +1,16 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deployment_metrics_response():
|
||||
return json.loads(Path(__file__).with_name("deployment_metrics.json").read_text())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def disable_analytics_env() -> None:
|
||||
"""Disable analytics for unit tests LANGGRAPH_CLI_NO_ANALYTICS."""
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-metrics",
|
||||
"title": "Metrics for deployment",
|
||||
"charts": [],
|
||||
"sub_sections": [
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-queue",
|
||||
"title": "Queue",
|
||||
"description": "Queue metrics",
|
||||
"index": 0,
|
||||
"charts": [
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Memory Usage %",
|
||||
"title": "Memory Usage %",
|
||||
"description": "Resource usage over time",
|
||||
"index": 0,
|
||||
"chart_type": "line",
|
||||
"series": [
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Avg Memory Usage %",
|
||||
"name": "Avg Memory Usage %",
|
||||
"metric": "run_count",
|
||||
"project_metric": "memory_usage",
|
||||
"metadata": null,
|
||||
"filters": null,
|
||||
"feedback_key": null,
|
||||
"workspace_id": null,
|
||||
"metric_definition": null,
|
||||
"group_by_definitions": null,
|
||||
"filter_definition": null,
|
||||
"group_by": null
|
||||
},
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Max Memory Usage %",
|
||||
"name": "Max Memory Usage %",
|
||||
"metric": "run_count",
|
||||
"project_metric": "memory_usage",
|
||||
"metadata": null,
|
||||
"filters": null,
|
||||
"feedback_key": null,
|
||||
"workspace_id": null,
|
||||
"metric_definition": null,
|
||||
"group_by_definitions": null,
|
||||
"filter_definition": null,
|
||||
"group_by": null
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Max Memory Usage %",
|
||||
"timestamp": "2026-10-28T00:02:00+00:00",
|
||||
"value": 50.33333,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Max Memory Usage %",
|
||||
"timestamp": "2026-10-28T00:01:00+00:00",
|
||||
"value": 40.22222,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Max Memory Usage %",
|
||||
"timestamp": "2026-10-28T00:00:00+00:00",
|
||||
"value": 30.11111,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Avg Memory Usage %",
|
||||
"timestamp": "2026-10-28T00:02:00+00:00",
|
||||
"value": null,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Avg Memory Usage %",
|
||||
"timestamp": "2026-10-28T00:01:00+00:00",
|
||||
"value": 20.23456,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Avg Memory Usage %",
|
||||
"timestamp": "2026-10-28T00:00:00+00:00",
|
||||
"value": 10.12345,
|
||||
"group": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Restarts",
|
||||
"title": "Restarts",
|
||||
"description": "Resource usage over time",
|
||||
"index": 0,
|
||||
"chart_type": "line",
|
||||
"series": [
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Restarts",
|
||||
"name": "Restarts",
|
||||
"metric": "run_count",
|
||||
"project_metric": "restart_count",
|
||||
"metadata": null,
|
||||
"filters": null,
|
||||
"feedback_key": null,
|
||||
"workspace_id": null,
|
||||
"metric_definition": null,
|
||||
"group_by_definitions": null,
|
||||
"filter_definition": null,
|
||||
"group_by": null
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Restarts",
|
||||
"timestamp": "2026-10-28T00:02:00+00:00",
|
||||
"value": 2,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Restarts",
|
||||
"timestamp": "2026-10-28T00:01:00+00:00",
|
||||
"value": 1,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-Restarts",
|
||||
"timestamp": "2026-10-28T00:00:00+00:00",
|
||||
"value": 0,
|
||||
"group": null
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Queue wait time",
|
||||
"title": "Queue wait time",
|
||||
"description": "Resource usage over time",
|
||||
"index": 0,
|
||||
"chart_type": "line",
|
||||
"series": [
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Queue wait time",
|
||||
"name": "Queue wait time",
|
||||
"metric": "run_count",
|
||||
"project_metric": "run_queue_wait_time",
|
||||
"metadata": null,
|
||||
"filters": null,
|
||||
"feedback_key": null,
|
||||
"workspace_id": null,
|
||||
"metric_definition": null,
|
||||
"group_by_definitions": null,
|
||||
"filter_definition": null,
|
||||
"group_by": null
|
||||
}
|
||||
],
|
||||
"data": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-api",
|
||||
"title": "API",
|
||||
"description": "API metrics",
|
||||
"index": 1,
|
||||
"charts": [
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-Latency",
|
||||
"title": "Latency",
|
||||
"description": "Resource usage over time",
|
||||
"index": 0,
|
||||
"chart_type": "line",
|
||||
"series": [
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-P95 Latency",
|
||||
"name": "P95 Latency",
|
||||
"metric": "run_count",
|
||||
"project_metric": "p95_latency",
|
||||
"metadata": null,
|
||||
"filters": null,
|
||||
"feedback_key": null,
|
||||
"workspace_id": null,
|
||||
"metric_definition": null,
|
||||
"group_by_definitions": null,
|
||||
"filter_definition": null,
|
||||
"group_by": null
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-P95 Latency",
|
||||
"timestamp": "2026-10-28T00:02:00+00:00",
|
||||
"value": 0.34567,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-P95 Latency",
|
||||
"timestamp": "2026-10-28T00:01:00+00:00",
|
||||
"value": 0.23456,
|
||||
"group": null
|
||||
},
|
||||
{
|
||||
"series_id": "019a0000-0000-7000-8000-000000000001-P95 Latency",
|
||||
"timestamp": "2026-10-28T00:00:00+00:00",
|
||||
"value": 0.12345,
|
||||
"group": null
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "019a0000-0000-7000-8000-000000000001-pg",
|
||||
"title": "Postgres",
|
||||
"description": "Postgres metrics",
|
||||
"index": 2,
|
||||
"charts": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from langgraph_cli.deployment_metrics import compact_deployment_metrics
|
||||
|
||||
|
||||
def reshape(response, deployment_id, **kwargs):
|
||||
return compact_deployment_metrics(
|
||||
response,
|
||||
deployment_id,
|
||||
start_time=kwargs.pop("start_time", "2026-10-28T00:00:00Z"),
|
||||
end_time=kwargs.pop("end_time", "2026-10-28T01:00:00Z"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_compact_metrics(deployment_metrics_response):
|
||||
original = deepcopy(deployment_metrics_response)
|
||||
result = reshape(deployment_metrics_response, "deployment-id")
|
||||
assert deployment_metrics_response == original
|
||||
assert result["deployment_id"] == "deployment-id"
|
||||
assert result["start_time"] == "2026-10-28T00:00:00Z"
|
||||
assert result["end_time"] == "2026-10-28T01:00:00Z"
|
||||
assert result["step_seconds"] == 60
|
||||
assert result["sections"]["Postgres"] == {"_empty": True}
|
||||
queue = result["sections"]["Queue"]
|
||||
memory = queue["Memory Usage %"]
|
||||
assert memory["Avg Memory Usage %"] == {
|
||||
"metric": "memory_usage",
|
||||
"unit": "percent",
|
||||
"latest": 20.235,
|
||||
"min": 10.123,
|
||||
"max": 20.235,
|
||||
"avg": 15.179,
|
||||
"points": [
|
||||
["2026-10-28T00:00:00Z", 10.123],
|
||||
["2026-10-28T00:01:00Z", 20.235],
|
||||
["2026-10-28T00:02:00Z", None],
|
||||
],
|
||||
}
|
||||
assert memory["Max Memory Usage %"]["max"] == 50.333
|
||||
assert queue["Restarts"]["Restarts"]["unit"] == "count"
|
||||
assert queue["Queue wait time"]["Queue wait time"] == {
|
||||
"metric": "run_queue_wait_time",
|
||||
"unit": None,
|
||||
"latest": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"avg": None,
|
||||
"points": [],
|
||||
}
|
||||
assert result["sections"]["API"]["Latency"]["P95 Latency"]["unit"] is None
|
||||
|
||||
|
||||
def test_summary_and_requested_bounds(deployment_metrics_response):
|
||||
result = reshape(
|
||||
deployment_metrics_response,
|
||||
"id",
|
||||
start_time="2026-10-27T23:00:00Z",
|
||||
end_time="2026-10-28T01:00:00Z",
|
||||
summary=True,
|
||||
)
|
||||
assert result["start_time"] == "2026-10-27T23:00:00Z"
|
||||
assert result["end_time"] == "2026-10-28T01:00:00Z"
|
||||
for section in result["sections"].values():
|
||||
if section == {"_empty": True}:
|
||||
continue
|
||||
for chart in section.values():
|
||||
for series in chart.values():
|
||||
assert set(series) == {"metric", "unit", "latest", "min", "max", "avg"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("values", [[], [None], [float("nan"), float("inf")]])
|
||||
def test_empty_and_nonfinite_values(deployment_metrics_response, values):
|
||||
chart = deployment_metrics_response["sub_sections"][0]["charts"][0]
|
||||
chart["series"] = chart["series"][:1]
|
||||
chart["data"] = [
|
||||
{
|
||||
"series_id": chart["series"][0]["id"],
|
||||
"timestamp": f"2026-10-28T00:0{i}:00Z",
|
||||
"value": value,
|
||||
}
|
||||
for i, value in enumerate(values)
|
||||
]
|
||||
result = reshape(deployment_metrics_response, "id")
|
||||
series = result["sections"]["Queue"]["Memory Usage %"]["Avg Memory Usage %"]
|
||||
assert all(series[key] is None for key in ["latest", "min", "max", "avg"])
|
||||
assert all(point[1] is None for point in series["points"])
|
||||
|
||||
|
||||
def test_tied_spacing_and_top_level_charts(deployment_metrics_response):
|
||||
response = deployment_metrics_response
|
||||
chart = response["sub_sections"][1]["charts"][0]
|
||||
chart["data"][0]["timestamp"] = "2026-10-28T00:02:30Z"
|
||||
response["charts"] = [chart]
|
||||
response["sub_sections"] = []
|
||||
result = reshape(response, "id")
|
||||
assert result["step_seconds"] == 60
|
||||
assert "Latency" in result["sections"][response["title"]]
|
||||
|
||||
|
||||
def test_missing_point_preserves_common_spacing(deployment_metrics_response):
|
||||
chart = deployment_metrics_response["sub_sections"][0]["charts"][0]
|
||||
series_id = chart["series"][0]["id"]
|
||||
chart["data"] = [
|
||||
point
|
||||
for point in chart["data"]
|
||||
if not (point["series_id"] == series_id and "00:01:00" in point["timestamp"])
|
||||
]
|
||||
result = reshape(deployment_metrics_response, "id")
|
||||
assert result["step_seconds"] == 60
|
||||
assert type(result["step_seconds"]) is int
|
||||
|
||||
|
||||
def test_only_single_point_and_empty_series(deployment_metrics_response):
|
||||
for section in deployment_metrics_response["sub_sections"]:
|
||||
for chart in section["charts"]:
|
||||
chart["data"] = chart["data"][:1]
|
||||
assert reshape(deployment_metrics_response, "id")["step_seconds"] is None
|
||||
|
||||
|
||||
def test_fractional_spacing(deployment_metrics_response):
|
||||
chart = deployment_metrics_response["sub_sections"][1]["charts"][0]
|
||||
chart["data"][0]["timestamp"] = "2026-10-28T00:00:01Z"
|
||||
chart["data"][1]["timestamp"] = "2026-10-28T00:00:00.500Z"
|
||||
deployment_metrics_response["sub_sections"] = []
|
||||
deployment_metrics_response["charts"] = [chart]
|
||||
assert reshape(deployment_metrics_response, "id")["step_seconds"] == 0.5
|
||||
|
||||
|
||||
def test_no_data():
|
||||
assert reshape({"charts": [], "sub_sections": []}, "id") == {
|
||||
"deployment_id": "id",
|
||||
"start_time": "2026-10-28T00:00:00Z",
|
||||
"end_time": "2026-10-28T01:00:00Z",
|
||||
"step_seconds": None,
|
||||
"sections": {"_empty": True},
|
||||
}
|
||||
Generated
+1998
-1414
File diff suppressed because it is too large.
Load diff
@@ -74,6 +74,8 @@ CONFIG_KEY_RUNTIME = sys.intern("__pregel_runtime")
|
||||
# holds a `Runtime` instance with context, store, stream writer, etc.
|
||||
CONFIG_KEY_RESUME_MAP = sys.intern("__pregel_resume_map")
|
||||
# holds a mapping of task ns -> resume value for resuming tasks
|
||||
CONFIG_KEY_STRICT_RESUME = sys.intern("__pregel_strict_resume")
|
||||
# when True, the root graph refuses a resume that can't be matched to its pending interrupts
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2 = sys.intern("__pregel_stream_messages_v2")
|
||||
# when True, attach StreamMessagesHandlerV2 so content-block (v2) events
|
||||
# flow through stream_mode="messages"; set by StreamingHandler only.
|
||||
@@ -134,6 +136,7 @@ RESERVED = {
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_TIMED_ATTEMPT_OBSERVER,
|
||||
CONFIG_KEY_RESUME_MAP,
|
||||
CONFIG_KEY_STRICT_RESUME,
|
||||
CONFIG_KEY_STREAM_MESSAGES_V2,
|
||||
# other constants
|
||||
PUSH,
|
||||
|
||||
@@ -28,6 +28,7 @@ __all__ = (
|
||||
"ParentCommand",
|
||||
"EmptyInputError",
|
||||
"TaskNotFound",
|
||||
"UnknownInterruptError",
|
||||
"is_invalid_resume",
|
||||
)
|
||||
|
||||
@@ -146,6 +147,46 @@ class TaskNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownInterruptError(Exception):
|
||||
"""Raised in strict resume mode when a resume doesn't match the pending interrupts.
|
||||
|
||||
`reason` says how it doesn't match:
|
||||
|
||||
- `"not_pending"`: the resume names interrupts that aren't pending, listed in
|
||||
`interrupt_ids`.
|
||||
- `"empty_map"`: the resume is an empty map.
|
||||
- `"ambiguous"`: the resume is a single value while several interrupts are
|
||||
pending.
|
||||
|
||||
`pending_ids` lists the interrupts still waiting for an answer. The error is
|
||||
raised before anything is written, so they can still be answered.
|
||||
|
||||
A single value with no interrupt pending is accepted, because it resumes a
|
||||
graph paused at a static breakpoint (`interrupt_before` or `interrupt_after`).
|
||||
|
||||
Each `interrupt()` call in a task has its own id, so an answer retried after
|
||||
the task asked its next question is refused instead of answering that
|
||||
question.
|
||||
"""
|
||||
|
||||
reason: Literal["not_pending", "empty_map", "ambiguous"]
|
||||
interrupt_ids: tuple[str, ...]
|
||||
pending_ids: tuple[str, ...]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
reason: Literal["not_pending", "empty_map", "ambiguous"],
|
||||
interrupt_ids: Sequence[str] = (),
|
||||
pending_ids: Sequence[str] = (),
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.reason = reason
|
||||
self.interrupt_ids = tuple(interrupt_ids)
|
||||
self.pending_ids = tuple(pending_ids)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NodeError:
|
||||
"""Failure context passed to a node-level error handler.
|
||||
|
||||
@@ -85,6 +85,7 @@ from langgraph.types import (
|
||||
RetryPolicy,
|
||||
Send,
|
||||
TimeoutPolicy,
|
||||
_interrupt_id,
|
||||
)
|
||||
|
||||
GetNextVersion = Callable[[V | None, None], V]
|
||||
@@ -626,7 +627,7 @@ def prepare_single_task(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
task_checkpoint_ns,
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -873,7 +874,7 @@ def prepare_push_task_functional(
|
||||
configurable.get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
task_checkpoint_ns,
|
||||
configurable.get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -1035,7 +1036,7 @@ def prepare_push_task_send(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
task_checkpoint_ns,
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -1184,7 +1185,7 @@ def prepare_node_error_handler_task(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
task_checkpoint_ns,
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -1280,7 +1281,7 @@ def _scratchpad(
|
||||
parent_scratchpad: PregelScratchpad | None,
|
||||
pending_writes: list[PendingWrite],
|
||||
task_id: str,
|
||||
namespace_hash: str,
|
||||
task_checkpoint_ns: str,
|
||||
resume_map: dict[str, Any] | None,
|
||||
step: int,
|
||||
stop: int,
|
||||
@@ -1307,10 +1308,15 @@ def _scratchpad(
|
||||
task_resume_write = []
|
||||
del w
|
||||
|
||||
# find namespace and task-specific resume value
|
||||
if resume_map and namespace_hash in resume_map:
|
||||
mapped_resume_write = resume_map[namespace_hash]
|
||||
task_resume_write.append(mapped_resume_write)
|
||||
# find the resume value for the task's next interrupt
|
||||
if resume_map:
|
||||
next_id = _interrupt_id(task_checkpoint_ns, len(task_resume_write))
|
||||
first_id = _interrupt_id(task_checkpoint_ns, 0)
|
||||
if next_id in resume_map:
|
||||
task_resume_write.append(resume_map[next_id])
|
||||
# Older versions gave all of a task's interrupts the first id.
|
||||
elif first_id in resume_map:
|
||||
task_resume_write.append(resume_map[first_id])
|
||||
|
||||
else:
|
||||
null_resume_write = None
|
||||
|
||||
@@ -48,6 +48,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_STRICT_RESUME,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_THREAD_ID,
|
||||
ERROR,
|
||||
@@ -77,6 +78,7 @@ from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import (
|
||||
EmptyInputError,
|
||||
GraphInterrupt,
|
||||
UnknownInterruptError,
|
||||
)
|
||||
from langgraph.managed.base import (
|
||||
ManagedValueMapping,
|
||||
@@ -1024,17 +1026,45 @@ class PregelLoop:
|
||||
"Cannot use Command(resume=...) without checkpointer"
|
||||
)
|
||||
|
||||
strict = (
|
||||
self.config[CONF].get(CONFIG_KEY_STRICT_RESUME, False)
|
||||
# Nested calls inherit the parent's configurable.
|
||||
and not self.is_nested
|
||||
# A finished task can rerun and interrupt again on replay, while
|
||||
# its earlier output still makes its interrupt look answered.
|
||||
and not self._addressed_checkpoint_superseded
|
||||
)
|
||||
if resume_is_map := (
|
||||
isinstance(resume, dict)
|
||||
and all(is_xxh3_128_hexdigest(k) for k in resume)
|
||||
):
|
||||
if strict:
|
||||
pending = self._pending_interrupts()
|
||||
if not resume:
|
||||
raise UnknownInterruptError(
|
||||
"The resume map names no interrupt",
|
||||
reason="empty_map",
|
||||
pending_ids=sorted(pending),
|
||||
)
|
||||
if unknown := sorted(resume.keys() - pending):
|
||||
raise UnknownInterruptError(
|
||||
f"Unknown or already-consumed interrupt: {unknown[0]}",
|
||||
reason="not_pending",
|
||||
interrupt_ids=unknown,
|
||||
pending_ids=sorted(pending),
|
||||
)
|
||||
self.config[CONF][CONFIG_KEY_RESUME_MAP] = resume
|
||||
else:
|
||||
if len(self._pending_interrupts()) > 1:
|
||||
raise RuntimeError(
|
||||
if len(pending := self._pending_interrupts()) > 1:
|
||||
message = (
|
||||
"When there are multiple pending interrupts, you must specify the interrupt id when resuming. "
|
||||
"Docs: https://docs.langchain.com/oss/python/langgraph/add-human-in-the-loop#resume-multiple-interrupts-with-one-invocation."
|
||||
"Docs: https://docs.langchain.com/oss/python/langgraph/interrupts#handling-multiple-interrupts."
|
||||
)
|
||||
if strict:
|
||||
raise UnknownInterruptError(
|
||||
message, reason="ambiguous", pending_ids=sorted(pending)
|
||||
)
|
||||
raise RuntimeError(message)
|
||||
|
||||
writes: defaultdict[str, list[tuple[str, Any]]] = defaultdict(list)
|
||||
# group writes by task ID
|
||||
|
||||
@@ -28,6 +28,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_RESUMING,
|
||||
CONFIG_KEY_RUNTIME,
|
||||
CONFIG_KEY_SCRATCHPAD,
|
||||
CONFIG_KEY_SEND,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
@@ -44,6 +45,7 @@ from langgraph.errors import (
|
||||
NodeTimeoutError,
|
||||
ParentCommand,
|
||||
)
|
||||
from langgraph.pregel._algo import LazyAtomicCounter
|
||||
from langgraph.pregel.protocol import StreamProtocol
|
||||
from langgraph.runtime import ExecutionInfo, Runtime
|
||||
from langgraph.types import Command, PregelExecutableTask, RetryPolicy, TimeoutPolicy
|
||||
@@ -571,6 +573,23 @@ def _checkpoint_ns_for_parent_command(ns: str) -> str:
|
||||
return NS_SEP.join(parts)
|
||||
|
||||
|
||||
def _reset_interrupt_counter(config: RunnableConfig) -> RunnableConfig:
|
||||
# A retry reruns the node from its first line, so its interrupt() calls count
|
||||
# from the first again to reuse the answers its task already has, rather than
|
||||
# asking again under an id the next resume can't match.
|
||||
scratchpad = config[CONF].get(CONFIG_KEY_SCRATCHPAD)
|
||||
if scratchpad is None:
|
||||
return config
|
||||
return patch_configurable(
|
||||
config,
|
||||
{
|
||||
CONFIG_KEY_SCRATCHPAD: replace(
|
||||
scratchpad, interrupt_counter=LazyAtomicCounter()
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run_with_retry(
|
||||
task: PregelExecutableTask,
|
||||
retry_policy: Sequence[RetryPolicy] | None,
|
||||
@@ -681,6 +700,7 @@ def run_with_retry(
|
||||
)
|
||||
# signal subgraphs to resume (if available)
|
||||
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
|
||||
config = _reset_interrupt_counter(config)
|
||||
|
||||
|
||||
async def arun_with_retry(
|
||||
@@ -829,6 +849,7 @@ async def arun_with_retry(
|
||||
)
|
||||
# signal subgraphs to resume (if available)
|
||||
config = patch_configurable(config, {CONFIG_KEY_RESUMING: True})
|
||||
config = _reset_interrupt_counter(config)
|
||||
|
||||
|
||||
def _return_value(task: PregelExecutableTask) -> Any:
|
||||
|
||||
@@ -884,11 +884,23 @@ class Pregel(
|
||||
)
|
||||
|
||||
def _subgraph_for_namespace(
|
||||
self, config: RunnableConfig, checkpointer: BaseCheckpointSaver
|
||||
self,
|
||||
config: RunnableConfig,
|
||||
checkpointer: BaseCheckpointSaver,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> tuple[PregelProtocol, RunnableConfig] | None:
|
||||
"""The subgraph a state method's namespaced config addresses, and the
|
||||
config to call it with, lending it `checkpointer`. `None` when the
|
||||
config is for this graph."""
|
||||
config is for this graph, or names no subgraph this graph declares or
|
||||
detects (such as one a tool invokes), so a read uses this graph's
|
||||
channels.
|
||||
|
||||
Raises:
|
||||
ValueError: If `for_update` and the namespace names no such
|
||||
subgraph: this graph's nodes and channels would write the wrong
|
||||
state into it.
|
||||
"""
|
||||
checkpoint_ns = config[CONF].get(CONFIG_KEY_CHECKPOINT_NS, "")
|
||||
if not checkpoint_ns or CONFIG_KEY_CHECKPOINTER in config[CONF]:
|
||||
return None
|
||||
@@ -897,7 +909,9 @@ class Pregel(
|
||||
return pregel, patch_configurable(
|
||||
config, {CONFIG_KEY_CHECKPOINTER: checkpointer}
|
||||
)
|
||||
raise ValueError(f"Subgraph {recast} not found")
|
||||
if for_update:
|
||||
raise ValueError(f"Subgraph {recast} not found")
|
||||
return None
|
||||
|
||||
def _apply_checkpointer_allowlist(
|
||||
self, checkpointer: BaseCheckpointSaver | None
|
||||
@@ -1143,7 +1157,9 @@ class Pregel(
|
||||
"""Get the subgraphs of the graph.
|
||||
|
||||
Args:
|
||||
namespace: The namespace to filter the subgraphs by.
|
||||
namespace: Yield only the subgraph at this exact namespace, such
|
||||
as `parent|child` (without task IDs). A nested namespace needs
|
||||
`recurse=True`.
|
||||
recurse: Whether to recurse into the subgraphs.
|
||||
If `False`, only the immediate subgraphs will be returned.
|
||||
|
||||
@@ -1151,9 +1167,9 @@ class Pregel(
|
||||
An iterator of the `(namespace, subgraph)` pairs.
|
||||
"""
|
||||
for name, node in self.nodes.items():
|
||||
# filter by prefix
|
||||
# filter by the namespace's first segment, matched whole
|
||||
if namespace is not None:
|
||||
if not namespace.startswith(name):
|
||||
if namespace != name and not namespace.startswith(name + NS_SEP):
|
||||
continue
|
||||
|
||||
# find the subgraph, if any
|
||||
@@ -1167,12 +1183,13 @@ class Pregel(
|
||||
if namespace is None:
|
||||
yield name, graph
|
||||
if recurse and isinstance(graph, Pregel):
|
||||
if namespace is not None:
|
||||
namespace = namespace[len(name) + 1 :]
|
||||
child_namespace = (
|
||||
None if namespace is None else namespace[len(name) + 1 :]
|
||||
)
|
||||
yield from (
|
||||
(f"{name}{NS_SEP}{n}", s)
|
||||
for n, s in graph.get_subgraphs(
|
||||
namespace=namespace, recurse=recurse
|
||||
namespace=child_namespace, recurse=recurse
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1182,7 +1199,9 @@ class Pregel(
|
||||
"""Get the subgraphs of the graph.
|
||||
|
||||
Args:
|
||||
namespace: The namespace to filter the subgraphs by.
|
||||
namespace: Yield only the subgraph at this exact namespace, such
|
||||
as `parent|child` (without task IDs). A nested namespace needs
|
||||
`recurse=True`.
|
||||
recurse: Whether to recurse into the subgraphs.
|
||||
If `False`, only the immediate subgraphs will be returned.
|
||||
|
||||
@@ -1641,7 +1660,9 @@ class Pregel(
|
||||
if any(len(u) == 0 for u in supersteps):
|
||||
raise ValueError("No updates provided")
|
||||
|
||||
if subgraph := self._subgraph_for_namespace(config, checkpointer):
|
||||
if subgraph := self._subgraph_for_namespace(
|
||||
config, checkpointer, for_update=True
|
||||
):
|
||||
pregel, subgraph_config = subgraph
|
||||
return pregel.bulk_update_state(subgraph_config, supersteps)
|
||||
|
||||
@@ -2142,7 +2163,9 @@ class Pregel(
|
||||
if any(len(u) == 0 for u in supersteps):
|
||||
raise ValueError("No updates provided")
|
||||
|
||||
if subgraph := self._subgraph_for_namespace(config, checkpointer):
|
||||
if subgraph := self._subgraph_for_namespace(
|
||||
config, checkpointer, for_update=True
|
||||
):
|
||||
pregel, subgraph_config = subgraph
|
||||
return await pregel.abulk_update_state(subgraph_config, supersteps)
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from langgraph._internal._constants import (
|
||||
CONFIG_KEY_CHECKPOINT_MAP,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_STREAM,
|
||||
CONFIG_KEY_STRICT_RESUME,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
INTERRUPT,
|
||||
NS_SEP,
|
||||
@@ -81,6 +82,7 @@ _CONF_DROPLIST = frozenset(
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_STRICT_RESUME,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from xxhash import xxh3_128_hexdigest
|
||||
|
||||
from langgraph._internal._cache import default_cache_key
|
||||
from langgraph._internal._constants import INTERRUPT as _INTERRUPT_KEY
|
||||
from langgraph._internal._constants import NS_SEP
|
||||
from langgraph._internal._fields import get_cached_annotated_keys, get_update_as_tuples
|
||||
from langgraph._internal._retry import default_retry_on
|
||||
from langgraph._internal._typing import MISSING, DeprecatedKwargs
|
||||
@@ -579,6 +580,15 @@ def omit_payload(_value: Any) -> dict[str, Any]:
|
||||
_DEFAULT_INTERRUPT_ID = "placeholder-id"
|
||||
|
||||
|
||||
def _interrupt_id(ns: str, answered: int) -> str:
|
||||
# Keyed by how many answers the task already has, the slot this interrupt's
|
||||
# answer fills; call order isn't stable across parallel calls in one task.
|
||||
# With none it's the bare namespace hash older versions gave every
|
||||
# interrupt, so saved ids still match. The suffix can't form another task's
|
||||
# namespace, whose segments all contain ":".
|
||||
return xxh3_128_hexdigest((f"{ns}{NS_SEP}{answered}" if answered else ns).encode())
|
||||
|
||||
|
||||
@final
|
||||
@dataclass(init=False, slots=True)
|
||||
class Interrupt(Generic[ResponseT]):
|
||||
@@ -641,7 +651,7 @@ class Interrupt(Generic[ResponseT]):
|
||||
) -> Interrupt[ResponseT]:
|
||||
return cls(
|
||||
value=value,
|
||||
id=xxh3_128_hexdigest(ns.encode()),
|
||||
id=_interrupt_id(ns, 0),
|
||||
response_schema=response_schema,
|
||||
)
|
||||
|
||||
@@ -1037,9 +1047,11 @@ def interrupt(
|
||||
# no resume value found
|
||||
raise GraphInterrupt(
|
||||
(
|
||||
Interrupt.from_ns(
|
||||
Interrupt(
|
||||
value=value,
|
||||
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
|
||||
id=_interrupt_id(
|
||||
conf[CONFIG_KEY_CHECKPOINT_NS], len(scratchpad.resume)
|
||||
),
|
||||
response_schema=adapter.json_schema() if adapter else response_schema,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.2.19"
|
||||
version = "1.2.20"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -16,7 +16,7 @@ from langgraph_sdk.schema import StreamPart
|
||||
from pydantic import BaseModel
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph._internal._constants import CONFIG_KEY_STREAM
|
||||
from langgraph._internal._constants import CONFIG_KEY_STREAM, CONFIG_KEY_STRICT_RESUME
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
|
||||
from langgraph.pregel import Pregel
|
||||
@@ -1360,6 +1360,7 @@ def test_sanitize_config():
|
||||
"checkpoint_map": {"key": "value"}, # Should be dropped
|
||||
"checkpoint_id": "123", # Should be dropped
|
||||
"checkpoint_ns": "ns", # Should be dropped
|
||||
CONFIG_KEY_STRICT_RESUME: True, # Should be dropped
|
||||
}
|
||||
}
|
||||
sanitized = remote._sanitize_config(config_with_drops)
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
import operator
|
||||
import sys
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
import pytest
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_core.runnables.config import get_executor_for_config
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from typing_extensions import TypedDict
|
||||
from xxhash import xxh3_128_hexdigest
|
||||
|
||||
from langgraph._internal._constants import CONFIG_KEY_STRICT_RESUME, INTERRUPT
|
||||
from langgraph.errors import UnknownInterruptError
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
from langgraph.types import Command, Durability, Interrupt, RetryPolicy, interrupt
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
NEEDS_CONTEXTVARS = pytest.mark.skipif(
|
||||
sys.version_info < (3, 11),
|
||||
reason="Python 3.11+ is required for async contextvars support",
|
||||
)
|
||||
|
||||
UNKNOWN_ID = "0" * 32
|
||||
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
log: Annotated[list[str], operator.add]
|
||||
|
||||
|
||||
def _config(*, strict: bool = True) -> RunnableConfig:
|
||||
return {
|
||||
"configurable": {
|
||||
"thread_id": str(uuid.uuid4()),
|
||||
CONFIG_KEY_STRICT_RESUME: strict,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _parallel(checkpointer: BaseCheckpointSaver) -> CompiledStateGraph:
|
||||
def a(state: State):
|
||||
return {"log": [f"a:{interrupt('a?')}"]}
|
||||
|
||||
def b(state: State):
|
||||
return {"log": [f"b:{interrupt('b?')}"]}
|
||||
|
||||
return (
|
||||
StateGraph(State)
|
||||
.add_node("a", a)
|
||||
.add_node("b", b)
|
||||
.add_edge(START, "a")
|
||||
.add_edge(START, "b")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _ask_twice(checkpointer: BaseCheckpointSaver) -> CompiledStateGraph:
|
||||
def ask_twice(state: State):
|
||||
first = interrupt("first?")
|
||||
second = interrupt("second?")
|
||||
return {"log": [f"{first},{second}"]}
|
||||
|
||||
return (
|
||||
StateGraph(State)
|
||||
.add_node("ask", ask_twice)
|
||||
.add_edge(START, "ask")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _ask_then_fail_once(checkpointer: BaseCheckpointSaver) -> CompiledStateGraph:
|
||||
failures = [ConnectionError("flaky api")]
|
||||
|
||||
def ask(state: State):
|
||||
answer = interrupt("delete a.txt?")
|
||||
if failures:
|
||||
raise failures.pop()
|
||||
return {"log": [answer]}
|
||||
|
||||
return (
|
||||
StateGraph(State)
|
||||
.add_node(
|
||||
"ask", ask, retry_policy=RetryPolicy(initial_interval=0, jitter=False)
|
||||
)
|
||||
.add_edge(START, "ask")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
|
||||
|
||||
def _pending_ids(graph: CompiledStateGraph, config: RunnableConfig) -> dict[str, str]:
|
||||
return {i.value: i.id for i in graph.get_state(config).interrupts}
|
||||
|
||||
|
||||
def test_unknown_id_is_refused_before_anything_is_written(
|
||||
sync_checkpointer: BaseCheckpointSaver, durability: Durability
|
||||
) -> None:
|
||||
graph = _parallel(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config, durability=durability)
|
||||
before = graph.get_state(config)
|
||||
checkpoints = len(list(sync_checkpointer.list(config)))
|
||||
ids = _pending_ids(graph, config)
|
||||
|
||||
with pytest.raises(
|
||||
UnknownInterruptError,
|
||||
match=f"Unknown or already-consumed interrupt: {UNKNOWN_ID}",
|
||||
) as refused:
|
||||
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), config, durability=durability)
|
||||
|
||||
assert (
|
||||
refused.value.reason,
|
||||
refused.value.interrupt_ids,
|
||||
refused.value.pending_ids,
|
||||
) == ("not_pending", (UNKNOWN_ID,), tuple(sorted(ids.values())))
|
||||
after = graph.get_state(config)
|
||||
assert len(list(sync_checkpointer.list(config))) == checkpoints
|
||||
assert (after.values, after.next, after.interrupts) == (
|
||||
before.values,
|
||||
before.next,
|
||||
before.interrupts,
|
||||
)
|
||||
result = graph.invoke(
|
||||
Command(resume={ids["a?"]: "yes", ids["b?"]: "no"}),
|
||||
config,
|
||||
durability=durability,
|
||||
)
|
||||
assert sorted(result["log"]) == ["a:yes", "b:no"]
|
||||
|
||||
|
||||
def test_an_answered_id_is_refused(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
graph = _parallel(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
ids = _pending_ids(graph, config)
|
||||
graph.invoke(Command(resume={ids["a?"]: "yes"}), config)
|
||||
|
||||
with pytest.raises(UnknownInterruptError, match=ids["a?"]) as refused:
|
||||
graph.invoke(Command(resume={ids["a?"]: "again"}), config)
|
||||
|
||||
assert (
|
||||
refused.value.reason,
|
||||
refused.value.interrupt_ids,
|
||||
refused.value.pending_ids,
|
||||
) == ("not_pending", (ids["a?"],), (ids["b?"],))
|
||||
|
||||
result = graph.invoke(Command(resume={ids["b?"]: "no"}), config)
|
||||
assert sorted(result["log"]) == ["a:yes", "b:no"]
|
||||
|
||||
|
||||
def test_an_empty_map_is_refused(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
graph = _parallel(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
|
||||
with pytest.raises(UnknownInterruptError, match="names no interrupt") as refused:
|
||||
graph.invoke(Command(resume={}), config)
|
||||
|
||||
assert (
|
||||
refused.value.reason,
|
||||
refused.value.interrupt_ids,
|
||||
refused.value.pending_ids,
|
||||
) == ("empty_map", (), tuple(sorted(_pending_ids(graph, config).values())))
|
||||
|
||||
|
||||
def test_one_value_is_refused_while_several_are_pending(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _parallel(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
|
||||
with pytest.raises(
|
||||
UnknownInterruptError, match="multiple pending interrupts"
|
||||
) as refused:
|
||||
graph.invoke(Command(resume="yes"), config)
|
||||
|
||||
ids = _pending_ids(graph, config)
|
||||
assert (refused.value.reason, refused.value.pending_ids) == (
|
||||
"ambiguous",
|
||||
tuple(sorted(ids.values())),
|
||||
)
|
||||
graph.invoke(Command(resume={ids["a?"]: "yes"}), config)
|
||||
result = graph.invoke(Command(resume="no"), config)
|
||||
assert sorted(result["log"]) == ["a:yes", "b:no"]
|
||||
|
||||
|
||||
def test_one_value_resumes_a_static_breakpoint(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("a", lambda state: {"log": ["a"]})
|
||||
.add_edge(START, "a")
|
||||
.compile(checkpointer=sync_checkpointer, interrupt_before=["a"])
|
||||
)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
|
||||
with pytest.raises(UnknownInterruptError, match=UNKNOWN_ID):
|
||||
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), config)
|
||||
|
||||
assert graph.invoke(Command(resume="go"), config) == {"log": ["a"]}
|
||||
|
||||
|
||||
def test_without_strict_mode_nothing_changes(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _parallel(sync_checkpointer)
|
||||
config = _config(strict=False)
|
||||
graph.invoke({"log": []}, config)
|
||||
|
||||
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), config)
|
||||
assert set(_pending_ids(graph, config)) == {"a?", "b?"}
|
||||
with pytest.raises(RuntimeError, match="multiple pending interrupts"):
|
||||
graph.invoke(Command(resume="yes"), config)
|
||||
|
||||
|
||||
def test_a_subgraph_interrupt_is_answered_by_its_id(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
def ask(state: State):
|
||||
return {"log": [f"asked:{interrupt('sub?')}"]}
|
||||
|
||||
subgraph = StateGraph(State).add_node("ask", ask).add_edge(START, "ask").compile()
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("sub", subgraph)
|
||||
.add_edge(START, "sub")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
|
||||
result = graph.invoke(
|
||||
Command(resume={_pending_ids(graph, config)["sub?"]: "yes"}), config
|
||||
)
|
||||
assert result["log"] == ["asked:yes"]
|
||||
|
||||
|
||||
def test_a_second_interrupt_in_one_node_gets_its_own_id(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _ask_twice(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
state = graph.get_state(config)
|
||||
(card,) = state.interrupts
|
||||
assert card.id == xxh3_128_hexdigest(f"ask:{state.tasks[0].id}".encode())
|
||||
graph.invoke(Command(resume={card.id: "1"}), config)
|
||||
(second,) = graph.get_state(config).interrupts
|
||||
assert second.id != card.id
|
||||
|
||||
result = graph.invoke(Command(resume={second.id: "2"}), config)
|
||||
assert result["log"] == ["1,2"]
|
||||
|
||||
|
||||
def test_a_retried_answer_in_one_node_is_refused(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _ask_twice(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
(card,) = graph.get_state(config).interrupts
|
||||
graph.invoke(Command(resume={card.id: "yes"}), config)
|
||||
(second,) = graph.get_state(config).interrupts
|
||||
|
||||
with pytest.raises(UnknownInterruptError) as refused:
|
||||
graph.invoke(Command(resume={card.id: "yes"}), config)
|
||||
|
||||
assert (
|
||||
refused.value.reason,
|
||||
refused.value.interrupt_ids,
|
||||
refused.value.pending_ids,
|
||||
) == ("not_pending", (card.id,), (second.id,))
|
||||
result = graph.invoke(Command(resume={second.id: "no"}), config)
|
||||
assert result["log"] == ["yes,no"]
|
||||
|
||||
|
||||
def test_parallel_interrupts_in_one_node_each_take_an_answer(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
def ask(name: str) -> str:
|
||||
return interrupt(f"{name}?")
|
||||
|
||||
def ask_in_parallel(state: State, config: RunnableConfig):
|
||||
with get_executor_for_config(config) as pool:
|
||||
b = pool.submit(ask, "b")
|
||||
b.exception()
|
||||
a = pool.submit(ask, "a")
|
||||
return {"log": [f"{a.result()},{b.result()}"]}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("ask", ask_in_parallel)
|
||||
.add_edge(START, "ask")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
for answer in ("1", "2"):
|
||||
(card,) = graph.get_state(config).interrupts
|
||||
graph.invoke(Command(resume={card.id: answer}), config)
|
||||
|
||||
assert graph.get_state(config).next == ()
|
||||
|
||||
|
||||
def test_a_later_interrupt_saved_with_the_first_id_is_still_answered(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _ask_twice(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
(card,) = graph.get_state(config).interrupts
|
||||
graph.invoke(Command(resume={card.id: "1"}), config)
|
||||
state = graph.get_state(config)
|
||||
sync_checkpointer.put_writes(
|
||||
state.config,
|
||||
[(INTERRUPT, (Interrupt(value="second?", id=card.id),))],
|
||||
state.tasks[0].id,
|
||||
)
|
||||
|
||||
result = graph.invoke(Command(resume={card.id: "2"}), config)
|
||||
assert result["log"] == ["1,2"]
|
||||
|
||||
|
||||
def test_a_retried_node_reuses_its_answer(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _ask_then_fail_once(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
(card,) = graph.get_state(config).interrupts
|
||||
|
||||
result = graph.invoke(Command(resume={card.id: "yes"}), config)
|
||||
assert result["log"] == ["yes"]
|
||||
|
||||
|
||||
def test_a_card_whose_resumed_task_raised_is_not_refused(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
failures = ["once"]
|
||||
|
||||
def ask_then_fail_once(state: State):
|
||||
answer = interrupt("approve?")
|
||||
if failures:
|
||||
failures.pop()
|
||||
raise ValueError("failed after the answer")
|
||||
return {"log": [f"answered:{answer}"]}
|
||||
|
||||
graph = (
|
||||
StateGraph(State)
|
||||
.add_node("ask", ask_then_fail_once)
|
||||
.add_edge(START, "ask")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
(card,) = graph.get_state(config).interrupts
|
||||
with pytest.raises(ValueError, match="failed after the answer"):
|
||||
graph.invoke(Command(resume={card.id: "first"}), config)
|
||||
|
||||
graph.invoke(Command(resume={card.id: "second"}), config)
|
||||
assert graph.get_state(config).next == ()
|
||||
|
||||
|
||||
def test_a_pinned_head_checkpoint_is_checked(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _parallel(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
head = graph.get_state(config).config
|
||||
pinned = {"configurable": {**config["configurable"], **head["configurable"]}}
|
||||
|
||||
with pytest.raises(UnknownInterruptError):
|
||||
graph.invoke(Command(resume={UNKNOWN_ID: "x"}), pinned)
|
||||
|
||||
ids = _pending_ids(graph, config)
|
||||
result = graph.invoke(Command(resume={ids["a?"]: "yes", ids["b?"]: "no"}), pinned)
|
||||
assert sorted(result["log"]) == ["a:yes", "b:no"]
|
||||
|
||||
|
||||
def test_a_superseded_checkpoint_is_not_checked(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _parallel(sync_checkpointer)
|
||||
config = _config()
|
||||
graph.invoke({"log": []}, config)
|
||||
paused = graph.get_state(config).config
|
||||
ids = _pending_ids(graph, config)
|
||||
graph.invoke(Command(resume={ids["a?"]: "yes", ids["b?"]: "no"}), config)
|
||||
|
||||
pinned = {"configurable": {**config["configurable"], **paused["configurable"]}}
|
||||
graph.invoke(Command(resume={ids["a?"]: "again", ids["b?"]: "again"}), pinned)
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_unknown_id_is_refused_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _parallel(async_checkpointer)
|
||||
config = _config()
|
||||
await graph.ainvoke({"log": []}, config)
|
||||
|
||||
with pytest.raises(UnknownInterruptError, match=UNKNOWN_ID):
|
||||
await graph.ainvoke(Command(resume={UNKNOWN_ID: "x"}), config)
|
||||
|
||||
state = await graph.aget_state(config)
|
||||
assert {i.value for i in state.interrupts} == {"a?", "b?"}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_a_retried_node_reuses_its_answer_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
graph = _ask_then_fail_once(async_checkpointer)
|
||||
config = _config()
|
||||
await graph.ainvoke({"log": []}, config)
|
||||
(card,) = (await graph.aget_state(config)).interrupts
|
||||
|
||||
result = await graph.ainvoke(Command(resume={card.id: "yes"}), config)
|
||||
assert result["log"] == ["yes"]
|
||||
@@ -6,16 +6,24 @@ Covers three checkpointer settings for subgraph state:
|
||||
interrupt support, but state resets each invocation. This is the common case
|
||||
when an agent is invoked from inside a tool used by another agent.
|
||||
- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
|
||||
|
||||
Also covers reading a subgraph's namespace from the parent graph, including
|
||||
one where no subgraph is declared, such as a subagent a tool invokes, and
|
||||
finding the subgraph a namespace names.
|
||||
"""
|
||||
|
||||
from typing import Annotated, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import MessagesState
|
||||
from langgraph.graph.message import MessagesState, _messages_delta_reducer
|
||||
from langgraph.types import Command, Interrupt, interrupt
|
||||
from tests.any_str import AnyStr
|
||||
|
||||
@@ -685,3 +693,301 @@ def test_child_with_own_thread_id_keeps_namespace(
|
||||
parent.invoke({"result": ""}, config=parent_config)
|
||||
state2 = child.get_state(child_config)
|
||||
assert state2.values.get("count") == 2
|
||||
|
||||
|
||||
# -- reading a subgraph's namespace from the parent graph --
|
||||
|
||||
|
||||
class NotesState(MessagesState):
|
||||
notes: str # a key only the subgraphs have
|
||||
|
||||
|
||||
class DeltaMessagesState(TypedDict):
|
||||
# Stores `messages` as deltas between snapshots, as deepagents' parent does.
|
||||
messages: Annotated[list[AnyMessage], DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
|
||||
class DeltaNotesState(DeltaMessagesState):
|
||||
notes: str
|
||||
|
||||
|
||||
def _notes_graph(label: str, state_schema: type = NotesState) -> Any:
|
||||
# Typed `dict` so it fits either schema (see `_run_tool_subgraph`).
|
||||
def work(state: dict) -> dict:
|
||||
return {"messages": [AIMessage(content=f"{label} done")], "notes": label}
|
||||
|
||||
return (
|
||||
StateGraph(state_schema)
|
||||
.add_node("work", work)
|
||||
.add_edge(START, "work")
|
||||
.compile()
|
||||
)
|
||||
|
||||
|
||||
def _namespace_configs(parent: Any) -> dict[str, RunnableConfig]:
|
||||
"""Run `parent` on a new thread. Returns a config for each subgraph
|
||||
namespace it wrote, keyed by the node the namespace starts with."""
|
||||
thread_id = str(uuid4())
|
||||
return {
|
||||
ns[0].split(":")[0]: {
|
||||
"configurable": {"thread_id": thread_id, "checkpoint_ns": ns[0]}
|
||||
}
|
||||
for ns, _ in parent.stream(
|
||||
{"messages": [HumanMessage(content="go")]},
|
||||
{"configurable": {"thread_id": thread_id}},
|
||||
subgraphs=True,
|
||||
)
|
||||
if ns
|
||||
}
|
||||
|
||||
|
||||
def _run_tool_subgraph(
|
||||
checkpointer: BaseCheckpointSaver,
|
||||
*,
|
||||
parent_schema: type = MessagesState,
|
||||
subagent_schema: type = NotesState,
|
||||
) -> tuple[Any, RunnableConfig]:
|
||||
"""Run a parent whose `tools` node invokes a subgraph it doesn't declare,
|
||||
as deepagents runs subagents. Returns the parent and a config for the
|
||||
subgraph's `tools:<task_id>` namespace."""
|
||||
# Subgraph detection doesn't follow a subscript, so `tools` declares none.
|
||||
subagents = {"researcher": _notes_graph("researcher", subagent_schema)}
|
||||
|
||||
# Typed `dict`: a schema here would add its `messages` channel to the
|
||||
# parent's, and a plain one clashes with a `DeltaChannel` one.
|
||||
def tools(state: dict) -> dict:
|
||||
subagents["researcher"].invoke(
|
||||
{"messages": [HumanMessage(content="research apples")]}
|
||||
)
|
||||
return {"messages": [AIMessage(content="tool done")]}
|
||||
|
||||
parent = (
|
||||
StateGraph(parent_schema)
|
||||
.add_node("tools", tools)
|
||||
.add_edge(START, "tools")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
return parent, _namespace_configs(parent)["tools"]
|
||||
|
||||
|
||||
def test_undeclared_subgraph_get_state(sync_checkpointer: BaseCheckpointSaver) -> None:
|
||||
"""A namespace with no declared subgraph is read with the parent graph."""
|
||||
parent, ns_config = _run_tool_subgraph(sync_checkpointer)
|
||||
|
||||
state = parent.get_state(ns_config)
|
||||
|
||||
assert [m.text for m in state.values["messages"]] == [
|
||||
"research apples",
|
||||
"researcher done",
|
||||
]
|
||||
assert state.config["configurable"]["checkpoint_ns"] == AnyStr("tools:")
|
||||
|
||||
|
||||
def test_undeclared_subgraph_get_state_history(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""History of a namespace with no declared subgraph is read with the parent
|
||||
graph, and only holds that namespace's checkpoints."""
|
||||
parent, ns_config = _run_tool_subgraph(sync_checkpointer)
|
||||
|
||||
history = list(parent.get_state_history(ns_config))
|
||||
|
||||
assert [[m.text for m in s.values.get("messages", [])] for s in history] == [
|
||||
["research apples", "researcher done"],
|
||||
["research apples"],
|
||||
[],
|
||||
]
|
||||
assert {s.config["configurable"]["checkpoint_ns"] for s in history} == {
|
||||
ns_config["configurable"]["checkpoint_ns"]
|
||||
}
|
||||
|
||||
|
||||
def test_undeclared_subgraph_update_state_raises(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Unlike a read, an update can't fall back to the parent graph: its nodes
|
||||
and channels would write the wrong state into the subgraph's namespace."""
|
||||
parent, ns_config = _run_tool_subgraph(sync_checkpointer)
|
||||
|
||||
with pytest.raises(ValueError, match="Subgraph tools not found"):
|
||||
parent.update_state(ns_config, {"messages": [AIMessage(content="edit")]})
|
||||
|
||||
assert len(list(parent.get_state_history(ns_config))) == 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("parent_schema", "subagent_schema"),
|
||||
[(DeltaMessagesState, NotesState), (MessagesState, DeltaNotesState)],
|
||||
ids=["delta_parent", "delta_subagent"],
|
||||
)
|
||||
def test_undeclared_subgraph_read_across_messages_channel_types(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
parent_schema: type,
|
||||
subagent_schema: type,
|
||||
) -> None:
|
||||
"""deepagents' parent stores `messages` in a `DeltaChannel`, and its
|
||||
subagents in a plain `add_messages` channel. Reading a subagent's namespace
|
||||
with the parent's channel restores its messages, either way round."""
|
||||
parent, ns_config = _run_tool_subgraph(
|
||||
sync_checkpointer,
|
||||
parent_schema=parent_schema,
|
||||
subagent_schema=subagent_schema,
|
||||
)
|
||||
|
||||
state = parent.get_state(ns_config)
|
||||
history = list(parent.get_state_history(ns_config))
|
||||
|
||||
assert [m.text for m in state.values["messages"]] == [
|
||||
"research apples",
|
||||
"researcher done",
|
||||
]
|
||||
assert [[m.text for m in s.values.get("messages", [])] for s in history] == [
|
||||
["research apples", "researcher done"],
|
||||
["research apples"],
|
||||
[],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("review_declared", [False, True])
|
||||
def test_prefix_named_namespaces_resolve_to_their_own_graph(
|
||||
sync_checkpointer: BaseCheckpointSaver, review_declared: bool
|
||||
) -> None:
|
||||
"""`research_review` starts with `research`, whose subgraph has a `review`
|
||||
node with its own subgraph. Each namespace still reads with its own graph:
|
||||
a declared subgraph's channels, or the parent's when none is declared."""
|
||||
research = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("review", _notes_graph("research"))
|
||||
.add_edge(START, "review")
|
||||
.compile()
|
||||
)
|
||||
|
||||
class ReviewState(NotesState):
|
||||
verdict: str # a key `research|review`'s graph doesn't have
|
||||
|
||||
def review(state: ReviewState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content="review done")],
|
||||
"notes": "review",
|
||||
"verdict": "approved",
|
||||
}
|
||||
|
||||
# Subgraph detection doesn't follow a subscript, so `call_review` declares
|
||||
# none.
|
||||
subgraphs = {
|
||||
"review": StateGraph(ReviewState)
|
||||
.add_node("work", review)
|
||||
.add_edge(START, "work")
|
||||
.compile()
|
||||
}
|
||||
|
||||
def call_review(state: MessagesState) -> dict:
|
||||
subgraphs["review"].invoke({"messages": [HumanMessage(content="check it")]})
|
||||
return {}
|
||||
|
||||
parent = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("research", research)
|
||||
.add_node(
|
||||
"research_review",
|
||||
subgraphs["review"] if review_declared else call_review,
|
||||
)
|
||||
.add_edge(START, "research")
|
||||
.add_edge("research", "research_review")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
configs = _namespace_configs(parent)
|
||||
|
||||
def values(name: str) -> dict:
|
||||
return {
|
||||
k: [m.text for m in v] if k == "messages" else v
|
||||
for k, v in parent.get_state(configs[name]).values.items()
|
||||
}
|
||||
|
||||
assert values("research") == {
|
||||
"messages": ["go", "research done"],
|
||||
"notes": "research",
|
||||
}
|
||||
if review_declared:
|
||||
assert values("research_review") == {
|
||||
"messages": ["go", "research done", "review done"],
|
||||
"notes": "review",
|
||||
"verdict": "approved",
|
||||
}
|
||||
else:
|
||||
# The parent's channels have no `notes` or `verdict`.
|
||||
assert values("research_review") == {"messages": ["check it", "review done"]}
|
||||
|
||||
|
||||
def _prefix_named_subgraphs() -> tuple[Any, dict[str, Any]]:
|
||||
"""A parent whose subgraph namespaces start with one another's names:
|
||||
`research` has a `review` subgraph, and the parent declares
|
||||
`research_review` and `review` too. Returns the parent and each subgraph,
|
||||
keyed by its namespace."""
|
||||
graphs = {
|
||||
"research|review": _notes_graph("research's review"),
|
||||
"research_review": _notes_graph("research_review"),
|
||||
"review": _notes_graph("review"),
|
||||
}
|
||||
graphs["research"] = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("review", graphs["research|review"])
|
||||
.add_edge(START, "review")
|
||||
.compile()
|
||||
)
|
||||
parent = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("research", graphs["research"])
|
||||
.add_node("research_review", graphs["research_review"])
|
||||
.add_node("review", graphs["review"])
|
||||
.add_edge(START, "research")
|
||||
.add_edge("research", "research_review")
|
||||
.add_edge("research_review", "review")
|
||||
.compile()
|
||||
)
|
||||
return parent, graphs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"namespace", ["research", "research|review", "research_review", "review"]
|
||||
)
|
||||
def test_get_subgraphs_matches_whole_namespace_segments(namespace: str) -> None:
|
||||
"""`get_subgraphs(namespace=...)` yields only the subgraph at that
|
||||
namespace. `research_review` isn't `research` then `review`, and
|
||||
`research|review` doesn't also find the parent's own `review`."""
|
||||
parent, graphs = _prefix_named_subgraphs()
|
||||
|
||||
assert list(parent.get_subgraphs(namespace=namespace, recurse=True)) == [
|
||||
(namespace, graphs[namespace])
|
||||
]
|
||||
|
||||
|
||||
def test_nested_namespace_update_raises_despite_top_level_namesake(
|
||||
sync_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""`research|review` names a `review` inside `research`'s subgraph, which
|
||||
declares no subgraph there. An update to it raises rather than writing
|
||||
through the parent's own `review`."""
|
||||
research = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("review", lambda state: {})
|
||||
.add_edge(START, "review")
|
||||
.compile()
|
||||
)
|
||||
parent = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("research", research)
|
||||
.add_node("review", _notes_graph("review"))
|
||||
.add_edge(START, "research")
|
||||
.add_edge("research", "review")
|
||||
.compile(checkpointer=sync_checkpointer)
|
||||
)
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": str(uuid4()),
|
||||
"checkpoint_ns": "research:a|review:b",
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match=r"Subgraph research\|review not found"):
|
||||
parent.update_state(config, {"notes": "edit"})
|
||||
@@ -6,18 +6,25 @@ Covers three checkpointer settings for subgraph state:
|
||||
interrupt support, but state resets each invocation. This is the common case
|
||||
when an agent is invoked from inside a tool used by another agent.
|
||||
- checkpointer=True: "stateful" — state accumulates across invocations on the same thread id
|
||||
|
||||
Also covers reading a subgraph's namespace from the parent graph, including
|
||||
one where no subgraph is declared, such as a subagent a tool invokes, and
|
||||
finding the subgraph a namespace names.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Annotated, Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from langgraph.channels.delta import DeltaChannel
|
||||
from langgraph.graph import START, StateGraph
|
||||
from langgraph.graph.message import MessagesState
|
||||
from langgraph.graph.message import MessagesState, _messages_delta_reducer
|
||||
from langgraph.types import Command, Interrupt, interrupt
|
||||
from tests.any_str import AnyStr
|
||||
|
||||
@@ -707,3 +714,315 @@ async def test_child_with_own_thread_id_keeps_namespace_async(
|
||||
await parent.ainvoke({"result": ""}, config=parent_config)
|
||||
state2 = await child.aget_state(child_config)
|
||||
assert state2.values.get("count") == 2
|
||||
|
||||
|
||||
# -- reading a subgraph's namespace from the parent graph --
|
||||
|
||||
|
||||
class NotesState(MessagesState):
|
||||
notes: str # a key only the subgraphs have
|
||||
|
||||
|
||||
class DeltaMessagesState(TypedDict):
|
||||
# Stores `messages` as deltas between snapshots, as deepagents' parent does.
|
||||
messages: Annotated[list[AnyMessage], DeltaChannel(_messages_delta_reducer)]
|
||||
|
||||
|
||||
class DeltaNotesState(DeltaMessagesState):
|
||||
notes: str
|
||||
|
||||
|
||||
def _notes_graph(label: str, state_schema: type = NotesState) -> Any:
|
||||
# Typed `dict` so it fits either schema (see `_run_tool_subgraph`).
|
||||
def work(state: dict) -> dict:
|
||||
return {"messages": [AIMessage(content=f"{label} done")], "notes": label}
|
||||
|
||||
return (
|
||||
StateGraph(state_schema)
|
||||
.add_node("work", work)
|
||||
.add_edge(START, "work")
|
||||
.compile()
|
||||
)
|
||||
|
||||
|
||||
async def _namespace_configs(parent: Any) -> dict[str, RunnableConfig]:
|
||||
"""Run `parent` on a new thread. Returns a config for each subgraph
|
||||
namespace it wrote, keyed by the node the namespace starts with."""
|
||||
thread_id = str(uuid4())
|
||||
return {
|
||||
ns[0].split(":")[0]: {
|
||||
"configurable": {"thread_id": thread_id, "checkpoint_ns": ns[0]}
|
||||
}
|
||||
async for ns, _ in parent.astream(
|
||||
{"messages": [HumanMessage(content="go")]},
|
||||
{"configurable": {"thread_id": thread_id}},
|
||||
subgraphs=True,
|
||||
)
|
||||
if ns
|
||||
}
|
||||
|
||||
|
||||
async def _run_tool_subgraph(
|
||||
checkpointer: BaseCheckpointSaver,
|
||||
*,
|
||||
parent_schema: type = MessagesState,
|
||||
subagent_schema: type = NotesState,
|
||||
) -> tuple[Any, RunnableConfig]:
|
||||
"""Run a parent whose `tools` node invokes a subgraph it doesn't declare,
|
||||
as deepagents runs subagents. Returns the parent and a config for the
|
||||
subgraph's `tools:<task_id>` namespace."""
|
||||
# Subgraph detection doesn't follow a subscript, so `tools` declares none.
|
||||
subagents = {"researcher": _notes_graph("researcher", subagent_schema)}
|
||||
|
||||
# Typed `dict`: a schema here would add its `messages` channel to the
|
||||
# parent's, and a plain one clashes with a `DeltaChannel` one.
|
||||
async def tools(state: dict) -> dict:
|
||||
await subagents["researcher"].ainvoke(
|
||||
{"messages": [HumanMessage(content="research apples")]}
|
||||
)
|
||||
return {"messages": [AIMessage(content="tool done")]}
|
||||
|
||||
parent = (
|
||||
StateGraph(parent_schema)
|
||||
.add_node("tools", tools)
|
||||
.add_edge(START, "tools")
|
||||
.compile(checkpointer=checkpointer)
|
||||
)
|
||||
return parent, (await _namespace_configs(parent))["tools"]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_undeclared_subgraph_get_state_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""A namespace with no declared subgraph is read with the parent graph."""
|
||||
parent, ns_config = await _run_tool_subgraph(async_checkpointer)
|
||||
|
||||
state = await parent.aget_state(ns_config)
|
||||
|
||||
assert [m.text for m in state.values["messages"]] == [
|
||||
"research apples",
|
||||
"researcher done",
|
||||
]
|
||||
assert state.config["configurable"]["checkpoint_ns"] == AnyStr("tools:")
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_undeclared_subgraph_get_state_history_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""History of a namespace with no declared subgraph is read with the parent
|
||||
graph, and only holds that namespace's checkpoints."""
|
||||
parent, ns_config = await _run_tool_subgraph(async_checkpointer)
|
||||
|
||||
history = [s async for s in parent.aget_state_history(ns_config)]
|
||||
|
||||
assert [[m.text for m in s.values.get("messages", [])] for s in history] == [
|
||||
["research apples", "researcher done"],
|
||||
["research apples"],
|
||||
[],
|
||||
]
|
||||
assert {s.config["configurable"]["checkpoint_ns"] for s in history} == {
|
||||
ns_config["configurable"]["checkpoint_ns"]
|
||||
}
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
async def test_undeclared_subgraph_update_state_raises_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""Unlike a read, an update can't fall back to the parent graph: its nodes
|
||||
and channels would write the wrong state into the subgraph's namespace."""
|
||||
parent, ns_config = await _run_tool_subgraph(async_checkpointer)
|
||||
|
||||
with pytest.raises(ValueError, match="Subgraph tools not found"):
|
||||
await parent.aupdate_state(ns_config, {"messages": [AIMessage(content="edit")]})
|
||||
|
||||
assert len([s async for s in parent.aget_state_history(ns_config)]) == 3
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize(
|
||||
("parent_schema", "subagent_schema"),
|
||||
[(DeltaMessagesState, NotesState), (MessagesState, DeltaNotesState)],
|
||||
ids=["delta_parent", "delta_subagent"],
|
||||
)
|
||||
async def test_undeclared_subgraph_read_across_messages_channel_types_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
parent_schema: type,
|
||||
subagent_schema: type,
|
||||
) -> None:
|
||||
"""deepagents' parent stores `messages` in a `DeltaChannel`, and its
|
||||
subagents in a plain `add_messages` channel. Reading a subagent's namespace
|
||||
with the parent's channel restores its messages, either way round."""
|
||||
parent, ns_config = await _run_tool_subgraph(
|
||||
async_checkpointer,
|
||||
parent_schema=parent_schema,
|
||||
subagent_schema=subagent_schema,
|
||||
)
|
||||
|
||||
state = await parent.aget_state(ns_config)
|
||||
history = [s async for s in parent.aget_state_history(ns_config)]
|
||||
|
||||
assert [m.text for m in state.values["messages"]] == [
|
||||
"research apples",
|
||||
"researcher done",
|
||||
]
|
||||
assert [[m.text for m in s.values.get("messages", [])] for s in history] == [
|
||||
["research apples", "researcher done"],
|
||||
["research apples"],
|
||||
[],
|
||||
]
|
||||
|
||||
|
||||
@NEEDS_CONTEXTVARS
|
||||
@pytest.mark.parametrize("review_declared", [False, True])
|
||||
async def test_prefix_named_namespaces_resolve_to_their_own_graph_async(
|
||||
async_checkpointer: BaseCheckpointSaver, review_declared: bool
|
||||
) -> None:
|
||||
"""`research_review` starts with `research`, whose subgraph has a `review`
|
||||
node with its own subgraph. Each namespace still reads with its own graph:
|
||||
a declared subgraph's channels, or the parent's when none is declared."""
|
||||
research = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("review", _notes_graph("research"))
|
||||
.add_edge(START, "review")
|
||||
.compile()
|
||||
)
|
||||
|
||||
class ReviewState(NotesState):
|
||||
verdict: str # a key `research|review`'s graph doesn't have
|
||||
|
||||
def review(state: ReviewState) -> dict:
|
||||
return {
|
||||
"messages": [AIMessage(content="review done")],
|
||||
"notes": "review",
|
||||
"verdict": "approved",
|
||||
}
|
||||
|
||||
# Subgraph detection doesn't follow a subscript, so `call_review` declares
|
||||
# none.
|
||||
subgraphs = {
|
||||
"review": StateGraph(ReviewState)
|
||||
.add_node("work", review)
|
||||
.add_edge(START, "work")
|
||||
.compile()
|
||||
}
|
||||
|
||||
async def call_review(state: MessagesState) -> dict:
|
||||
await subgraphs["review"].ainvoke(
|
||||
{"messages": [HumanMessage(content="check it")]}
|
||||
)
|
||||
return {}
|
||||
|
||||
parent = (
|
||||
StateGraph(MessagesState)
|
||||
.add_node("research", research)
|
||||
.add_node(
|
||||
"research_review",
|
||||
subgraphs["review"] if review_declared else call_review,
|
||||
)
|
||||
.add_edge(START, "research")
|
||||
.add_edge("research", "research_review")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
configs = await _namespace_configs(parent)
|
||||
|
||||
async def values(name: str) -> dict:
|
||||
state = await parent.aget_state(configs[name])
|
||||
return {
|
||||
k: [m.text for m in v] if k == "messages" else v
|
||||
for k, v in state.values.items()
|
||||
}
|
||||
|
||||
assert await values("research") == {
|
||||
"messages": ["go", "research done"],
|
||||
"notes": "research",
|
||||
}
|
||||
if review_declared:
|
||||
assert await values("research_review") == {
|
||||
"messages": ["go", "research done", "review done"],
|
||||
"notes": "review",
|
||||
"verdict": "approved",
|
||||
}
|
||||
else:
|
||||
# The parent's channels have no `notes` or `verdict`.
|
||||
assert await values("research_review") == {
|
||||
"messages": ["check it", "review done"]
|
||||
}
|
||||
|
||||
|
||||
def _prefix_named_subgraphs() -> tuple[Any, dict[str, Any]]:
|
||||
"""A parent whose subgraph namespaces start with one another's names:
|
||||
`research` has a `review` subgraph, and the parent declares
|
||||
`research_review` and `review` too. Returns the parent and each subgraph,
|
||||
keyed by its namespace."""
|
||||
graphs = {
|
||||
"research|review": _notes_graph("research's review"),
|
||||
"research_review": _notes_graph("research_review"),
|
||||
"review": _notes_graph("review"),
|
||||
}
|
||||
graphs["research"] = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("review", graphs["research|review"])
|
||||
.add_edge(START, "review")
|
||||
.compile()
|
||||
)
|
||||
parent = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("research", graphs["research"])
|
||||
.add_node("research_review", graphs["research_review"])
|
||||
.add_node("review", graphs["review"])
|
||||
.add_edge(START, "research")
|
||||
.add_edge("research", "research_review")
|
||||
.add_edge("research_review", "review")
|
||||
.compile()
|
||||
)
|
||||
return parent, graphs
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"namespace", ["research", "research|review", "research_review", "review"]
|
||||
)
|
||||
async def test_get_subgraphs_matches_whole_namespace_segments_async(
|
||||
namespace: str,
|
||||
) -> None:
|
||||
"""`aget_subgraphs(namespace=...)` yields only the subgraph at that
|
||||
namespace. `research_review` isn't `research` then `review`, and
|
||||
`research|review` doesn't also find the parent's own `review`."""
|
||||
parent, graphs = _prefix_named_subgraphs()
|
||||
|
||||
assert [
|
||||
s async for s in parent.aget_subgraphs(namespace=namespace, recurse=True)
|
||||
] == [(namespace, graphs[namespace])]
|
||||
|
||||
|
||||
async def test_nested_namespace_update_raises_despite_top_level_namesake_async(
|
||||
async_checkpointer: BaseCheckpointSaver,
|
||||
) -> None:
|
||||
"""`research|review` names a `review` inside `research`'s subgraph, which
|
||||
declares no subgraph there. An update to it raises rather than writing
|
||||
through the parent's own `review`."""
|
||||
research = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("review", lambda state: {})
|
||||
.add_edge(START, "review")
|
||||
.compile()
|
||||
)
|
||||
parent = (
|
||||
StateGraph(NotesState)
|
||||
.add_node("research", research)
|
||||
.add_node("review", _notes_graph("review"))
|
||||
.add_edge(START, "research")
|
||||
.add_edge("research", "review")
|
||||
.compile(checkpointer=async_checkpointer)
|
||||
)
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": str(uuid4()),
|
||||
"checkpoint_ns": "research:a|review:b",
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match=r"Subgraph research\|review not found"):
|
||||
await parent.aupdate_state(config, {"notes": "edit"})
|
||||
Generated
+3056
-1762
File diff suppressed because it is too large.
Load diff
Generated
+1386
-730
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@ from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
||||
from langgraph_sdk.priority import Priority
|
||||
from langgraph_sdk.priority.types import PriorityContext, PrioritySettings
|
||||
|
||||
__version__ = "0.4.12"
|
||||
__version__ = "0.4.14"
|
||||
|
||||
__all__ = [
|
||||
"Auth",
|
||||
|
||||
@@ -100,6 +100,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
version: Literal["v1"] = "v1",
|
||||
) -> AsyncIterator[StreamPart]: ...
|
||||
|
||||
@@ -133,6 +134,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
version: Literal["v2"],
|
||||
) -> AsyncIterator[StreamPartV2]: ...
|
||||
|
||||
@@ -228,6 +230,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
version: StreamVersion = "v1",
|
||||
) -> AsyncIterator[StreamPart | StreamPartV2]:
|
||||
"""Create a run and stream the results.
|
||||
@@ -272,6 +275,10 @@ class RunsClient:
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
strict_resume: Refuse a resume that answers an interrupt that isn't waiting,
|
||||
instead of ignoring the answer: the run fails with `UnknownInterruptError`
|
||||
and the thread stays as it was. Takes effect on servers whose `/info`
|
||||
reports `strict_resume`.
|
||||
version: Stream format version. "v1" (default) returns raw SSE StreamPart
|
||||
NamedTuples. "v2" returns typed dicts with `type`, `ns`, and `data` keys.
|
||||
|
||||
@@ -305,7 +312,7 @@ class RunsClient:
|
||||
StreamPart(event='metadata', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2'})
|
||||
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}]})
|
||||
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}, {'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]})
|
||||
StreamPart(event='end', data=None)
|
||||
StreamPart(event='end', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', 'status': 'success'})
|
||||
```
|
||||
|
||||
"""
|
||||
@@ -341,6 +348,7 @@ class RunsClient:
|
||||
"on_completion": on_completion,
|
||||
"after_seconds": after_seconds,
|
||||
"durability": durability,
|
||||
"strict_resume": strict_resume,
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
@@ -422,6 +430,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> Run: ...
|
||||
|
||||
async def create(
|
||||
@@ -452,6 +461,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> Run:
|
||||
"""Create a background run.
|
||||
|
||||
@@ -491,6 +501,10 @@ class RunsClient:
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
strict_resume: Refuse a resume that answers an interrupt that isn't waiting,
|
||||
instead of ignoring the answer: the run fails with `UnknownInterruptError`
|
||||
and the thread stays as it was. Takes effect on servers whose `/info`
|
||||
reports `strict_resume`.
|
||||
|
||||
Returns:
|
||||
The created background run.
|
||||
@@ -595,6 +609,7 @@ class RunsClient:
|
||||
"on_completion": on_completion,
|
||||
"after_seconds": after_seconds,
|
||||
"durability": durability,
|
||||
"strict_resume": strict_resume,
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
@@ -656,6 +671,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
@@ -712,6 +728,7 @@ class RunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]:
|
||||
"""Create a run, wait until it finishes and return the final state.
|
||||
|
||||
@@ -749,6 +766,10 @@ class RunsClient:
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
strict_resume: Refuse a resume that answers an interrupt that isn't waiting,
|
||||
instead of ignoring the answer: the run fails with `UnknownInterruptError`
|
||||
and the thread stays as it was. Takes effect on servers whose `/info`
|
||||
reports `strict_resume`.
|
||||
|
||||
Returns:
|
||||
The output of the run.
|
||||
@@ -829,6 +850,7 @@ class RunsClient:
|
||||
"on_completion": on_completion,
|
||||
"after_seconds": after_seconds,
|
||||
"durability": durability,
|
||||
"strict_resume": strict_resume,
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
|
||||
@@ -100,6 +100,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
version: Literal["v1"] = "v1",
|
||||
) -> Iterator[StreamPart]: ...
|
||||
|
||||
@@ -133,6 +134,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
version: Literal["v2"],
|
||||
) -> Iterator[StreamPartV2]: ...
|
||||
|
||||
@@ -228,6 +230,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
version: StreamVersion = "v1",
|
||||
) -> Iterator[StreamPart | StreamPartV2]:
|
||||
"""Create a run and stream the results.
|
||||
@@ -271,6 +274,10 @@ class SyncRunsClient:
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
strict_resume: Refuse a resume that answers an interrupt that isn't waiting,
|
||||
instead of ignoring the answer: the run fails with `UnknownInterruptError`
|
||||
and the thread stays as it was. Takes effect on servers whose `/info`
|
||||
reports `strict_resume`.
|
||||
version: Stream format version. "v1" (default) returns raw SSE StreamPart
|
||||
NamedTuples. "v2" returns typed dicts with `type`, `ns`, and `data` keys.
|
||||
|
||||
@@ -302,7 +309,7 @@ class SyncRunsClient:
|
||||
StreamPart(event='metadata', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2'})
|
||||
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}]})
|
||||
StreamPart(event='values', data={'messages': [{'content': 'how are you?', 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'human', 'name': None, 'id': 'fe0a5778-cfe9-42ee-b807-0adaa1873c10', 'example': False}, {'content': "I'm doing well, thanks for asking! I'm an AI assistant created by Anthropic to be helpful, honest, and harmless.", 'additional_kwargs': {}, 'response_metadata': {}, 'type': 'ai', 'name': None, 'id': 'run-159b782c-b679-4830-83c6-cef87798fe8b', 'example': False, 'tool_calls': [], 'invalid_tool_calls': [], 'usage_metadata': None}]})
|
||||
StreamPart(event='end', data=None)
|
||||
StreamPart(event='end', data={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', 'status': 'success'})
|
||||
```
|
||||
"""
|
||||
if checkpoint_during is not None:
|
||||
@@ -336,6 +343,7 @@ class SyncRunsClient:
|
||||
"on_completion": on_completion,
|
||||
"after_seconds": after_seconds,
|
||||
"durability": durability,
|
||||
"strict_resume": strict_resume,
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
endpoint = (
|
||||
@@ -417,6 +425,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> Run: ...
|
||||
|
||||
def create(
|
||||
@@ -447,6 +456,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> Run:
|
||||
"""Create a background run.
|
||||
|
||||
@@ -486,6 +496,10 @@ class SyncRunsClient:
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
strict_resume: Refuse a resume that answers an interrupt that isn't waiting,
|
||||
instead of ignoring the answer: the run fails with `UnknownInterruptError`
|
||||
and the thread stays as it was. Takes effect on servers whose `/info`
|
||||
reports `strict_resume`.
|
||||
|
||||
Returns:
|
||||
The created background `Run`.
|
||||
@@ -590,6 +604,7 @@ class SyncRunsClient:
|
||||
"on_completion": on_completion,
|
||||
"after_seconds": after_seconds,
|
||||
"durability": durability,
|
||||
"strict_resume": strict_resume,
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
payload = {k: v for k, v in payload.items() if v is not None}
|
||||
@@ -651,6 +666,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]: ...
|
||||
|
||||
@overload
|
||||
@@ -707,6 +723,7 @@ class SyncRunsClient:
|
||||
params: QueryParamTypes | None = None,
|
||||
on_run_created: Callable[[RunCreateMetadata], None] | None = None,
|
||||
durability: Durability | None = None,
|
||||
strict_resume: bool | None = None,
|
||||
) -> builtins.list[dict] | dict[str, Any]:
|
||||
"""Create a run, wait until it finishes and return the final state.
|
||||
|
||||
@@ -745,6 +762,10 @@ class SyncRunsClient:
|
||||
"async" means checkpoints are persisted async while next graph step executes, replaces checkpoint_during=True
|
||||
"sync" means checkpoints are persisted sync after graph step executes, replaces checkpoint_during=False
|
||||
"exit" means checkpoints are only persisted when the run exits, does not save intermediate steps
|
||||
strict_resume: Refuse a resume that answers an interrupt that isn't waiting,
|
||||
instead of ignoring the answer: the run fails with `UnknownInterruptError`
|
||||
and the thread stays as it was. Takes effect on servers whose `/info`
|
||||
reports `strict_resume`.
|
||||
|
||||
Returns:
|
||||
The output of the `Run`.
|
||||
@@ -826,6 +847,7 @@ class SyncRunsClient:
|
||||
"on_completion": on_completion,
|
||||
"after_seconds": after_seconds,
|
||||
"durability": durability,
|
||||
"strict_resume": strict_resume,
|
||||
"langsmith_tracer": langsmith_tracing,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ StreamMode = Literal[
|
||||
"debug",
|
||||
"custom",
|
||||
"messages-tuple",
|
||||
"tools",
|
||||
"lifecycle",
|
||||
]
|
||||
"""
|
||||
Defines the mode of streaming:
|
||||
@@ -69,6 +71,8 @@ Defines the mode of streaming:
|
||||
- "tasks": Stream task start and finish events.
|
||||
- "debug": Stream detailed debug information.
|
||||
- "custom": Stream custom events.
|
||||
- "tools": Stream tool events: `on_tool_start`, `on_tool_event`, `on_tool_end` and `on_tool_error`.
|
||||
- "lifecycle": Stream an event when each subgraph starts and ends, with the id of the tool call that started it.
|
||||
"""
|
||||
|
||||
DisconnectMode = Literal["cancel", "continue"]
|
||||
@@ -380,6 +384,8 @@ class Run(TypedDict):
|
||||
"""The run metadata."""
|
||||
multitask_strategy: MultitaskStrategy
|
||||
"""Strategy to handle concurrent runs on the same thread."""
|
||||
cancel_reason: NotRequired[str | None]
|
||||
"""Why the run was cancelled, if it was. Omitted by older servers."""
|
||||
|
||||
|
||||
class Cron(TypedDict):
|
||||
@@ -505,6 +511,7 @@ RunSelectField = Literal[
|
||||
"metadata",
|
||||
"kwargs",
|
||||
"multitask_strategy",
|
||||
"cancel_reason",
|
||||
]
|
||||
|
||||
CronSelectField = Literal[
|
||||
|
||||
@@ -136,7 +136,7 @@ def test_sync_api_matches_async(async_cls, sync_cls):
|
||||
# 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"},
|
||||
"None": {"checkpoint", "checkpoint_id", "multitask_strategy", "strict_resume"},
|
||||
"str": {"on_completion"},
|
||||
}
|
||||
|
||||
|
||||
@@ -212,3 +212,55 @@ async def test_wait_returns_the_run_error_without_raise_error(asynchronous):
|
||||
|
||||
assert result == run_error
|
||||
assert "raise_error" not in json.loads(requests[0].content)
|
||||
|
||||
|
||||
def _strict_resume_handler(requests: list[httpx.Request]):
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
if request.url.path.endswith("/stream"):
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/event-stream"},
|
||||
content=b"event: end\ndata: null\n\n",
|
||||
)
|
||||
return httpx.Response(200, json={"run_id": "r1"})
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["create", "stream", "wait"])
|
||||
@pytest.mark.parametrize("asynchronous", [False, True])
|
||||
@pytest.mark.parametrize("strict_resume", [True, None], ids=["opted-in", "default"])
|
||||
async def test_runs_send_strict_resume_only_when_set(
|
||||
method: str, asynchronous: bool, strict_resume: bool | None
|
||||
):
|
||||
requests: list[httpx.Request] = []
|
||||
transport = httpx.MockTransport(_strict_resume_handler(requests))
|
||||
kwargs: dict[str, Any] = {
|
||||
"command": {"resume": {"0" * 32: "yes"}},
|
||||
"strict_resume": strict_resume,
|
||||
}
|
||||
if asynchronous:
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
result = getattr(RunsClient(HttpClient(client)), method)(
|
||||
"t1", "agent", **kwargs
|
||||
)
|
||||
if method == "stream":
|
||||
async for _ in result:
|
||||
pass
|
||||
else:
|
||||
await result
|
||||
else:
|
||||
with httpx.Client(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
result = getattr(SyncRunsClient(SyncHttpClient(client)), method)(
|
||||
"t1", "agent", **kwargs
|
||||
)
|
||||
if method == "stream":
|
||||
for _ in result:
|
||||
pass
|
||||
|
||||
assert json.loads(requests[0].content).get("strict_resume") == strict_resume
|
||||
Generated
+1211
-617
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user