diff --git a/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/__init__.py b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/__init__.py index 077d062b9..0fd21db7a 100644 --- a/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/__init__.py +++ b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/__init__.py @@ -2,5 +2,15 @@ LangGraph Checkpoint AWS - A LangChain checkpointer implementation using Bedrock Session Management Service. """ +from langgraph_checkpoint_aws.agentcore.saver import ( + AgentCoreMemorySaver, +) + __version__ = "0.1.2" SDK_USER_AGENT = f"LangGraphCheckpointAWS#{__version__}" + +# Expose the saver class at the package level +__all__ = [ + "AgentCoreMemorySaver", + "SDK_USER_AGENT", +] diff --git a/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/__init__.py b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/__init__.py new file mode 100644 index 000000000..9c52f924f --- /dev/null +++ b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/__init__.py @@ -0,0 +1,3 @@ +from langgraph_checkpoint_aws.agentcore.saver import AgentCoreMemorySaver + +__all__ = ["AgentCoreMemorySaver"] diff --git a/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/constants.py b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/constants.py new file mode 100644 index 000000000..6374989f2 --- /dev/null +++ b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/constants.py @@ -0,0 +1,29 @@ +""" +Constants and exceptions for AgentCore Memory Checkpoint Saver. +""" + +EMPTY_CHANNEL_VALUE = "_empty" + + +class AgentCoreMemoryError(Exception): + """Base exception for AgentCore Memory errors.""" + + pass + + +class EventDecodingError(AgentCoreMemoryError): + """Raised when event decoding fails.""" + + pass + + +class InvalidConfigError(AgentCoreMemoryError): + """Raised when configuration is invalid.""" + + pass + + +class EventNotFoundError(AgentCoreMemoryError): + """Raised when expected event is not found.""" + + pass diff --git a/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/helpers.py b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/helpers.py new file mode 100644 index 000000000..0af27bbf0 --- /dev/null +++ b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/helpers.py @@ -0,0 +1,322 @@ +""" +Helper classes for AgentCore Memory Checkpoint Saver. +""" + +from __future__ import annotations + +import base64 +import datetime +import json +import logging +from collections import defaultdict +from typing import Any, Dict, List, Union + +import boto3 +from botocore.config import Config +from langgraph.checkpoint.base import CheckpointTuple, SerializerProtocol + +from langgraph_checkpoint_aws.agentcore.constants import ( + EMPTY_CHANNEL_VALUE, + EventDecodingError, +) +from langgraph_checkpoint_aws.agentcore.models import ( + ChannelDataEvent, + CheckpointerConfig, + CheckpointEvent, + WriteItem, + WritesEvent, +) + +logger = logging.getLogger(__name__) + +# Union type for all events +EventType = Union[CheckpointEvent, ChannelDataEvent, WritesEvent] + + +class EventSerializer: + """Handles serialization and deserialization of events to store in AgentCore Memory.""" + + def __init__(self, serde: SerializerProtocol): + self.serde = serde + + def serialize_value(self, value: Any) -> Dict[str, Any]: + """Serialize a value using the serde protocol.""" + type_tag, binary_data = self.serde.dumps_typed(value) + return {"type": type_tag, "data": base64.b64encode(binary_data).decode("utf-8")} + + def deserialize_value(self, serialized: Dict[str, Any]) -> Any: + """Deserialize a value using the serde protocol.""" + try: + type_tag = serialized["type"] + binary_data = base64.b64decode(serialized["data"]) + return self.serde.loads_typed((type_tag, binary_data)) + except Exception as e: + raise EventDecodingError(f"Failed to deserialize value: {e}") + + def serialize_event(self, event: EventType) -> str: + """Serialize an event to JSON string.""" + + # Create a custom serializer for Pydantic models + def custom_serializer(obj): + if hasattr(obj, "model_dump"): + return obj.model_dump() + raise TypeError(f"Object of type {type(obj)} is not JSON serializable") + + # Get the base dictionary + event_dict = event.model_dump(exclude_none=True) + + # Handle special serialization for specific fields + if isinstance(event, CheckpointEvent): + event_dict["checkpoint_data"] = self.serialize_value(event.checkpoint_data) + event_dict["metadata"] = self.serialize_value(event.metadata) + + elif isinstance(event, ChannelDataEvent): + if event.value != EMPTY_CHANNEL_VALUE: + event_dict["value"] = self.serialize_value(event.value) + + elif isinstance(event, WritesEvent): + # The writes field is already properly serialized by model_dump() + # We just need to serialize the value field in each write + for write in event_dict["writes"]: + val = write.get("value", EMPTY_CHANNEL_VALUE) + write["value"] = self.serialize_value(val) + + return json.dumps(event_dict, default=custom_serializer) + + def deserialize_event(self, data: str) -> EventType: + """Deserialize JSON string to event.""" + try: + event_dict = json.loads(data) + event_type = event_dict.get("event_type") + + if event_type == "checkpoint": + # Deserialize checkpoint data and metadata + event_dict["checkpoint_data"] = self.deserialize_value( + event_dict["checkpoint_data"] + ) + event_dict["metadata"] = self.deserialize_value(event_dict["metadata"]) + return CheckpointEvent(**event_dict) + + elif event_type == "channel_data": + # Deserialize channel value if not empty + if "value" in event_dict and isinstance(event_dict["value"], dict): + event_dict["value"] = self.deserialize_value(event_dict["value"]) + return ChannelDataEvent(**event_dict) + + elif event_type == "writes": + # Deserialize write values + for write in event_dict["writes"]: + if isinstance(write["value"], dict): + write["value"] = self.deserialize_value(write["value"]) + return WritesEvent(**event_dict) + + else: + raise EventDecodingError(f"Unknown event type: {event_type}") + + except json.JSONDecodeError as e: + raise EventDecodingError(f"Failed to parse JSON: {e}") + except Exception as e: + raise EventDecodingError(f"Failed to deserialize event: {e}") + + +class AgentCoreEventClient: + """Handles low-level event storage and retrieval from AgentCore Memory for checkpoints.""" + + def __init__( + self, memory_id: str, serializer: EventSerializer = None, **boto3_kwargs + ): + self.memory_id = memory_id + self.serializer = serializer + + config = Config( + user_agent_extra="x-client-framework:langgraph_agentcore_memory" + ) + self.client = boto3.client("bedrock-agentcore", config=config, **boto3_kwargs) + + def store_blob_event( + self, event: EventType, session_id: str, actor_id: str + ) -> None: + """Store an event in AgentCore Memory.""" + serialized = self.serializer.serialize_event(event) + + self.client.create_event( + memoryId=self.memory_id, + actorId=actor_id, + sessionId=session_id, + eventTimestamp=datetime.datetime.now(datetime.timezone.utc), + payload=[{"blob": serialized}], + ) + + def store_blob_events_batch( + self, events: List[EventType], session_id: str, actor_id: str + ) -> None: + """Store multiple events in a single API call to AgentCore Memory.""" + # Serialize all events into payload blobs + payload = [] + timestamp = datetime.datetime.now(datetime.timezone.utc) + + for event in events: + serialized = self.serializer.serialize_event(event) + payload.append({"blob": serialized}) + + # Store all events in a single create_event call + self.client.create_event( + memoryId=self.memory_id, + actorId=actor_id, + sessionId=session_id, + eventTimestamp=timestamp, + payload=payload, + ) + + def get_events( + self, session_id: str, actor_id: str, limit: int = 100 + ) -> List[EventType]: + """Retrieve events from AgentCore Memory.""" + + if limit is not None and limit <= 0: + return [] + + all_events = [] + next_token = None + + while True: + params = { + "memoryId": self.memory_id, + "actorId": actor_id, + "sessionId": session_id, + "maxResults": 100, + "includePayloads": True, + } + + if next_token: + params["nextToken"] = next_token + + response = self.client.list_events(**params) + + for event in response.get("events", []): + for payload_item in event.get("payload", []): + blob = payload_item.get("blob") + if blob: + try: + parsed_event = self.serializer.deserialize_event(blob) + all_events.append(parsed_event) + except EventDecodingError as e: + logger.warning(f"Failed to decode event: {e}") + + next_token = response.get("nextToken") + if not next_token or (limit is not None and len(all_events) >= limit): + break + + return all_events + + def delete_events(self, session_id: str, actor_id: str) -> None: + """Delete all events for a session.""" + params = { + "memoryId": self.memory_id, + "actorId": actor_id, + "sessionId": session_id, + "maxResults": 100, + "includePayloads": False, + } + + while True: + response = self.client.list_events(**params) + events = response.get("events", []) + + if not events: + break + + for event in events: + self.client.delete_event( + memoryId=self.memory_id, + sessionId=session_id, + eventId=event["eventId"], + actorId=actor_id, + ) + + next_token = response.get("nextToken") + if not next_token: + break + params["nextToken"] = next_token + + +class EventProcessor: + """Processes events into checkpoint data structures.""" + + @staticmethod + def process_events( + events: List[EventType], + ) -> tuple[ + Dict[str, CheckpointEvent], + Dict[str, List[WriteItem]], + Dict[tuple[str, str], Any], + ]: + """Process events into organized data structures.""" + checkpoints = {} + writes_by_checkpoint = defaultdict(list) + channel_data_by_version = {} + + for event in events: + if isinstance(event, CheckpointEvent): + checkpoints[event.checkpoint_id] = event + + elif isinstance(event, WritesEvent): + writes_by_checkpoint[event.checkpoint_id].extend(event.writes) + + elif isinstance(event, ChannelDataEvent): + if event.value != EMPTY_CHANNEL_VALUE: + channel_data_by_version[(event.channel, event.version)] = ( + event.value + ) + + return checkpoints, writes_by_checkpoint, channel_data_by_version + + @staticmethod + def build_checkpoint_tuple( + checkpoint_event: CheckpointEvent, + writes: List[WriteItem], + channel_data: Dict[tuple[str, str], Any], + config: CheckpointerConfig, + ) -> CheckpointTuple: + """Build a CheckpointTuple from processed data.""" + # Build pending writes + pending_writes = [ + (write.task_id, write.channel, write.value) for write in writes + ] + + # Build parent config + parent_config = None + if checkpoint_event.parent_checkpoint_id: + parent_config = { + "configurable": { + "thread_id": config.thread_id, + "actor_id": config.actor_id, + "checkpoint_ns": config.checkpoint_ns, + "checkpoint_id": checkpoint_event.parent_checkpoint_id, + } + } + + # Build checkpoint with channel values + checkpoint = checkpoint_event.checkpoint_data.copy() + channel_values = {} + + for channel, version in checkpoint.get("channel_versions", {}).items(): + if (channel, version) in channel_data: + channel_values[channel] = channel_data[(channel, version)] + + checkpoint["channel_values"] = channel_values + + return CheckpointTuple( + config={ + "configurable": { + "thread_id": config.thread_id, + "actor_id": config.actor_id, + "checkpoint_ns": config.checkpoint_ns, + "checkpoint_id": checkpoint_event.checkpoint_id, + } + }, + checkpoint=checkpoint, + metadata=checkpoint_event.metadata, + parent_config=parent_config, + pending_writes=pending_writes, + ) diff --git a/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/models.py b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/models.py new file mode 100644 index 000000000..6a466a395 --- /dev/null +++ b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/models.py @@ -0,0 +1,88 @@ +""" +Data models for AgentCore Memory Checkpoint Saver. +""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class CheckpointerConfig(BaseModel): + """Configuration for checkpoint operations.""" + + thread_id: str + actor_id: str + checkpoint_ns: str = "" + checkpoint_id: Optional[str] = None + + @property + def session_id(self) -> str: + """Generate session ID from thread_id and checkpoint_ns.""" + if self.checkpoint_ns: + # Use underscore separator to ensure valid session ID pattern + return f"{self.thread_id}_{self.checkpoint_ns}" + return self.thread_id + + @classmethod + def from_runnable_config(cls, config: Dict[str, Any]) -> "CheckpointerConfig": + """Create CheckpointerConfig from RunnableConfig.""" + from .constants import InvalidConfigError + + configurable = config.get("configurable", {}) + + if not configurable.get("thread_id"): + raise InvalidConfigError( + "RunnableConfig must contain 'thread_id' for AgentCore Checkpointer" + ) + + if not configurable.get("actor_id"): + raise InvalidConfigError( + "RunnableConfig must contain 'actor_id' for AgentCore Checkpointer" + ) + + return cls( + thread_id=configurable["thread_id"], + actor_id=configurable["actor_id"], + checkpoint_ns=configurable.get("checkpoint_ns", ""), + checkpoint_id=configurable.get("checkpoint_id"), + ) + + +class WriteItem(BaseModel): + """Individual write operation.""" + + task_id: str + channel: str + value: Any + task_path: str = "" + + +class CheckpointEvent(BaseModel): + """Event representing a checkpoint.""" + + event_type: str = Field(default="checkpoint") + checkpoint_id: str + checkpoint_data: Dict[str, Any] + metadata: Dict[str, Any] + parent_checkpoint_id: Optional[str] = None + thread_id: str + checkpoint_ns: str = "" + + +class ChannelDataEvent(BaseModel): + """Event representing channel data.""" + + event_type: str = Field(default="channel_data") + channel: str + version: str + value: Any + thread_id: str + checkpoint_ns: str = "" + + +class WritesEvent(BaseModel): + """Event representing pending writes.""" + + event_type: str = Field(default="writes") + checkpoint_id: str + writes: List[WriteItem] diff --git a/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/saver.py b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/saver.py new file mode 100644 index 000000000..bd13dd4f9 --- /dev/null +++ b/libs/langgraph-checkpoint-aws/langgraph_checkpoint_aws/agentcore/saver.py @@ -0,0 +1,284 @@ +""" +AgentCore Memory Checkpoint Saver implementation. +""" + +from __future__ import annotations + +import random +from collections.abc import AsyncIterator, Iterator, Sequence +from typing import Any, Dict + +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import ( + BaseCheckpointSaver, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + SerializerProtocol, + get_checkpoint_id, + get_checkpoint_metadata, +) + +from langgraph_checkpoint_aws.agentcore.constants import ( + EMPTY_CHANNEL_VALUE, + InvalidConfigError, +) +from langgraph_checkpoint_aws.agentcore.helpers import ( + AgentCoreEventClient, + EventProcessor, + EventSerializer, +) +from langgraph_checkpoint_aws.agentcore.models import ( + ChannelDataEvent, + CheckpointerConfig, + CheckpointEvent, + WriteItem, + WritesEvent, +) + + +class AgentCoreMemorySaver(BaseCheckpointSaver[str]): + """ + AgentCore Memory checkpoint saver. + + This saver persists Checkpoints as serialized blob events in AgentCore Memory. + + Args: + memory_id: the ID of the memory resource created in AgentCore Memory + serde: serialization protocol to be used. Defaults to JSONPlusSerializer + """ + + def __init__( + self, + memory_id: str, + *, + serde: SerializerProtocol | None = None, + **boto3_kwargs: Any, + ) -> None: + super().__init__(serde=serde) + + self.memory_id = memory_id + self.serializer = EventSerializer(self.serde) + self.checkpoint_event_client = AgentCoreEventClient( + memory_id, self.serializer, **boto3_kwargs + ) + self.processor = EventProcessor() + + def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + """Get a checkpoint tuple from Bedrock AgentCore Memory.""" + + # TODO: There is room for caching here on the client side + + checkpoint_config = CheckpointerConfig.from_runnable_config(config) + + events = self.checkpoint_event_client.get_events( + checkpoint_config.session_id, checkpoint_config.actor_id + ) + + checkpoints, writes_by_checkpoint, channel_data = self.processor.process_events( + events + ) + + if not checkpoints: + return None + + # Find the specific checkpoint if `checkpoint_id` is provided or return the latest one + if checkpoint_config.checkpoint_id: + checkpoint_event = checkpoints.get(checkpoint_config.checkpoint_id) + if not checkpoint_event: + return None + else: + latest_checkpoint_id = max(checkpoints.keys()) + checkpoint_event = checkpoints[latest_checkpoint_id] + + # Build and return checkpoint tuple + writes = writes_by_checkpoint.get(checkpoint_event.checkpoint_id, []) + return self.processor.build_checkpoint_tuple( + checkpoint_event, writes, channel_data, checkpoint_config + ) + + def list( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + """List checkpoints from Bedrock AgentCore Memory.""" + + # TODO: There is room for caching here on the client side + + checkpoint_config = CheckpointerConfig.from_runnable_config(config) + config_checkpoint_id = get_checkpoint_id(config) if config else None + + events = self.checkpoint_event_client.get_events( + checkpoint_config.session_id, checkpoint_config.actor_id, limit + ) + + checkpoints, writes_by_checkpoint, channel_data = self.processor.process_events( + events + ) + + # Build and yield CheckpointTuples + count = 0 + before_checkpoint_id = get_checkpoint_id(before) if before else None + + # Sort checkpoints by ID in descending order (most recent first) + for checkpoint_id in sorted(checkpoints.keys(), reverse=True): + checkpoint_event = checkpoints[checkpoint_id] + # Apply filters + if config_checkpoint_id and checkpoint_id != config_checkpoint_id: + continue + + if before_checkpoint_id and checkpoint_id >= before_checkpoint_id: + continue + + if limit is not None and count >= limit: + break + + writes = writes_by_checkpoint.get(checkpoint_id, []) + + yield self.processor.build_checkpoint_tuple( + checkpoint_event, writes, channel_data, checkpoint_config + ) + + count += 1 + + def put( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + """Save a checkpoint to AgentCore Memory.""" + checkpoint_config = CheckpointerConfig.from_runnable_config(config) + + # Extract channel values + checkpoint_copy = checkpoint.copy() + channel_values: Dict[str, Any] = checkpoint_copy.pop("channel_values", {}) + + # Create all events to be stored in a single batch + events_to_store = [] + + # Create channel data events + for channel, version in new_versions.items(): + channel_event = ChannelDataEvent( + channel=channel, + version=version, + value=channel_values.get(channel, EMPTY_CHANNEL_VALUE), + thread_id=checkpoint_config.thread_id, + checkpoint_ns=checkpoint_config.checkpoint_ns, + ) + events_to_store.append(channel_event) + + checkpoint_event = CheckpointEvent( + checkpoint_id=checkpoint["id"], + checkpoint_data=checkpoint_copy, + metadata=get_checkpoint_metadata(config, metadata), + parent_checkpoint_id=checkpoint_config.checkpoint_id, + thread_id=checkpoint_config.thread_id, + checkpoint_ns=checkpoint_config.checkpoint_ns, + ) + events_to_store.append(checkpoint_event) + + self.checkpoint_event_client.store_blob_events_batch( + events_to_store, checkpoint_config.session_id, checkpoint_config.actor_id + ) + + return { + "configurable": { + "thread_id": checkpoint_config.thread_id, + "actor_id": checkpoint_config.actor_id, + "checkpoint_ns": checkpoint_config.checkpoint_ns, + "checkpoint_id": checkpoint["id"], + } + } + + def put_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + """Save pending writes to AgentCore Memory.""" + checkpoint_config = CheckpointerConfig.from_runnable_config(config) + + if not checkpoint_config.checkpoint_id: + raise InvalidConfigError("checkpoint_id is required for put_writes") + + # Create write items + write_items = [ + WriteItem( + task_id=task_id, + channel=channel, + value=value, + task_path=task_path, + ) + for channel, value in writes + ] + + writes_event = WritesEvent( + checkpoint_id=checkpoint_config.checkpoint_id, + writes=write_items, + ) + + self.checkpoint_event_client.store_blob_event( + writes_event, checkpoint_config.session_id, checkpoint_config.actor_id + ) + + def delete_thread(self, thread_id: str, actor_id: str) -> None: + """Delete all checkpoints and writes associated with a thread.""" + self.checkpoint_event_client.delete_events(thread_id, actor_id) + + # ===== Async methods ( TODO: NOT IMPLEMENTED YET ) ===== + async def aget_tuple(self, config: RunnableConfig) -> CheckpointTuple | None: + return self.get_tuple(config) + + async def alist( + self, + config: RunnableConfig | None, + *, + filter: dict[str, Any] | None = None, + before: RunnableConfig | None = None, + limit: int | None = None, + ) -> AsyncIterator[CheckpointTuple]: + for item in self.list(config, filter=filter, before=before, limit=limit): + yield item + + async def aput( + self, + config: RunnableConfig, + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> RunnableConfig: + return self.put(config, checkpoint, metadata, new_versions) + + async def aput_writes( + self, + config: RunnableConfig, + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + return self.put_writes(config, writes, task_id, task_path) + + async def adelete_thread(self, thread_id: str, actor_id: str) -> None: + return self.delete_thread(thread_id, actor_id) + + def get_next_version(self, current: str | None, channel: None) -> str: + """Generate next version string.""" + if current is None: + current_v = 0 + elif isinstance(current, int): + current_v = current + else: + current_v = int(current.split(".")[0]) + + next_v = current_v + 1 + next_h = random.random() + return f"{next_v:032}.{next_h:016}" diff --git a/libs/langgraph-checkpoint-aws/tests/integration_tests/agentcore/test_saver.py b/libs/langgraph-checkpoint-aws/tests/integration_tests/agentcore/test_saver.py new file mode 100644 index 000000000..7848ee67b --- /dev/null +++ b/libs/langgraph-checkpoint-aws/tests/integration_tests/agentcore/test_saver.py @@ -0,0 +1,312 @@ +import datetime +import os +import random +import string +from typing import Literal + +import pytest +from langchain_aws import ChatBedrock +from langchain_core.tools import tool +from langgraph.checkpoint.base import Checkpoint, uuid6 +from langgraph.prebuilt import create_react_agent + +from langgraph_checkpoint_aws.agentcore.saver import AgentCoreMemorySaver + + +def generate_valid_session_id(): + """Generate a valid session ID that matches AgentCore pattern [a-zA-Z0-9][a-zA-Z0-9-_]*""" + # Start with letter, then 6 random alphanumeric chars + chars = string.ascii_letters + string.digits + return "test" + "".join(random.choices(chars, k=6)) + + +def generate_valid_actor_id(): + """Generate a valid actor ID that matches AgentCore pattern [a-zA-Z0-9][a-zA-Z0-9-_]*""" + # Start with letter, then 6 random alphanumeric chars + chars = string.ascii_letters + string.digits + return "actor" + "".join(random.choices(chars, k=6)) + + +@tool +def add(a: int, b: int): + """Add two integers and return the result.""" + return a + b + + +@tool +def multiply(a: int, b: int): + """Multiply two integers and return the result.""" + return a * b + + +@tool +def get_weather(city: Literal["nyc", "sf"]): + """Use this to get weather information.""" + if city == "nyc": + return "It might be cloudy in nyc" + elif city == "sf": + return "It's always sunny in sf" + else: + raise AssertionError("Unknown city") + + +class TestAgentCoreMemorySaver: + @pytest.fixture + def tools(self): + return [add, multiply, get_weather] + + @pytest.fixture + def model(self): + return ChatBedrock( + model="anthropic.claude-3-sonnet-20240229-v1:0", region="us-west-2" + ) + + @pytest.fixture + def memory_id(self): + memory_id = os.environ.get("AGENTCORE_MEMORY_ID") + if not memory_id: + pytest.skip("AGENTCORE_MEMORY_ID environment variable not set") + return memory_id + + @pytest.fixture + def memory_saver(self, memory_id): + return AgentCoreMemorySaver(memory_id=memory_id, region_name="us-west-2") + + @pytest.fixture + def boto_agentcore_client(self, memory_saver): + return memory_saver.checkpoint_event_client.client + + def test_tool_responses(self): + assert add.invoke({"a": 5, "b": 3}) == 8 + assert multiply.invoke({"a": 4, "b": 6}) == 24 + assert get_weather.invoke("sf") == "It's always sunny in sf" + assert get_weather.invoke("nyc") == "It might be cloudy in nyc" + + def test_checkpoint_save_and_retrieve(self, memory_saver): + thread_id = generate_valid_session_id() + actor_id = generate_valid_actor_id() + + config = { + "configurable": { + "thread_id": thread_id, + "actor_id": actor_id, + "checkpoint_ns": "test_namespace", + } + } + + checkpoint = Checkpoint( + v=1, + id=str(uuid6(clock_seq=-2)), + ts=datetime.datetime.now(datetime.timezone.utc).isoformat(), + channel_values={ + "messages": ["test message"], + "results": {"status": "completed"}, + }, + channel_versions={"messages": "v1", "results": "v1"}, + versions_seen={"node1": {"messages": "v1"}}, + pending_sends=[], + ) + + checkpoint_metadata = { + "source": "input", + "step": 1, + "writes": {"node1": ["write1", "write2"]}, + } + + try: + saved_config = memory_saver.put( + config, + checkpoint, + checkpoint_metadata, + {"messages": "v2", "results": "v2"}, + ) + + assert saved_config["configurable"]["checkpoint_id"] == checkpoint["id"] + assert saved_config["configurable"]["thread_id"] == thread_id + assert saved_config["configurable"]["actor_id"] == actor_id + assert saved_config["configurable"]["checkpoint_ns"] == "test_namespace" + + checkpoint_tuple = memory_saver.get_tuple(saved_config) + assert checkpoint_tuple.checkpoint["id"] == checkpoint["id"] + + # Metadata includes original metadata plus actor_id from config + expected_metadata = checkpoint_metadata.copy() + expected_metadata["actor_id"] = actor_id + assert checkpoint_tuple.metadata == expected_metadata + assert checkpoint_tuple.config == saved_config + + finally: + memory_saver.delete_thread(thread_id, actor_id) + + def test_math_agent_with_checkpointing(self, tools, model, memory_saver): + thread_id = generate_valid_session_id() + actor_id = generate_valid_actor_id() + + try: + graph = create_react_agent(model, tools=tools, checkpointer=memory_saver) + config = { + "configurable": { + "thread_id": thread_id, + "actor_id": actor_id, + } + } + + response = graph.invoke( + { + "messages": [ + ("human", "What is 15 times 23? Then add 100 to the result.") + ] + }, + config, + ) + assert response, "Response should not be empty" + assert "messages" in response + assert len(response["messages"]) > 1 + + checkpoint = memory_saver.get(config) + assert checkpoint, "Checkpoint should not be empty" + + checkpoint_tuples = list(memory_saver.list(config)) + assert checkpoint_tuples, "Checkpoint tuples should not be empty" + assert isinstance(checkpoint_tuples, list) + + # Continue conversation to test state persistence + response2 = graph.invoke( + { + "messages": [ + ( + "human", + "What was the final result from my previous calculation?", + ) + ] + }, + config, + ) + assert response2, "Second response should not be empty" + + # Verify we have more checkpoints after second interaction + checkpoint_tuples_after = list(memory_saver.list(config)) + assert len(checkpoint_tuples_after) > len(checkpoint_tuples) + + finally: + memory_saver.delete_thread(thread_id, actor_id) + + def test_weather_query_with_checkpointing(self, tools, model, memory_saver): + thread_id = generate_valid_session_id() + actor_id = generate_valid_actor_id() + + try: + graph = create_react_agent(model, tools=tools, checkpointer=memory_saver) + config = { + "configurable": { + "thread_id": thread_id, + "actor_id": actor_id, + } + } + + response = graph.invoke( + {"messages": [("human", "What's the weather in sf and nyc?")]}, config + ) + assert response, "Response should not be empty" + + checkpoint = memory_saver.get(config) + assert checkpoint, "Checkpoint should not be empty" + + checkpoint_tuples = list(memory_saver.list(config)) + assert checkpoint_tuples, "Checkpoint tuples should not be empty" + + finally: + memory_saver.delete_thread(thread_id, actor_id) + + def test_multiple_sessions_isolation(self, tools, model, memory_saver): + thread_id_1 = generate_valid_session_id() + thread_id_2 = generate_valid_session_id() + actor_id = generate_valid_actor_id() + + try: + graph = create_react_agent(model, tools=tools, checkpointer=memory_saver) + + config_1 = { + "configurable": { + "thread_id": thread_id_1, + "actor_id": actor_id, + } + } + + config_2 = { + "configurable": { + "thread_id": thread_id_2, + "actor_id": actor_id, + } + } + + # First session + response_1 = graph.invoke( + {"messages": [("human", "Calculate 10 times 5")]}, config_1 + ) + assert response_1, "First session response should not be empty" + + # Second session + response_2 = graph.invoke( + {"messages": [("human", "What's the weather in sf?")]}, config_2 + ) + assert response_2, "Second session response should not be empty" + + # Verify sessions are isolated + checkpoints_1 = list(memory_saver.list(config_1)) + checkpoints_2 = list(memory_saver.list(config_2)) + + assert len(checkpoints_1) > 0 + assert len(checkpoints_2) > 0 + + # Verify different checkpoint IDs + checkpoint_ids_1 = { + cp.config["configurable"]["checkpoint_id"] for cp in checkpoints_1 + } + checkpoint_ids_2 = { + cp.config["configurable"]["checkpoint_id"] for cp in checkpoints_2 + } + assert checkpoint_ids_1.isdisjoint(checkpoint_ids_2) + + finally: + memory_saver.delete_thread(thread_id_1, actor_id) + memory_saver.delete_thread(thread_id_2, actor_id) + + def test_checkpoint_listing_with_limit(self, tools, model, memory_saver): + thread_id = generate_valid_session_id() + actor_id = generate_valid_actor_id() + + try: + graph = create_react_agent(model, tools=tools, checkpointer=memory_saver) + config = { + "configurable": { + "thread_id": thread_id, + "actor_id": actor_id, + } + } + + # Create multiple interactions to generate several checkpoints + for i in range(3): + graph.invoke( + {"messages": [("human", f"Calculate {i + 1} times 2")]}, config + ) + + # Test listing with limit + all_checkpoints = list(memory_saver.list(config)) + limited_checkpoints = list(memory_saver.list(config, limit=2)) + + assert len(all_checkpoints) >= 3 + assert len(limited_checkpoints) == 2 + + # Verify limited checkpoints are the most recent ones + assert ( + limited_checkpoints[0].config["configurable"]["checkpoint_id"] + == all_checkpoints[0].config["configurable"]["checkpoint_id"] + ) + assert ( + limited_checkpoints[1].config["configurable"]["checkpoint_id"] + == all_checkpoints[1].config["configurable"]["checkpoint_id"] + ) + + finally: + memory_saver.delete_thread(thread_id, actor_id) diff --git a/libs/langgraph-checkpoint-aws/tests/unit_tests/agentcore/__init__.py b/libs/langgraph-checkpoint-aws/tests/unit_tests/agentcore/__init__.py new file mode 100644 index 000000000..22cd57e2c --- /dev/null +++ b/libs/langgraph-checkpoint-aws/tests/unit_tests/agentcore/__init__.py @@ -0,0 +1 @@ +"""Unit tests for AgentCore Memory Checkpoint Saver.""" diff --git a/libs/langgraph-checkpoint-aws/tests/unit_tests/agentcore/test_saver.py b/libs/langgraph-checkpoint-aws/tests/unit_tests/agentcore/test_saver.py new file mode 100644 index 000000000..1de512199 --- /dev/null +++ b/libs/langgraph-checkpoint-aws/tests/unit_tests/agentcore/test_saver.py @@ -0,0 +1,968 @@ +""" +Unit tests for AgentCore Memory Checkpoint Saver. +""" + +import json +from unittest.mock import ANY, MagicMock, Mock, patch + +import pytest +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata, CheckpointTuple +from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer +from langgraph.constants import TASKS + +from langgraph_checkpoint_aws.agentcore.constants import ( + EMPTY_CHANNEL_VALUE, + EventDecodingError, + InvalidConfigError, +) +from langgraph_checkpoint_aws.agentcore.helpers import ( + AgentCoreEventClient, + EventProcessor, + EventSerializer, +) +from langgraph_checkpoint_aws.agentcore.models import ( + ChannelDataEvent, + CheckpointerConfig, + CheckpointEvent, + WriteItem, + WritesEvent, +) +from langgraph_checkpoint_aws.agentcore.saver import AgentCoreMemorySaver + + +@pytest.fixture +def sample_checkpoint_event(): + return CheckpointEvent( + checkpoint_id="checkpoint_123", + checkpoint_data={ + "v": 1, + "id": "checkpoint_123", + "ts": "2024-01-01T00:00:00Z", + "channel_versions": {"default": "v1", "tasks": "v2"}, + "versions_seen": {}, + "pending_sends": [], + }, + metadata={"source": "input", "step": -1}, + parent_checkpoint_id="parent_checkpoint_id", + thread_id="test_thread_id", + checkpoint_ns="test_namespace", + ) + + +@pytest.fixture +def sample_channel_data_event(): + return ChannelDataEvent( + channel="default", + version="v1", + value="test_value", + thread_id="test_thread_id", + checkpoint_ns="test_namespace", + ) + + +@pytest.fixture +def sample_writes_event(): + return WritesEvent( + checkpoint_id="checkpoint_123", + writes=[ + WriteItem( + task_id="task_1", + channel="channel_1", + value="value_1", + task_path="/path/1", + ), + WriteItem( + task_id="task_2", + channel=TASKS, + value="value_2", + task_path="/path/2", + ), + ], + ) + + +class TestAgentCoreMemorySaver: + """Test suite for AgentCoreMemorySaver.""" + + @pytest.fixture + def mock_boto_client(self): + mock_client = Mock() + mock_client.create_event = MagicMock() + mock_client.list_events = MagicMock() + mock_client.delete_event = MagicMock() + return mock_client + + @pytest.fixture + def memory_id(self): + return "test-memory-id" + + @pytest.fixture + def saver(self, mock_boto_client, memory_id): + with patch("boto3.client") as mock_boto3_client: + mock_boto3_client.return_value = mock_boto_client + yield AgentCoreMemorySaver(memory_id=memory_id) + + @pytest.fixture + def runnable_config(self): + return RunnableConfig( + configurable={ + "thread_id": "test_thread_id", + "actor_id": "test_actor_id", + "checkpoint_ns": "test_namespace", + "checkpoint_id": "test_checkpoint_id", + } + ) + + @pytest.fixture + def sample_checkpoint(self): + return Checkpoint( + v=1, + id="checkpoint_123", + ts="2024-01-01T00:00:00Z", + channel_values={ + "default": "value1", + "tasks": ["task1", "task2"], + "results": {"status": "completed"}, + }, + channel_versions={"default": "v1", "tasks": "v2", "results": "v1"}, + versions_seen={ + "node1": {"default": "v1", "tasks": "v2"}, + "node2": {"results": "v1"}, + }, + pending_sends=[], + ) + + @pytest.fixture + def sample_checkpoint_metadata(self): + return CheckpointMetadata( + source="input", + step=-1, + writes={"node1": ["write1", "write2"], "node2": {"key": "value"}}, + parents={ + "namespace1": "parent_checkpoint_1", + "namespace2": "parent_checkpoint_2", + }, + ) + + def test_init_with_default_client(self, memory_id): + with patch("boto3.client") as mock_boto3_client: + mock_client = Mock() + mock_boto3_client.return_value = mock_client + + saver = AgentCoreMemorySaver(memory_id=memory_id) + + assert saver.memory_id == memory_id + assert isinstance(saver.serializer, EventSerializer) + assert isinstance(saver.checkpoint_event_client, AgentCoreEventClient) + assert isinstance(saver.processor, EventProcessor) + mock_boto3_client.assert_called_once_with("bedrock-agentcore", config=ANY) + + def test_init_with_custom_parameters(self, memory_id): + with patch("boto3.client") as mock_boto3_client: + mock_client = Mock() + mock_boto3_client.return_value = mock_client + + saver = AgentCoreMemorySaver( + memory_id=memory_id, + region_name="us-west-2", + ) + + assert saver.memory_id == memory_id + mock_boto3_client.assert_called_once_with( + "bedrock-agentcore", region_name="us-west-2", config=ANY + ) + + def test_get_tuple_success( + self, + saver, + mock_boto_client, + runnable_config, + sample_checkpoint_event, + sample_channel_data_event, + ): + # Remove specific checkpoint_id from config to get latest + runnable_config["configurable"].pop("checkpoint_id", None) + + mock_boto_client.list_events.return_value = { + "events": [ + { + "eventId": "event_1", + "payload": [ + { + "blob": saver.serializer.serialize_event( + sample_checkpoint_event + ) + } + ], + }, + { + "eventId": "event_2", + "payload": [ + { + "blob": saver.serializer.serialize_event( + sample_channel_data_event + ) + } + ], + }, + ] + } + + result = saver.get_tuple(runnable_config) + + assert isinstance(result, CheckpointTuple) + assert result.config["configurable"]["checkpoint_id"] == "checkpoint_123" + assert result.checkpoint["id"] == "checkpoint_123" + mock_boto_client.list_events.assert_called() + + def test_get_tuple_no_checkpoints(self, saver, mock_boto_client, runnable_config): + # Mock empty list_events response + mock_boto_client.list_events.return_value = {"events": []} + + result = saver.get_tuple(runnable_config) + + assert result is None + mock_boto_client.list_events.assert_called_once() + + def test_get_tuple_with_specific_checkpoint_id( + self, + saver, + mock_boto_client, + runnable_config, + sample_checkpoint_event, + ): + # Set specific checkpoint_id + runnable_config["configurable"]["checkpoint_id"] = "checkpoint_123" + + mock_boto_client.list_events.return_value = { + "events": [ + { + "eventId": "event_1", + "payload": [ + { + "blob": saver.serializer.serialize_event( + sample_checkpoint_event + ) + } + ], + } + ] + } + + result = saver.get_tuple(runnable_config) + + assert isinstance(result, CheckpointTuple) + assert result.config["configurable"]["checkpoint_id"] == "checkpoint_123" + + def test_get_tuple_checkpoint_not_found( + self, saver, mock_boto_client, runnable_config + ): + # Set specific checkpoint_id that doesn't exist + runnable_config["configurable"]["checkpoint_id"] = "non_existent_checkpoint" + + sample_event = CheckpointEvent( + checkpoint_id="different_checkpoint", + checkpoint_data={}, + metadata={}, + thread_id="test_thread_id", + checkpoint_ns="test_namespace", + ) + + mock_boto_client.list_events.return_value = { + "events": [ + { + "eventId": "event_1", + "payload": [ + {"blob": saver.serializer.serialize_event(sample_event)} + ], + } + ] + } + + result = saver.get_tuple(runnable_config) + + assert result is None + + def test_list_success( + self, + saver, + mock_boto_client, + runnable_config, + sample_checkpoint_event, + ): + # Remove specific checkpoint_id from config to list all + runnable_config["configurable"].pop("checkpoint_id", None) + + checkpoint_event_1 = sample_checkpoint_event + checkpoint_event_2 = CheckpointEvent( + checkpoint_id="checkpoint_456", + checkpoint_data={ + "v": 1, + "id": "checkpoint_456", + "ts": "2024-01-02T00:00:00Z", + "channel_versions": {}, + "versions_seen": {}, + "pending_sends": [], + }, + metadata={"source": "output", "step": 1}, + parent_checkpoint_id="checkpoint_123", + thread_id="test_thread_id", + checkpoint_ns="test_namespace", + ) + + mock_boto_client.list_events.return_value = { + "events": [ + { + "eventId": "event_1", + "payload": [ + {"blob": saver.serializer.serialize_event(checkpoint_event_1)} + ], + }, + { + "eventId": "event_2", + "payload": [ + {"blob": saver.serializer.serialize_event(checkpoint_event_2)} + ], + }, + ] + } + + results = list(saver.list(runnable_config)) + + assert len(results) == 2 + assert all(isinstance(r, CheckpointTuple) for r in results) + # Should be sorted in descending order + assert results[0].config["configurable"]["checkpoint_id"] == "checkpoint_456" + assert results[1].config["configurable"]["checkpoint_id"] == "checkpoint_123" + + def test_list_with_limit( + self, + saver, + mock_boto_client, + runnable_config, + ): + # Remove specific checkpoint_id from config to list all + runnable_config["configurable"].pop("checkpoint_id", None) + + events = [] + for i in range(5): + checkpoint_event = CheckpointEvent( + checkpoint_id=f"checkpoint_{i}", + checkpoint_data={ + "v": 1, + "id": f"checkpoint_{i}", + "ts": f"2024-01-0{i + 1}T00:00:00Z", + "channel_versions": {}, + "versions_seen": {}, + "pending_sends": [], + }, + metadata={"step": i}, + thread_id="test_thread_id", + checkpoint_ns="test_namespace", + ) + events.append( + { + "eventId": f"event_{i}", + "payload": [ + {"blob": saver.serializer.serialize_event(checkpoint_event)} + ], + } + ) + + mock_boto_client.list_events.return_value = {"events": events} + + results = list(saver.list(runnable_config, limit=3)) + + assert len(results) == 3 + + def test_list_with_before( + self, + saver, + mock_boto_client, + runnable_config, + ): + # Remove specific checkpoint_id from config to list all + runnable_config["configurable"].pop("checkpoint_id", None) + + events = [] + for i in range(3): + checkpoint_event = CheckpointEvent( + checkpoint_id=f"checkpoint_{i}", + checkpoint_data={ + "v": 1, + "id": f"checkpoint_{i}", + "ts": f"2024-01-0{i + 1}T00:00:00Z", + "channel_versions": {}, + "versions_seen": {}, + "pending_sends": [], + }, + metadata={"step": i}, + thread_id="test_thread_id", + checkpoint_ns="test_namespace", + ) + events.append( + { + "eventId": f"event_{i}", + "payload": [ + {"blob": saver.serializer.serialize_event(checkpoint_event)} + ], + } + ) + + mock_boto_client.list_events.return_value = {"events": events} + + before_config = RunnableConfig(configurable={"checkpoint_id": "checkpoint_2"}) + + results = list(saver.list(runnable_config, before=before_config)) + + assert len(results) == 2 + assert results[0].config["configurable"]["checkpoint_id"] == "checkpoint_1" + assert results[1].config["configurable"]["checkpoint_id"] == "checkpoint_0" + + def test_list_empty(self, saver, mock_boto_client, runnable_config): + # Mock empty list_events response + mock_boto_client.list_events.return_value = {"events": []} + + results = list(saver.list(runnable_config)) + + assert len(results) == 0 + + def test_put_success( + self, + saver, + mock_boto_client, + runnable_config, + sample_checkpoint, + sample_checkpoint_metadata, + ): + new_versions = {"default": "v2", "tasks": "v3"} + result = saver.put( + runnable_config, + sample_checkpoint, + sample_checkpoint_metadata, + new_versions, + ) + + assert result["configurable"]["checkpoint_id"] == "checkpoint_123" + assert result["configurable"]["thread_id"] == "test_thread_id" + assert result["configurable"]["checkpoint_ns"] == "test_namespace" + + mock_boto_client.create_event.assert_called_once() + call_args = mock_boto_client.create_event.call_args[1] + assert call_args["memoryId"] == saver.memory_id + assert call_args["actorId"] == "test_actor_id" + assert "payload" in call_args + # Should have channel events + checkpoint event + assert len(call_args["payload"]) == len(new_versions) + 1 + + def test_put_with_empty_channel_values( + self, + saver, + mock_boto_client, + runnable_config, + sample_checkpoint, + sample_checkpoint_metadata, + ): + sample_checkpoint["channel_values"] = {} + + new_versions = {"empty_channel": "v1"} + result = saver.put( + runnable_config, + sample_checkpoint, + sample_checkpoint_metadata, + new_versions, + ) + + assert result["configurable"]["checkpoint_id"] == "checkpoint_123" + mock_boto_client.create_event.assert_called_once() + + def test_put_writes_success( + self, + saver, + mock_boto_client, + runnable_config, + ): + # Set checkpoint_id in config + runnable_config["configurable"]["checkpoint_id"] = "checkpoint_123" + + writes = [ + ("channel_1", "value_1"), + ("channel_2", "value_2"), + (TASKS, {"task": "data"}), + ] + + saver.put_writes( + runnable_config, + writes, + task_id="task_123", + task_path="/test/path", + ) + + mock_boto_client.create_event.assert_called_once() + call_args = mock_boto_client.create_event.call_args[1] + assert call_args["memoryId"] == saver.memory_id + assert call_args["actorId"] == "test_actor_id" + assert len(call_args["payload"]) == 1 + + def test_put_writes_no_checkpoint_id( + self, + saver, + mock_boto_client, + ): + # Create config without checkpoint_id + config = RunnableConfig( + configurable={ + "thread_id": "test_thread_id", + "actor_id": "test_actor_id", + } + ) + + with pytest.raises(InvalidConfigError) as exc_info: + saver.put_writes(config, [("channel", "value")], "task_id") + + assert "checkpoint_id is required" in str(exc_info.value) + + def test_delete_thread_success( + self, + saver, + mock_boto_client, + ): + mock_boto_client.list_events.return_value = { + "events": [ + {"eventId": "event_1"}, + {"eventId": "event_2"}, + ] + } + + saver.delete_thread("thread_id", "actor_id") + + assert mock_boto_client.list_events.called + assert mock_boto_client.delete_event.call_count == 2 + mock_boto_client.delete_event.assert_any_call( + memoryId=saver.memory_id, + sessionId="thread_id", + eventId="event_1", + actorId="actor_id", + ) + mock_boto_client.delete_event.assert_any_call( + memoryId=saver.memory_id, + sessionId="thread_id", + eventId="event_2", + actorId="actor_id", + ) + + def test_delete_thread_with_pagination( + self, + saver, + mock_boto_client, + ): + # Mock paginated list_events responses + mock_boto_client.list_events.side_effect = [ + { + "events": [{"eventId": "event_1"}, {"eventId": "event_2"}], + "nextToken": "token_1", + }, + { + "events": [{"eventId": "event_3"}], + "nextToken": None, + }, + ] + + saver.delete_thread("thread_id", "actor_id") + + assert mock_boto_client.list_events.call_count == 2 + assert mock_boto_client.delete_event.call_count == 3 + + def test_get_next_version(self, saver): + # Test with None + version = saver.get_next_version(None, None) + assert version.startswith("00000000000000000000000000000001.") + + version = saver.get_next_version(5, None) + assert version.startswith("00000000000000000000000000000006.") + + version = saver.get_next_version( + "00000000000000000000000000000010.123456", None + ) + assert version.startswith("00000000000000000000000000000011.") + + +class TestCheckpointerConfig: + """Test suite for CheckpointerConfig.""" + + def test_from_runnable_config_success(self): + config = RunnableConfig( + configurable={ + "thread_id": "test_thread", + "actor_id": "test_actor", + "checkpoint_ns": "test_ns", + "checkpoint_id": "test_checkpoint", + } + ) + + checkpoint_config = CheckpointerConfig.from_runnable_config(config) + + assert checkpoint_config.thread_id == "test_thread" + assert checkpoint_config.actor_id == "test_actor" + assert checkpoint_config.checkpoint_ns == "test_ns" + assert checkpoint_config.checkpoint_id == "test_checkpoint" + assert checkpoint_config.session_id == "test_thread_test_ns" + + def test_from_runnable_config_no_namespace(self): + config = RunnableConfig( + configurable={ + "thread_id": "test_thread", + "actor_id": "test_actor", + } + ) + + checkpoint_config = CheckpointerConfig.from_runnable_config(config) + + assert checkpoint_config.thread_id == "test_thread" + assert checkpoint_config.actor_id == "test_actor" + assert checkpoint_config.checkpoint_ns == "" + assert checkpoint_config.checkpoint_id is None + assert checkpoint_config.session_id == "test_thread" + + def test_from_runnable_config_missing_thread_id(self): + config = RunnableConfig( + configurable={ + "actor_id": "test_actor", + } + ) + + with pytest.raises(InvalidConfigError) as exc_info: + CheckpointerConfig.from_runnable_config(config) + + assert "thread_id" in str(exc_info.value) + + def test_from_runnable_config_missing_actor_id(self): + config = RunnableConfig( + configurable={ + "thread_id": "test_thread", + } + ) + + with pytest.raises(InvalidConfigError) as exc_info: + CheckpointerConfig.from_runnable_config(config) + + assert "actor_id" in str(exc_info.value) + + +class TestEventSerializer: + """Test suite for EventSerializer.""" + + @pytest.fixture + def serializer(self): + return EventSerializer(JsonPlusSerializer()) + + def test_serialize_deserialize_checkpoint_event( + self, serializer, sample_checkpoint_event + ): + # Serialize + serialized = serializer.serialize_event(sample_checkpoint_event) + assert isinstance(serialized, str) + + deserialized = serializer.deserialize_event(serialized) + assert isinstance(deserialized, CheckpointEvent) + assert deserialized.checkpoint_id == sample_checkpoint_event.checkpoint_id + assert deserialized.thread_id == sample_checkpoint_event.thread_id + + def test_serialize_deserialize_channel_data_event( + self, serializer, sample_channel_data_event + ): + # Serialize + serialized = serializer.serialize_event(sample_channel_data_event) + assert isinstance(serialized, str) + + deserialized = serializer.deserialize_event(serialized) + assert isinstance(deserialized, ChannelDataEvent) + assert deserialized.channel == sample_channel_data_event.channel + assert deserialized.value == sample_channel_data_event.value + + def test_serialize_deserialize_writes_event(self, serializer, sample_writes_event): + # Serialize + serialized = serializer.serialize_event(sample_writes_event) + assert isinstance(serialized, str) + + deserialized = serializer.deserialize_event(serialized) + assert isinstance(deserialized, WritesEvent) + assert deserialized.checkpoint_id == sample_writes_event.checkpoint_id + assert len(deserialized.writes) == len(sample_writes_event.writes) + + def test_serialize_channel_data_with_empty_value(self, serializer): + event = ChannelDataEvent( + channel="test", + version="v1", + value=EMPTY_CHANNEL_VALUE, + thread_id="thread", + checkpoint_ns="ns", + ) + + serialized = serializer.serialize_event(event) + deserialized = serializer.deserialize_event(serialized) + + assert deserialized.value == EMPTY_CHANNEL_VALUE + + def test_deserialize_invalid_json(self, serializer): + with pytest.raises(EventDecodingError) as exc_info: + serializer.deserialize_event("invalid json {") + + assert "Failed to parse JSON" in str(exc_info.value) + + def test_deserialize_unknown_event_type(self, serializer): + invalid_event = json.dumps({"event_type": "unknown_type", "data": "test"}) + + with pytest.raises(EventDecodingError) as exc_info: + serializer.deserialize_event(invalid_event) + + assert "Unknown event type" in str(exc_info.value) + + +class TestAgentCoreEventClient: + """Test suite for AgentCoreEventClient.""" + + @pytest.fixture + def mock_boto_client(self): + mock_client = Mock() + mock_client.create_event = MagicMock() + mock_client.list_events = MagicMock() + mock_client.delete_event = MagicMock() + return mock_client + + @pytest.fixture + def serializer(self): + return EventSerializer(JsonPlusSerializer()) + + @pytest.fixture + def client(self, mock_boto_client, serializer): + with patch("boto3.client") as mock_boto3_client: + mock_boto3_client.return_value = mock_boto_client + yield AgentCoreEventClient("test-memory-id", serializer) + + def test_store_blob_event(self, client, mock_boto_client, sample_checkpoint_event): + client.store_blob_event(sample_checkpoint_event, "session_id", "actor_id") + + mock_boto_client.create_event.assert_called_once() + call_args = mock_boto_client.create_event.call_args[1] + assert call_args["memoryId"] == "test-memory-id" + assert call_args["actorId"] == "actor_id" + assert call_args["sessionId"] == "session_id" + assert len(call_args["payload"]) == 1 + + def test_store_blob_events_batch( + self, + client, + mock_boto_client, + sample_checkpoint_event, + sample_channel_data_event, + ): + events = [sample_checkpoint_event, sample_channel_data_event] + client.store_blob_events_batch(events, "session_id", "actor_id") + + mock_boto_client.create_event.assert_called_once() + call_args = mock_boto_client.create_event.call_args[1] + assert len(call_args["payload"]) == 2 + + def test_get_events( + self, client, mock_boto_client, serializer, sample_checkpoint_event + ): + # Mock list_events response + mock_boto_client.list_events.return_value = { + "events": [ + { + "eventId": "event_1", + "payload": [ + {"blob": serializer.serialize_event(sample_checkpoint_event)} + ], + } + ] + } + + events = client.get_events("session_id", "actor_id") + + assert len(events) == 1 + assert isinstance(events[0], CheckpointEvent) + mock_boto_client.list_events.assert_called_once() + + def test_get_events_with_pagination( + self, client, mock_boto_client, serializer, sample_checkpoint_event + ): + # Mock paginated responses + mock_boto_client.list_events.side_effect = [ + { + "events": [ + { + "eventId": "event_1", + "payload": [ + { + "blob": serializer.serialize_event( + sample_checkpoint_event + ) + } + ], + } + ], + "nextToken": "token_1", + }, + { + "events": [ + { + "eventId": "event_2", + "payload": [ + { + "blob": serializer.serialize_event( + sample_checkpoint_event + ) + } + ], + } + ], + "nextToken": None, + }, + ] + + events = client.get_events("session_id", "actor_id") + + assert len(events) == 2 + assert mock_boto_client.list_events.call_count == 2 + + def test_get_events_with_decoding_error(self, client, mock_boto_client, serializer): + # Mock list_events response with invalid blob + mock_boto_client.list_events.return_value = { + "events": [ + { + "eventId": "event_1", + "payload": [{"blob": "invalid json {"}], + } + ] + } + + events = client.get_events("session_id", "actor_id") + + assert len(events) == 0 + + def test_delete_events(self, client, mock_boto_client): + # Mock list_events response + mock_boto_client.list_events.return_value = { + "events": [ + {"eventId": "event_1"}, + {"eventId": "event_2"}, + ] + } + + client.delete_events("session_id", "actor_id") + + assert mock_boto_client.list_events.called + assert mock_boto_client.delete_event.call_count == 2 + + +class TestEventProcessor: + """Test suite for EventProcessor.""" + + @pytest.fixture + def processor(self): + return EventProcessor() + + def test_process_events( + self, + processor, + sample_checkpoint_event, + sample_channel_data_event, + sample_writes_event, + ): + events = [ + sample_checkpoint_event, + sample_channel_data_event, + sample_writes_event, + ] + + checkpoints, writes_by_checkpoint, channel_data = processor.process_events( + events + ) + + assert len(checkpoints) == 1 + assert "checkpoint_123" in checkpoints + assert len(writes_by_checkpoint["checkpoint_123"]) == 2 + assert ("default", "v1") in channel_data + + def test_process_events_empty_channel_value( + self, processor, sample_channel_data_event + ): + sample_channel_data_event.value = EMPTY_CHANNEL_VALUE + events = [sample_channel_data_event] + + checkpoints, writes_by_checkpoint, channel_data = processor.process_events( + events + ) + + assert len(channel_data) == 0 + + def test_build_checkpoint_tuple( + self, + processor, + sample_checkpoint_event, + ): + writes = [ + WriteItem( + task_id="task_1", + channel="channel_1", + value="value_1", + task_path="/path/1", + ) + ] + channel_data = {("default", "v1"): "test_value"} + config = CheckpointerConfig( + thread_id="test_thread", + actor_id="test_actor", + checkpoint_ns="test_ns", + ) + + tuple_result = processor.build_checkpoint_tuple( + sample_checkpoint_event, writes, channel_data, config + ) + + assert isinstance(tuple_result, CheckpointTuple) + assert tuple_result.checkpoint["id"] == "checkpoint_123" + assert len(tuple_result.pending_writes) == 1 + assert tuple_result.checkpoint["channel_values"]["default"] == "test_value" + + def test_build_checkpoint_tuple_with_parent( + self, + processor, + sample_checkpoint_event, + ): + config = CheckpointerConfig( + thread_id="test_thread", + actor_id="test_actor", + checkpoint_ns="test_ns", + ) + + tuple_result = processor.build_checkpoint_tuple( + sample_checkpoint_event, [], {}, config + ) + + assert tuple_result.parent_config is not None + assert ( + tuple_result.parent_config["configurable"]["checkpoint_id"] + == "parent_checkpoint_id" + ) + + def test_build_checkpoint_tuple_no_parent( + self, + processor, + sample_checkpoint_event, + ): + sample_checkpoint_event.parent_checkpoint_id = None + config = CheckpointerConfig( + thread_id="test_thread", + actor_id="test_actor", + checkpoint_ns="test_ns", + ) + + tuple_result = processor.build_checkpoint_tuple( + sample_checkpoint_event, [], {}, config + ) + + assert tuple_result.parent_config is None diff --git a/samples/memory/agentcore_memory_checkpointer.ipynb b/samples/memory/agentcore_memory_checkpointer.ipynb new file mode 100644 index 000000000..88f1e129e --- /dev/null +++ b/samples/memory/agentcore_memory_checkpointer.ipynb @@ -0,0 +1,413 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "8208f7d6-391d-4054-9e67-ac0f85878dcd", + "metadata": {}, + "source": [ + "# Bedrock AgentCore Memory Checkpointer Walkthrough\n", + "\n", + "This sample notebook walks through setup and usage of the Bedrock AgentCore Memory Checkpointer with LangGraph. This approach enables saving of conversations and state data to the Memory API for persistent storage, fault tolerance, and human-in-the-loop workflows.\n", + "\n", + "### Setup\n", + "For this notebook you will need:\n", + "1. An Amazon Web Services development account\n", + "2. Bedrock Model Access (i.e. Claude 3.7 Sonnet)\n", + "3. An AgentCore Memory Resource configured (see below section for details)\n", + "\n", + "### AgentCore Memory Resource\n", + "\n", + "Either in the AWS developer portal or using the boto3 library you must create an [AgentCore Memory Resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-agentcore-control/client/create_memory.html). For just using the `AgentCoreMemorySaver` checkpointer in this notebook, you do not need to specify any specific long-term memory strategies. However, it may be beneficial to supplement this approach with the `AgentCoreMemoryStore` to save and extract conversational insights, so you may want to enable strategies for that use case.\n", + "\n", + "Once you have the Memory enabled and in a `ACTIVE` state, take note of the `memoryId`, we will need it later." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6acbf4e", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install langchain langchain-aws" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "8eff8706-6715-4981-983b-934561ee0a19", + "metadata": {}, + "outputs": [], + "source": [ + "# Import LangGraph and LangChain components\n", + "from langchain.chat_models import init_chat_model\n", + "from langchain.tools import tool\n", + "from langgraph.prebuilt import create_react_agent" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "fc12fd92-b84a-4d2f-b96d-83d3da08bf70", + "metadata": {}, + "outputs": [], + "source": [ + "# Import the AgentCoreMemorySaver that we will use as a checkpointer\n", + "from langgraph_checkpoint_aws import AgentCoreMemorySaver\n", + "\n", + "import logging\n", + "logging.getLogger().setLevel(logging.DEBUG)" + ] + }, + { + "cell_type": "markdown", + "id": "51d91a14-541c-426c-a52c-5fdcf29f8953", + "metadata": {}, + "source": [ + "## AgentCore Memory Configuration\n", + "- `REGION` corresponds to the AWS region that your resources are present in, these are passed to the `AgentCoreMemorySaver`.\n", + "- `MEMORY_ID` corresponds to your top level AgentCore Memory resource. Within this resource we will store checkpoints for multiple actors and sessions\n", + "- `MODEL_ID` this is the bedrock model that will power our LangGraph agent through Bedrock Converse.\n", + "\n", + "We will use the `MEMORY_ID` and any additional boto3 client keyword args (in our case, `REGION`) to instantiate our checkpointer." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "226d094c-a05d-4f88-851d-cc42ff63ef11", + "metadata": {}, + "outputs": [], + "source": [ + "REGION = \"us-west-2\"\n", + "MEMORY_ID = \"YOUR_MEMORY_ID\"\n", + "MODEL_ID = \"us.anthropic.claude-3-7-sonnet-20250219-v1:0\"\n", + "\n", + "# Initialize checkpointer for state persistence\n", + "checkpointer = AgentCoreMemorySaver(MEMORY_ID, region_name=REGION)\n", + "\n", + "# Initialize LLM\n", + "llm = init_chat_model(MODEL_ID, model_provider=\"bedrock_converse\", region_name=REGION)" + ] + }, + { + "cell_type": "markdown", + "id": "c56c77aa-4b19-49a5-af3c-db2b4798384b", + "metadata": {}, + "source": [ + "## Define our tools\n", + "\n", + "For this demo notebook we will only give our agent access to two simple tools, adding and multiplying." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "dd81ba1c-3fb8-474c-ae40-0ee7c62ca234", + "metadata": {}, + "outputs": [], + "source": [ + "@tool\n", + "def add(a: int, b: int):\n", + " \"\"\"Add two integers and return the result\"\"\"\n", + " return a + b\n", + "\n", + "\n", + "@tool\n", + "def multiply(a: int, b: int):\n", + " \"\"\"Multiply two integers and return the result\"\"\"\n", + " return a * b\n", + "\n", + "\n", + "tools = [add, multiply]" + ] + }, + { + "cell_type": "markdown", + "id": "651b6e3f-8b41-4c07-8a32-680666b2661e", + "metadata": {}, + "source": [ + "## Build our LangGraph agent graph\n", + "\n", + "Our agent will be built with the `create_react_agent` builder. It just has a few simple nodes, mainly a chatbot node and a tool node. This will enable our chatbot to use the add and multiply tools as much as it needs and then return a response. We will visualize this graph below." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "a116a57f", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "graph = create_react_agent(\n", + " model=llm,\n", + " tools=tools,\n", + " prompt=\"You are a helpful assistant\",\n", + " checkpointer=checkpointer,\n", + ")\n", + "\n", + "graph" + ] + }, + { + "cell_type": "markdown", + "id": "b6cbbb3a-f85c-42f5-bbfb-40f5f6b8b48c", + "metadata": {}, + "source": [ + "## IMPORTANT: Input and Config\n", + "\n", + "### Graph Invoke Input\n", + "We only need to pass the newest user message in as an argument `inputs`. This could include other state variables too but for the simple `create_react_agent`, messages are all that's required.\n", + "\n", + "### LangGraph RuntimeConfig\n", + "In LangGraph, config is a `RuntimeConfig` that contains attributes that are necessary at invocation time, for example user IDs or session IDs. For the `AgentCoreMemorySaver`, `thread_id` and `actor_id` must be set in the config. For instance, your AgentCore invocation endpoint could assign this based on the identity or user ID of the caller. Additional documentation here: [https://langchain-ai.github.io/langgraphjs/how-tos/configuration/](https://langchain-ai.github.io/langgraphjs/how-tos/configuration/)\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "859fee36-1eff-4713-9c3b-387b702c0301", + "metadata": {}, + "outputs": [], + "source": [ + "config = {\n", + " \"configurable\": {\n", + " \"thread_id\": \"session-1\", # REQUIRED: This maps to Bedrock AgentCore session_id under the hood\n", + " \"actor_id\": \"react-agent-1\", # REQUIRED: This maps to Bedrock AgentCore actor_id under the hood\n", + " }\n", + "}\n", + "\n", + "inputs = {\"messages\": [{\"role\": \"user\", \"content\": \"What is 1337 times 515321? Then add 412 and return the value to me.\"}]}" + ] + }, + { + "cell_type": "markdown", + "id": "96044de0-2d32-4811-ac30-43f4416f4303", + "metadata": {}, + "source": [ + "### Run the agent" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "c2bc4869-58cd-4914-9a7a-8d39ecd16226", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent': {'messages': [AIMessage(content=[{'type': 'text', 'text': \"I'll help you calculate this step by step using the available tools.\\n\\nFirst, I'll multiply 1337 by 515321, and then add 412 to the result.\"}, {'type': 'tool_use', 'name': 'multiply', 'input': {'a': 1337, 'b': 515321}, 'id': 'tooluse_8NTeZaI6SRWVrWS3msurvQ'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'cb32ee7b-35c8-42f4-b28d-ab81611f57f3', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 23 Sep 2025 00:51:44 GMT', 'content-type': 'application/json', 'content-length': '563', 'connection': 'keep-alive', 'x-amzn-requestid': 'cb32ee7b-35c8-42f4-b28d-ab81611f57f3'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [4900]}, 'model_name': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'}, id='run--8fa9fc8d-4491-4a79-bf9e-8921f710b1a4-0', tool_calls=[{'name': 'multiply', 'args': {'a': 1337, 'b': 515321}, 'id': 'tooluse_8NTeZaI6SRWVrWS3msurvQ', 'type': 'tool_call'}], usage_metadata={'input_tokens': 482, 'output_tokens': 110, 'total_tokens': 592, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})]}}\n", + "{'tools': {'messages': [ToolMessage(content='688984177', name='multiply', id='9232d136-aa20-4283-82fa-a64502656160', tool_call_id='tooluse_8NTeZaI6SRWVrWS3msurvQ')]}}\n", + "{'agent': {'messages': [AIMessage(content=[{'type': 'text', 'text': \"Now I'll add 412 to this result:\"}, {'type': 'tool_use', 'name': 'add', 'input': {'a': 688984177, 'b': 412}, 'id': 'tooluse_uowB7HdbQn-R-YQvufO3Pg'}], additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'dfd50e9e-0dfe-43d9-807b-7a29d0bc8f3b', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 23 Sep 2025 00:51:47 GMT', 'content-type': 'application/json', 'content-length': '451', 'connection': 'keep-alive', 'x-amzn-requestid': 'dfd50e9e-0dfe-43d9-807b-7a29d0bc8f3b'}, 'RetryAttempts': 0}, 'stopReason': 'tool_use', 'metrics': {'latencyMs': [2412]}, 'model_name': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'}, id='run--524ea0f5-ddb0-4752-a224-6a3285f27d7e-0', tool_calls=[{'name': 'add', 'args': {'a': 688984177, 'b': 412}, 'id': 'tooluse_uowB7HdbQn-R-YQvufO3Pg', 'type': 'tool_call'}], usage_metadata={'input_tokens': 606, 'output_tokens': 83, 'total_tokens': 689, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})]}}\n", + "{'tools': {'messages': [ToolMessage(content='688984589', name='add', id='3a368934-2258-491d-96f1-e61852deaeaa', tool_call_id='tooluse_uowB7HdbQn-R-YQvufO3Pg')]}}\n", + "{'agent': {'messages': [AIMessage(content='The result of multiplying 1337 by 515321 and then adding 412 is 688,984,589.', additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': 'dadc1ae0-5f52-4ead-be93-90399d29359a', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 23 Sep 2025 00:51:48 GMT', 'content-type': 'application/json', 'content-length': '391', 'connection': 'keep-alive', 'x-amzn-requestid': 'dadc1ae0-5f52-4ead-be93-90399d29359a'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [1377]}, 'model_name': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'}, id='run--ffd35948-1221-4958-8212-5b5aee7ce904-0', usage_metadata={'input_tokens': 703, 'output_tokens': 32, 'total_tokens': 735, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})]}}\n" + ] + } + ], + "source": [ + "for chunk in graph.stream(inputs, stream_mode=\"updates\", config=config):\n", + " print(chunk)" + ] + }, + { + "cell_type": "markdown", + "id": "f0d28db8-c9cc-4fa1-922d-89476e9f71c5", + "metadata": {}, + "source": [ + "## Inspect the current state with AgentCoreMemory\n", + "\n", + "Under the hood when you call `graph.get_state(config)` it calls the checkpointer to retrieve the latest checkpoint saved for our actor and session. We can take a look at the values saved at this point in the conversation from `messages`." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "60042ec5-cd64-48f3-bd7d-cb7ca9e21b39", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "human: What is 1337 times 515321? Then add 412 and return the value to me.\n", + "=========================================\n", + "ai: I'll help you calculate this step by step using the available tools.\n", + "\n", + "First, I'll multiply 1337 by 515321, and then add 412 to the result.\n", + "=========================================\n", + "tool: 688984177\n", + "=========================================\n", + "ai: Now I'll add 412 to this result:\n", + "=========================================\n", + "tool: 688984589\n", + "=========================================\n", + "ai: The result of multiplying 1337 by 515321 and then adding 412 is 688,984,589.\n", + "=========================================\n" + ] + } + ], + "source": [ + "for message in graph.get_state(config).values.get(\"messages\"):\n", + " print(f\"{message.type}: {message.text()}\")\n", + " print(\"=========================================\")" + ] + }, + { + "cell_type": "markdown", + "id": "3aa0c9bd-6fb6-467d-b51a-84696238b4fa", + "metadata": {}, + "source": [ + "## Look at a previous checkpoints during execution\n", + "Using the `graph.get_state_history(config)` we will see the checkpoints that were saved, then inspect a previous checkpoint's values for `messages`. Checkpoints are listed so the most recent checkpoints appear first." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "c2232c2b-154d-4b50-93b0-925eb88e67c8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(Checkpoint ID: 1f098177-c42d-6f60-8005-354f815e0136) # of messages in state: 6\n", + "(Checkpoint ID: 1f098177-b654-6baa-8004-a8b0f118635a) # of messages in state: 5\n", + "(Checkpoint ID: 1f098177-b647-6b76-8003-6283dc729a79) # of messages in state: 4\n", + "(Checkpoint ID: 1f098177-9e9b-670c-8002-588abfad5280) # of messages in state: 3\n", + "(Checkpoint ID: 1f098177-9e89-628c-8001-2bc2df91b7a3) # of messages in state: 2\n", + "(Checkpoint ID: 1f098177-6d2f-6fce-8000-6268b3fc0a3c) # of messages in state: 1\n", + "(Checkpoint ID: 1f098177-6d20-6a7e-bfff-c58a1ddceda6) # of messages in state: 0\n" + ] + } + ], + "source": [ + "for checkpoint in graph.get_state_history(config):\n", + " print(\n", + " f\"(Checkpoint ID: {checkpoint.config['configurable']['checkpoint_id']}) # of messages in state: {len(checkpoint.values.get('messages'))}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "ab07194f-49b4-4fb9-a74b-d86b970cace6", + "metadata": {}, + "source": [ + "## Continue the conversation\n", + "By using the same config with our session and actor from before, we can continue the conversation by invoking our LangGraph agent with a new invocation state. The checkpointer in the background will take care of loading in context so that all the previous messages from the first interaction are loaded in." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "b7117c6a-d1d5-4866-aee9-2b7229fd73fd", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent': {'messages': [AIMessage(content='The first calculations you asked me to do were:\\n1. Multiply 1337 by 515321\\n2. Add 412 to the result of the multiplication\\n\\nSpecifically, I calculated 1337 × 515321 = 688,984,177, and then added 412 to get the final answer of 688,984,589.', additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '4dc65f04-85c5-4eb7-8139-1f852c3709bc', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 23 Sep 2025 00:52:08 GMT', 'content-type': 'application/json', 'content-length': '557', 'connection': 'keep-alive', 'x-amzn-requestid': '4dc65f04-85c5-4eb7-8139-1f852c3709bc'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [2033]}, 'model_name': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'}, id='run--b19686f9-8c7d-4950-91e2-7c9a1bb5534d-0', usage_metadata={'input_tokens': 749, 'output_tokens': 81, 'total_tokens': 830, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})]}}\n" + ] + } + ], + "source": [ + "inputs = {\"messages\": [{\"role\": \"user\", \"content\": \"What were the first calculations I asked you to do?\"}]}\n", + "\n", + "for chunk in graph.stream(inputs, stream_mode=\"updates\", config=config):\n", + " print(chunk)" + ] + }, + { + "cell_type": "markdown", + "id": "dc602c5b-e6b3-4099-820a-a82266039849", + "metadata": {}, + "source": [ + "## Start a new chat by using a new session ID\n", + "\n", + "By providing a new thread_id, a new conversation will be started (while still persisting the old conversation in AgentCore Memory)." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "8ce88359-ee1e-44bd-9095-ad22b880dda8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'agent': {'messages': [AIMessage(content=\"I don't see that you've asked me to multiply or add any specific values yet. While I have tools available to perform multiplication and addition, you haven't provided any numbers for me to work with.\\n\\nWould you like me to multiply or add some numbers? If so, please provide the values you'd like me to use.\", additional_kwargs={}, response_metadata={'ResponseMetadata': {'RequestId': '8ddf83ff-6536-4d41-b6b1-9763a1085fe2', 'HTTPStatusCode': 200, 'HTTPHeaders': {'date': 'Tue, 23 Sep 2025 00:52:21 GMT', 'content-type': 'application/json', 'content-length': '623', 'connection': 'keep-alive', 'x-amzn-requestid': '8ddf83ff-6536-4d41-b6b1-9763a1085fe2'}, 'RetryAttempts': 0}, 'stopReason': 'end_turn', 'metrics': {'latencyMs': [2118]}, 'model_name': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'}, id='run--052bbac2-e944-4adf-8ff5-24263d10c209-0', usage_metadata={'input_tokens': 470, 'output_tokens': 70, 'total_tokens': 540, 'input_token_details': {'cache_creation': 0, 'cache_read': 0}})]}}\n" + ] + } + ], + "source": [ + "config = {\n", + " \"configurable\": {\n", + " \"thread_id\": \"session-2\", # New session ID\n", + " \"actor_id\": \"react-agent-1\", # Same Actor ID\n", + " }\n", + "}\n", + "\n", + "inputs = {\"messages\": [{\"role\": \"user\", \"content\": \"What values did I ask you to multiply and add?\"}]}\n", + "for chunk in graph.stream(inputs, stream_mode=\"updates\", config=config):\n", + " print(chunk)" + ] + }, + { + "cell_type": "markdown", + "id": "6fa7bd51-0ece-47d6-aae3-2a9041d98645", + "metadata": {}, + "source": [ + "## Wrapping Up\n", + "\n", + "As you can see, the AgentCore checkpointer is very powerful for persisting conversational and graph state in the background. For more information, check out the official AgentCore Memory documentation here: https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eda8649a-d48f-467c-94b9-99dd8fb2c078", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/samples/memory/agentcore_memory_checkpointer_human_loop.ipynb b/samples/memory/agentcore_memory_checkpointer_human_loop.ipynb new file mode 100644 index 000000000..6c7b625ff --- /dev/null +++ b/samples/memory/agentcore_memory_checkpointer_human_loop.ipynb @@ -0,0 +1,339 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "e7d0b686-22f6-494b-952b-c97ee3bb0b60", + "metadata": {}, + "source": [ + "# Bedrock AgentCore Memory Checkpointer - Human in the Loop Example\n", + "\n", + "This sample notebook walks through setup and usage of the Bedrock AgentCore Memory Checkpointer with LangGraph. This example specifically showcases the ability to use a human-in-the-loop workflow to interrupt graph execution and resume it with human intervention.\n", + "\n", + "This notebook closely follows the walkthrough here on LangGraph - https://langchain-ai.github.io/langgraph/tutorials/get-started/4-human-in-the-loop/\n", + "\n", + "### Setup\n", + "For this notebook you will need:\n", + "1. An Amazon Web Services development account\n", + "2. Bedrock Model Access (i.e. Claude 3.7 Sonnet)\n", + "3. An AgentCore Memory Resource configured (see below section for details)\n", + "\n", + "### AgentCore Memory Resource\n", + "\n", + "Either in the AWS developer portal or using the boto3 library you must create an [AgentCore Memory Resource](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-agentcore-control/client/create_memory.html). For just using the `AgentCoreMemorySaver` checkpointer in this notebook, you do not need to specify any specific long-term memory strategies. However, it may be beneficial to supplement this approach with the `AgentCoreMemoryStore` to save and extract conversational insights, so you may want to enable strategies for that use case.\n", + "\n", + "Once you have the Memory enabled and in a `ACTIVE` state, take note of the `memoryId`, we will need it later." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "bc7a3dfc-c14f-4a81-9c44-b44546d4196d", + "metadata": {}, + "outputs": [], + "source": [ + "# Import LangGraph and LangChain components\n", + "from langchain.chat_models import init_chat_model\n", + "from langchain.tools import tool\n", + "from langgraph.prebuilt import create_react_agent\n", + "\n", + "# Imports that enable human-in-the-loop\n", + "from langgraph.types import Command, interrupt" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "630de2fb-235a-4de5-a8e9-685a2babcdd1", + "metadata": {}, + "outputs": [], + "source": [ + "# Import the AgentCoreMemorySaver that we will use as a checkpointer\n", + "from langgraph_checkpoint_aws import AgentCoreMemorySaver\n", + "\n", + "import logging\n", + "logging.getLogger().setLevel(logging.DEBUG)" + ] + }, + { + "cell_type": "markdown", + "id": "a16f95ec-beb2-4007-a46c-730d0c156d18", + "metadata": {}, + "source": [ + "## AgentCore Memory Configuration\n", + "- `REGION` corresponds to the AWS region that your resources are present in, these are passed to the `AgentCoreMemorySaver`.\n", + "- `MEMORY_ID` corresponds to your top level AgentCore Memory resource. Within this resource we will store checkpoints for multiple actors and sessions\n", + "- `MODEL_ID` this is the bedrock model that will power our LangGraph agent through Bedrock Converse.\n", + "\n", + "We will use the `MEMORY_ID` and any additional boto3 client keyword args (in our case, `REGION`) to instantiate our checkpointer." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "fb67b4ee-ff3f-4576-8072-8885c2a47e11", + "metadata": {}, + "outputs": [], + "source": [ + "REGION = \"us-west-2\"\n", + "MEMORY_ID = \"YOUR_MEMORY_ID\"\n", + "MODEL_ID = \"us.anthropic.claude-3-7-sonnet-20250219-v1:0\"\n", + "\n", + "# Initialize checkpointer for state persistence\n", + "checkpointer = AgentCoreMemorySaver(MEMORY_ID, region_name=REGION)\n", + "\n", + "# Initialize LLM\n", + "llm = init_chat_model(MODEL_ID, model_provider=\"bedrock_converse\", region_name=REGION)" + ] + }, + { + "cell_type": "markdown", + "id": "783f9b1e-fd88-4546-afdb-1168e3a88ff2", + "metadata": {}, + "source": [ + "## Enable the Human Assistance Tool\n", + "\n", + "Using the LangGraph `interrupt` type, we can interrupt the agent graph execution to give the chance for a human to intervene and respond to the query to continue execution. " + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "aecb558a-8ee4-486c-92ab-3ff4485fdfe5", + "metadata": {}, + "outputs": [], + "source": [ + "@tool\n", + "def human_assistance(query: str) -> str:\n", + " \"\"\"Request assistance from a human.\"\"\"\n", + " human_response = interrupt({\"query\": query})\n", + " return human_response[\"data\"]\n", + "\n", + "@tool\n", + "def add(a: int, b: int):\n", + " \"\"\"Add two integers and return the result\"\"\"\n", + " return a + b\n", + "\n", + "@tool\n", + "def multiply(a: int, b: int):\n", + " \"\"\"Multiply two integers and return the result\"\"\"\n", + " return a * b\n", + "\n", + "\n", + "tools = [add, multiply, human_assistance]" + ] + }, + { + "cell_type": "markdown", + "id": "30359ac1-90b1-4627-a4b0-203c1d598bf1", + "metadata": {}, + "source": [ + "## Build our LangGraph agent graph\n", + "\n", + "Our agent will be built with the `create_react_agent` builder. It just has a few simple nodes, mainly a chatbot node and a tool node. This will enable our chatbot to use the add, multiply, and human assistance tools as much as it needs and then return a response. We will visualize this graph below." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "2cbc7289-6595-4b31-8472-47d2de00c4bd", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "graph = create_react_agent(\n", + " model=llm,\n", + " tools=tools,\n", + " prompt=\"You are a helpful assistant\",\n", + " checkpointer=checkpointer,\n", + ")\n", + "\n", + "graph" + ] + }, + { + "cell_type": "markdown", + "id": "fcd4b967-3c83-4f1c-ae22-d5ba97b7fbe1", + "metadata": {}, + "source": [ + "## IMPORTANT: Input and Config\n", + "\n", + "For this example we will ask explicitly for user assistance to make sure that the human assistance tool is called. In reality, this could be triggered by several conditions, for example a safety flag may route a conversation to a human if certain keywords are used.\n", + "\n", + "### Graph Invoke Input\n", + "We only need to pass the newest user message in as an argument `inputs`. This could include other state variables too but for the simple `create_react_agent`, messages are all that's required.\n", + "\n", + "### LangGraph RuntimeConfig\n", + "In LangGraph, config is a `RuntimeConfig` that contains attributes that are necessary at invocation time, for example user IDs or session IDs. For the `AgentCoreMemorySaver`, `thread_id` and `actor_id` must be set in the config. For instance, your AgentCore invocation endpoint could assign this based on the identity or user ID of the caller. Additional documentation here: [https://langchain-ai.github.io/langgraphjs/how-tos/configuration/](https://langchain-ai.github.io/langgraphjs/how-tos/configuration/)\n", + "\n", + "\n", + "\n", + "For the example, when the human assistance tool is called we expect the execution to be interrupted until a human intervenes." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c50a6a6e-b16f-41dc-8f16-1d102f6debc4", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "================================\u001b[1m Human Message \u001b[0m=================================\n", + "\n", + "I would like to work with a customer service human agent.\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "[{'type': 'text', 'text': \"I understand you'd like to connect with a customer service human agent. I can help you with that by requesting human assistance.\"}, {'type': 'tool_use', 'name': 'human_assistance', 'input': {'query': 'Customer requesting to speak with a human customer service agent.'}, 'id': 'tooluse_tFutleh5RK-mGOvg6YQwYw'}]\n", + "Tool Calls:\n", + " human_assistance (tooluse_tFutleh5RK-mGOvg6YQwYw)\n", + " Call ID: tooluse_tFutleh5RK-mGOvg6YQwYw\n", + " Args:\n", + " query: Customer requesting to speak with a human customer service agent.\n" + ] + } + ], + "source": [ + "user_input = \"I would like to work with a customer service human agent.\"\n", + "config = {\"configurable\": {\"thread_id\": \"1\", \"actor_id\": \"demo-notebook\"}}\n", + "\n", + "events = graph.stream(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": user_input}]},\n", + " config,\n", + " stream_mode=\"values\",\n", + ")\n", + "for event in events:\n", + " if \"messages\" in event:\n", + " event[\"messages\"][-1].pretty_print()" + ] + }, + { + "cell_type": "markdown", + "id": "b0f2af97-524a-4626-8dc4-85c5b677af90", + "metadata": {}, + "source": [ + "### Human-in-the-loop\n", + "\n", + "As you can see, execution paused when the human assistance tool was called. Inspecting the state (which uses the AgentCore memory checkpointer), you can see that the execution stopped at the tool node." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "0629af32-b660-4157-97ca-597b21ec66c3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "('tools',)" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "snapshot = graph.get_state(config)\n", + "snapshot.next" + ] + }, + { + "cell_type": "markdown", + "id": "ae31390c-0b1d-4b7f-9185-a28ee2bb79aa", + "metadata": {}, + "source": [ + "### Responding as a Human\n", + "\n", + "Using the LangGraph `Command` with our response, we can resume the execution and pass our response back to the LLM for it to respond to the user. This powerful workflow is enabled by checkpointing so that conversation and graph state is persisted under the hood, enabling pausing and resuming chats on demand through certain conditions. " + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "aaf88072-ab8d-4e5f-a605-6d65e66f6207", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "[{'type': 'text', 'text': \"I understand you'd like to connect with a customer service human agent. I can help you with that by requesting human assistance.\"}, {'type': 'tool_use', 'name': 'human_assistance', 'input': {'query': 'Customer requesting to speak with a human customer service agent.'}, 'id': 'tooluse_tFutleh5RK-mGOvg6YQwYw'}]\n", + "Tool Calls:\n", + " human_assistance (tooluse_tFutleh5RK-mGOvg6YQwYw)\n", + " Call ID: tooluse_tFutleh5RK-mGOvg6YQwYw\n", + " Args:\n", + " query: Customer requesting to speak with a human customer service agent.\n", + "=================================\u001b[1m Tool Message \u001b[0m=================================\n", + "Name: human_assistance\n", + "\n", + "I'm sorry to hear that you are frustrated. Looking at the past conversation history, I can see that you've requested a refund. I've gone ahead and credited it to your account.\n", + "==================================\u001b[1m Ai Message \u001b[0m==================================\n", + "\n", + "A human customer service agent has responded to your request and provided the following information:\n", + "\n", + "\"I'm sorry to hear that you are frustrated. Looking at the past conversation history, I can see that you've requested a refund. I've gone ahead and credited it to your account.\"\n", + "\n", + "Is there anything else you'd like help with regarding your refund or any other customer service matters?\n" + ] + } + ], + "source": [ + "human_response = (\n", + " \"I'm sorry to hear that you are frustrated. Looking at the past conversation history, I can see that you've requested a refund. I've gone ahead and credited it to your account.\"\n", + ")\n", + "\n", + "human_command = Command(resume={\"data\": human_response})\n", + "\n", + "events = graph.stream(human_command, config, stream_mode=\"values\")\n", + "for event in events:\n", + " if \"messages\" in event:\n", + " event[\"messages\"][-1].pretty_print()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a83eaf68-9730-4356-9b0c-454cc6e4c371", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}