mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-22 07:32:25 +02:00
WIP Waiting edge
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator, Generic, Optional, Sequence, Type
|
||||
|
||||
from typing_extensions import Self
|
||||
|
||||
from langgraph.channels.base import (
|
||||
BaseChannel,
|
||||
EmptyChannelError,
|
||||
InvalidUpdateError,
|
||||
Value,
|
||||
)
|
||||
|
||||
|
||||
class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, Value]):
|
||||
"""A channel that waits until all named values are received before making the value available."""
|
||||
|
||||
def __init__(self, typ: Type[Value], names: set[str]) -> None:
|
||||
self.typ = typ
|
||||
self.names = names
|
||||
self.seen = set()
|
||||
|
||||
@property
|
||||
def ValueType(self) -> Type[Value]:
|
||||
"""The type of the value stored in the channel."""
|
||||
return self.typ
|
||||
|
||||
@property
|
||||
def UpdateType(self) -> Type[Value]:
|
||||
"""The type of the update received by the channel."""
|
||||
return self.typ
|
||||
|
||||
@contextmanager
|
||||
def empty(self, checkpoint: Optional[Value] = None) -> Generator[Self, None, None]:
|
||||
empty = self.__class__(self.typ, self.names)
|
||||
if checkpoint is not None:
|
||||
empty.value = checkpoint
|
||||
try:
|
||||
yield empty
|
||||
finally:
|
||||
try:
|
||||
del empty.value
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
def update(self, values: Sequence[Value]) -> None:
|
||||
if self.seen == self.names:
|
||||
self.seen = set()
|
||||
for value in values:
|
||||
if value in self.names:
|
||||
self.seen.add(value)
|
||||
else:
|
||||
raise InvalidUpdateError(f"Value {value} not in {self.names}")
|
||||
|
||||
def get(self) -> Value:
|
||||
if self.seen != self.names:
|
||||
raise EmptyChannelError()
|
||||
return None
|
||||
|
||||
def checkpoint(self) -> Value:
|
||||
return self.seen
|
||||
@@ -146,8 +146,13 @@ class Graph:
|
||||
def set_finish_point(self, key: str) -> None:
|
||||
return self.add_edge(key, END)
|
||||
|
||||
def validate(self, interrupt: Optional[Sequence[str]] = None) -> None:
|
||||
all_starts = {src for src, _ in self.edges} | {src for src in self.branches}
|
||||
def validate(
|
||||
self,
|
||||
interrupt: Optional[Sequence[str]] = None,
|
||||
additional_edges: Optional[set[tuple[str, str]]] = None,
|
||||
) -> None:
|
||||
edges = self.edges.union(additional_edges or [])
|
||||
all_starts = {src for src, _ in edges} | {src for src in self.branches}
|
||||
for node in self.nodes:
|
||||
if node not in all_starts:
|
||||
raise ValueError(f"Node `{node}` is a dead-end")
|
||||
@@ -158,7 +163,7 @@ class Graph:
|
||||
if self.entry_point_branch is not None:
|
||||
branches.append(self.entry_point_branch)
|
||||
|
||||
all_hard_ends = {end for _, end in self.edges}
|
||||
all_hard_ends = {end for _, end in edges}
|
||||
if self.entry_point is not None:
|
||||
all_hard_ends.add(self.entry_point)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from functools import partial
|
||||
from inspect import signature
|
||||
@@ -11,12 +12,15 @@ from langgraph.channels.base import BaseChannel, InvalidUpdateError
|
||||
from langgraph.channels.binop import BinaryOperatorAggregate
|
||||
from langgraph.channels.ephemeral_value import EphemeralValue
|
||||
from langgraph.channels.last_value import LastValue
|
||||
from langgraph.channels.named_barrier_value import NamedBarrierValue
|
||||
from langgraph.checkpoint import BaseCheckpointSaver
|
||||
from langgraph.graph.graph import END, START, CompiledGraph, Graph
|
||||
from langgraph.pregel import Channel
|
||||
from langgraph.pregel.read import ChannelInvoke
|
||||
from langgraph.pregel.write import SKIP_WRITE, ChannelWrite, ChannelWriteEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StateGraph(Graph):
|
||||
def __init__(self, schema: Type[Any]) -> None:
|
||||
@@ -25,6 +29,7 @@ class StateGraph(Graph):
|
||||
self.channels = _get_channels(schema)
|
||||
if any(isinstance(c, BinaryOperatorAggregate) for c in self.channels.values()):
|
||||
self.support_multiple_edges = True
|
||||
self.w_edges: set[tuple[tuple[str, ...], str]] = set()
|
||||
|
||||
def add_node(self, key: str, action: RunnableLike) -> None:
|
||||
if key in self.channels:
|
||||
@@ -34,6 +39,24 @@ class StateGraph(Graph):
|
||||
)
|
||||
return super().add_node(key, action)
|
||||
|
||||
def add_waiting_edge(self, starts: Sequence[str], end: str) -> None:
|
||||
if self.compiled:
|
||||
logger.warning(
|
||||
"Adding an edge to a graph that has already been compiled. This will "
|
||||
"not be reflected in the compiled graph."
|
||||
)
|
||||
for start in starts:
|
||||
if start == END:
|
||||
raise ValueError("END cannot be a start node")
|
||||
if start not in self.nodes:
|
||||
raise ValueError(f"Need to add_node `{start}` first")
|
||||
if end == END:
|
||||
raise ValueError("END cannot be an end node")
|
||||
if end not in self.nodes:
|
||||
raise ValueError(f"Need to add_node `{end}` first")
|
||||
|
||||
self.w_edges.add((tuple(starts), end))
|
||||
|
||||
def compile(
|
||||
self,
|
||||
checkpointer: Optional[BaseCheckpointSaver] = None,
|
||||
@@ -43,7 +66,12 @@ class StateGraph(Graph):
|
||||
) -> CompiledGraph:
|
||||
interrupt_before = interrupt_before or []
|
||||
interrupt_after = interrupt_after or []
|
||||
self.validate(interrupt=interrupt_before + interrupt_after)
|
||||
self.validate(
|
||||
interrupt=interrupt_before + interrupt_after,
|
||||
additional_edges={
|
||||
(start, end) for starts, end in self.w_edges for start in starts
|
||||
},
|
||||
)
|
||||
|
||||
state_keys = list(self.channels)
|
||||
state_keys_read = state_keys[0] if state_keys == ["__root__"] else state_keys
|
||||
@@ -68,14 +96,27 @@ class StateGraph(Graph):
|
||||
else None
|
||||
)
|
||||
|
||||
waiting_edges = {
|
||||
(f"{starts}:{end}", starts, end) for starts, end in self.w_edges
|
||||
}
|
||||
waiting_edge_channels = {
|
||||
key: NamedBarrierValue(str, set(starts)) for key, starts, _ in waiting_edges
|
||||
}
|
||||
|
||||
outgoing_edges = defaultdict(list)
|
||||
for start, end in self.edges:
|
||||
outgoing_edges[start].append(f"{end}:inbox" if end != END else END)
|
||||
for key, starts, end in waiting_edges:
|
||||
for start in starts:
|
||||
outgoing_edges[start].append(key)
|
||||
|
||||
nodes = {
|
||||
key: (
|
||||
ChannelInvoke(
|
||||
triggers=[f"{key}:inbox"],
|
||||
triggers=[
|
||||
f"{key}:inbox",
|
||||
*[chan for chan, _, end in waiting_edges if end == key],
|
||||
],
|
||||
channels=state_channels,
|
||||
mapper=coerce_state,
|
||||
)
|
||||
@@ -142,6 +183,7 @@ class StateGraph(Graph):
|
||||
**self.channels,
|
||||
**node_inboxes,
|
||||
**node_outboxes,
|
||||
**waiting_edge_channels,
|
||||
END: LastValue(self.schema),
|
||||
},
|
||||
input=f"{START}:inbox",
|
||||
|
||||
+15
-3
@@ -1,9 +1,21 @@
|
||||
from typing import Any, Iterator, Mapping, Optional, Sequence, Union
|
||||
|
||||
from langgraph.channels.base import BaseChannel
|
||||
from langgraph.channels.base import BaseChannel, EmptyChannelError
|
||||
from langgraph.pregel.log import logger
|
||||
|
||||
|
||||
def _read_channel(
|
||||
channels: Mapping[str, BaseChannel], chan: str, catch: bool = True
|
||||
) -> Any:
|
||||
try:
|
||||
return channels[chan].get()
|
||||
except EmptyChannelError:
|
||||
if catch:
|
||||
return None
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def map_input(
|
||||
input_channels: Union[str, Sequence[str]],
|
||||
chunk: Optional[Union[dict[str, Any], Any]],
|
||||
@@ -31,8 +43,8 @@ def map_output(
|
||||
"""Map pending writes (a sequence of tuples (channel, value)) to output chunk."""
|
||||
if isinstance(output_channels, str):
|
||||
if any(chan == output_channels for chan, _ in pending_writes):
|
||||
return channels[output_channels].get()
|
||||
return _read_channel(channels, output_channels)
|
||||
else:
|
||||
if updated := {c for c, _ in pending_writes if c in output_channels}:
|
||||
return {chan: channels[chan].get() for chan in updated}
|
||||
return {chan: _read_channel(channels, chan) for chan in updated}
|
||||
return None
|
||||
|
||||
@@ -2783,3 +2783,83 @@ def test_in_one_fan_out_out_one_graph_state() -> None:
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_in_one_fan_out_waiting_edge_graph_state() -> None:
|
||||
def sorted_add(
|
||||
x: list[str], y: Union[list[str], list[tuple[str, str]]]
|
||||
) -> list[str]:
|
||||
if isinstance(y[0], tuple):
|
||||
for rem, _ in y:
|
||||
x.remove(rem)
|
||||
y = [t[1] for t in y]
|
||||
return sorted(operator.add(x, y))
|
||||
|
||||
class State(TypedDict, total=False):
|
||||
query: str
|
||||
answer: str
|
||||
docs: Annotated[list[str], sorted_add]
|
||||
|
||||
def rewrite_query(data: State) -> State:
|
||||
return {"query": f'query: {data["query"]}'}
|
||||
|
||||
def analyzer_one(data: State) -> State:
|
||||
return {"query": f'analyzed: {data["query"]}'}
|
||||
|
||||
def retriever_one(data: State) -> State:
|
||||
return {"docs": ["doc1", "doc2"]}
|
||||
|
||||
def retriever_two(data: State) -> State:
|
||||
return {"docs": ["doc3", "doc4"]}
|
||||
|
||||
def qa(data: State) -> State:
|
||||
return {"answer": ",".join(data["docs"])}
|
||||
|
||||
workflow = StateGraph(State)
|
||||
|
||||
workflow.add_node("rewrite_query", rewrite_query)
|
||||
workflow.add_node("analyzer_one", analyzer_one)
|
||||
workflow.add_node("retriever_one", retriever_one)
|
||||
workflow.add_node("retriever_two", retriever_two)
|
||||
workflow.add_node("qa", qa)
|
||||
|
||||
workflow.set_entry_point("rewrite_query")
|
||||
workflow.add_edge("rewrite_query", "analyzer_one")
|
||||
workflow.add_edge("analyzer_one", "retriever_one")
|
||||
workflow.add_edge("rewrite_query", "retriever_two")
|
||||
workflow.add_waiting_edge(["retriever_one", "retriever_two"], "qa")
|
||||
workflow.set_finish_point("qa")
|
||||
|
||||
app = workflow.compile()
|
||||
|
||||
assert app.invoke({"query": "what is weather in sf"}, debug=True) == {
|
||||
"query": "analyzed: query: what is weather in sf",
|
||||
"docs": ["doc1", "doc2", "doc3", "doc4"],
|
||||
"answer": "doc1,doc2,doc3,doc4",
|
||||
}
|
||||
|
||||
assert [*app.stream({"query": "what is weather in sf"})] == [
|
||||
{"rewrite_query": {"query": "query: what is weather in sf"}},
|
||||
{
|
||||
"analyzer_one": {"query": "analyzed: query: what is weather in sf"},
|
||||
"retriever_two": {"docs": ["doc3", "doc4"]},
|
||||
},
|
||||
{"('retriever_one', 'retriever_two'):qa": None},
|
||||
{"retriever_one": {"docs": ["doc1", "doc2"]}, "qa": {"answer": "doc3,doc4"}},
|
||||
{
|
||||
"('retriever_on', 'retriever_two'):qa": None,
|
||||
"__end__": {
|
||||
"query": "analyzed: query: what is weather in sf",
|
||||
"answer": "doc3,doc4",
|
||||
"docs": ["doc1", "doc2", "doc3", "doc4"],
|
||||
},
|
||||
},
|
||||
{"qa": {"answer": "doc1,doc2,doc3,doc4"}},
|
||||
{
|
||||
"__end__": {
|
||||
"query": "analyzed: query: what is weather in sf",
|
||||
"answer": "doc1,doc2,doc3,doc4",
|
||||
"docs": ["doc1", "doc2", "doc3", "doc4"],
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user