Implement get_graph for imperative api (#3076)

This commit is contained in:
Nuno Campos
2025-01-17 15:53:50 -08:00
committed by GitHub
7 changed files with 907 additions and 55 deletions
+103 -4
View File
@@ -4,6 +4,7 @@ import concurrent.futures
import functools
import inspect
import types
from collections.abc import Iterator
from typing import (
Any,
Awaitable,
@@ -14,6 +15,9 @@ from typing import (
overload,
)
from langchain_core.runnables.base import Runnable
from langchain_core.runnables.config import RunnableConfig
from langchain_core.runnables.graph import Graph, Node
from typing_extensions import ParamSpec
from langgraph.channels.ephemeral_value import EphemeralValue
@@ -22,6 +26,7 @@ from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.constants import CONF, END, START, TAG_HIDDEN
from langgraph.pregel import Pregel
from langgraph.pregel.call import get_runnable_for_func
from langgraph.pregel.protocol import PregelProtocol
from langgraph.pregel.read import PregelNode
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
from langgraph.store.base import BaseStore
@@ -157,9 +162,9 @@ def task(
def _tick(__allargs__: tuple) -> T:
return func(*__allargs__[0], **__allargs__[1])
return functools.update_wrapper(
functools.partial(call, _tick, retry=retry), func
)
wrapper = functools.partial(call, _tick, retry=retry)
object.__setattr__(wrapper, "_is_pregel_task", True)
return functools.update_wrapper(wrapper, func)
if __func_or_none__ is not None:
return decorator(__func_or_none__)
@@ -408,7 +413,7 @@ def entrypoint(
else Any
)
return Pregel(
return EntrypointPregel(
nodes={
func.__name__: PregelNode(
bound=bound,
@@ -432,3 +437,97 @@ def entrypoint(
)
return _imp
class EntrypointPregel(Pregel):
def get_graph(
self,
config: Optional[RunnableConfig] = None,
*,
xray: Union[int, bool] = False,
) -> Graph:
name, entrypoint = next(iter(self.nodes.items()))
graph = Graph()
node = Node(f"__{name}", name, entrypoint.bound, None)
graph.nodes[node.id] = node
candidates: list[tuple[Node, Union[Callable, PregelProtocol]]] = [
*_find_children(entrypoint.bound, node)
]
seen: set[Union[Callable, PregelProtocol]] = set()
for parent, child in candidates:
if child in seen:
continue
else:
seen.add(child)
if callable(child):
node = Node(f"__{child.__name__}", child.__name__, child, None) # type: ignore[arg-type]
graph.nodes[node.id] = node
graph.add_edge(parent, node, conditional=True)
graph.add_edge(node, parent)
candidates.extend(_find_children(child, node))
elif isinstance(child, Runnable):
if xray > 0:
graph = child.get_graph(config, xray=xray - 1 if xray else 0)
graph.trim_first_node()
graph.trim_last_node()
s, e = graph.extend(graph, prefix=child.name or "")
if s is None:
raise ValueError(
f"Could not extend subgraph '{child.name}' due to missing entrypoint"
)
else:
graph.add_edge(parent, s, conditional=True)
if e is not None:
graph.add_edge(e, parent)
else:
node = graph.add_node(child, child.name)
graph.add_edge(parent, node, conditional=True)
graph.add_edge(node, parent)
return graph
def _find_children(
candidate: Union[Callable, Runnable], parent: Node
) -> Iterator[tuple[Node, Union[Callable, PregelProtocol]]]:
from langchain_core.runnables.utils import get_function_nonlocals
from langgraph.utils.runnable import (
RunnableCallable,
RunnableLambda,
RunnableSeq,
RunnableSequence,
)
candidates: list[Union[Callable, Runnable]] = []
if callable(candidate) and getattr(candidate, "_is_pregel_task", False) is True:
candidates.extend(
nl.__self__ if hasattr(nl, "__self__") else nl
for nl in get_function_nonlocals(
candidate.__wrapped__
if hasattr(candidate, "__wrapped__") and callable(candidate.__wrapped__)
else candidate
)
)
else:
candidates.append(candidate)
for c in candidates:
if callable(c) and getattr(c, "_is_pregel_task", False) is True:
yield (parent, c)
elif isinstance(c, PregelProtocol):
yield (parent, c)
elif isinstance(c, RunnableSequence) or isinstance(c, RunnableSeq):
candidates.extend(c.steps)
elif isinstance(c, RunnableLambda):
candidates.extend(c.deps)
elif isinstance(c, RunnableCallable):
if c.func is not None:
candidates.extend(
nl.__self__ if hasattr(nl, "__self__") else nl
for nl in get_function_nonlocals(c.func)
)
elif c.afunc is not None:
candidates.extend(
nl.__self__ if hasattr(nl, "__self__") else nl
for nl in get_function_nonlocals(c.afunc)
)
+96 -40
View File
@@ -1,5 +1,6 @@
import asyncio
import concurrent.futures
import threading
import time
from functools import partial
from typing import (
@@ -7,11 +8,13 @@ from typing import (
AsyncIterator,
Awaitable,
Callable,
Generic,
Iterable,
Iterator,
Optional,
Sequence,
Type,
TypeVar,
Union,
cast,
)
@@ -39,6 +42,56 @@ from langgraph.pregel.retry import arun_with_retry, run_with_retry
from langgraph.types import PregelExecutableTask, RetryPolicy
from langgraph.utils.future import chain_future
F = TypeVar("F", concurrent.futures.Future, asyncio.Future)
E = TypeVar("E", threading.Event, asyncio.Event)
class FuturesDict(Generic[F, E], dict[F, Optional[PregelExecutableTask]]):
event: E
callback: Callable[[PregelExecutableTask, Optional[BaseException]], None]
counter: int
done: set[F]
lock: threading.Lock
def __init__(
self,
event: E,
callback: Callable[[PregelExecutableTask, Optional[BaseException]], None],
future_type: Type[F],
# used for generic typing, newer py supports FutureDict[...](...)
) -> None:
super().__init__()
self.lock = threading.Lock()
self.event = event
self.callback = callback
self.counter = 0
self.done: set[F] = set()
def __setitem__(
self,
key: F,
value: Optional[PregelExecutableTask],
) -> None:
super().__setitem__(key, value) # type: ignore[index]
if value is not None:
with self.lock:
self.counter += 1
key.add_done_callback(partial(self.on_done, value))
def on_done(
self,
task: PregelExecutableTask,
fut: F,
) -> None:
try:
self.callback(task, _exception(fut))
finally:
with self.lock:
self.done.add(fut)
self.counter -= 1
if self.counter == 0 or _should_stop_others(self.done):
self.event.set()
class PregelRunner:
"""Responsible for executing a set of Pregel tasks concurrently, committing
@@ -138,7 +191,6 @@ class PregelRunner:
# updates from this tick are committed/streamed first
__next_tick__=True,
)
fut.add_done_callback(partial(self.commit, next_task))
futures[fut] = next_task
rtn[idx] = fut
return [rtn.get(i) for i in range(len(writes))]
@@ -151,17 +203,26 @@ class PregelRunner:
retry: Optional[RetryPolicy] = None,
callbacks: Callbacks = None,
) -> concurrent.futures.Future[Any]:
if asyncio.iscoroutinefunction(func):
raise RuntimeError("In an sync context async tasks cannot be called")
(fut,) = writer(
task,
[(PUSH, None)],
calls=[Call(func, input, retry=retry, callbacks=callbacks)],
)
assert fut is not None, "writer did not return a future for call"
return fut
# return a chained future to ensure commit() callback is called
# before the returned future is resolved, to ensure stream order etc
sfut: concurrent.futures.Future[Any] = concurrent.futures.Future()
chain_future(fut, sfut)
return sfut
tasks = tuple(tasks)
futures: dict[concurrent.futures.Future, Optional[PregelExecutableTask]] = {}
done_futures: set[concurrent.futures.Future] = set()
futures = FuturesDict(
callback=self.commit,
event=threading.Event(),
future_type=concurrent.futures.Future,
)
# give control back to the caller
yield
# fast path if single task with no timeout and no waiter
@@ -178,12 +239,12 @@ class PregelRunner:
)
self.commit(t, None)
except Exception as exc:
self.commit(t, None, exc)
self.commit(t, exc)
if reraise and futures:
# will be re-raised after futures are done
fut: concurrent.futures.Future = concurrent.futures.Future()
fut.set_exception(exc)
done_futures.add(fut)
futures.done.add(fut)
elif reraise:
raise
if not futures: # maybe `t` schuduled another task
@@ -206,7 +267,6 @@ class PregelRunner:
},
__reraise_on_exit__=reraise,
)
fut.add_done_callback(partial(self.commit, t))
futures[fut] = t
# execute tasks, and wait for one to fail or all to finish.
# each task is independent from all other concurrent tasks
@@ -226,9 +286,6 @@ class PregelRunner:
# waiter task finished, schedule another
if inflight and get_waiter is not None:
futures[get_waiter()] = None
else:
# store for panic check
done_futures.add(fut)
else:
# remove references to loop vars
del fut, task
@@ -237,13 +294,13 @@ class PregelRunner:
break
# give control back to the caller
yield
# wait for pending done callbacks
# if a 2nd future finishes while `wait` is returning, it's possible
# that done callbacks for the 2nd future aren't called until next tick
time.sleep(0)
# wait for done callbacks
futures.event.wait(
timeout=(max(0, end_time - time.monotonic()) if end_time else None)
)
# panic on failure or timeout
_panic_or_proceed(
done_futures.union(f for f, t in futures.items() if t is not None),
futures.done.union(f for f, t in futures.items() if t is not None),
panic=reraise,
)
@@ -293,7 +350,7 @@ class PregelRunner:
rtn[idx] = fut
elif next_task.writes:
# if it already ran, return the result
fut = asyncio.Future()
fut = asyncio.Future(loop=loop)
ret = next(
(v for c, v in next_task.writes if c == RETURN), MISSING
)
@@ -331,7 +388,6 @@ class PregelRunner:
__next_tick__=True,
),
)
fut.add_done_callback(partial(self.commit, next_task))
futures[fut] = next_task
rtn[idx] = fut
return [rtn.get(i) for i in range(len(writes))]
@@ -344,23 +400,29 @@ class PregelRunner:
retry: Optional[RetryPolicy] = None,
callbacks: Callbacks = None,
) -> Union[asyncio.Future[Any], concurrent.futures.Future[Any]]:
if not asyncio.iscoroutinefunction(func):
raise RuntimeError(
"In an async context use func.to_thread(...) to invoke tasks"
)
(fut,) = writer(
task,
[(PUSH, None)],
calls=[Call(func, input, retry=retry, callbacks=callbacks)],
)
assert fut is not None, "writer did not return a future for call"
if asyncio.iscoroutinefunction(func):
return fut
# adapted from asyncio.run_coroutine_threadsafe
sfut: concurrent.futures.Future = concurrent.futures.Future()
loop.call_soon_threadsafe(chain_future, fut, sfut)
# return a chained future to ensure commit() callback is called
# before the returned future is resolved, to ensure stream order etc
sfut: asyncio.Future[Any] = asyncio.Future(loop=loop)
chain_future(fut, sfut)
return sfut
loop = asyncio.get_event_loop()
tasks = tuple(tasks)
futures: dict[asyncio.Future, Optional[PregelExecutableTask]] = {}
done_futures: set[asyncio.Future] = set()
futures = FuturesDict(
callback=self.commit,
event=asyncio.Event(),
future_type=asyncio.Future,
)
# give control back to the caller
yield
# fast path if single task with no waiter and no timeout
@@ -378,12 +440,12 @@ class PregelRunner:
)
self.commit(t, None)
except Exception as exc:
self.commit(t, None, exc)
self.commit(t, exc)
if reraise and futures:
# will be re-raised after futures are done
fut: asyncio.Future = loop.create_future()
fut.set_exception(exc)
done_futures.add(fut)
futures.done.add(fut)
elif reraise:
raise
if not futures: # maybe `t` schuduled another task
@@ -412,7 +474,6 @@ class PregelRunner:
__reraise_on_exit__=reraise,
),
)
fut.add_done_callback(partial(self.commit, t))
futures[fut] = t
# execute tasks, and wait for one to fail or all to finish.
# each task is independent from all other concurrent tasks
@@ -432,9 +493,6 @@ class PregelRunner:
# waiter task finished, schedule another
if inflight and get_waiter is not None:
futures[get_waiter()] = None
else:
# store for panic check
done_futures.add(fut)
else:
# remove references to loop vars
del fut, task
@@ -443,16 +501,17 @@ class PregelRunner:
break
# give control back to the caller
yield
# wait for pending done callbacks
# if a 2nd future finishes while `wait` is returning, it's possible
# that done callbacks for the 2nd future aren't called until next tick
await asyncio.sleep(0)
# wait for done callbacks
await asyncio.wait_for(
futures.event.wait(),
timeout=(max(0, end_time - loop.time()) if end_time else None),
)
# cancel waiter task
for fut in futures:
fut.cancel()
# panic on failure or timeout
_panic_or_proceed(
done_futures.union(f for f, t in futures.items() if t is not None),
futures.done.union(f for f, t in futures.items() if t is not None),
timeout_exc_cls=asyncio.TimeoutError,
panic=reraise,
)
@@ -460,11 +519,8 @@ class PregelRunner:
def commit(
self,
task: PregelExecutableTask,
fut: Union[None, concurrent.futures.Future[Any], asyncio.Future[Any]],
exception: Optional[BaseException] = None,
exception: Optional[BaseException],
) -> None:
if fut is not None:
exception = _exception(fut)
if isinstance(exception, asyncio.CancelledError):
# for cancelled tasks, also save error in task,
# so loop can finish super-step
@@ -495,7 +551,7 @@ class PregelRunner:
def _should_stop_others(
done: Union[set[concurrent.futures.Future[Any]], set[asyncio.Future[Any]]],
done: set[F],
) -> bool:
"""Check if any task failed, if so, cancel all other tasks.
GraphInterrupts are not considered failures."""
+5 -2
View File
@@ -112,13 +112,16 @@ def _chain_future(source: AnyFuture, destination: AnyFuture) -> None:
source.add_done_callback(_call_set_state)
def chain_future(source: AnyFuture, destination: concurrent.futures.Future) -> None:
def chain_future(source: AnyFuture, destination: AnyFuture) -> None:
# adapted from asyncio.run_coroutine_threadsafe
try:
_chain_future(source, destination)
except (SystemExit, KeyboardInterrupt):
raise
except BaseException as exc:
if destination.set_running_or_notify_cancel():
if isinstance(destination, concurrent.futures.Future):
if destination.set_running_or_notify_cancel():
destination.set_exception(exc)
else:
destination.set_exception(exc)
raise
+3 -3
View File
@@ -1324,14 +1324,14 @@ files = [
[[package]]
name = "langchain-core"
version = "0.3.25"
version = "0.3.30"
description = "Building applications with LLMs through composability"
optional = false
python-versions = "<4.0,>=3.9"
groups = ["main", "dev"]
files = [
{file = "langchain_core-0.3.25-py3-none-any.whl", hash = "sha256:e10581c6c74ba16bdc6fdf16b00cced2aa447cc4024ed19746a1232918edde38"},
{file = "langchain_core-0.3.25.tar.gz", hash = "sha256:fdb8df41e5cdd928c0c2551ebbde1cea770ee3c64598395367ad77ddf9acbae7"},
{file = "langchain_core-0.3.30-py3-none-any.whl", hash = "sha256:0a4c4e02fac5968b67fbb0142c00c2b976c97e45fce62c7ac9eb1636a6926493"},
{file = "langchain_core-0.3.30.tar.gz", hash = "sha256:0f1281b4416977df43baf366633ad18e96c5dcaaeae6fcb8a799f9889c853243"},
]
[package.dependencies]
File diff suppressed because one or more lines are too long
+85 -5
View File
@@ -1521,9 +1521,77 @@ def test_imp_task(request: pytest.FixtureRequest, checkpointer_name: str) -> Non
assert mapper_calls == 2
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_nested(
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
def mynode(input: list[str]) -> list[str]:
return [it + "a" for it in input]
builder = StateGraph(list[str])
builder.add_node(mynode)
builder.add_edge(START, "mynode")
add_a = builder.compile()
@task
def submapper(input: int) -> str:
return str(input)
@task()
def mapper(input: int) -> str:
time.sleep(input / 100)
return submapper(input).result() * 2
@entrypoint(checkpointer=checkpointer)
def graph(input: list[int]) -> list[str]:
futures = [mapper(i) for i in input]
mapped = [f.result() for f in futures]
answer = interrupt("question")
final = [m + answer for m in mapped]
return add_a.invoke(final)
assert graph.get_input_jsonschema() == {
"type": "array",
"items": {"type": "integer"},
"title": "LangGraphInput",
}
assert graph.get_output_jsonschema() == {
"type": "array",
"items": {"type": "string"},
"title": "LangGraphOutput",
}
assert graph.get_graph().draw_mermaid() == snapshot
thread1 = {"configurable": {"thread_id": "1"}}
assert [*graph.stream([0, 1], thread1)] == [
{"submapper": "0"},
{"mapper": "00"},
{"submapper": "1"},
{"mapper": "11"},
{
"__interrupt__": (
Interrupt(
value="question",
resumable=True,
ns=[AnyStr("graph:")],
when="during",
),
)
},
]
assert graph.invoke(Command(resume="answer"), thread1) == [
"00answera",
"11answera",
]
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_imp_stream_order(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
) -> None:
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
@@ -1546,6 +1614,8 @@ def test_imp_stream_order(
fut_baz = baz(fut_bar.result())
return fut_baz.result()
assert graph.get_graph().draw_mermaid() == snapshot
thread1 = {"configurable": {"thread_id": "1"}}
assert [c for c in graph.stream({"a": "0"}, thread1)] == [
{
@@ -4951,7 +5021,7 @@ def test_interrupt_loop(request: pytest.FixtureRequest, checkpointer_name: str):
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_interrupt_functional(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
) -> None:
checkpointer: BaseCheckpointSaver = request.getfixturevalue(
f"checkpointer_{checkpointer_name}"
@@ -4973,6 +5043,8 @@ def test_interrupt_functional(
fut_bar = bar(bar_input)
return fut_bar.result()
assert graph.get_graph().draw_mermaid() == snapshot
config = {"configurable": {"thread_id": "1"}}
# First run, interrupted at bar
graph.invoke({"a": ""}, config)
@@ -4983,7 +5055,7 @@ def test_interrupt_functional(
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_interrupt_task_functional(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
) -> None:
checkpointer: BaseCheckpointSaver = request.getfixturevalue(
f"checkpointer_{checkpointer_name}"
@@ -5004,6 +5076,8 @@ def test_interrupt_task_functional(
fut_bar = bar(fut_foo.result())
return fut_bar.result()
assert graph.get_graph().draw_mermaid() == snapshot
config = {"configurable": {"thread_id": "1"}}
# First run, interrupted at bar
graph.invoke({"a": ""}, config)
@@ -5432,7 +5506,9 @@ def test_multiple_updates() -> None:
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_falsy_return_from_task(request: pytest.FixtureRequest, checkpointer_name: str):
def test_falsy_return_from_task(
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
):
"""Test with a falsy return from a task."""
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
@@ -5446,6 +5522,8 @@ def test_falsy_return_from_task(request: pytest.FixtureRequest, checkpointer_nam
falsy_task().result()
interrupt("test")
assert graph.get_graph().draw_mermaid() == snapshot
configurable = {"configurable": {"thread_id": str(uuid.uuid4())}}
graph.invoke({"a": 5}, configurable)
graph.invoke(Command(resume="123"), configurable)
@@ -5453,7 +5531,7 @@ def test_falsy_return_from_task(request: pytest.FixtureRequest, checkpointer_nam
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_SYNC)
def test_multiple_interrupts_imperative(
request: pytest.FixtureRequest, checkpointer_name: str
request: pytest.FixtureRequest, checkpointer_name: str, snapshot: SnapshotAssertion
):
"""Test multiple interrupts with an imperative API."""
checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}")
@@ -5478,6 +5556,8 @@ def test_multiple_interrupts_imperative(
return {"values": values}
assert graph.get_graph().draw_mermaid() == snapshot
configurable = {"configurable": {"thread_id": str(uuid.uuid4())}}
graph.invoke({}, configurable)
graph.invoke(Command(resume="a"), configurable)
+3 -1
View File
@@ -1132,7 +1132,8 @@ async def test_node_not_cancelled_on_other_node_interrupted(
assert awhiles == 1
async def test_step_timeout_on_stream_hang() -> None:
@pytest.mark.parametrize("stream_hang_s", [0.3, 0.6])
async def test_step_timeout_on_stream_hang(stream_hang_s: float) -> None:
inner_task_cancelled = False
async def awhile(input: Any) -> None:
@@ -2534,6 +2535,7 @@ async def test_imp_task_cancel(checkpointer_name: str) -> None:
assert mapper_cancels == 2
@pytest.mark.skip("TODO: re-enable")
@NEEDS_CONTEXTVARS
@pytest.mark.parametrize("checkpointer_name", ALL_CHECKPOINTERS_ASYNC)
async def test_imp_sync_from_async(checkpointer_name: str) -> None: