Fix IsLastStep counter for runs with checkpointers

- Share step/stop logic with PregelLoop
- Add RemainingSteps value which contains the number of remaining steps
- Switch create_react_agent to use RemainingSteps, so that it behave correctly for return_direct tools
This commit is contained in:
Nuno Campos
2024-10-14 16:54:16 -07:00
parent ff310cc8d6
commit ecd75a8c4d
11 changed files with 168 additions and 110 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
from langgraph.managed.is_last_step import IsLastStep
from langgraph.managed.is_last_step import IsLastStep, RemainingSteps
__all__ = ["IsLastStep"]
__all__ = ["IsLastStep", "RemainingSteps"]
+9 -8
View File
@@ -13,22 +13,23 @@ from typing import (
Union,
)
from langchain_core.runnables import RunnableConfig
from typing_extensions import Self, TypeGuard
from langgraph.types import LoopProtocol
V = TypeVar("V")
U = TypeVar("U")
class ManagedValue(ABC, Generic[V]):
def __init__(self, config: RunnableConfig) -> None:
self.config = config
def __init__(self, loop: LoopProtocol) -> None:
self.loop = loop
@classmethod
@contextmanager
def enter(cls, config: RunnableConfig, **kwargs: Any) -> Iterator[Self]:
def enter(cls, loop: LoopProtocol, **kwargs: Any) -> Iterator[Self]:
try:
value = cls(config, **kwargs)
value = cls(loop, **kwargs)
yield value
finally:
# because managed value and Pregel have reference to each other
@@ -40,9 +41,9 @@ class ManagedValue(ABC, Generic[V]):
@classmethod
@asynccontextmanager
async def aenter(cls, config: RunnableConfig, **kwargs: Any) -> AsyncIterator[Self]:
async def aenter(cls, loop: LoopProtocol, **kwargs: Any) -> AsyncIterator[Self]:
try:
value = cls(config, **kwargs)
value = cls(loop, **kwargs)
yield value
finally:
# because managed value and Pregel have reference to each other
@@ -53,7 +54,7 @@ class ManagedValue(ABC, Generic[V]):
pass
@abstractmethod
def __call__(self, step: int) -> V: ...
def __call__(self) -> V: ...
class WritableManagedValue(Generic[V, U], ManagedValue[V], ABC):
+10 -10
View File
@@ -13,10 +13,10 @@ from typing import (
Union,
)
from langchain_core.runnables import RunnableConfig
from typing_extensions import Self
from langgraph.managed.base import ConfiguredManagedValue, ManagedValue, V
from langgraph.types import LoopProtocol
class Context(ManagedValue[V], Generic[V]):
@@ -46,14 +46,14 @@ class Context(ManagedValue[V], Generic[V]):
@classmethod
@contextmanager
def enter(cls, config: RunnableConfig, **kwargs: Any) -> Iterator[Self]:
with super().enter(config, **kwargs) as self:
def enter(cls, loop: LoopProtocol, **kwargs: Any) -> Iterator[Self]:
with super().enter(loop, **kwargs) as self:
if self.ctx is None:
raise ValueError(
"Synchronous context manager not found. Please initialize Context value with a sync context manager, or invoke your graph asynchronously."
)
ctx = (
self.ctx(config) # type: ignore[call-arg]
self.ctx(loop.config) # type: ignore[call-arg]
if signature(self.ctx).parameters.get("config")
else self.ctx()
)
@@ -63,17 +63,17 @@ class Context(ManagedValue[V], Generic[V]):
@classmethod
@asynccontextmanager
async def aenter(cls, config: RunnableConfig, **kwargs: Any) -> AsyncIterator[Self]:
async with super().aenter(config, **kwargs) as self:
async def aenter(cls, loop: LoopProtocol, **kwargs: Any) -> AsyncIterator[Self]:
async with super().aenter(loop, **kwargs) as self:
if self.actx is not None:
ctx = (
self.actx(config) # type: ignore[call-arg]
self.actx(loop.config) # type: ignore[call-arg]
if signature(self.actx).parameters.get("config")
else self.actx()
)
elif self.ctx is not None:
ctx = (
self.ctx(config) # type: ignore
self.ctx(loop.config) # type: ignore
if signature(self.ctx).parameters.get("config")
else self.ctx()
)
@@ -96,7 +96,7 @@ class Context(ManagedValue[V], Generic[V]):
def __init__(
self,
config: RunnableConfig,
loop: LoopProtocol,
*,
ctx: Union[None, Type[ContextManager[V]], Type[AsyncContextManager[V]]] = None,
actx: Optional[Type[AsyncContextManager[V]]] = None,
@@ -104,5 +104,5 @@ class Context(ManagedValue[V], Generic[V]):
self.ctx = ctx
self.actx = actx
def __call__(self, step: int) -> V:
def __call__(self) -> V:
return self.value
@@ -4,8 +4,16 @@ from langgraph.managed.base import ManagedValue
class IsLastStepManager(ManagedValue[bool]):
def __call__(self, step: int) -> bool:
return step == self.config.get("recursion_limit", 0) - 1
def __call__(self) -> bool:
return self.loop.step == self.loop.stop - 1
IsLastStep = Annotated[bool, IsLastStepManager]
class RemainingStepsManager(ManagedValue[bool]):
def __call__(self) -> bool:
return self.loop.stop - self.loop.step
RemainingSteps = Annotated[bool, RemainingStepsManager]
@@ -7,13 +7,11 @@ from typing import (
Optional,
Sequence,
Type,
cast,
)
from langchain_core.runnables import RunnableConfig
from typing_extensions import NotRequired, Required, Self
from langgraph.constants import CONF, CONFIG_KEY_STORE
from langgraph.constants import CONF
from langgraph.errors import InvalidUpdateError
from langgraph.managed.base import (
ChannelKeyPlaceholder,
@@ -21,7 +19,8 @@ from langgraph.managed.base import (
ConfiguredManagedValue,
WritableManagedValue,
)
from langgraph.store.base import BaseStore, PutOp
from langgraph.store.base import PutOp
from langgraph.types import LoopProtocol
V = dict[str, Any]
@@ -55,25 +54,26 @@ class SharedValue(WritableManagedValue[Value, Update]):
@classmethod
@contextmanager
def enter(cls, config: RunnableConfig, **kwargs: Any) -> Iterator[Self]:
with super().enter(config, **kwargs) as value:
if value.store is not None:
saved = value.store.search(value.ns)
def enter(cls, loop: LoopProtocol, **kwargs: Any) -> Iterator[Self]:
with super().enter(loop, **kwargs) as value:
if loop.store is not None:
saved = loop.store.search(value.ns)
value.value = {it.key: it.value for it in saved}
yield value
@classmethod
@asynccontextmanager
async def aenter(cls, config: RunnableConfig, **kwargs: Any) -> AsyncIterator[Self]:
async with super().aenter(config, **kwargs) as value:
if value.store is not None:
saved = await value.store.asearch(value.ns)
async def aenter(cls, loop: LoopProtocol, **kwargs: Any) -> AsyncIterator[Self]:
async with super().aenter(loop, **kwargs) as value:
if loop.store is not None:
saved = await loop.store.asearch(value.ns)
value.value = {it.key: it.value for it in saved}
yield value
def __init__(
self, config: RunnableConfig, *, typ: Type[Any], scope: str, key: str
self, loop: LoopProtocol, *, typ: Type[Any], scope: str, key: str
) -> None:
super().__init__(loop)
if typ := _strip_extras(typ):
if typ not in (
dict,
@@ -83,18 +83,17 @@ class SharedValue(WritableManagedValue[Value, Update]):
raise ValueError("SharedValue must be a dict")
self.scope = scope
self.value: Value = {}
self.store = cast(BaseStore, config[CONF].get(CONFIG_KEY_STORE))
if self.store is None:
if self.loop.store is None:
pass
elif scope_value := config[CONF].get(self.scope):
elif scope_value := self.loop.config[CONF].get(self.scope):
self.ns = ("scoped", scope, key, scope_value)
else:
raise ValueError(
f"Scope {scope} for shared state key not in config.configurable"
)
def __call__(self, step: int) -> Value:
return self.value.copy()
def __call__(self) -> Value:
return self.value
def _process_update(self, values: Sequence[Update]) -> list[PutOp]:
writes: list[PutOp] = []
@@ -112,13 +111,13 @@ class SharedValue(WritableManagedValue[Value, Update]):
return writes
def update(self, values: Sequence[Update]) -> None:
if self.store is None:
if self.loop.store is None:
self._process_update(values)
else:
return self.store.batch(self._process_update(values))
return self.loop.store.batch(self._process_update(values))
async def aupdate(self, writes: Sequence[Update]) -> None:
if self.store is None:
if self.loop.store is None:
self._process_update(writes)
else:
return await self.store.abatch(self._process_update(writes))
return await self.loop.store.abatch(self._process_update(writes))
@@ -14,7 +14,7 @@ from langgraph._api.deprecation import deprecated_parameter
from langgraph.graph import StateGraph
from langgraph.graph.graph import CompiledGraph
from langgraph.graph.message import add_messages
from langgraph.managed import IsLastStep
from langgraph.managed import IsLastStep, RemainingSteps
from langgraph.prebuilt.tool_executor import ToolExecutor
from langgraph.prebuilt.tool_node import ToolNode
from langgraph.store.base import BaseStore
@@ -33,6 +33,8 @@ class AgentState(TypedDict):
is_last_step: IsLastStep
remaining_steps: RemainingSteps
StateSchema = TypeVar("StateSchema", bound=AgentState)
StateSchemaType = Type[StateSchema]
@@ -529,10 +531,14 @@ def create_react_agent(
# Define the function that calls the model
def call_model(state: AgentState, config: RunnableConfig) -> AgentState:
response = model_runnable.invoke(state, config)
if (
state["is_last_step"]
and isinstance(response, AIMessage)
and response.tool_calls
has_tool_calls = isinstance(response, AIMessage) and response.tool_calls
all_tools_return_direct = (
all(call["name"] in should_return_direct for call in response.tool_calls)
if isinstance(response, AIMessage)
else False
)
if (state["remaining_steps"] < 1 and all_tools_return_direct) or (
state["remaining_steps"] < 2 and has_tool_calls
):
return {
"messages": [
@@ -547,10 +553,14 @@ def create_react_agent(
async def acall_model(state: AgentState, config: RunnableConfig) -> AgentState:
response = await model_runnable.ainvoke(state, config)
if (
state["is_last_step"]
and isinstance(response, AIMessage)
and response.tool_calls
has_tool_calls = isinstance(response, AIMessage) and response.tool_calls
all_tools_return_direct = (
all(call["name"] in should_return_direct for call in response.tool_calls)
if isinstance(response, AIMessage)
else False
)
if (state["remaining_steps"] < 1 and all_tools_return_direct) or (
state["remaining_steps"] < 2 and has_tool_calls
):
return {
"messages": [
+27 -5
View File
@@ -88,7 +88,7 @@ from langgraph.pregel.utils import find_subgraph_pregel, get_new_channel_version
from langgraph.pregel.validate import validate_graph, validate_keys
from langgraph.pregel.write import ChannelWrite, ChannelWriteEntry
from langgraph.store.base import BaseStore
from langgraph.types import All, Checkpointer, StateSnapshot, StreamMode
from langgraph.types import All, Checkpointer, LoopProtocol, StateSnapshot, StreamMode
from langgraph.utils.config import (
ensure_config,
merge_configs,
@@ -433,7 +433,14 @@ class Pregel(Runnable[Union[dict[str, Any], Any], Union[dict[str, Any], Any]]):
)
with ChannelsManager(
self.channels, saved.checkpoint, saved.config, skip_context=True
self.channels,
saved.checkpoint,
LoopProtocol(
config=saved.config,
step=saved.metadata["step"],
stop=saved.metadata["step"] + 1,
),
skip_context=True,
) as (channels, managed):
# tasks for this checkpoint
next_tasks = prepare_next_tasks(
@@ -511,7 +518,14 @@ class Pregel(Runnable[Union[dict[str, Any], Any], Union[dict[str, Any], Any]]):
)
async with AsyncChannelsManager(
self.channels, saved.checkpoint, saved.config, skip_context=True
self.channels,
saved.checkpoint,
LoopProtocol(
config=saved.config,
step=saved.metadata["step"],
stop=saved.metadata["step"] + 1,
),
skip_context=True,
) as (
channels,
managed,
@@ -835,7 +849,11 @@ class Pregel(Runnable[Union[dict[str, Any], Any], Union[dict[str, Any], Any]]):
if as_node not in self.nodes:
raise InvalidUpdateError(f"Node {as_node} does not exist")
# update channels
with ChannelsManager(self.channels, checkpoint, config) as (
with ChannelsManager(
self.channels,
checkpoint,
LoopProtocol(config=config, step=step, stop=step + 1),
) as (
channels,
managed,
):
@@ -981,7 +999,11 @@ class Pregel(Runnable[Union[dict[str, Any], Any], Union[dict[str, Any], Any]]):
if as_node not in self.nodes:
raise InvalidUpdateError(f"Node {as_node} does not exist")
# update channels, acting as the chosen node
async with AsyncChannelsManager(self.channels, checkpoint, config) as (
async with AsyncChannelsManager(
self.channels,
checkpoint,
LoopProtocol(config=config, step=step, stop=step + 1),
) as (
channels,
managed,
):
+5 -8
View File
@@ -57,7 +57,7 @@ from langgraph.pregel.log import logger
from langgraph.pregel.manager import ChannelsManager
from langgraph.pregel.read import PregelNode
from langgraph.store.base import BaseStore
from langgraph.types import All, PregelExecutableTask, PregelTask
from langgraph.types import All, LoopProtocol, PregelExecutableTask, PregelTask
from langgraph.utils.config import merge_configs, patch_config
GetNextVersion = Callable[[Optional[V], BaseChannel], V]
@@ -148,7 +148,7 @@ def local_read(
with ChannelsManager(
{k: v for k, v in channels.items() if k in updated},
checkpoint,
config,
LoopProtocol(config=config, step=step, stop=step + 1),
skip_context=True,
) as (local_channels, _):
apply_writes(copy_checkpoint(checkpoint), local_channels, [task], None)
@@ -156,7 +156,7 @@ def local_read(
else:
values = read_channels(channels, select)
if managed_keys:
values.update({k: managed[k](step) for k in managed_keys})
values.update({k: managed[k]() for k in managed_keys})
return values
@@ -493,9 +493,7 @@ def prepare_single_task(
):
try:
val = next(
_proc_input(
step, proc, managed, channels, for_execution=for_execution
)
_proc_input(proc, managed, channels, for_execution=for_execution)
)
except StopIteration:
return
@@ -583,7 +581,6 @@ def prepare_single_task(
def _proc_input(
step: int,
proc: PregelNode,
managed: ManagedValueMapping,
channels: Mapping[str, BaseChannel],
@@ -605,7 +602,7 @@ def _proc_input(
except EmptyChannelError:
continue
else:
val[k] = managed[k](step)
val[k] = managed[k]()
except EmptyChannelError:
return
elif isinstance(proc.channels, list):
+11 -28
View File
@@ -100,7 +100,7 @@ from langgraph.pregel.manager import AsyncChannelsManager, ChannelsManager
from langgraph.pregel.read import PregelNode
from langgraph.pregel.utils import get_new_channel_versions
from langgraph.store.base import BaseStore
from langgraph.types import All, PregelExecutableTask, StreamMode
from langgraph.types import All, LoopProtocol, PregelExecutableTask, StreamProtocol
from langgraph.utils.config import patch_configurable
V = TypeVar("V")
@@ -112,22 +112,6 @@ INPUT_RESUMING = object()
SPECIAL_CHANNELS = (ERROR, INTERRUPT, SCHEDULED)
class StreamProtocol:
__slots__ = ("modes", "__call__")
modes: set[StreamMode]
__call__: Callable[[StreamChunk], None]
def __init__(
self,
__call__: Callable[[StreamChunk], None],
modes: set[StreamMode],
) -> None:
self.__call__ = __call__
self.modes = modes
def DuplexStream(*streams: StreamProtocol) -> StreamProtocol:
def __call__(value: StreamChunk) -> None:
for stream in streams:
@@ -137,16 +121,13 @@ def DuplexStream(*streams: StreamProtocol) -> StreamProtocol:
return StreamProtocol(__call__, {mode for s in streams for mode in s.modes})
class PregelLoop:
class PregelLoop(LoopProtocol):
input: Optional[Any]
config: RunnableConfig
store: Optional[BaseStore]
checkpointer: Optional[BaseCheckpointSaver]
nodes: Mapping[str, PregelNode]
specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]]
output_keys: Union[str, Sequence[str]]
stream_keys: Union[str, Sequence[str]]
stream: Optional[StreamProtocol]
skip_done_tasks: bool
is_nested: bool
@@ -177,8 +158,6 @@ class PregelLoop:
checkpoint_previous_versions: dict[str, Union[str, float, int]]
prev_checkpoint_config: Optional[RunnableConfig]
step: int
stop: int
status: Literal[
"pending", "done", "interrupt_before", "interrupt_after", "out_of_steps"
]
@@ -202,10 +181,14 @@ class PregelLoop:
check_subgraphs: bool = True,
debug: bool = False,
) -> None:
self.stream = stream
super().__init__(
step=0,
stop=0,
config=config,
stream=stream,
store=store,
)
self.input = input
self.config = config
self.store = store
self.checkpointer = checkpointer
self.nodes = nodes
self.specs = specs
@@ -730,7 +713,7 @@ class SyncPregelLoop(PregelLoop, ContextManager):
self.submit = self.stack.enter_context(BackgroundExecutor(self.config))
self.channels, self.managed = self.stack.enter_context(
ChannelsManager(self.specs, self.checkpoint, self.config, self.store)
ChannelsManager(self.specs, self.checkpoint, self)
)
self.stack.push(self._suppress_interrupt)
self.status = "pending"
@@ -858,7 +841,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager):
self.submit = await self.stack.enter_async_context(AsyncBackgroundExecutor())
self.channels, self.managed = await self.stack.enter_async_context(
AsyncChannelsManager(self.specs, self.checkpoint, self.config, self.store)
AsyncChannelsManager(self.specs, self.checkpoint, self)
)
self.stack.push(self._suppress_interrupt)
self.status = "pending"
+8 -16
View File
@@ -1,33 +1,27 @@
import asyncio
from contextlib import AsyncExitStack, ExitStack, asynccontextmanager, contextmanager
from typing import AsyncIterator, Iterator, Mapping, Optional, Union
from langchain_core.runnables import RunnableConfig
from typing import AsyncIterator, Iterator, Mapping, Union
from langgraph.channels.base import BaseChannel
from langgraph.checkpoint.base import Checkpoint
from langgraph.constants import CONFIG_KEY_STORE
from langgraph.managed.base import (
ConfiguredManagedValue,
ManagedValueMapping,
ManagedValueSpec,
)
from langgraph.managed.context import Context
from langgraph.store.base import BaseStore
from langgraph.utils.config import patch_configurable
from langgraph.types import LoopProtocol
@contextmanager
def ChannelsManager(
specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]],
checkpoint: Checkpoint,
config: RunnableConfig,
store: Optional[BaseStore] = None,
loop: LoopProtocol,
*,
skip_context: bool = False,
) -> Iterator[tuple[Mapping[str, BaseChannel], ManagedValueMapping]]:
"""Manage channels for the lifetime of a Pregel invocation (multiple steps)."""
config_for_managed = patch_configurable(config, {CONFIG_KEY_STORE: store})
channel_specs: dict[str, BaseChannel] = {}
managed_specs: dict[str, ManagedValueSpec] = {}
for k, v in specs.items():
@@ -48,9 +42,9 @@ def ChannelsManager(
ManagedValueMapping(
{
key: stack.enter_context(
value.cls.enter(config_for_managed, **value.kwargs)
value.cls.enter(loop, **value.kwargs)
if isinstance(value, ConfiguredManagedValue)
else value.enter(config_for_managed)
else value.enter(loop)
)
for key, value in managed_specs.items()
}
@@ -62,13 +56,11 @@ def ChannelsManager(
async def AsyncChannelsManager(
specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]],
checkpoint: Checkpoint,
config: RunnableConfig,
store: Optional[BaseStore] = None,
loop: LoopProtocol,
*,
skip_context: bool = False,
) -> AsyncIterator[tuple[Mapping[str, BaseChannel], ManagedValueMapping]]:
"""Manage channels for the lifetime of a Pregel invocation (multiple steps)."""
config_for_managed = patch_configurable(config, {CONFIG_KEY_STORE: store})
channel_specs: dict[str, BaseChannel] = {}
managed_specs: dict[str, ManagedValueSpec] = {}
for k, v in specs.items():
@@ -85,9 +77,9 @@ async def AsyncChannelsManager(
if tasks := {
asyncio.create_task(
stack.enter_async_context(
value.cls.aenter(config_for_managed, **value.kwargs)
value.cls.aenter(loop, **value.kwargs)
if isinstance(value, ConfiguredManagedValue)
else value.aenter(config_for_managed)
else value.aenter(loop)
)
): key
for key, value in managed_specs.items()
+46
View File
@@ -1,6 +1,7 @@
from collections import deque
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
Callable,
Literal,
@@ -15,6 +16,9 @@ from langchain_core.runnables import Runnable, RunnableConfig
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
if TYPE_CHECKING:
from langgraph.store.base import BaseStore
All = Literal["*"]
"""Special value to indicate that graph should interrupt on all nodes."""
@@ -213,3 +217,45 @@ class Send:
and self.node == value.node
and self.arg == value.arg
)
StreamChunk = tuple[tuple[str, ...], str, Any]
class StreamProtocol:
__slots__ = ("modes", "__call__")
modes: set[StreamMode]
__call__: Callable[[StreamChunk], None]
def __init__(
self,
__call__: Callable[[StreamChunk], None],
modes: set[StreamMode],
) -> None:
self.__call__ = __call__
self.modes = modes
class LoopProtocol:
config: RunnableConfig
store: Optional["BaseStore"]
stream: Optional[StreamProtocol]
step: int
stop: int
def __init__(
self,
*,
step: int,
stop: int,
config: RunnableConfig,
store: Optional["BaseStore"] = None,
stream: Optional[StreamProtocol] = None,
) -> None:
self.stream = stream
self.config = config
self.store = store
self.step = step
self.stop = stop