mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-11-11 00:17:43 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd2d92c31a |
No files matched your search
@@ -115,16 +115,21 @@ 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"
|
||||
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
|
||||
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
|
||||
|
||||
- name: Build and test prerelease reqs fail service
|
||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.41"
|
||||
__version__ = "0.4.40"
|
||||
@@ -154,12 +154,9 @@ def _get_pip_cleanup_lines(
|
||||
pip_installer: Literal["uv", "pip"],
|
||||
) -> str:
|
||||
commands = [
|
||||
f"""# -- Ensure user deps didn't inadvertently overwrite langgraph-api
|
||||
RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \
|
||||
touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py
|
||||
RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api
|
||||
# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --
|
||||
# -- Removing build deps from the final image ~<:===~~~ --"""
|
||||
"mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && "
|
||||
"touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py",
|
||||
f"PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api",
|
||||
]
|
||||
if to_uninstall:
|
||||
for pack in to_uninstall:
|
||||
@@ -168,30 +165,31 @@ RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api
|
||||
f"Invalid build tool: {pack}; must be one of {', '.join(_BUILD_TOOLS)}"
|
||||
)
|
||||
packs_str = " ".join(sorted(to_uninstall))
|
||||
commands.append(f"RUN pip uninstall -y {packs_str}")
|
||||
commands.append(f"pip uninstall -y {packs_str}")
|
||||
# Ensure the directories are removed entirely
|
||||
packages_rm = " ".join(
|
||||
f"/usr/local/lib/python*/site-packages/{pack}*" for pack in to_uninstall
|
||||
)
|
||||
if "pip" in to_uninstall:
|
||||
packages_rm += ' && find /usr/local/bin -name "pip*" -delete || true'
|
||||
commands.append(f"RUN rm -rf {packages_rm}")
|
||||
commands.append(f"rm -rf {packages_rm}")
|
||||
wolfi_packages_rm = " ".join(
|
||||
f"/usr/lib/python*/site-packages/{pack}*" for pack in to_uninstall
|
||||
)
|
||||
if "pip" in to_uninstall:
|
||||
wolfi_packages_rm += ' && find /usr/bin -name "pip*" -delete || true'
|
||||
commands.append(f"RUN rm -rf {wolfi_packages_rm}")
|
||||
commands.append(f"rm -rf {wolfi_packages_rm}")
|
||||
if pip_installer == "uv":
|
||||
commands.append(
|
||||
f"RUN uv pip uninstall --system {packs_str} && rm /usr/bin/uv /usr/bin/uvx"
|
||||
f"uv pip uninstall --system {packs_str} && rm /usr/bin/uv /usr/bin/uvx"
|
||||
)
|
||||
else:
|
||||
if pip_installer == "uv":
|
||||
commands.append(
|
||||
"RUN rm /usr/bin/uv /usr/bin/uvx\n# -- End of build deps removal --"
|
||||
)
|
||||
return "\n".join(commands)
|
||||
elif pip_installer == "uv":
|
||||
commands.append("rm /usr/bin/uv /usr/bin/uvx")
|
||||
# One RUN, since each layer is costly on copy-based snapshotters; subshells scope each `|| true`.
|
||||
return (
|
||||
"# -- Restore langgraph-api over user deps, then remove build deps --\n"
|
||||
"RUN " + " && \\\n ".join(f"({command})" for command in commands)
|
||||
)
|
||||
|
||||
|
||||
def _parse_version(version_str: str) -> tuple[int, int]:
|
||||
@@ -1608,7 +1606,10 @@ ADD {relpath} /deps/{name}
|
||||
pip_installer=pip_installer,
|
||||
),
|
||||
"",
|
||||
f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "",
|
||||
# The JS install step already set this WORKDIR.
|
||||
f"WORKDIR {local_deps.working_dir}"
|
||||
if local_deps.working_dir and not js_inst_str
|
||||
else "",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1690,7 +1691,7 @@ def node_config_to_docker(
|
||||
"",
|
||||
os.linesep.join(env_vars),
|
||||
"",
|
||||
f"WORKDIR {build_workdir}",
|
||||
f"WORKDIR {build_workdir}" if build_workdir != install_workdir else "",
|
||||
"",
|
||||
build_step,
|
||||
]
|
||||
|
||||
@@ -65,6 +65,7 @@ class UvLockPlan:
|
||||
@dataclass(slots=True)
|
||||
class DockerBuildPlan:
|
||||
lines: list[str]
|
||||
workdir: str | None = None
|
||||
|
||||
def add_blank(self) -> None:
|
||||
self.lines.append("")
|
||||
@@ -73,6 +74,11 @@ class DockerBuildPlan:
|
||||
self.lines.append(line)
|
||||
|
||||
def add_instruction(self, opcode: str, value: str | None = None) -> None:
|
||||
# Repeating the current WORKDIR changes nothing but still costs a build step.
|
||||
if opcode == "WORKDIR":
|
||||
if value == self.workdir:
|
||||
return
|
||||
self.workdir = value
|
||||
if value:
|
||||
self.lines.append(f"{opcode} {value}")
|
||||
else:
|
||||
|
||||
@@ -1231,7 +1231,6 @@ ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
|
||||
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
|
||||
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
|
||||
WORKDIR /deps/unit_tests
|
||||
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
@@ -1422,7 +1421,6 @@ ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
|
||||
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
|
||||
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
|
||||
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
|
||||
WORKDIR /deps/unit_tests
|
||||
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
@@ -1550,8 +1548,7 @@ ENV NODE_VERSION=20
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests
|
||||
RUN npm i && tsx /api/langgraph_api/js/build.mts
|
||||
# -- End of JS dependencies install --
|
||||
{FORMATTED_CLEANUP_LINES}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests"""
|
||||
{FORMATTED_CLEANUP_LINES}"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
@@ -1595,8 +1592,7 @@ ENV NODE_VERSION=22
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests
|
||||
RUN npm i && tsx /api/langgraph_api/js/build.mts
|
||||
# -- End of JS dependencies install --
|
||||
{FORMATTED_CLEANUP_LINES}
|
||||
WORKDIR /deps/outer-unit_tests/unit_tests"""
|
||||
{FORMATTED_CLEANUP_LINES}"""
|
||||
|
||||
assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
|
||||
assert additional_contexts == {}
|
||||
@@ -2152,7 +2148,8 @@ def test_config_to_docker_uv_lock_detects_js_pm_from_target_package_root():
|
||||
config_path, config, base_image="langchain/langgraph-api:0.2.47"
|
||||
)
|
||||
|
||||
assert "WORKDIR /deps/workspace/apps/agent" in docker
|
||||
# Package install, JS install and runtime all share this dir: one WORKDIR.
|
||||
assert docker.splitlines().count("WORKDIR /deps/workspace/apps/agent") == 1
|
||||
assert (
|
||||
"RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts"
|
||||
in docker
|
||||
@@ -2803,7 +2800,7 @@ def test_config_retain_build_tools():
|
||||
"/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_true
|
||||
for pckg in _BUILD_TOOLS
|
||||
)
|
||||
assert "RUN pip uninstall -y pip setuptools wheel" not in docker_true
|
||||
assert "(pip uninstall -y pip setuptools wheel)" not in docker_true
|
||||
config_false = validate_config(
|
||||
{**copy.deepcopy(base_config), "keep_pkg_tools": False}
|
||||
)
|
||||
@@ -2814,7 +2811,7 @@ def test_config_retain_build_tools():
|
||||
"/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_false
|
||||
for pckg in _BUILD_TOOLS
|
||||
)
|
||||
assert "RUN pip uninstall -y pip setuptools wheel" in docker_false
|
||||
assert "(pip uninstall -y pip setuptools wheel)" in docker_false
|
||||
config_list = validate_config(
|
||||
{**copy.deepcopy(base_config), "keep_pkg_tools": ["pip", "setuptools"]}
|
||||
)
|
||||
@@ -2829,8 +2826,15 @@ def test_config_retain_build_tools():
|
||||
"/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list
|
||||
for pckg in ("pip", "setuptools")
|
||||
)
|
||||
assert "RUN pip uninstall -y wheel" in docker_list
|
||||
assert "RUN pip uninstall -y pip setuptools" not in docker_list
|
||||
assert "(pip uninstall -y wheel)" in docker_list
|
||||
assert "(pip uninstall -y pip setuptools)" not in docker_list
|
||||
|
||||
|
||||
def test_pip_cleanup_is_a_single_run_with_scoped_fallbacks():
|
||||
cleanup = _get_pip_cleanup_lines("uv pip install --system", _BUILD_TOOLS, "uv")
|
||||
assert cleanup.count("RUN ") == 1
|
||||
# Each `|| true` must close its own subshell so it can't mask later steps.
|
||||
assert cleanup.count("|| true)") == cleanup.count("|| true") == 2
|
||||
|
||||
|
||||
# config_to_compose
|
||||
|
||||
@@ -74,8 +74,6 @@ 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.
|
||||
@@ -136,7 +134,6 @@ 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,7 +28,6 @@ __all__ = (
|
||||
"ParentCommand",
|
||||
"EmptyInputError",
|
||||
"TaskNotFound",
|
||||
"UnknownInterruptError",
|
||||
"is_invalid_resume",
|
||||
)
|
||||
|
||||
@@ -147,45 +146,6 @@ 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`).
|
||||
|
||||
The interrupts one task raises share an id, so an answer retried after the
|
||||
task asked its next question answers 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,7 +85,6 @@ from langgraph.types import (
|
||||
RetryPolicy,
|
||||
Send,
|
||||
TimeoutPolicy,
|
||||
_interrupt_id,
|
||||
)
|
||||
|
||||
GetNextVersion = Callable[[V | None, None], V]
|
||||
@@ -627,7 +626,7 @@ def prepare_single_task(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
task_checkpoint_ns,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -874,7 +873,7 @@ def prepare_push_task_functional(
|
||||
configurable.get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
task_checkpoint_ns,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
configurable.get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -1036,7 +1035,7 @@ def prepare_push_task_send(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
task_checkpoint_ns,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -1185,7 +1184,7 @@ def prepare_node_error_handler_task(
|
||||
config[CONF].get(CONFIG_KEY_SCRATCHPAD),
|
||||
pending_writes,
|
||||
task_id,
|
||||
task_checkpoint_ns,
|
||||
xxh3_128_hexdigest(task_checkpoint_ns.encode()),
|
||||
config[CONF].get(CONFIG_KEY_RESUME_MAP),
|
||||
step,
|
||||
stop,
|
||||
@@ -1281,7 +1280,7 @@ def _scratchpad(
|
||||
parent_scratchpad: PregelScratchpad | None,
|
||||
pending_writes: list[PendingWrite],
|
||||
task_id: str,
|
||||
task_checkpoint_ns: str,
|
||||
namespace_hash: str,
|
||||
resume_map: dict[str, Any] | None,
|
||||
step: int,
|
||||
stop: int,
|
||||
@@ -1308,15 +1307,10 @@ def _scratchpad(
|
||||
task_resume_write = []
|
||||
del w
|
||||
|
||||
# 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])
|
||||
# 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)
|
||||
|
||||
else:
|
||||
null_resume_write = None
|
||||
|
||||
@@ -48,7 +48,6 @@ 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,
|
||||
@@ -78,7 +77,6 @@ from langgraph.constants import TAG_HIDDEN
|
||||
from langgraph.errors import (
|
||||
EmptyInputError,
|
||||
GraphInterrupt,
|
||||
UnknownInterruptError,
|
||||
)
|
||||
from langgraph.managed.base import (
|
||||
ManagedValueMapping,
|
||||
@@ -1026,45 +1024,17 @@ 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(pending := self._pending_interrupts()) > 1:
|
||||
message = (
|
||||
if len(self._pending_interrupts()) > 1:
|
||||
raise RuntimeError(
|
||||
"When there are multiple pending interrupts, you must specify the interrupt id when resuming. "
|
||||
"Docs: https://docs.langchain.com/oss/python/langgraph/interrupts#handling-multiple-interrupts."
|
||||
"Docs: https://docs.langchain.com/oss/python/langgraph/add-human-in-the-loop#resume-multiple-interrupts-with-one-invocation."
|
||||
)
|
||||
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,7 +28,6 @@ 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,
|
||||
@@ -45,7 +44,6 @@ 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
|
||||
@@ -573,23 +571,6 @@ 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,
|
||||
@@ -700,7 +681,6 @@ 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(
|
||||
@@ -849,7 +829,6 @@ 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:
|
||||
|
||||
@@ -50,7 +50,6 @@ 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,
|
||||
@@ -82,7 +81,6 @@ _CONF_DROPLIST = frozenset(
|
||||
CONFIG_KEY_CHECKPOINT_ID,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
CONFIG_KEY_TASK_ID,
|
||||
CONFIG_KEY_STRICT_RESUME,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ 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
|
||||
@@ -580,15 +579,6 @@ 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]):
|
||||
@@ -651,7 +641,7 @@ class Interrupt(Generic[ResponseT]):
|
||||
) -> Interrupt[ResponseT]:
|
||||
return cls(
|
||||
value=value,
|
||||
id=_interrupt_id(ns, 0),
|
||||
id=xxh3_128_hexdigest(ns.encode()),
|
||||
response_schema=response_schema,
|
||||
)
|
||||
|
||||
@@ -1047,11 +1037,9 @@ def interrupt(
|
||||
# no resume value found
|
||||
raise GraphInterrupt(
|
||||
(
|
||||
Interrupt(
|
||||
Interrupt.from_ns(
|
||||
value=value,
|
||||
id=_interrupt_id(
|
||||
conf[CONFIG_KEY_CHECKPOINT_NS], len(scratchpad.resume)
|
||||
),
|
||||
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
|
||||
response_schema=adapter.json_schema() if adapter else response_schema,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.2.20"
|
||||
version = "1.2.19"
|
||||
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, CONFIG_KEY_STRICT_RESUME
|
||||
from langgraph._internal._constants import CONFIG_KEY_STREAM
|
||||
from langgraph.errors import GraphInterrupt
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph, add_messages
|
||||
from langgraph.pregel import Pregel
|
||||
@@ -1360,7 +1360,6 @@ 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)
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
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"]
|
||||
Generated
+1
-1
@@ -1439,7 +1439,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.20"
|
||||
version = "1.2.19"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Generated
+1
-1
@@ -286,7 +286,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.20"
|
||||
version = "1.2.19"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
@@ -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.13"
|
||||
__version__ = "0.4.12"
|
||||
|
||||
__all__ = [
|
||||
"Auth",
|
||||
|
||||
@@ -305,7 +305,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={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', 'status': 'success'})
|
||||
StreamPart(event='end', data=None)
|
||||
```
|
||||
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,6 @@ from langgraph_sdk.schema import (
|
||||
ThreadSelectField,
|
||||
ThreadSortBy,
|
||||
ThreadState,
|
||||
ThreadStateSelectField,
|
||||
ThreadStatus,
|
||||
ThreadStreamMode,
|
||||
ThreadUpdateStateResponse,
|
||||
@@ -490,7 +489,6 @@ class ThreadsClient:
|
||||
checkpoint_id: str | None = None, # deprecated
|
||||
*,
|
||||
subgraphs: bool = False,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> ThreadState:
|
||||
@@ -501,9 +499,6 @@ class ThreadsClient:
|
||||
checkpoint: The checkpoint to get the state of.
|
||||
checkpoint_id: (deprecated) The checkpoint ID to get the state of.
|
||||
subgraphs: Include subgraphs states.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -596,30 +591,16 @@ class ThreadsClient:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Fetch only the `messages` state key:
|
||||
|
||||
```python
|
||||
thread_state = await client.threads.get_state(
|
||||
thread_id="my_thread_id",
|
||||
select=["values.messages"],
|
||||
)
|
||||
```
|
||||
"""
|
||||
if checkpoint:
|
||||
payload: dict[str, Any] = {"checkpoint": checkpoint, "subgraphs": subgraphs}
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json=payload,
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
elif checkpoint_id:
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
@@ -628,9 +609,7 @@ class ThreadsClient:
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return await self.http.get(
|
||||
@@ -713,7 +692,6 @@ class ThreadsClient:
|
||||
before: str | Checkpoint | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
checkpoint: Checkpoint | None = None,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[ThreadState]:
|
||||
@@ -725,9 +703,6 @@ class ThreadsClient:
|
||||
limit: The maximum number of states to return.
|
||||
before: Return states before this checkpoint.
|
||||
metadata: Filter states by metadata key-value pairs.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
@@ -754,8 +729,6 @@ class ThreadsClient:
|
||||
payload["metadata"] = metadata
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return await self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
|
||||
@@ -302,7 +302,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={'run_id': '1ef4a9b8-d7da-679a-a45a-872054341df2', 'status': 'success'})
|
||||
StreamPart(event='end', data=None)
|
||||
```
|
||||
"""
|
||||
if checkpoint_during is not None:
|
||||
|
||||
@@ -21,7 +21,6 @@ from langgraph_sdk.schema import (
|
||||
ThreadSelectField,
|
||||
ThreadSortBy,
|
||||
ThreadState,
|
||||
ThreadStateSelectField,
|
||||
ThreadStatus,
|
||||
ThreadStreamMode,
|
||||
ThreadUpdateStateResponse,
|
||||
@@ -481,7 +480,6 @@ class SyncThreadsClient:
|
||||
checkpoint_id: str | None = None, # deprecated
|
||||
*,
|
||||
subgraphs: bool = False,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> ThreadState:
|
||||
@@ -491,9 +489,6 @@ class SyncThreadsClient:
|
||||
thread_id: The ID of the thread to get the state of.
|
||||
checkpoint: The checkpoint to get the state of.
|
||||
subgraphs: Include subgraphs states.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
@@ -586,29 +581,16 @@ class SyncThreadsClient:
|
||||
}
|
||||
```
|
||||
|
||||
Fetch only the `messages` state key:
|
||||
|
||||
```python
|
||||
thread_state = client.threads.get_state(
|
||||
thread_id="my_thread_id",
|
||||
select=["values.messages"],
|
||||
)
|
||||
```
|
||||
"""
|
||||
if checkpoint:
|
||||
payload: dict[str, Any] = {"checkpoint": checkpoint, "subgraphs": subgraphs}
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/state/checkpoint",
|
||||
json=payload,
|
||||
json={"checkpoint": checkpoint, "subgraphs": subgraphs},
|
||||
headers=headers,
|
||||
params=params,
|
||||
)
|
||||
elif checkpoint_id:
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
@@ -617,9 +599,7 @@ class SyncThreadsClient:
|
||||
headers=headers,
|
||||
)
|
||||
else:
|
||||
get_params: dict[str, Any] = {"subgraphs": subgraphs}
|
||||
if select:
|
||||
get_params["select"] = select
|
||||
get_params = {"subgraphs": subgraphs}
|
||||
if params:
|
||||
get_params = {**get_params, **dict(params)}
|
||||
return self.http.get(
|
||||
@@ -699,7 +679,6 @@ class SyncThreadsClient:
|
||||
before: str | Checkpoint | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
checkpoint: Checkpoint | None = None,
|
||||
select: list[ThreadStateSelectField] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[ThreadState]:
|
||||
@@ -711,9 +690,6 @@ class SyncThreadsClient:
|
||||
limit: The maximum number of states to return.
|
||||
before: Return states before this checkpoint.
|
||||
metadata: Filter states by metadata key-value pairs.
|
||||
select: Fields to include in the response. Use `values.<key>` to
|
||||
return a single state key (e.g. `values.messages`), which lets the
|
||||
server skip loading the rest of the state. Defaults to all fields.
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
@@ -741,8 +717,6 @@ class SyncThreadsClient:
|
||||
payload["metadata"] = metadata
|
||||
if checkpoint:
|
||||
payload["checkpoint"] = checkpoint
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return self.http.post(
|
||||
f"/threads/{_quote_path_param(thread_id)}/history",
|
||||
json=payload,
|
||||
|
||||
@@ -380,8 +380,6 @@ 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):
|
||||
@@ -482,21 +480,6 @@ ThreadSelectField = Literal[
|
||||
"interrupts",
|
||||
]
|
||||
|
||||
ThreadStateSelectField = (
|
||||
Literal[
|
||||
"values",
|
||||
"next",
|
||||
"tasks",
|
||||
"checkpoint",
|
||||
"metadata",
|
||||
"created_at",
|
||||
"parent_checkpoint",
|
||||
"interrupts",
|
||||
]
|
||||
| str
|
||||
)
|
||||
"""Also accepts `values.<key>` for a single state key, e.g. `values.messages`."""
|
||||
|
||||
RunSelectField = Literal[
|
||||
"run_id",
|
||||
"thread_id",
|
||||
@@ -507,7 +490,6 @@ RunSelectField = Literal[
|
||||
"metadata",
|
||||
"kwargs",
|
||||
"multitask_strategy",
|
||||
"cancel_reason",
|
||||
]
|
||||
|
||||
CronSelectField = Literal[
|
||||
|
||||
@@ -11,14 +11,6 @@ from langgraph_sdk.client import (
|
||||
SyncThreadsClient,
|
||||
ThreadsClient,
|
||||
)
|
||||
from langgraph_sdk.schema import Checkpoint
|
||||
|
||||
CHECKPOINT: Checkpoint = {
|
||||
"thread_id": "thread_123",
|
||||
"checkpoint_ns": "",
|
||||
"checkpoint_id": "cp_1",
|
||||
"checkpoint_map": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -64,121 +56,3 @@ def test_sync_threads_update_return_minimal():
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_threads_get_state_select():
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get_state("thread_123", select=["values.messages", "next"])
|
||||
await threads_client.get_state(
|
||||
"thread_123", checkpoint_id="cp_1", select=["values.messages"]
|
||||
)
|
||||
await threads_client.get_state(
|
||||
"thread_123",
|
||||
checkpoint=CHECKPOINT,
|
||||
select=["values.messages"],
|
||||
)
|
||||
await threads_client.get_state("thread_123")
|
||||
|
||||
latest, by_id, by_checkpoint, default = requests
|
||||
assert latest.method == "GET"
|
||||
assert latest.url.path == "/threads/thread_123/state"
|
||||
assert latest.url.params.get_list("select") == ["values.messages", "next"]
|
||||
assert by_id.method == "GET"
|
||||
assert by_id.url.path == "/threads/thread_123/state/cp_1"
|
||||
assert by_id.url.params.get_list("select") == ["values.messages"]
|
||||
assert by_checkpoint.method == "POST"
|
||||
assert by_checkpoint.url.path == "/threads/thread_123/state/checkpoint"
|
||||
assert json.loads(by_checkpoint.content) == {
|
||||
"checkpoint": CHECKPOINT,
|
||||
"subgraphs": False,
|
||||
"select": ["values.messages"],
|
||||
}
|
||||
assert "select" not in default.url.params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_threads_get_history_select():
|
||||
bodies: list[dict] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/threads/thread_123/history"
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
threads_client = ThreadsClient(HttpClient(client))
|
||||
await threads_client.get_history("thread_123", select=["values.messages"])
|
||||
await threads_client.get_history("thread_123")
|
||||
|
||||
assert bodies == [{"limit": 10, "select": ["values.messages"]}, {"limit": 10}]
|
||||
|
||||
|
||||
def test_sync_threads_get_state_select():
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append(request)
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get_state("thread_123", select=["values.messages", "next"])
|
||||
threads_client.get_state(
|
||||
"thread_123", checkpoint_id="cp_1", select=["values.messages"]
|
||||
)
|
||||
threads_client.get_state(
|
||||
"thread_123",
|
||||
checkpoint=CHECKPOINT,
|
||||
select=["values.messages"],
|
||||
)
|
||||
threads_client.get_state("thread_123")
|
||||
|
||||
latest, by_id, by_checkpoint, default = requests
|
||||
assert latest.method == "GET"
|
||||
assert latest.url.path == "/threads/thread_123/state"
|
||||
assert latest.url.params.get_list("select") == ["values.messages", "next"]
|
||||
assert by_id.method == "GET"
|
||||
assert by_id.url.path == "/threads/thread_123/state/cp_1"
|
||||
assert by_id.url.params.get_list("select") == ["values.messages"]
|
||||
assert by_checkpoint.method == "POST"
|
||||
assert by_checkpoint.url.path == "/threads/thread_123/state/checkpoint"
|
||||
assert json.loads(by_checkpoint.content) == {
|
||||
"checkpoint": CHECKPOINT,
|
||||
"subgraphs": False,
|
||||
"select": ["values.messages"],
|
||||
}
|
||||
assert "select" not in default.url.params
|
||||
|
||||
|
||||
def test_sync_threads_get_history_select():
|
||||
bodies: list[dict] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/threads/thread_123/history"
|
||||
bodies.append(json.loads(request.content))
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
threads_client = SyncThreadsClient(SyncHttpClient(client))
|
||||
threads_client.get_history("thread_123", select=["values.messages"])
|
||||
threads_client.get_history("thread_123")
|
||||
|
||||
assert bodies == [{"limit": 10, "select": ["values.messages"]}, {"limit": 10}]
|
||||
Generated
+1
-1
@@ -301,7 +301,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.20"
|
||||
version = "1.2.19"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
|
||||
Reference in new issue
Block a user