From dcb7278c5678db08e84b6ec2593cc9b7bdc2a7ec Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Mon, 13 May 2024 15:55:38 -0700 Subject: [PATCH 01/15] Add search and asearch APIs to BaseCheckpointerSaver class. Implement search and asearch in MemorySaver. --- langgraph/checkpoint/base.py | 24 ++++++++ langgraph/checkpoint/memory.py | 73 ++++++++++++++++++++++ tests/checkpoint/__init__.py | 0 tests/checkpoint/test_memory.py | 106 ++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 tests/checkpoint/__init__.py create mode 100644 tests/checkpoint/test_memory.py diff --git a/langgraph/checkpoint/base.py b/langgraph/checkpoint/base.py index 695eb3d96..8306511bc 100644 --- a/langgraph/checkpoint/base.py +++ b/langgraph/checkpoint/base.py @@ -36,6 +36,11 @@ class CheckpointMetadata(TypedDict, total=False): Mapping from node name to writes emitted by that node. """ + score: Optional[int] + """The score of the checkpoint. + + The score can be used to mark a checkpoint as "good". + """ class Checkpoint(TypedDict): @@ -148,6 +153,15 @@ class BaseCheckpointSaver(ABC): ) -> Iterator[CheckpointTuple]: raise NotImplementedError + def search( + self, + metadata: CheckpointMetadata, + *, + before: Optional[RunnableConfig] = None, + limit: Optional[int] = None, + ) -> Iterator[CheckpointTuple]: + raise NotImplementedError + def put( self, config: RunnableConfig, @@ -173,6 +187,16 @@ class BaseCheckpointSaver(ABC): raise NotImplementedError yield + def asearch( + self, + metadata: CheckpointMetadata, + *, + before: Optional[RunnableConfig] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[CheckpointTuple]: + raise NotImplementedError + yield + async def aput( self, config: RunnableConfig, diff --git a/langgraph/checkpoint/memory.py b/langgraph/checkpoint/memory.py index 04f94afd5..f1751835d 100644 --- a/langgraph/checkpoint/memory.py +++ b/langgraph/checkpoint/memory.py @@ -119,6 +119,55 @@ class MemorySaver(BaseCheckpointSaver): metadata=self.serde.loads(metadata), ) + def search( + self, + metadata_query: CheckpointMetadata, + *, + before: Optional[RunnableConfig] = None, + limit: Optional[int] = None, + ) -> Iterator[CheckpointTuple]: + """Search for checkpoints by metadata. + + This method retrieves a list of checkpoint tuples from the in-memory + storage based on the provided metadata query. The metadata query does + not need to contain all keys defined in the CheckpointMetadata class. + The checkpoints are ordered by timestamp in descending order. + + Args: + metadata_query (CheckpointMetadata): The metadata query to use for searching the checkpoints. + before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None. + limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None. + + Yields: + Iterator[CheckpointTuple]: An iterator of checkpoint tuples. + """ + for thread_id, checkpoints in self.storage.items(): + for ts, (checkpoint_bytes, metadata_bytes) in checkpoints.items(): + # filter by thread_ts + if before and ts >= before["configurable"]["thread_ts"]: + continue + + # check if all query key/value pairs match the metadata + metadata = self.serde.loads(metadata_bytes) + all_keys_match = all( + query_value == metadata[query_key] + for query_key, query_value in metadata_query.items() + ) + + # if all query key/value pairs match, yield the checkpoint + if all_keys_match: + # limit search results + if limit is not None: + if limit <= 0: + break + limit -= 1 + + yield CheckpointTuple( + config={"configurable": {"thread_id": thread_id, "thread_ts": ts}}, + checkpoint=self.serde.loads(checkpoint_bytes), + metadata=metadata, + ) + def put( self, config: RunnableConfig, @@ -188,6 +237,30 @@ class MemorySaver(BaseCheckpointSaver): except StopIteration: return + async def asearch( + self, + metadata_query: CheckpointMetadata, + ) -> AsyncIterator[CheckpointTuple]: + """Asynchronous version of search. + + This method is an asynchronous wrapper around search that runs the synchronous + method in a separate thread using asyncio. + """ + loop = asyncio.get_running_loop() + iter = await loop.run_in_executor(None, self.search, metadata_query) + + def next_item(iter: Iterator[CheckpointTuple]) -> CheckpointTuple: + try: + return next(iter) + except StopIteration: + return None + + while True: + result = await loop.run_in_executor(None, next_item, iter) + if result is None: + break + yield result + async def aput( self, config: RunnableConfig, diff --git a/tests/checkpoint/__init__.py b/tests/checkpoint/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/checkpoint/test_memory.py b/tests/checkpoint/test_memory.py new file mode 100644 index 000000000..9b5bf5660 --- /dev/null +++ b/tests/checkpoint/test_memory.py @@ -0,0 +1,106 @@ +import pytest +from typing import AsyncIterator + +from langchain_core.runnables import RunnableConfig + +from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata +from langgraph.checkpoint.memory import MemorySaver + + +class TestMemorySaver: + @pytest.fixture(autouse=True) + def setup(self): + self.memory_saver = MemorySaver() + + # objects for test setup + self.config_1: RunnableConfig = {"configurable": {"thread_id": "thread-1", "thread_ts": "1"}} + self.config_2: RunnableConfig = {"configurable": {"thread_id": "thread-2", "thread_ts": "2"}} + + self.chkpnt_1: Checkpoint = { + "v": 1, + "ts": "1", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {} + } + self.chkpnt_2: Checkpoint = { + "v": 2, + "ts": "2", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {} + } + + self.metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "writes": {}, + "score": 1, + } + self.metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "writes": {"foo": "bar"}, + "score": None, + } + + async def _async_iterator_to_list(self, async_iterator: AsyncIterator): + result = [] + async for item in async_iterator: + result.append(item) + return result + + async def test_search(self): + # set up test + # save checkpoints + self.memory_saver.put(self.config_1, self.chkpnt_1, self.metadata_1) + self.memory_saver.put(self.config_2, self.chkpnt_2, self.metadata_2) + + # call method / assertions + query_1: CheckpointMetadata = {"source": "input"} # search by 1 key + query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints + query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match + + search_results_1 = list(self.memory_saver.search(query_1)) + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = list(self.memory_saver.search(query_2)) + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = list(self.memory_saver.search(query_3)) + assert len(search_results_3) == 2 + + search_results_4 = list(self.memory_saver.search(query_4)) + assert len(search_results_4) == 0 + + # TODO: test before and limit params + + async def test_asearch(self): + # set up test + # save checkpoints + self.memory_saver.put(self.config_1, self.chkpnt_1, self.metadata_1) + self.memory_saver.put(self.config_2, self.chkpnt_2, self.metadata_2) + + # call method / assertions + query_1: CheckpointMetadata = {"source": "input"} # search by 1 key + query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints + query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match + + + search_results_1 = [c async for c in self.memory_saver.asearch(query_1)] + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = [c async for c in self.memory_saver.asearch(query_2)] + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = [c async for c in self.memory_saver.asearch(query_3)] + assert len(search_results_3) == 2 + + search_results_4 = [c async for c in self.memory_saver.asearch(query_4)] + assert len(search_results_4) == 0 From 9b1efe969808ba12a2668a77e1a4b2e9e38a06ea Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Mon, 13 May 2024 16:59:59 -0700 Subject: [PATCH 02/15] Implement search() for SqliteSaver. --- langgraph/checkpoint/sqlite.py | 91 +++++++++++++++++++++++++++++++++ tests/checkpoint/test_memory.py | 7 --- tests/checkpoint/test_sqlite.py | 77 ++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 7 deletions(-) create mode 100644 tests/checkpoint/test_sqlite.py diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index 84dd96a82..e3bd33efb 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -1,3 +1,4 @@ +import json import pickle import sqlite3 import threading @@ -337,6 +338,62 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): ), ) + def search( + self, + metadata_query: CheckpointMetadata, + *, + before: Optional[RunnableConfig] = None, + limit: Optional[int] = None, + ) -> Iterator[CheckpointTuple]: + """Search for checkpoints by metadata. + + This method retrieves a list of checkpoint tuples from the SQLite + database based on the provided metadata query. The metadata query does + not need to contain all keys defined in the CheckpointMetadata class. + The checkpoints are ordered by timestamp in descending order. + + Args: + metadata_query (CheckpointMetadata): The metadata query to use for searching the checkpoints. + before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None. + limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None. + + Yields: + Iterator[CheckpointTuple]: An iterator of checkpoint tuples. + """ + query = ( + f"SELECT json_extract(CAST(metadata AS TEXT), '$.writes'), thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {self.search_where(metadata_query)}ORDER BY thread_ts DESC" + if before is None + else f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {self.search_where(metadata_query)}AND thread_ts < ? ORDER BY thread_ts DESC" + ) + if limit: + query += f" LIMIT {limit}" + + print("final query", query) + with self.cursor(transaction=False) as cur: + cur.execute( + query, + ( + () if before is None else (before["configurable"]["thread_ts"],) + ), + ) + for writes, thread_id, thread_ts, parent_ts, value, metadata in cur: + print("writes after json extract", writes) + yield CheckpointTuple( + {"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}}, + self.serde.loads(value), + self.serde.loads(metadata) if metadata is not None else {}, + ( + { + "configurable": { + "thread_id": thread_id, + "thread_ts": parent_ts, + } + } + if parent_ts + else None + ), + ) + def put( self, config: RunnableConfig, @@ -382,3 +439,37 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): "thread_ts": checkpoint["ts"], } } + + def search_where(self, metadata_query: CheckpointMetadata) -> str: + """Return WHERE clause for (a)search() given metadata query. + + This method returns the operator as well (=, IS). + """ + def _where_value(query_value: Any) -> str: + if query_value is None: + return "IS NULL" + elif isinstance(query_value, str): + return f"= '{query_value}'" + elif isinstance(query_value, int) or isinstance(query_value, float): + return f"= {query_value}" + elif isinstance(query_value, bool): + return f"= {1 if query_value else 0}" + elif isinstance(query_value, dict) or isinstance(query_value, list): + # query value for JSON object cannot have trailing space after separators (, :) + # SQLite json_extract() returns JSON string without whitespace + return f"= '{json.dumps(query_value, separators=(',', ':'))}'" + else: + return f"= '{str(query_value)}'" + + where = "WHERE " + for query_key, query_value in metadata_query.items(): + where += f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {_where_value(query_value)} AND " + + if where == "WHERE ": + # there are no query key/value pairs + return "" + else: + # remove trailing AND + where = where[:-4] + # where clause contains an extra trailing space + return where diff --git a/tests/checkpoint/test_memory.py b/tests/checkpoint/test_memory.py index 9b5bf5660..a271f2703 100644 --- a/tests/checkpoint/test_memory.py +++ b/tests/checkpoint/test_memory.py @@ -44,12 +44,6 @@ class TestMemorySaver: "score": None, } - async def _async_iterator_to_list(self, async_iterator: AsyncIterator): - result = [] - async for item in async_iterator: - result.append(item) - return result - async def test_search(self): # set up test # save checkpoints @@ -90,7 +84,6 @@ class TestMemorySaver: query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match - search_results_1 = [c async for c in self.memory_saver.asearch(query_1)] assert len(search_results_1) == 1 assert search_results_1[0].metadata == self.metadata_1 diff --git a/tests/checkpoint/test_sqlite.py b/tests/checkpoint/test_sqlite.py new file mode 100644 index 000000000..2f66b6ee9 --- /dev/null +++ b/tests/checkpoint/test_sqlite.py @@ -0,0 +1,77 @@ +import pytest + +from langchain_core.runnables import RunnableConfig + +from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata +from langgraph.checkpoint.sqlite import SqliteSaver + + +class TestMemorySaver: + @pytest.fixture(autouse=True) + def setup(self): + self.sqlite_saver = SqliteSaver.from_conn_string(":memory:") + + # objects for test setup + self.config_1: RunnableConfig = {"configurable": {"thread_id": "thread-1", "thread_ts": "1"}} + self.config_2: RunnableConfig = {"configurable": {"thread_id": "thread-2", "thread_ts": "2"}} + + self.chkpnt_1: Checkpoint = { + "v": 1, + "ts": "1", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {} + } + self.chkpnt_2: Checkpoint = { + "v": 2, + "ts": "2", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {} + } + + self.metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "writes": {}, + "score": 1, + } + self.metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "writes": {"foo": "bar"}, + "score": None, + } + + def test_search(self): + # set up test + # save checkpoints + self.sqlite_saver.put(self.config_1, self.chkpnt_1, self.metadata_1) + self.sqlite_saver.put(self.config_2, self.chkpnt_2, self.metadata_2) + + # call method / assertions + query_1: CheckpointMetadata = {"source": "input"} # search by 1 key + query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints + query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match + + search_results_1 = list(self.sqlite_saver.search(query_1)) + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = list(self.sqlite_saver.search(query_2)) + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = list(self.sqlite_saver.search(query_3)) + assert len(search_results_3) == 2 + + search_results_4 = list(self.sqlite_saver.search(query_4)) + assert len(search_results_4) == 0 + + # TODO: test before and limit params + + def test_create_where(self): + # call method / assertions + expected_where = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'loop' AND json_extract(CAST(metadata AS TEXT), '$.step') = 1 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{\"foo\":\"bar\"}' AND json_extract(CAST(metadata AS TEXT), '$.score') IS NULL " + assert self.sqlite_saver.search_where(self.metadata_2) == expected_where From 7a8cbe18f954323b06c30f6c52a81c62e15b02b4 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Mon, 13 May 2024 17:21:17 -0700 Subject: [PATCH 03/15] Implement asearch() in AsyncSqliteSaver. --- langgraph/checkpoint/aiosqlite.py | 52 ++++++++++++++++++++- langgraph/checkpoint/sqlite.py | 71 +++++++++++++++-------------- tests/checkpoint/test_aiosqlite.py | 72 ++++++++++++++++++++++++++++++ tests/checkpoint/test_sqlite.py | 4 +- 4 files changed, 160 insertions(+), 39 deletions(-) create mode 100644 tests/checkpoint/test_aiosqlite.py diff --git a/langgraph/checkpoint/aiosqlite.py b/langgraph/checkpoint/aiosqlite.py index 38fb1eb67..b76524861 100644 --- a/langgraph/checkpoint/aiosqlite.py +++ b/langgraph/checkpoint/aiosqlite.py @@ -14,7 +14,7 @@ from langgraph.checkpoint.base import ( CheckpointTuple, SerializerProtocol, ) -from langgraph.checkpoint.sqlite import JsonPlusSerializerCompat +from langgraph.checkpoint.sqlite import JsonPlusSerializerCompat, search_where class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): @@ -255,6 +255,56 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): else None, ) + async def asearch( + self, + metadata_query: CheckpointMetadata, + *, + before: Optional[RunnableConfig] = None, + limit: Optional[int] = None, + ) -> AsyncIterator[CheckpointTuple]: + """Search for checkpoints by metadata asynchronously. + + This method retrieves a list of checkpoint tuples from the SQLite + database based on the provided metadata query. The metadata query does + not need to contain all keys defined in the CheckpointMetadata class. + The checkpoints are ordered by timestamp in descending order. + + Args: + metadata_query (CheckpointMetadata): The metadata query to use for searching the checkpoints. + before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None. + limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None. + + Yields: + Iterator[CheckpointTuple]: An iterator of checkpoint tuples. + """ + await self.setup() + query = ( + f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}ORDER BY thread_ts DESC" + if before is None + else f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}AND thread_ts < ? ORDER BY thread_ts DESC" + ) + if limit: + query += f" LIMIT {limit}" + async with self.conn.execute( + query, + ( + () + if before is None + else ( + str(before["configurable"]["thread_ts"]), + ) + ), + ) as cursor: + async for thread_id, thread_ts, parent_ts, value, metadata in cursor: + yield CheckpointTuple( + {"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}}, + self.serde.loads(value), + self.serde.loads(metadata) if metadata is not None else {}, + {"configurable": {"thread_id": thread_id, "thread_ts": parent_ts}} + if parent_ts + else None, + ) + async def aput( self, config: RunnableConfig, diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index e3bd33efb..110422f5c 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -361,9 +361,9 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): Iterator[CheckpointTuple]: An iterator of checkpoint tuples. """ query = ( - f"SELECT json_extract(CAST(metadata AS TEXT), '$.writes'), thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {self.search_where(metadata_query)}ORDER BY thread_ts DESC" + f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}ORDER BY thread_ts DESC" if before is None - else f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {self.search_where(metadata_query)}AND thread_ts < ? ORDER BY thread_ts DESC" + else f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}AND thread_ts < ? ORDER BY thread_ts DESC" ) if limit: query += f" LIMIT {limit}" @@ -376,8 +376,7 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): () if before is None else (before["configurable"]["thread_ts"],) ), ) - for writes, thread_id, thread_ts, parent_ts, value, metadata in cur: - print("writes after json extract", writes) + for thread_id, thread_ts, parent_ts, value, metadata in cur: yield CheckpointTuple( {"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}}, self.serde.loads(value), @@ -440,36 +439,36 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): } } - def search_where(self, metadata_query: CheckpointMetadata) -> str: - """Return WHERE clause for (a)search() given metadata query. - - This method returns the operator as well (=, IS). - """ - def _where_value(query_value: Any) -> str: - if query_value is None: - return "IS NULL" - elif isinstance(query_value, str): - return f"= '{query_value}'" - elif isinstance(query_value, int) or isinstance(query_value, float): - return f"= {query_value}" - elif isinstance(query_value, bool): - return f"= {1 if query_value else 0}" - elif isinstance(query_value, dict) or isinstance(query_value, list): - # query value for JSON object cannot have trailing space after separators (, :) - # SQLite json_extract() returns JSON string without whitespace - return f"= '{json.dumps(query_value, separators=(',', ':'))}'" - else: - return f"= '{str(query_value)}'" - - where = "WHERE " - for query_key, query_value in metadata_query.items(): - where += f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {_where_value(query_value)} AND " - - if where == "WHERE ": - # there are no query key/value pairs - return "" +def search_where(metadata_query: CheckpointMetadata) -> str: + """Return WHERE clause for (a)search() given metadata query. + + This method returns the operator as well (=, IS). + """ + def _where_value(query_value: Any) -> str: + if query_value is None: + return "IS NULL" + elif isinstance(query_value, str): + return f"= '{query_value}'" + elif isinstance(query_value, int) or isinstance(query_value, float): + return f"= {query_value}" + elif isinstance(query_value, bool): + return f"= {1 if query_value else 0}" + elif isinstance(query_value, dict) or isinstance(query_value, list): + # query value for JSON object cannot have trailing space after separators (, :) + # SQLite json_extract() returns JSON string without whitespace + return f"= '{json.dumps(query_value, separators=(',', ':'))}'" else: - # remove trailing AND - where = where[:-4] - # where clause contains an extra trailing space - return where + return f"= '{str(query_value)}'" + + where = "WHERE " + for query_key, query_value in metadata_query.items(): + where += f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {_where_value(query_value)} AND " + + if where == "WHERE ": + # there are no query key/value pairs + return "" + else: + # remove trailing AND + where = where[:-4] + # where clause contains an extra trailing space + return where diff --git a/tests/checkpoint/test_aiosqlite.py b/tests/checkpoint/test_aiosqlite.py new file mode 100644 index 000000000..42b091af6 --- /dev/null +++ b/tests/checkpoint/test_aiosqlite.py @@ -0,0 +1,72 @@ +import pytest + +from langchain_core.runnables import RunnableConfig + +from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata +from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver + + +class TestMemorySaver: + @pytest.fixture(autouse=True) + def setup(self): + self.sqlite_saver = AsyncSqliteSaver.from_conn_string(":memory:") + + # objects for test setup + self.config_1: RunnableConfig = {"configurable": {"thread_id": "thread-1", "thread_ts": "1"}} + self.config_2: RunnableConfig = {"configurable": {"thread_id": "thread-2", "thread_ts": "2"}} + + self.chkpnt_1: Checkpoint = { + "v": 1, + "ts": "1", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {} + } + self.chkpnt_2: Checkpoint = { + "v": 2, + "ts": "2", + "channel_values": {}, + "channel_versions": {}, + "versions_seen": {} + } + + self.metadata_1: CheckpointMetadata = { + "source": "input", + "step": 2, + "writes": {}, + "score": 1, + } + self.metadata_2: CheckpointMetadata = { + "source": "loop", + "step": 1, + "writes": {"foo": "bar"}, + "score": None, + } + + async def test_asearch(self): + # set up test + # save checkpoints + await self.sqlite_saver.aput(self.config_1, self.chkpnt_1, self.metadata_1) + await self.sqlite_saver.aput(self.config_2, self.chkpnt_2, self.metadata_2) + + # call method / assertions + query_1: CheckpointMetadata = {"source": "input"} # search by 1 key + query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints + query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match + + search_results_1 = [c async for c in self.sqlite_saver.asearch(query_1)] + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 + + search_results_2 = [c async for c in self.sqlite_saver.asearch(query_2)] + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 + + search_results_3 = [c async for c in self.sqlite_saver.asearch(query_3)] + assert len(search_results_3) == 2 + + search_results_4 = [c async for c in self.sqlite_saver.asearch(query_4)] + assert len(search_results_4) == 0 + + # TODO: test before and limit params diff --git a/tests/checkpoint/test_sqlite.py b/tests/checkpoint/test_sqlite.py index 2f66b6ee9..ba1e4b495 100644 --- a/tests/checkpoint/test_sqlite.py +++ b/tests/checkpoint/test_sqlite.py @@ -3,7 +3,7 @@ import pytest from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata -from langgraph.checkpoint.sqlite import SqliteSaver +from langgraph.checkpoint.sqlite import search_where, SqliteSaver class TestMemorySaver: @@ -74,4 +74,4 @@ class TestMemorySaver: def test_create_where(self): # call method / assertions expected_where = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'loop' AND json_extract(CAST(metadata AS TEXT), '$.step') = 1 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{\"foo\":\"bar\"}' AND json_extract(CAST(metadata AS TEXT), '$.score') IS NULL " - assert self.sqlite_saver.search_where(self.metadata_2) == expected_where + assert search_where(self.metadata_2) == expected_where From aae05407f4006a22e3490a82b531ccf0123e023b Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Mon, 13 May 2024 18:22:07 -0700 Subject: [PATCH 04/15] Lint code. --- langgraph/checkpoint/aiosqlite.py | 8 +- langgraph/checkpoint/memory.py | 6 +- langgraph/checkpoint/sqlite.py | 10 +- langgraph/managed/few_shot.py | 59 ++++++++++ tests/checkpoint/test_aiosqlite.py | 43 ++++--- tests/checkpoint/test_memory.py | 24 ++-- tests/checkpoint/test_sqlite.py | 28 +++-- tests/test_pregel.py | 175 ++++++++++++++++++++++++++++- tests/test_pregel_async.py | 161 +++++++++++++++++++++++++- 9 files changed, 461 insertions(+), 53 deletions(-) create mode 100644 langgraph/managed/few_shot.py diff --git a/langgraph/checkpoint/aiosqlite.py b/langgraph/checkpoint/aiosqlite.py index b76524861..c569beb52 100644 --- a/langgraph/checkpoint/aiosqlite.py +++ b/langgraph/checkpoint/aiosqlite.py @@ -287,13 +287,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): query += f" LIMIT {limit}" async with self.conn.execute( query, - ( - () - if before is None - else ( - str(before["configurable"]["thread_ts"]), - ) - ), + (() if before is None else (str(before["configurable"]["thread_ts"]),)), ) as cursor: async for thread_id, thread_ts, parent_ts, value, metadata in cursor: yield CheckpointTuple( diff --git a/langgraph/checkpoint/memory.py b/langgraph/checkpoint/memory.py index f1751835d..c8d0d4eca 100644 --- a/langgraph/checkpoint/memory.py +++ b/langgraph/checkpoint/memory.py @@ -127,7 +127,7 @@ class MemorySaver(BaseCheckpointSaver): limit: Optional[int] = None, ) -> Iterator[CheckpointTuple]: """Search for checkpoints by metadata. - + This method retrieves a list of checkpoint tuples from the in-memory storage based on the provided metadata query. The metadata query does not need to contain all keys defined in the CheckpointMetadata class. @@ -163,7 +163,9 @@ class MemorySaver(BaseCheckpointSaver): limit -= 1 yield CheckpointTuple( - config={"configurable": {"thread_id": thread_id, "thread_ts": ts}}, + config={ + "configurable": {"thread_id": thread_id, "thread_ts": ts} + }, checkpoint=self.serde.loads(checkpoint_bytes), metadata=metadata, ) diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index 110422f5c..21ff4d591 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -367,14 +367,10 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): ) if limit: query += f" LIMIT {limit}" - - print("final query", query) with self.cursor(transaction=False) as cur: cur.execute( query, - ( - () if before is None else (before["configurable"]["thread_ts"],) - ), + (() if before is None else (before["configurable"]["thread_ts"],)), ) for thread_id, thread_ts, parent_ts, value, metadata in cur: yield CheckpointTuple( @@ -439,11 +435,13 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): } } + def search_where(metadata_query: CheckpointMetadata) -> str: """Return WHERE clause for (a)search() given metadata query. - + This method returns the operator as well (=, IS). """ + def _where_value(query_value: Any) -> str: if query_value is None: return "IS NULL" diff --git a/langgraph/managed/few_shot.py b/langgraph/managed/few_shot.py new file mode 100644 index 000000000..4738f0b2e --- /dev/null +++ b/langgraph/managed/few_shot.py @@ -0,0 +1,59 @@ +from contextlib import asynccontextmanager, contextmanager +from typing import ( + TYPE_CHECKING, + AsyncGenerator, + AsyncIterator, + Generator, + Generic, + Iterator, + Sequence, +) + +from langchain_core.runnables import RunnableConfig +from typing_extensions import Self + +from langgraph.channels.base import AsyncChannelsManager, ChannelsManager +from langgraph.checkpoint.base import CheckpointTuple +from langgraph.managed.base import ManagedValue, V +from langgraph.pregel.io import read_channels +from langgraph.pregel.types import PregelTaskDescription + +if TYPE_CHECKING: + from langgraph.pregel import Pregel + + +class FewShotExamples(ManagedValue[Sequence[V]], Generic[V]): + examples: list[V] + + def iter(self, score: int = 1, k: int = 5) -> Iterator[V]: + for example in self.graph.checkpointer.search({"score": score}, limit=k): + with ChannelsManager(self.graph.channels, example.checkpoint) as channels: + yield read_channels(channels, self.graph.output_channels) + + async def aiter(self, score: int = 1, k: int = 5) -> AsyncIterator[V]: + async for example in self.graph.checkpointer.asearch({"score": score}, limit=k): + async with AsyncChannelsManager( + self.graph.channels, example.checkpoint + ) as channels: + yield read_channels(channels, self.graph.output_channels) + + @classmethod + @contextmanager + def enter( + cls, config: RunnableConfig, graph: "Pregel" + ) -> Generator[Self, None, None]: + with super().enter(config, graph) as value: + value.examples = list(value.iter()) + yield value + + @classmethod + @asynccontextmanager + async def aenter( + cls, config: RunnableConfig, graph: "Pregel" + ) -> AsyncGenerator[Self, None]: + async with super().aenter(config, graph) as value: + value.examples = [e async for e in value.aiter()] + yield value + + def __call__(self, step: int, task: PregelTaskDescription) -> Sequence[V]: + return self.examples diff --git a/tests/checkpoint/test_aiosqlite.py b/tests/checkpoint/test_aiosqlite.py index 42b091af6..541228c7c 100644 --- a/tests/checkpoint/test_aiosqlite.py +++ b/tests/checkpoint/test_aiosqlite.py @@ -1,9 +1,8 @@ import pytest - from langchain_core.runnables import RunnableConfig -from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver +from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata class TestMemorySaver: @@ -12,22 +11,26 @@ class TestMemorySaver: self.sqlite_saver = AsyncSqliteSaver.from_conn_string(":memory:") # objects for test setup - self.config_1: RunnableConfig = {"configurable": {"thread_id": "thread-1", "thread_ts": "1"}} - self.config_2: RunnableConfig = {"configurable": {"thread_id": "thread-2", "thread_ts": "2"}} + self.config_1: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "thread_ts": "1"} + } + self.config_2: RunnableConfig = { + "configurable": {"thread_id": "thread-2", "thread_ts": "2"} + } self.chkpnt_1: Checkpoint = { "v": 1, "ts": "1", "channel_values": {}, "channel_versions": {}, - "versions_seen": {} + "versions_seen": {}, } self.chkpnt_2: Checkpoint = { "v": 2, "ts": "2", "channel_values": {}, "channel_versions": {}, - "versions_seen": {} + "versions_seen": {}, } self.metadata_1: CheckpointMetadata = { @@ -51,22 +54,26 @@ class TestMemorySaver: # call method / assertions query_1: CheckpointMetadata = {"source": "input"} # search by 1 key - query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_2: CheckpointMetadata = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match - search_results_1 = [c async for c in self.sqlite_saver.asearch(query_1)] - assert len(search_results_1) == 1 - assert search_results_1[0].metadata == self.metadata_1 + async with self.sqlite_saver as sqlite_saver: + search_results_1 = [c async for c in sqlite_saver.asearch(query_1)] + assert len(search_results_1) == 1 + assert search_results_1[0].metadata == self.metadata_1 - search_results_2 = [c async for c in self.sqlite_saver.asearch(query_2)] - assert len(search_results_2) == 1 - assert search_results_2[0].metadata == self.metadata_2 + search_results_2 = [c async for c in sqlite_saver.asearch(query_2)] + assert len(search_results_2) == 1 + assert search_results_2[0].metadata == self.metadata_2 - search_results_3 = [c async for c in self.sqlite_saver.asearch(query_3)] - assert len(search_results_3) == 2 + search_results_3 = [c async for c in sqlite_saver.asearch(query_3)] + assert len(search_results_3) == 2 - search_results_4 = [c async for c in self.sqlite_saver.asearch(query_4)] - assert len(search_results_4) == 0 + search_results_4 = [c async for c in sqlite_saver.asearch(query_4)] + assert len(search_results_4) == 0 - # TODO: test before and limit params + # TODO: test before and limit params diff --git a/tests/checkpoint/test_memory.py b/tests/checkpoint/test_memory.py index a271f2703..bbbf0822e 100644 --- a/tests/checkpoint/test_memory.py +++ b/tests/checkpoint/test_memory.py @@ -1,6 +1,6 @@ -import pytest from typing import AsyncIterator +import pytest from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata @@ -13,22 +13,26 @@ class TestMemorySaver: self.memory_saver = MemorySaver() # objects for test setup - self.config_1: RunnableConfig = {"configurable": {"thread_id": "thread-1", "thread_ts": "1"}} - self.config_2: RunnableConfig = {"configurable": {"thread_id": "thread-2", "thread_ts": "2"}} + self.config_1: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "thread_ts": "1"} + } + self.config_2: RunnableConfig = { + "configurable": {"thread_id": "thread-2", "thread_ts": "2"} + } self.chkpnt_1: Checkpoint = { "v": 1, "ts": "1", "channel_values": {}, "channel_versions": {}, - "versions_seen": {} + "versions_seen": {}, } self.chkpnt_2: Checkpoint = { "v": 2, "ts": "2", "channel_values": {}, "channel_versions": {}, - "versions_seen": {} + "versions_seen": {}, } self.metadata_1: CheckpointMetadata = { @@ -52,7 +56,10 @@ class TestMemorySaver: # call method / assertions query_1: CheckpointMetadata = {"source": "input"} # search by 1 key - query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_2: CheckpointMetadata = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match @@ -80,7 +87,10 @@ class TestMemorySaver: # call method / assertions query_1: CheckpointMetadata = {"source": "input"} # search by 1 key - query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_2: CheckpointMetadata = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match diff --git a/tests/checkpoint/test_sqlite.py b/tests/checkpoint/test_sqlite.py index ba1e4b495..95baf7da8 100644 --- a/tests/checkpoint/test_sqlite.py +++ b/tests/checkpoint/test_sqlite.py @@ -1,9 +1,8 @@ import pytest - from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata -from langgraph.checkpoint.sqlite import search_where, SqliteSaver +from langgraph.checkpoint.sqlite import SqliteSaver, search_where class TestMemorySaver: @@ -12,22 +11,26 @@ class TestMemorySaver: self.sqlite_saver = SqliteSaver.from_conn_string(":memory:") # objects for test setup - self.config_1: RunnableConfig = {"configurable": {"thread_id": "thread-1", "thread_ts": "1"}} - self.config_2: RunnableConfig = {"configurable": {"thread_id": "thread-2", "thread_ts": "2"}} + self.config_1: RunnableConfig = { + "configurable": {"thread_id": "thread-1", "thread_ts": "1"} + } + self.config_2: RunnableConfig = { + "configurable": {"thread_id": "thread-2", "thread_ts": "2"} + } self.chkpnt_1: Checkpoint = { "v": 1, "ts": "1", "channel_values": {}, "channel_versions": {}, - "versions_seen": {} + "versions_seen": {}, } self.chkpnt_2: Checkpoint = { "v": 2, "ts": "2", "channel_values": {}, "channel_versions": {}, - "versions_seen": {} + "versions_seen": {}, } self.metadata_1: CheckpointMetadata = { @@ -42,6 +45,7 @@ class TestMemorySaver: "writes": {"foo": "bar"}, "score": None, } + self.metadata_3: CheckpointMetadata = {} def test_search(self): # set up test @@ -51,7 +55,10 @@ class TestMemorySaver: # call method / assertions query_1: CheckpointMetadata = {"source": "input"} # search by 1 key - query_2: CheckpointMetadata = {"step": 1, "writes": {"foo": "bar"}} # search by multiple keys + query_2: CheckpointMetadata = { + "step": 1, + "writes": {"foo": "bar"}, + } # search by multiple keys query_3: CheckpointMetadata = {} # search by no keys, return all checkpoints query_4: CheckpointMetadata = {"source": "update", "step": 1} # no match @@ -73,5 +80,8 @@ class TestMemorySaver: def test_create_where(self): # call method / assertions - expected_where = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'loop' AND json_extract(CAST(metadata AS TEXT), '$.step') = 1 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{\"foo\":\"bar\"}' AND json_extract(CAST(metadata AS TEXT), '$.score') IS NULL " - assert search_where(self.metadata_2) == expected_where + expected_where_2 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'loop' AND json_extract(CAST(metadata AS TEXT), '$.step') = 1 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{\"foo\":\"bar\"}' AND json_extract(CAST(metadata AS TEXT), '$.score') IS NULL " + expected_where_3 = "" + + assert search_where(self.metadata_2) == expected_where_2 + assert search_where(self.metadata_3) == expected_where_3 diff --git a/tests/test_pregel.py b/tests/test_pregel.py index 4529e595c..723e993d6 100644 --- a/tests/test_pregel.py +++ b/tests/test_pregel.py @@ -5,10 +5,20 @@ import warnings from collections import Counter from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager -from typing import Annotated, Any, Generator, Literal, Optional, TypedDict, Union +from typing import ( + Annotated, + Any, + Generator, + Iterator, + Literal, + Optional, + Sequence, + TypedDict, + Union, +) import pytest -from langchain_core.runnables import RunnableLambda, RunnablePassthrough +from langchain_core.runnables import RunnableConfig, RunnableLambda, RunnablePassthrough from pytest_mock import MockerFixture from syrupy import SnapshotAssertion @@ -16,12 +26,14 @@ from langgraph.channels.binop import BinaryOperatorAggregate from langgraph.channels.context import Context from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic +from langgraph.checkpoint.base import CheckpointMetadata, CheckpointTuple from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.errors import InvalidUpdateError from langgraph.graph import END, Graph from langgraph.graph.graph import START -from langgraph.graph.message import MessageGraph +from langgraph.graph.message import MessageGraph, add_messages from langgraph.graph.state import StateGraph +from langgraph.managed.few_shot import FewShotExamples from langgraph.prebuilt.chat_agent_executor import ( create_function_calling_executor, create_tool_calling_executor, @@ -2576,6 +2588,163 @@ def test_state_graph_w_config(snapshot: SnapshotAssertion) -> None: assert app.config_schema().schema_json() == snapshot +def test_state_graph_few_shot(snapshot: SnapshotAssertion) -> None: + from langchain.chat_models.fake import FakeMessagesListChatModel + from langchain_community.tools import tool + from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage + from langchain_core.prompts import ChatPromptTemplate + + class BaseState(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + + class AgentState(BaseState): + examples: Annotated[Sequence[BaseState], FewShotExamples[BaseState]] + + # Assemble the tools + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + """You are a nice assistant. +Some examples of past conversations: +{examples}""", + ), + ("placeholder", "{messages}"), + ] + ) + + model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage(content="answer"), + ] + ) + + def agent(state: AgentState, config: RunnableConfig) -> AgentState: + # begin: testing code + assert state["examples"] == config["configurable"]["expected_examples"] + # end: testing code + formatted = prompt.invoke(state) + response = model.invoke(formatted) + return {"messages": response} + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # Logic to decide whether to continue in the loop or exit + if not data["messages"][-1].tool_calls: + return "exit" + else: + return "continue" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("agent", agent) + workflow.add_node("tools", ToolNode(tools)) + workflow.set_entry_point("agent") + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + workflow.add_edge("tools", "agent") + + with SqliteSaver.from_conn_string(":memory:") as saver: + app = workflow.compile(checkpointer=saver) + + first_messages = [ + HumanMessage(content="what is weather in sf", id=AnyStr()), + AIMessage( + content="", + id=AnyStr(), + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + } + ], + ), + ToolMessage( + content="result for query", + name="search_api", + id=AnyStr(), + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id=AnyStr()), + ] + assert app.invoke( + {"messages": "what is weather in sf"}, + {"configurable": {"thread_id": "1", "expected_examples": []}}, + ) == {"messages": first_messages} + + # get first checkpoint + chkpnt_tuple_1 = saver.get_tuple({"configurable": {"thread_id": "1"}}) + config = chkpnt_tuple_1.config + checkpoint = chkpnt_tuple_1.checkpoint + metadata = chkpnt_tuple_1.metadata + + # not needed in application code, only for testing + hiscored = list(saver.search({"score": 1})) + assert hiscored == [] + + # mark as "good" + metadata["score"] = 1 + saver.put(config, checkpoint, metadata) + + # not needed in application code, only for testing + hiscored = list(saver.search({"score": 1})) + assert len(hiscored) == 1 + assert hiscored[0].checkpoint["channel_values"]["messages"] == first_messages + + assert app.invoke( + {"messages": "what is weather in la"}, + { + "configurable": { + "thread_id": "2", + # below is only for testing purposes, not part of few shot api + "expected_examples": [{"messages": first_messages}], + } + }, + ) == { + "messages": [ + HumanMessage(content="what is weather in la", id=AnyStr()), + AIMessage( + content="", + id=AnyStr(), + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + } + ], + ), + ToolMessage( + content="result for query", + name="search_api", + id=AnyStr(), + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id=AnyStr()), + ] + } + + def test_conditional_entrypoint_graph_state(snapshot: SnapshotAssertion) -> None: class AgentState(TypedDict, total=False): input: str diff --git a/tests/test_pregel_async.py b/tests/test_pregel_async.py index c2a52ab11..43b41a2fb 100644 --- a/tests/test_pregel_async.py +++ b/tests/test_pregel_async.py @@ -10,6 +10,7 @@ from typing import ( AsyncIterator, Generator, Optional, + Sequence, TypedDict, Union, ) @@ -28,12 +29,14 @@ from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver from langgraph.errors import InvalidUpdateError from langgraph.graph import END, Graph, StateGraph from langgraph.graph.graph import START -from langgraph.graph.message import MessageGraph +from langgraph.graph.message import MessageGraph, add_messages +from langgraph.managed.few_shot import FewShotExamples from langgraph.prebuilt.chat_agent_executor import ( create_function_calling_executor, create_tool_calling_executor, ) from langgraph.prebuilt.tool_executor import ToolExecutor +from langgraph.prebuilt.tool_node import ToolNode from langgraph.pregel import Channel, GraphRecursionError, Pregel, StateSnapshot from tests.any_str import AnyStr from tests.memory_assert import MemorySaverAssertImmutable @@ -2313,6 +2316,162 @@ async def test_conditional_graph_state() -> None: ) +async def test_state_graph_few_shot() -> None: + from langchain.chat_models.fake import FakeMessagesListChatModel + from langchain_community.tools import tool + from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolMessage + from langchain_core.prompts import ChatPromptTemplate + + class BaseState(TypedDict): + messages: Annotated[list[AnyMessage], add_messages] + + class AgentState(BaseState): + examples: Annotated[Sequence[BaseState], FewShotExamples[BaseState]] + + # Assemble the tools + @tool() + def search_api(query: str) -> str: + """Searches the API for the query.""" + return f"result for {query}" + + tools = [search_api] + + prompt = ChatPromptTemplate.from_messages( + [ + ( + "system", + """You are a nice assistant. +Some examples of past conversations: +{examples}""", + ), + ("placeholder", "{messages}"), + ] + ) + + model = FakeMessagesListChatModel( + responses=[ + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "search_api", + "args": {"query": "query"}, + }, + ], + ), + AIMessage(content="answer"), + ] + ) + + async def agent(state: AgentState, config: RunnableConfig) -> AgentState: + # begin: testing code + assert state["examples"] == config["configurable"]["expected_examples"] + # end: testing code + formatted = await prompt.ainvoke(state) + response = await model.ainvoke(formatted) + return {"messages": response} + + # Define decision-making logic + def should_continue(data: AgentState) -> str: + # Logic to decide whether to continue in the loop or exit + if not data["messages"][-1].tool_calls: + return "exit" + else: + return "continue" + + # Define a new graph + workflow = StateGraph(AgentState) + + workflow.add_node("agent", agent) + workflow.add_node("tools", ToolNode(tools)) + workflow.set_entry_point("agent") + workflow.add_conditional_edges( + "agent", should_continue, {"continue": "tools", "exit": END} + ) + workflow.add_edge("tools", "agent") + + async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: + app = workflow.compile(checkpointer=saver) + + first_messages = [ + HumanMessage(content="what is weather in sf", id=AnyStr()), + AIMessage( + content="", + id=AnyStr(), + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + } + ], + ), + ToolMessage( + content="result for query", + name="search_api", + id=AnyStr(), + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id=AnyStr()), + ] + assert await app.ainvoke( + {"messages": "what is weather in sf"}, + {"configurable": {"thread_id": "1", "expected_examples": []}}, + ) == {"messages": first_messages} + + # get first checkpoint + chkpnt_tuple_1 = await saver.aget_tuple({"configurable": {"thread_id": "1"}}) + config = chkpnt_tuple_1.config + checkpoint = chkpnt_tuple_1.checkpoint + metadata = chkpnt_tuple_1.metadata + + # not needed in application code, only for testing + assert [c async for c in saver.asearch({"score": 1})] == [] + + # mark as "good" + metadata["score"] = 1 + await saver.aput(config, checkpoint, metadata) + + # not needed in application code, only for testing + hiscored = [c async for c in saver.asearch({"score": 1})] + assert len(hiscored) == 1 + assert hiscored[0].checkpoint["channel_values"]["messages"] == first_messages + + assert await app.ainvoke( + {"messages": "what is weather in la"}, + { + "configurable": { + "thread_id": "2", + # below is only for testing purposes, not part of few shot api + "expected_examples": [{"messages": first_messages}], + } + }, + ) == { + "messages": [ + HumanMessage(content="what is weather in la", id=AnyStr()), + AIMessage( + content="", + id=AnyStr(), + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + } + ], + ), + ToolMessage( + content="result for query", + name="search_api", + id=AnyStr(), + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id=AnyStr()), + ] + } + + async def test_conditional_entrypoint_graph() -> None: async def left(data: str) -> str: return data + "->left" From 065e0ff36066e4232436b866d5f49e968db5486d Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Mon, 13 May 2024 18:34:54 -0700 Subject: [PATCH 05/15] Remove unused imports. --- langgraph/managed/few_shot.py | 1 - tests/checkpoint/test_memory.py | 2 -- tests/test_pregel.py | 2 -- 3 files changed, 5 deletions(-) diff --git a/langgraph/managed/few_shot.py b/langgraph/managed/few_shot.py index 4738f0b2e..1e2147006 100644 --- a/langgraph/managed/few_shot.py +++ b/langgraph/managed/few_shot.py @@ -13,7 +13,6 @@ from langchain_core.runnables import RunnableConfig from typing_extensions import Self from langgraph.channels.base import AsyncChannelsManager, ChannelsManager -from langgraph.checkpoint.base import CheckpointTuple from langgraph.managed.base import ManagedValue, V from langgraph.pregel.io import read_channels from langgraph.pregel.types import PregelTaskDescription diff --git a/tests/checkpoint/test_memory.py b/tests/checkpoint/test_memory.py index bbbf0822e..2a4f26e34 100644 --- a/tests/checkpoint/test_memory.py +++ b/tests/checkpoint/test_memory.py @@ -1,5 +1,3 @@ -from typing import AsyncIterator - import pytest from langchain_core.runnables import RunnableConfig diff --git a/tests/test_pregel.py b/tests/test_pregel.py index 723e993d6..7ccc1bd7d 100644 --- a/tests/test_pregel.py +++ b/tests/test_pregel.py @@ -9,7 +9,6 @@ from typing import ( Annotated, Any, Generator, - Iterator, Literal, Optional, Sequence, @@ -26,7 +25,6 @@ from langgraph.channels.binop import BinaryOperatorAggregate from langgraph.channels.context import Context from langgraph.channels.last_value import LastValue from langgraph.channels.topic import Topic -from langgraph.checkpoint.base import CheckpointMetadata, CheckpointTuple from langgraph.checkpoint.sqlite import SqliteSaver from langgraph.errors import InvalidUpdateError from langgraph.graph import END, Graph From c80090465ffd1cf75be3d53be2ab0d102af5aebf Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Mon, 13 May 2024 18:42:55 -0700 Subject: [PATCH 06/15] Copy learning.ipynb. --- examples/learning.ipynb | 648 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 648 insertions(+) create mode 100644 examples/learning.ipynb diff --git a/examples/learning.ipynb b/examples/learning.ipynb new file mode 100644 index 000000000..62313b61e --- /dev/null +++ b/examples/learning.ipynb @@ -0,0 +1,648 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "51466c8d-8ce4-4b3d-be4e-18fdbeda5f53", + "metadata": {}, + "source": [ + "# Get/Update State\n", + "\n", + "When running LangGraph agents, you can easily save good threads and use them in the future.\n", + "\n", + "**Note:** this requires passing in a checkpointer." + ] + }, + { + "cell_type": "markdown", + "id": "7cbd446a-808f-4394-be92-d45ab818953c", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "First we need to install the packages required" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "af4ce0ba-7596-4e5f-8bf8-0b0bd6e62833", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m23.3.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m23.3.2\u001b[0m\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n" + ] + } + ], + "source": [ + "!pip install --quiet -U langchain langchain_openai tavily-python" + ] + }, + { + "cell_type": "markdown", + "id": "0abe11f4-62ed-4dc4-8875-3db21e260d1d", + "metadata": {}, + "source": [ + "Next, we need to set API keys for OpenAI (the LLM we will use) and Tavily (the search tool we will use)" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c903a1cf-2977-4e2d-ad7d-8b3946821d89", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "OpenAI API Key: ········\n", + "Tavily API Key: ········\n" + ] + } + ], + "source": [ + "import os\n", + "import getpass\n", + "\n", + "os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"OpenAI API Key:\")\n", + "os.environ[\"TAVILY_API_KEY\"] = getpass.getpass(\"Tavily API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "f0ed46a8-effe-4596-b0e1-a6a29ee16f5c", + "metadata": {}, + "source": [ + "Optionally, we can set API key for [LangSmith tracing](https://smith.langchain.com/), which will give us best-in-class observability." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95e25aec-7c9f-4a63-b143-225d0e9a79c3", + "metadata": {}, + "outputs": [], + "source": [ + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "os.environ[\"LANGCHAIN_API_KEY\"] = getpass.getpass(\"LangSmith API Key:\")" + ] + }, + { + "cell_type": "markdown", + "id": "21ac643b-cb06-4724-a80c-2862ba4773f1", + "metadata": {}, + "source": [ + "## Set up the tools\n", + "\n", + "We will first define the tools we want to use.\n", + "For this simple example, we will use a built-in search tool via Tavily.\n", + "However, it is really easy to create your own tools - see documentation [here](https://python.langchain.com/docs/modules/agents/tools/custom_tools) on how to do that.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d7ef57dd-5d6e-4ad3-9377-a92201c1310e", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_community.tools.tavily_search import TavilySearchResults\n", + "\n", + "tools = [TavilySearchResults(max_results=1)]" + ] + }, + { + "cell_type": "markdown", + "id": "01885785-b71a-44d1-b1d6-7b5b14d53b58", + "metadata": {}, + "source": [ + "We can now wrap these tools in a simple ToolNode.\n", + "This is a prebuilt node that extracts tool calls from the most recent AIMessage, executes them, and returns a ToolMessage with the results.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "5cf3331e-ccb3-41c8-aeb9-a840a94d41e7", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.prebuilt import ToolNode\n", + "\n", + "tool_node = ToolNode(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "5497ed70-fce3-47f1-9cad-46f912bad6a5", + "metadata": {}, + "source": [ + "## Set up the model\n", + "\n", + "Now we need to load the chat model we want to use.\n", + "Importantly, this should satisfy two criteria:\n", + "\n", + "1. It should work with messages. We will represent all agent state in the form of messages, so it needs to be able to work well with them.\n", + "2. It should work with OpenAI function calling. This means it should either be an OpenAI model or a model that exposes a similar interface.\n", + "\n", + "Note: these model requirements are not requirements for using LangGraph - they are just requirements for this one example." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "892b54b9-75f0-4804-9ed0-88b5e5532989", + "metadata": {}, + "outputs": [], + "source": [ + "from langchain_openai import ChatOpenAI\n", + "\n", + "model = ChatOpenAI(temperature=0)" + ] + }, + { + "cell_type": "markdown", + "id": "a77995c0-bae2-4cee-a036-8688a90f05b9", + "metadata": {}, + "source": [ + "\n", + "After we've done this, we should make sure the model knows that it has these tools available to call.\n", + "We can do this using the `.bind_tools()` method, common to many of LangChain's chat models.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cd3cbae5-d92c-4559-a4aa-44721b80d107", + "metadata": {}, + "outputs": [], + "source": [ + "model = model.bind_tools(tools)" + ] + }, + { + "cell_type": "markdown", + "id": "e03c5094-9297-4d19-a04e-3eedc75cefb4", + "metadata": {}, + "source": [ + "## Define the nodes\n", + "\n", + "We now need to define a few different nodes in our graph.\n", + "In `langgraph`, a node can be either a function or a [runnable](https://python.langchain.com/docs/expression_language/).\n", + "There are two main nodes we need for this:\n", + "\n", + "1. The agent: responsible for deciding what (if any) actions to take.\n", + "2. A function to invoke tools: if the agent decides to take an action, this node will then execute that action.\n", + "\n", + "We will also need to define some edges.\n", + "Some of these edges may be conditional.\n", + "The reason they are conditional is that based on the output of a node, one of several paths may be taken.\n", + "The path that is taken is not known until that node is run (the LLM decides).\n", + "\n", + "1. Conditional Edge: after the agent is called, we should either:\n", + " a. If the agent said to take an action, then the function to invoke tools should be called\n", + " b. If the agent said that it was finished, then it should finish\n", + "2. Normal Edge: after the tools are invoked, it should always go back to the agent to decide what to do next\n", + "\n", + "Let's define the nodes, as well as a function to decide how what conditional edge to take." + ] + }, + { + "cell_type": "code", + "execution_count": 77, + "id": "3b541bb9-900c-40d0-964d-7b5dfee30667", + "metadata": {}, + "outputs": [], + "source": [ + "# Define the function that determines whether to continue or not\n", + "def should_continue(state):\n", + " last_message = state['messages'][-1]\n", + " # If there is no function call, then we finish\n", + " if not last_message.tool_calls:\n", + " return \"end\"\n", + " # Otherwise if there is, we continue\n", + " else:\n", + " return \"continue\"" + ] + }, + { + "cell_type": "markdown", + "id": "ffd6e892-946c-4899-8cc0-7c9291c1f73b", + "metadata": {}, + "source": [ + "## Define the graph\n", + "\n", + "We can now put it all together and define the graph!" + ] + }, + { + "cell_type": "code", + "execution_count": 154, + "id": "812b4e70-4956-4415-8880-db48b3dcbad2", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.graph import StateGraph, END\n", + "from langgraph.graph.message import add_messages\n", + "from langgraph.managed.few_shot import FewShotExamples\n", + "from typing import TypedDict, Annotated\n", + "from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage\n", + "\n", + "class BaseState(TypedDict):\n", + " messages: Annotated[list[AnyMessage], add_messages]\n", + " examples: Annotated[list, FewShotExamples]\n", + "\n", + "\n", + "from langchain_core.messages import AIMessage, ToolMessage\n", + "def _render_message(m):\n", + " if isinstance(m, HumanMessage):\n", + " return \"Human: \" + m.content\n", + " elif isinstance(m, AIMessage):\n", + " _m = \"AI: \" + m.content\n", + " if len(m.tool_calls) > 0:\n", + " _m += f\" Tools: {m.tool_calls}\"\n", + " return _m\n", + " elif isinstance(m, ToolMessage):\n", + " return \"Tool Result: ...\"\n", + " else:\n", + " raise ValueError\n", + "def _render_messages(ms):\n", + " m_string = [_render_message(m) for m in ms]\n", + " return \"\\n\".join(m_string)\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(BaseState)\n", + "\n", + "def _agent(state: BaseState):\n", + " if len(state['examples']) > 0:\n", + " _examples = \"\\n\\n\".join([f\"Example {i}: \" + _render_messages(e['messages']) for i, e in enumerate(state['examples'])])\n", + " system_message = \"\"\"You are a helpful assistant. Below are some examples of interactions you had with users. \\\n", + "These were good interactions where the final result they got was the desired one. As much as possible, you should learn from these interactions and mimic them in the future. \\\n", + "Pay particularly close attention to when tools are called, and what the inputs are.!\n", + "\n", + "{examples}\n", + "\n", + "Assist the user as they require!\"\"\".format(examples=_examples)\n", + "\n", + " else:\n", + " system_message = \"\"\"You are a helpful assistant\"\"\"\n", + " output = model.invoke([SystemMessage(content=system_message)] + state['messages'])\n", + " return {\"messages\": [output]}\n", + "\n", + "# Define the two nodes we will cycle between\n", + "workflow.add_node(\"agent\", _agent)\n", + "workflow.add_node(\"action\", tool_node)\n", + "\n", + "# Set the entrypoint as `agent`\n", + "# This means that this node is the first one called\n", + "workflow.set_entry_point(\"agent\")\n", + "\n", + "# We now add a conditional edge\n", + "workflow.add_conditional_edges(\n", + " # First, we define the start node. We use `agent`.\n", + " # This means these are the edges taken after the `agent` node is called.\n", + " \"agent\",\n", + " # Next, we pass in the function that will determine which node is called next.\n", + " should_continue,\n", + " # Finally we pass in a mapping.\n", + " # The keys are strings, and the values are other nodes.\n", + " # END is a special node marking that the graph should finish.\n", + " # What will happen is we will call `should_continue`, and then the output of that\n", + " # will be matched against the keys in this mapping.\n", + " # Based on which one it matches, that node will then be called.\n", + " {\n", + " # If `tools`, then we call the tool node.\n", + " \"continue\": \"action\",\n", + " # Otherwise we finish.\n", + " \"end\": END,\n", + " },\n", + ")\n", + "\n", + "# We now add a normal edge from `tools` to `agent`.\n", + "# This means that after `tools` is called, `agent` node is called next.\n", + "workflow.add_edge(\"action\", \"agent\")" + ] + }, + { + "cell_type": "markdown", + "id": "bc9c8536-f90b-44fa-958d-5df016c66d8f", + "metadata": {}, + "source": [ + "**Persistence**\n", + "\n", + "To add in persistence, we pass in a checkpoint when compiling the graph" + ] + }, + { + "cell_type": "code", + "execution_count": 115, + "id": "6845ed6a-d155-4105-9160-28849877248b", + "metadata": {}, + "outputs": [], + "source": [ + "from langgraph.checkpoint.sqlite import SqliteSaver\n", + "\n", + "memory = SqliteSaver.from_conn_string(\":memory:\")" + ] + }, + { + "cell_type": "code", + "execution_count": 155, + "id": "79d29875-8aa8-434c-9f20-1c58346a6249", + "metadata": {}, + "outputs": [], + "source": [ + "# Finally, we compile it!\n", + "# This compiles it into a LangChain Runnable,\n", + "# meaning you can use it as you would any other runnable\n", + "app = workflow.compile(checkpointer=memory, interrupt_before=['action'])" + ] + }, + { + "cell_type": "markdown", + "id": "e8aff75b-563e-42b1-969b-742201514fc3", + "metadata": {}, + "source": [ + "## Preview the graph" + ] + }, + { + "cell_type": "code", + "execution_count": 156, + "id": "c9ab60eb-679b-4eef-9e64-5ffbf3dffc70", + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "" + ] + }, + "execution_count": 156, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from IPython.display import Image\n", + "\n", + "Image(app.get_graph().draw_png())" + ] + }, + { + "cell_type": "markdown", + "id": "2a1b56c5-bd61-4192-8bdb-458a1e9f0159", + "metadata": {}, + "source": [ + "## Interacting with the Agent\n", + "\n", + "We can now interact with the agent. Between interactions you can get and update state.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 118, + "id": "cfd140f0-a5a6-4697-8115-322242f197b5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h', 'function': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 94, 'total_tokens': 115}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-3764f79e-17b4-4aa3-bbe4-4f92b11ca52c-0', tool_calls=[{'name': 'tavily_search_results_json', 'args': {'query': 'weather in San Francisco'}, 'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h'}])]}\n" + ] + } + ], + "source": [ + "from langchain_core.messages import HumanMessage\n", + "\n", + "thread = {\"configurable\": {\"thread_id\": '1'}}\n", + "for event in app.stream({\"messages\": [HumanMessage(content=\"whats the weather in sf?\")]}, thread):\n", + " for v in event.values():\n", + " print(v)" + ] + }, + { + "cell_type": "code", + "execution_count": 120, + "id": "4479f8ae-7c46-4117-8ca2-0a9c2ef9785b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'messages': [HumanMessage(content='whats the weather in sf?', id='1cfd0c2f-9b60-48da-8938-408fd6aeda13'),\n", + " AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h', 'function': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 94, 'total_tokens': 115}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-3764f79e-17b4-4aa3-bbe4-4f92b11ca52c-0', tool_calls=[{'name': 'tavily_search_results_json', 'args': {'query': 'weather in San Francisco'}, 'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h'}])]}" + ] + }, + "execution_count": 120, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "current_values = app.get_state(thread)\n", + "current_values.values" + ] + }, + { + "cell_type": "code", + "execution_count": 122, + "id": "1a0cdb78-40c6-4550-8c27-8f1b02d9e678", + "metadata": {}, + "outputs": [], + "source": [ + "current_values.values['messages'][-1].tool_calls[0]['args']['query'] = \"weather in San Francisco, Accuweather\"" + ] + }, + { + "cell_type": "code", + "execution_count": 126, + "id": "652f699a-89bc-4277-b37a-c3d94b835df5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'configurable': {'thread_id': '1',\n", + " 'thread_ts': '2024-04-20T01:13:15.108790+00:00'}}" + ] + }, + "execution_count": 126, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "app.update_state(thread, current_values.values)" + ] + }, + { + "cell_type": "code", + "execution_count": 127, + "id": "e2b29825-a108-4d40-b377-22e8f4629d64", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "StateSnapshot(values={'messages': [HumanMessage(content='whats the weather in sf?', id='1cfd0c2f-9b60-48da-8938-408fd6aeda13'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h', 'function': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 94, 'total_tokens': 115}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-3764f79e-17b4-4aa3-bbe4-4f92b11ca52c-0', tool_calls=[{'name': 'tavily_search_results_json', 'args': {'query': 'weather in San Francisco, Accuweather'}, 'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h'}])]}, next=('action',), config={'configurable': {'thread_id': '1', 'thread_ts': '2024-04-20T01:13:15.108790+00:00'}}, parent_config=None)" + ] + }, + "execution_count": 127, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "app.get_state(thread)" + ] + }, + { + "cell_type": "code", + "execution_count": 128, + "id": "f0aad8a6-056e-42ca-bbc2-b45f768da75f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'messages': [ToolMessage(content='[{\"url\": \"https://www.weatherapi.com/\", \"content\": \"{\\'location\\': {\\'name\\': \\'San Francisco\\', \\'region\\': \\'California\\', \\'country\\': \\'United States of America\\', \\'lat\\': 37.78, \\'lon\\': -122.42, \\'tz_id\\': \\'America/Los_Angeles\\', \\'localtime_epoch\\': 1713575495, \\'localtime\\': \\'2024-04-19 18:11\\'}, \\'current\\': {\\'last_updated_epoch\\': 1713574800, \\'last_updated\\': \\'2024-04-19 18:00\\', \\'temp_c\\': 16.1, \\'temp_f\\': 61.0, \\'is_day\\': 1, \\'condition\\': {\\'text\\': \\'Sunny\\', \\'icon\\': \\'//cdn.weatherapi.com/weather/64x64/day/113.png\\', \\'code\\': 1000}, \\'wind_mph\\': 16.1, \\'wind_kph\\': 25.9, \\'wind_degree\\': 300, \\'wind_dir\\': \\'WNW\\', \\'pressure_mb\\': 1015.0, \\'pressure_in\\': 29.97, \\'precip_mm\\': 0.0, \\'precip_in\\': 0.0, \\'humidity\\': 67, \\'cloud\\': 0, \\'feelslike_c\\': 16.1, \\'feelslike_f\\': 61.0, \\'vis_km\\': 16.0, \\'vis_miles\\': 9.0, \\'uv\\': 4.0, \\'gust_mph\\': 20.6, \\'gust_kph\\': 33.1}}\"}]', name='tavily_search_results_json', id='8c7e9af3-6569-4982-a83d-be1ec02f828a', tool_call_id='call_yQrJa8CEOfKBdpVl80jzWf5h')]}\n", + "{'messages': [AIMessage(content='The current weather in San Francisco is sunny with a temperature of 61.0°F (16.1°C). The wind speed is 25.9 km/h coming from the west-northwest direction. The humidity is at 67%, and there is no precipitation at the moment.', response_metadata={'token_usage': {'completion_tokens': 59, 'prompt_tokens': 476, 'total_tokens': 535}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'stop', 'logprobs': None}, id='run-7d652a0d-cb00-4857-b9fb-d3e37b0a6d23-0')]}\n" + ] + } + ], + "source": [ + "for event in app.stream(None, thread):\n", + " for v in event.values():\n", + " print(v)" + ] + }, + { + "cell_type": "code", + "execution_count": 129, + "id": "84748206-975e-4a33-a178-d43df683298c", + "metadata": {}, + "outputs": [], + "source": [ + "chkpnt_tuple = memory.get_tuple({\"configurable\": {\"thread_id\": \"1\"}})\n", + "config = chkpnt_tuple.config\n", + "checkpoint = chkpnt_tuple.checkpoint\n", + "metadata = chkpnt_tuple.metadata\n", + "\n", + "# mark as \"good\"\n", + "metadata[\"score\"] = 1\n", + "memory.put(config, checkpoint, metadata)" + ] + }, + { + "cell_type": "code", + "execution_count": 130, + "id": "ce7fa228-8c37-4001-afd4-0001b268e1db", + "metadata": {}, + "outputs": [], + "source": [ + "examples = list(memory.search({\"score\": 1}))" + ] + }, + { + "cell_type": "code", + "execution_count": 131, + "id": "0543a501-b4cb-4890-8236-3350ebee5af9", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[CheckpointTuple(config={'configurable': {'thread_id': '1', 'thread_ts': '2024-04-20T01:13:36.933600+00:00'}}, checkpoint={'v': 1, 'ts': '2024-04-20T01:13:36.933600+00:00', 'channel_values': {'messages': [HumanMessage(content='whats the weather in sf?', id='1cfd0c2f-9b60-48da-8938-408fd6aeda13'), AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h', 'function': {'arguments': '{\"query\":\"weather in San Francisco\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 21, 'prompt_tokens': 94, 'total_tokens': 115}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-3764f79e-17b4-4aa3-bbe4-4f92b11ca52c-0', tool_calls=[{'name': 'tavily_search_results_json', 'args': {'query': 'weather in San Francisco, Accuweather'}, 'id': 'call_yQrJa8CEOfKBdpVl80jzWf5h'}]), ToolMessage(content='[{\"url\": \"https://www.weatherapi.com/\", \"content\": \"{\\'location\\': {\\'name\\': \\'San Francisco\\', \\'region\\': \\'California\\', \\'country\\': \\'United States of America\\', \\'lat\\': 37.78, \\'lon\\': -122.42, \\'tz_id\\': \\'America/Los_Angeles\\', \\'localtime_epoch\\': 1713575495, \\'localtime\\': \\'2024-04-19 18:11\\'}, \\'current\\': {\\'last_updated_epoch\\': 1713574800, \\'last_updated\\': \\'2024-04-19 18:00\\', \\'temp_c\\': 16.1, \\'temp_f\\': 61.0, \\'is_day\\': 1, \\'condition\\': {\\'text\\': \\'Sunny\\', \\'icon\\': \\'//cdn.weatherapi.com/weather/64x64/day/113.png\\', \\'code\\': 1000}, \\'wind_mph\\': 16.1, \\'wind_kph\\': 25.9, \\'wind_degree\\': 300, \\'wind_dir\\': \\'WNW\\', \\'pressure_mb\\': 1015.0, \\'pressure_in\\': 29.97, \\'precip_mm\\': 0.0, \\'precip_in\\': 0.0, \\'humidity\\': 67, \\'cloud\\': 0, \\'feelslike_c\\': 16.1, \\'feelslike_f\\': 61.0, \\'vis_km\\': 16.0, \\'vis_miles\\': 9.0, \\'uv\\': 4.0, \\'gust_mph\\': 20.6, \\'gust_kph\\': 33.1}}\"}]', name='tavily_search_results_json', id='8c7e9af3-6569-4982-a83d-be1ec02f828a', tool_call_id='call_yQrJa8CEOfKBdpVl80jzWf5h'), AIMessage(content='The current weather in San Francisco is sunny with a temperature of 61.0°F (16.1°C). The wind speed is 25.9 km/h coming from the west-northwest direction. The humidity is at 67%, and there is no precipitation at the moment.', response_metadata={'token_usage': {'completion_tokens': 59, 'prompt_tokens': 476, 'total_tokens': 535}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'stop', 'logprobs': None}, id='run-7d652a0d-cb00-4857-b9fb-d3e37b0a6d23-0')], 'agent': {'messages': [AIMessage(content='The current weather in San Francisco is sunny with a temperature of 61.0°F (16.1°C). The wind speed is 25.9 km/h coming from the west-northwest direction. The humidity is at 67%, and there is no precipitation at the moment.', response_metadata={'token_usage': {'completion_tokens': 59, 'prompt_tokens': 476, 'total_tokens': 535}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'stop', 'logprobs': None}, id='run-7d652a0d-cb00-4857-b9fb-d3e37b0a6d23-0')]}}, 'channel_versions': defaultdict(, {'__start__': 1, 'messages': 6, 'start:agent': 2, 'action': 5, 'agent': 6, 'branch:agent:should_continue:action': 4}), 'versions_seen': defaultdict(, {'__start__': defaultdict(, {'__start__': 1}), 'agent': defaultdict(, {'start:agent': 2, 'action': 5}), 'action': defaultdict(, {'branch:agent:should_continue:action': 4}), '__interrupt__': defaultdict(, {'messages': 4})})}, parent_config={'configurable': {'thread_id': '1', 'thread_ts': '2024-04-20T01:13:35.392072+00:00'}})]" + ] + }, + "execution_count": 131, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "examples" + ] + }, + { + "cell_type": "code", + "execution_count": 157, + "id": "336a70d3-d8c7-4310-a373-df2be3320030", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'messages': [AIMessage(content='', additional_kwargs={'tool_calls': [{'id': 'call_amNrYLgitup6hCDiHUYKwodH', 'function': {'arguments': '{\"query\":\"weather in Los Angeles, Accuweather\"}', 'name': 'tavily_search_results_json'}, 'type': 'function'}]}, response_metadata={'token_usage': {'completion_tokens': 25, 'prompt_tokens': 296, 'total_tokens': 321}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'tool_calls', 'logprobs': None}, id='run-6b852685-e84f-48e7-b8d0-5b0a44ac9776-0', tool_calls=[{'name': 'tavily_search_results_json', 'args': {'query': 'weather in Los Angeles, Accuweather'}, 'id': 'call_amNrYLgitup6hCDiHUYKwodH'}])]}\n" + ] + } + ], + "source": [ + "thread = {\"configurable\": {\"thread_id\": '7'}}\n", + "for event in app.stream({\"messages\": [HumanMessage(content=\"whats the weather in la?\")]}, thread):\n", + " for v in event.values():\n", + " print(v)" + ] + }, + { + "cell_type": "code", + "execution_count": 158, + "id": "ab245e7c-e47d-45e4-8b79-361fab359e76", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'messages': [ToolMessage(content='[{\"url\": \"https://www.weatherapi.com/\", \"content\": \"{\\'location\\': {\\'name\\': \\'Los Angeles\\', \\'region\\': \\'California\\', \\'country\\': \\'United States of America\\', \\'lat\\': 34.05, \\'lon\\': -118.24, \\'tz_id\\': \\'America/Los_Angeles\\', \\'localtime_epoch\\': 1713576177, \\'localtime\\': \\'2024-04-19 18:22\\'}, \\'current\\': {\\'last_updated_epoch\\': 1713575700, \\'last_updated\\': \\'2024-04-19 18:15\\', \\'temp_c\\': 17.8, \\'temp_f\\': 64.0, \\'is_day\\': 1, \\'condition\\': {\\'text\\': \\'Partly cloudy\\', \\'icon\\': \\'//cdn.weatherapi.com/weather/64x64/day/116.png\\', \\'code\\': 1003}, \\'wind_mph\\': 4.3, \\'wind_kph\\': 6.8, \\'wind_degree\\': 250, \\'wind_dir\\': \\'WSW\\', \\'pressure_mb\\': 1014.0, \\'pressure_in\\': 29.94, \\'precip_mm\\': 0.0, \\'precip_in\\': 0.0, \\'humidity\\': 65, \\'cloud\\': 50, \\'feelslike_c\\': 17.8, \\'feelslike_f\\': 64.0, \\'vis_km\\': 16.0, \\'vis_miles\\': 9.0, \\'uv\\': 5.0, \\'gust_mph\\': 10.3, \\'gust_kph\\': 16.6}}\"}]', name='tavily_search_results_json', id='6477bd73-bdf1-46c5-ab0a-cc808b1a183e', tool_call_id='call_amNrYLgitup6hCDiHUYKwodH')]}\n", + "{'messages': [AIMessage(content='The current weather in Los Angeles is partly cloudy with a temperature of 64.0°F (17.8°C). The wind speed is 6.8 km/h coming from the west-southwest direction. The humidity is at 65%, and there is no precipitation at the moment.', response_metadata={'token_usage': {'completion_tokens': 60, 'prompt_tokens': 679, 'total_tokens': 739}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': 'fp_c2295e73ad', 'finish_reason': 'stop', 'logprobs': None}, id='run-a604c04e-c3c3-4545-804f-a89aee6bf516-0')]}\n" + ] + } + ], + "source": [ + "for event in app.stream(None, thread):\n", + " for v in event.values():\n", + " print(v)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9ab115de-9b11-4e8b-8ace-c23e1369300b", + "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.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From d6d33f9551d825005b16a30fa99262d4f84266a6 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 10:38:26 -0700 Subject: [PATCH 07/15] Fix bug with WHERE clause being incorrect when passing before param. --- langgraph/checkpoint/aiosqlite.py | 23 +++++++++++-------- langgraph/checkpoint/sqlite.py | 38 +++++++++++++++++++------------ tests/checkpoint/test_sqlite.py | 2 ++ 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/langgraph/checkpoint/aiosqlite.py b/langgraph/checkpoint/aiosqlite.py index c569beb52..c0ae80e82 100644 --- a/langgraph/checkpoint/aiosqlite.py +++ b/langgraph/checkpoint/aiosqlite.py @@ -278,17 +278,20 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): Iterator[CheckpointTuple]: An iterator of checkpoint tuples. """ await self.setup() - query = ( - f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}ORDER BY thread_ts DESC" - if before is None - else f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}AND thread_ts < ? ORDER BY thread_ts DESC" + + # construct query + SELECT = "SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints " + WHERE = search_where( + metadata_query, [] if before is None else ["thread_ts < ?"] ) - if limit: - query += f" LIMIT {limit}" - async with self.conn.execute( - query, - (() if before is None else (str(before["configurable"]["thread_ts"]),)), - ) as cursor: + ORDER_BY = "ORDER BY thread_ts DESC " + LIMIT = f"LIMIT {limit}" if limit else "" + + query = f"{SELECT}{WHERE}{ORDER_BY}{LIMIT}" + params = () if before is None else (str(before["configurable"]["thread_ts"]),) + + # execute query + async with self.conn.execute(query, params) as cursor: async for thread_id, thread_ts, parent_ts, value, metadata in cursor: yield CheckpointTuple( {"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}}, diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index 21ff4d591..b4db7584f 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -4,7 +4,7 @@ import sqlite3 import threading from contextlib import AbstractContextManager, contextmanager from types import TracebackType -from typing import Any, Iterator, Optional +from typing import Any, Iterator, List, Optional from langchain_core.runnables import RunnableConfig from typing_extensions import Self @@ -360,18 +360,21 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): Yields: Iterator[CheckpointTuple]: An iterator of checkpoint tuples. """ - query = ( - f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}ORDER BY thread_ts DESC" - if before is None - else f"SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints {search_where(metadata_query)}AND thread_ts < ? ORDER BY thread_ts DESC" + # construct query + SELECT = "SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints " + WHERE = search_where( + metadata_query, [] if before is None else ["thread_ts < ?"] ) - if limit: - query += f" LIMIT {limit}" + ORDER_BY = "ORDER BY thread_ts DESC " + LIMIT = f"LIMIT {limit}" if limit else "" + + query = f"{SELECT}{WHERE}{ORDER_BY}{LIMIT}" + params = () if before is None else (before["configurable"]["thread_ts"],) + + # execute query with self.cursor(transaction=False) as cur: - cur.execute( - query, - (() if before is None else (before["configurable"]["thread_ts"],)), - ) + cur.execute(query, params) + for thread_id, thread_ts, parent_ts, value, metadata in cur: yield CheckpointTuple( {"configurable": {"thread_id": thread_id, "thread_ts": thread_ts}}, @@ -436,8 +439,9 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): } -def search_where(metadata_query: CheckpointMetadata) -> str: - """Return WHERE clause for (a)search() given metadata query. +def search_where(metadata_query: CheckpointMetadata, predicates: List[str] = []) -> str: + """Return WHERE clause for (a)search() given metadata query and + predicates. This method returns the operator as well (=, IS). """ @@ -459,11 +463,17 @@ def search_where(metadata_query: CheckpointMetadata) -> str: return f"= '{str(query_value)}'" where = "WHERE " + + # process metadata query for query_key, query_value in metadata_query.items(): where += f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {_where_value(query_value)} AND " + # process predicates + for predicate in predicates: + where += f"{predicate} AND " + if where == "WHERE ": - # there are no query key/value pairs + # there are no query key/value pairs or predicates return "" else: # remove trailing AND diff --git a/tests/checkpoint/test_sqlite.py b/tests/checkpoint/test_sqlite.py index 95baf7da8..ee046c606 100644 --- a/tests/checkpoint/test_sqlite.py +++ b/tests/checkpoint/test_sqlite.py @@ -80,8 +80,10 @@ class TestMemorySaver: def test_create_where(self): # call method / assertions + expected_where_1 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'input' AND json_extract(CAST(metadata AS TEXT), '$.step') = 2 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{}' AND json_extract(CAST(metadata AS TEXT), '$.score') = 1 AND thread_ts < ? " expected_where_2 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'loop' AND json_extract(CAST(metadata AS TEXT), '$.step') = 1 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{\"foo\":\"bar\"}' AND json_extract(CAST(metadata AS TEXT), '$.score') IS NULL " expected_where_3 = "" + assert search_where(self.metadata_1, ["thread_ts < ?"]) == expected_where_1 assert search_where(self.metadata_2) == expected_where_2 assert search_where(self.metadata_3) == expected_where_3 From 44c6b414c3bbaa933b8a4351915f549ef25576e8 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 11:23:41 -0700 Subject: [PATCH 08/15] Update implementation of MemorySaver.asearch() to not use custom next_item() iterator function. --- langgraph/checkpoint/memory.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/langgraph/checkpoint/memory.py b/langgraph/checkpoint/memory.py index c8d0d4eca..79b516446 100644 --- a/langgraph/checkpoint/memory.py +++ b/langgraph/checkpoint/memory.py @@ -1,5 +1,6 @@ import asyncio from collections import defaultdict +from functools import partial from typing import AsyncIterator, Iterator, Optional from langchain_core.runnables import RunnableConfig @@ -242,6 +243,9 @@ class MemorySaver(BaseCheckpointSaver): async def asearch( self, metadata_query: CheckpointMetadata, + *, + before: Optional[RunnableConfig] = None, + limit: Optional[int] = None, ) -> AsyncIterator[CheckpointTuple]: """Asynchronous version of search. @@ -249,19 +253,15 @@ class MemorySaver(BaseCheckpointSaver): method in a separate thread using asyncio. """ loop = asyncio.get_running_loop() - iter = await loop.run_in_executor(None, self.search, metadata_query) - - def next_item(iter: Iterator[CheckpointTuple]) -> CheckpointTuple: - try: - return next(iter) - except StopIteration: - return None + iter = await loop.run_in_executor( + None, partial(self.search, before=before, limit=limit), metadata_query + ) while True: - result = await loop.run_in_executor(None, next_item, iter) - if result is None: + if item := await loop.run_in_executor(None, next, iter, None): + yield item + else: break - yield result async def aput( self, From 0093b773f02990b73e6314c49b006c55f9a0b3a4 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 11:37:04 -0700 Subject: [PATCH 09/15] Remove extra blank line. --- langgraph/checkpoint/sqlite.py | 1 - 1 file changed, 1 deletion(-) diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index b4db7584f..94c64cf34 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -445,7 +445,6 @@ def search_where(metadata_query: CheckpointMetadata, predicates: List[str] = []) This method returns the operator as well (=, IS). """ - def _where_value(query_value: Any) -> str: if query_value is None: return "IS NULL" From 3f34b03ba1697203b6e179ca74fc8d97cedd5fe3 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 11:38:46 -0700 Subject: [PATCH 10/15] Wow. Adding back extra blank line. --- langgraph/checkpoint/sqlite.py | 1 + 1 file changed, 1 insertion(+) diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index 94c64cf34..b4db7584f 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -445,6 +445,7 @@ def search_where(metadata_query: CheckpointMetadata, predicates: List[str] = []) This method returns the operator as well (=, IS). """ + def _where_value(query_value: Any) -> str: if query_value is None: return "IS NULL" From 28e5d8f699d6b2388ff79abb88752e45605706ed Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 12:41:55 -0700 Subject: [PATCH 11/15] Update FewShotExamples class to support setting filter and limit params. --- langgraph/managed/base.py | 85 ++++++++++++++++++++++------------- langgraph/managed/few_shot.py | 47 +++++++++++++++---- langgraph/pregel/__init__.py | 41 +++++++++-------- langgraph/pregel/read.py | 6 +-- tests/test_pregel.py | 55 ++++++++++++++++++++++- 5 files changed, 169 insertions(+), 65 deletions(-) diff --git a/langgraph/managed/base.py b/langgraph/managed/base.py index 15b47070b..b06f6a7c7 100644 --- a/langgraph/managed/base.py +++ b/langgraph/managed/base.py @@ -8,9 +8,10 @@ from typing import ( AsyncGenerator, Generator, Generic, - Sequence, + NamedTuple, Type, TypeVar, + Union, ) from langchain_core.runnables import RunnableConfig @@ -32,10 +33,10 @@ class ManagedValue(ABC, Generic[V]): @classmethod @contextmanager def enter( - cls, config: RunnableConfig, graph: "Pregel" + cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any ) -> Generator[Self, None, None]: try: - value = cls(config, graph) + value = cls(config, graph, **kwargs) yield value finally: # because managed value and Pregel have reference to each other @@ -48,10 +49,10 @@ class ManagedValue(ABC, Generic[V]): @classmethod @asynccontextmanager async def aenter( - cls, config: RunnableConfig, graph: "Pregel" + cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any ) -> AsyncGenerator[Self, None]: try: - value = cls(config, graph) + value = cls(config, graph, **kwargs) yield value finally: # because managed value and Pregel have reference to each other @@ -66,40 +67,64 @@ class ManagedValue(ABC, Generic[V]): ... -def is_managed_value(value: Any) -> TypeGuard[Type[ManagedValue]]: - return isclass(value) and issubclass(value, ManagedValue) +class ConfiguredManagedValue(NamedTuple): + cls: Type[ManagedValue] + kwargs: dict[str, Any] + + +ManagedValueSpec = Union[Type[ManagedValue], ConfiguredManagedValue] + +ManagedValueMapping = dict[str, ManagedValue] + + +def is_managed_value(value: Any) -> TypeGuard[ManagedValueSpec]: + return (isclass(value) and issubclass(value, ManagedValue)) or isinstance( + value, ConfiguredManagedValue + ) @contextmanager def ManagedValuesManager( - values: Sequence[Type[ManagedValue]], + values: dict[str, ManagedValueSpec], config: RunnableConfig, graph: "Pregel", -) -> Generator[Sequence[ManagedValue], None, None]: - with ExitStack() as stack: - unique: list[Type[ManagedValue]] = [] - for value in values: - if value not in unique: - unique.append(value) - - yield [stack.enter_context(value.enter(config, graph)) for value in unique] +) -> Generator[ManagedValueMapping, None, None]: + if values: + with ExitStack() as stack: + yield { + key: stack.enter_context( + value.cls.enter(config, graph, **value.kwargs) + if isinstance(value, ConfiguredManagedValue) + else value.enter(config, graph) + ) + for key, value in values.items() + } + else: + yield {} @asynccontextmanager async def AsyncManagedValuesManager( - values: Sequence[Type[ManagedValue]], + values: dict[str, ManagedValueSpec], config: RunnableConfig, graph: "Pregel", -) -> AsyncGenerator[Sequence[ManagedValue], None]: - async with AsyncExitStack() as stack: - unique: list[Type[ManagedValue]] = [] - for value in values: - if value not in unique: - unique.append(value) - - yield await asyncio.gather( - *( - stack.enter_async_context(value.aenter(config, graph)) - for value in unique - ) - ) +) -> AsyncGenerator[ManagedValueMapping, None]: + if values: + async with AsyncExitStack() as stack: + # create enter tasks with reference to spec + tasks = { + asyncio.create_task( + stack.enter_async_context( + value.cls.aenter(config, graph, **value.kwargs) + if isinstance(value, ConfiguredManagedValue) + else value.aenter(config, graph) + ) + ): key + for key, value in values.items() + } + # wait for all enter tasks + done, _ = await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED) + # build mapping from spec to result + yield {tasks[task]: task.result() for task in done} + else: + yield {} diff --git a/langgraph/managed/few_shot.py b/langgraph/managed/few_shot.py index 1e2147006..d7cf399f4 100644 --- a/langgraph/managed/few_shot.py +++ b/langgraph/managed/few_shot.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager, contextmanager from typing import ( TYPE_CHECKING, + Any, AsyncGenerator, AsyncIterator, Generator, @@ -13,7 +14,8 @@ from langchain_core.runnables import RunnableConfig from typing_extensions import Self from langgraph.channels.base import AsyncChannelsManager, ChannelsManager -from langgraph.managed.base import ManagedValue, V +from langgraph.managed.base import ConfiguredManagedValue, ManagedValue, V +from langgraph.pregel import Pregel from langgraph.pregel.io import read_channels from langgraph.pregel.types import PregelTaskDescription @@ -24,13 +26,40 @@ if TYPE_CHECKING: class FewShotExamples(ManagedValue[Sequence[V]], Generic[V]): examples: list[V] - def iter(self, score: int = 1, k: int = 5) -> Iterator[V]: - for example in self.graph.checkpointer.search({"score": score}, limit=k): + def __init__( + self, + config: RunnableConfig, + graph: Pregel, + k: int = 5, + metadata_filter: dict[str, Any] = None, + ) -> None: + super().__init__(config, graph) + self.k = k + self.metadata_filter = metadata_filter or {} + + @classmethod + def configure( + cls, k: int = 5, metadata_filter: dict[str, Any] = None + ) -> ConfiguredManagedValue: + return ConfiguredManagedValue( + cls, + { + "k": k, + "metadata_filter": metadata_filter, + }, + ) + + def iter(self, score: int = 1) -> Iterator[V]: + for example in self.graph.checkpointer.search( + {"score": score, **self.metadata_filter}, limit=self.k + ): with ChannelsManager(self.graph.channels, example.checkpoint) as channels: yield read_channels(channels, self.graph.output_channels) - async def aiter(self, score: int = 1, k: int = 5) -> AsyncIterator[V]: - async for example in self.graph.checkpointer.asearch({"score": score}, limit=k): + async def aiter(self, score: int = 1) -> AsyncIterator[V]: + async for example in self.graph.checkpointer.asearch( + {"score": score, **self.metadata_filter}, limit=self.k + ): async with AsyncChannelsManager( self.graph.channels, example.checkpoint ) as channels: @@ -39,18 +68,18 @@ class FewShotExamples(ManagedValue[Sequence[V]], Generic[V]): @classmethod @contextmanager def enter( - cls, config: RunnableConfig, graph: "Pregel" + cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any ) -> Generator[Self, None, None]: - with super().enter(config, graph) as value: + with super().enter(config, graph, **kwargs) as value: value.examples = list(value.iter()) yield value @classmethod @asynccontextmanager async def aenter( - cls, config: RunnableConfig, graph: "Pregel" + cls, config: RunnableConfig, graph: "Pregel", **kwargs: Any ) -> AsyncGenerator[Self, None]: - async with super().aenter(config, graph) as value: + async with super().aenter(config, graph, **kwargs) as value: value.examples = [e async for e in value.aiter()] yield value diff --git a/langgraph/pregel/__init__.py b/langgraph/pregel/__init__.py index 597f617d5..d7039b734 100644 --- a/langgraph/pregel/__init__.py +++ b/langgraph/pregel/__init__.py @@ -4,7 +4,6 @@ import asyncio import concurrent.futures from collections import defaultdict, deque from functools import partial -from inspect import isclass from typing import ( Any, AsyncIterator, @@ -70,8 +69,9 @@ from langgraph.constants import ( from langgraph.errors import GraphRecursionError, InvalidUpdateError from langgraph.managed.base import ( AsyncManagedValuesManager, - ManagedValue, + ManagedValueMapping, ManagedValuesManager, + ManagedValueSpec, is_managed_value, ) from langgraph.pregel.debug import ( @@ -327,14 +327,14 @@ class Pregel( return self.stream_channels or [k for k in self.channels] @property - def managed_values_list(self) -> Sequence[Type[ManagedValue]]: - return [ - v + def managed_values_dict(self) -> dict[str, ManagedValueSpec]: + return { + k: v for node in self.nodes.values() if isinstance(node.channels, dict) - for v in node.channels.values() + for k, v in node.channels.items() if is_managed_value(v) - ] + } def get_state(self, config: RunnableConfig) -> StateSnapshot: """Get the current state of the graph.""" @@ -347,7 +347,7 @@ class Pregel( with ChannelsManager( self.channels, checkpoint ) as channels, ManagedValuesManager( - self.managed_values_list, ensure_config(config), self + self.managed_values_dict, ensure_config(config), self ) as managed: _, next_tasks = _prepare_next_tasks( checkpoint, @@ -378,7 +378,7 @@ class Pregel( async with AsyncChannelsManager( self.channels, checkpoint ) as channels, AsyncManagedValuesManager( - self.managed_values_list, ensure_config(config), self + self.managed_values_dict, ensure_config(config), self ) as managed: _, next_tasks = _prepare_next_tasks( checkpoint, @@ -414,7 +414,7 @@ class Pregel( with ChannelsManager( self.channels, checkpoint ) as channels, ManagedValuesManager( - self.managed_values_list, ensure_config(config), self + self.managed_values_dict, ensure_config(config), self ) as managed: _, next_tasks = _prepare_next_tasks( checkpoint, @@ -453,7 +453,7 @@ class Pregel( async with AsyncChannelsManager( self.channels, checkpoint ) as channels, AsyncManagedValuesManager( - self.managed_values_list, ensure_config(config), self + self.managed_values_dict, ensure_config(config), self ) as managed: _, next_tasks = _prepare_next_tasks( checkpoint, @@ -725,7 +725,7 @@ class Pregel( ) as channels, get_executor_for_config( config ) as executor, ManagedValuesManager( - self.managed_values_list, config, self + self.managed_values_dict, config, self ) as managed: # map inputs to channel updates if input_writes := deque(map_input(input_keys, input)): @@ -1021,7 +1021,7 @@ class Pregel( async with AsyncChannelsManager( self.channels, checkpoint ) as channels, AsyncManagedValuesManager( - self.managed_values_list, config, self + self.managed_values_dict, config, self ) as managed: # map inputs to channel updates if input_writes := deque(map_input(input_keys, input)): @@ -1478,7 +1478,7 @@ def _prepare_next_tasks( checkpoint: Checkpoint, processes: Mapping[str, PregelNode], channels: Mapping[str, BaseChannel], - managed: Sequence[ManagedValue], + managed: ManagedValueMapping, config: RunnableConfig, step: int, for_execution: Literal[False], @@ -1491,7 +1491,7 @@ def _prepare_next_tasks( checkpoint: Checkpoint, processes: Mapping[str, PregelNode], channels: Mapping[str, BaseChannel], - managed: Sequence[ManagedValue], + managed: ManagedValueMapping, config: RunnableConfig, step: int, for_execution: Literal[True], @@ -1503,7 +1503,7 @@ def _prepare_next_tasks( checkpoint: Checkpoint, processes: Mapping[str, PregelNode], channels: Mapping[str, BaseChannel], - managed: Sequence[ManagedValue], + managed: ManagedValueMapping, config: RunnableConfig, step: int, *, @@ -1536,11 +1536,10 @@ def _prepare_next_tasks( managed_values = {} for key, chan in proc.channels.items(): - for mv in managed: - if isclass(chan) and isinstance(mv, chan): - managed_values[key] = mv( - step, PregelTaskDescription(name, val) - ) + if is_managed_value(chan): + managed_values[key] = managed[key]( + step, PregelTaskDescription(name, val) + ) val.update(managed_values) except EmptyChannelError: diff --git a/langgraph/pregel/read.py b/langgraph/pregel/read.py index e8576e22f..eefed2b47 100644 --- a/langgraph/pregel/read.py +++ b/langgraph/pregel/read.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Callable, Mapping, Optional, Sequence, Type, Union +from typing import Any, Callable, Mapping, Optional, Sequence, Union from langchain_core.pydantic_v1 import Field from langchain_core.runnables import ( @@ -15,7 +15,7 @@ from langchain_core.runnables.config import merge_configs from langchain_core.runnables.utils import ConfigurableFieldSpec from langgraph.constants import CONFIG_KEY_READ -from langgraph.managed.base import ManagedValue +from langgraph.managed.base import ManagedValueSpec from langgraph.pregel.write import ChannelWrite from langgraph.utils import RunnableCallable @@ -100,7 +100,7 @@ DEFAULT_BOUND: RunnablePassthrough = RunnablePassthrough() class PregelNode(RunnableBindingBase): - channels: Union[list[str], Mapping[str, Union[str, Type[ManagedValue]]]] + channels: Union[list[str], Mapping[str, Union[str, ManagedValueSpec]]] triggers: list[str] = Field(default_factory=list) diff --git a/tests/test_pregel.py b/tests/test_pregel.py index 7ccc1bd7d..01e0dbb0f 100644 --- a/tests/test_pregel.py +++ b/tests/test_pregel.py @@ -2596,7 +2596,9 @@ def test_state_graph_few_shot(snapshot: SnapshotAssertion) -> None: messages: Annotated[list[AnyMessage], add_messages] class AgentState(BaseState): - examples: Annotated[Sequence[BaseState], FewShotExamples[BaseState]] + examples: Annotated[ + Sequence[BaseState], FewShotExamples[BaseState].configure(k=1) + ] # Assemble the tools @tool() @@ -2709,6 +2711,27 @@ Some examples of past conversations: assert len(hiscored) == 1 assert hiscored[0].checkpoint["channel_values"]["messages"] == first_messages + second_messages = [ + HumanMessage(content="what is weather in la", id=AnyStr()), + AIMessage( + content="", + id=AnyStr(), + tool_calls=[ + { + "name": "search_api", + "args": {"query": "query"}, + "id": "tool_call123", + } + ], + ), + ToolMessage( + content="result for query", + name="search_api", + id=AnyStr(), + tool_call_id="tool_call123", + ), + AIMessage(content="answer", id=AnyStr()), + ] assert app.invoke( {"messages": "what is weather in la"}, { @@ -2718,9 +2741,37 @@ Some examples of past conversations: "expected_examples": [{"messages": first_messages}], } }, + ) == {"messages": second_messages} + + # get first checkpoint + chkpnt_tuple_2 = saver.get_tuple({"configurable": {"thread_id": "2"}}) + config = chkpnt_tuple_2.config + checkpoint = chkpnt_tuple_2.checkpoint + metadata = chkpnt_tuple_2.metadata + + # not needed in application code, only for testing + hiscored = list(saver.search({"score": 1})) + assert len(hiscored) == 1 + + # mark as "good" + metadata["score"] = 1 + saver.put(config, checkpoint, metadata) + + hiscored = list(saver.search({"score": 1})) + assert len(hiscored) == 2 + + assert app.invoke( + {"messages": "what is weather in ny"}, + { + "configurable": { + "thread_id": "3", + # below is only for testing purposes, not part of few shot api + "expected_examples": [{"messages": second_messages}], + } + }, ) == { "messages": [ - HumanMessage(content="what is weather in la", id=AnyStr()), + HumanMessage(content="what is weather in ny", id=AnyStr()), AIMessage( content="", id=AnyStr(), From 6ce9a2860bb0f31139ed645ad3297a018ccfd896 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 12:45:16 -0700 Subject: [PATCH 12/15] Rename metadata_query to metadata_filter. --- langgraph/checkpoint/aiosqlite.py | 8 ++++---- langgraph/checkpoint/base.py | 2 +- langgraph/checkpoint/memory.py | 12 ++++++------ langgraph/checkpoint/sqlite.py | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/langgraph/checkpoint/aiosqlite.py b/langgraph/checkpoint/aiosqlite.py index c0ae80e82..acf62b355 100644 --- a/langgraph/checkpoint/aiosqlite.py +++ b/langgraph/checkpoint/aiosqlite.py @@ -257,7 +257,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): async def asearch( self, - metadata_query: CheckpointMetadata, + metadata_filter: CheckpointMetadata, *, before: Optional[RunnableConfig] = None, limit: Optional[int] = None, @@ -265,12 +265,12 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): """Search for checkpoints by metadata asynchronously. This method retrieves a list of checkpoint tuples from the SQLite - database based on the provided metadata query. The metadata query does + database based on the provided metadata filter. The metadata filter does not need to contain all keys defined in the CheckpointMetadata class. The checkpoints are ordered by timestamp in descending order. Args: - metadata_query (CheckpointMetadata): The metadata query to use for searching the checkpoints. + metadata_filter (CheckpointMetadata): The metadata filter to use for searching the checkpoints. before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None. limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None. @@ -282,7 +282,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): # construct query SELECT = "SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints " WHERE = search_where( - metadata_query, [] if before is None else ["thread_ts < ?"] + metadata_filter, [] if before is None else ["thread_ts < ?"] ) ORDER_BY = "ORDER BY thread_ts DESC " LIMIT = f"LIMIT {limit}" if limit else "" diff --git a/langgraph/checkpoint/base.py b/langgraph/checkpoint/base.py index 8306511bc..19ced7cfa 100644 --- a/langgraph/checkpoint/base.py +++ b/langgraph/checkpoint/base.py @@ -155,7 +155,7 @@ class BaseCheckpointSaver(ABC): def search( self, - metadata: CheckpointMetadata, + metadata_filter: CheckpointMetadata, *, before: Optional[RunnableConfig] = None, limit: Optional[int] = None, diff --git a/langgraph/checkpoint/memory.py b/langgraph/checkpoint/memory.py index 79b516446..57217f6ad 100644 --- a/langgraph/checkpoint/memory.py +++ b/langgraph/checkpoint/memory.py @@ -122,7 +122,7 @@ class MemorySaver(BaseCheckpointSaver): def search( self, - metadata_query: CheckpointMetadata, + metadata_filter: CheckpointMetadata, *, before: Optional[RunnableConfig] = None, limit: Optional[int] = None, @@ -130,12 +130,12 @@ class MemorySaver(BaseCheckpointSaver): """Search for checkpoints by metadata. This method retrieves a list of checkpoint tuples from the in-memory - storage based on the provided metadata query. The metadata query does + storage based on the provided metadata filter. The metadata filter does not need to contain all keys defined in the CheckpointMetadata class. The checkpoints are ordered by timestamp in descending order. Args: - metadata_query (CheckpointMetadata): The metadata query to use for searching the checkpoints. + metadata_filter (CheckpointMetadata): The metadata filter to use for searching the checkpoints. before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None. limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None. @@ -152,7 +152,7 @@ class MemorySaver(BaseCheckpointSaver): metadata = self.serde.loads(metadata_bytes) all_keys_match = all( query_value == metadata[query_key] - for query_key, query_value in metadata_query.items() + for query_key, query_value in metadata_filter.items() ) # if all query key/value pairs match, yield the checkpoint @@ -242,7 +242,7 @@ class MemorySaver(BaseCheckpointSaver): async def asearch( self, - metadata_query: CheckpointMetadata, + metadata_filter: CheckpointMetadata, *, before: Optional[RunnableConfig] = None, limit: Optional[int] = None, @@ -254,7 +254,7 @@ class MemorySaver(BaseCheckpointSaver): """ loop = asyncio.get_running_loop() iter = await loop.run_in_executor( - None, partial(self.search, before=before, limit=limit), metadata_query + None, partial(self.search, before=before, limit=limit), metadata_filter ) while True: diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index b4db7584f..a1210f8e1 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -340,7 +340,7 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): def search( self, - metadata_query: CheckpointMetadata, + metadata_filter: CheckpointMetadata, *, before: Optional[RunnableConfig] = None, limit: Optional[int] = None, @@ -348,12 +348,12 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): """Search for checkpoints by metadata. This method retrieves a list of checkpoint tuples from the SQLite - database based on the provided metadata query. The metadata query does + database based on the provided metadata filter. The metadata filter does not need to contain all keys defined in the CheckpointMetadata class. The checkpoints are ordered by timestamp in descending order. Args: - metadata_query (CheckpointMetadata): The metadata query to use for searching the checkpoints. + metadata_filter (CheckpointMetadata): The metadata filter to use for searching the checkpoints. before (Optional[RunnableConfig]): If provided, only checkpoints before the specified timestamp are returned. Defaults to None. limit (Optional[int]): The maximum number of checkpoints to return. Defaults to None. @@ -363,7 +363,7 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): # construct query SELECT = "SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints " WHERE = search_where( - metadata_query, [] if before is None else ["thread_ts < ?"] + metadata_filter, [] if before is None else ["thread_ts < ?"] ) ORDER_BY = "ORDER BY thread_ts DESC " LIMIT = f"LIMIT {limit}" if limit else "" From 78e3a240f18c04133b24f1bec7bafcbcbdbf6b78 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 14:44:59 -0700 Subject: [PATCH 13/15] Update implementation for constructing WHERE clause for SqliteSaver so that parameter values are not hardcoded, but bound instead. --- langgraph/checkpoint/aiosqlite.py | 6 +- langgraph/checkpoint/sqlite.py | 104 ++++++++++++++++++++--------- tests/checkpoint/test_aiosqlite.py | 2 +- tests/checkpoint/test_sqlite.py | 40 ++++++++--- 4 files changed, 106 insertions(+), 46 deletions(-) diff --git a/langgraph/checkpoint/aiosqlite.py b/langgraph/checkpoint/aiosqlite.py index acf62b355..77bb9d4be 100644 --- a/langgraph/checkpoint/aiosqlite.py +++ b/langgraph/checkpoint/aiosqlite.py @@ -281,14 +281,12 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): # construct query SELECT = "SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints " - WHERE = search_where( - metadata_filter, [] if before is None else ["thread_ts < ?"] - ) + WHERE, params = search_where(metadata_filter, before) ORDER_BY = "ORDER BY thread_ts DESC " LIMIT = f"LIMIT {limit}" if limit else "" query = f"{SELECT}{WHERE}{ORDER_BY}{LIMIT}" - params = () if before is None else (str(before["configurable"]["thread_ts"]),) + # params = () if before is None else (str(before["configurable"]["thread_ts"]),) # execute query async with self.conn.execute(query, params) as cursor: diff --git a/langgraph/checkpoint/sqlite.py b/langgraph/checkpoint/sqlite.py index a1210f8e1..bceb5dddd 100644 --- a/langgraph/checkpoint/sqlite.py +++ b/langgraph/checkpoint/sqlite.py @@ -4,7 +4,7 @@ import sqlite3 import threading from contextlib import AbstractContextManager, contextmanager from types import TracebackType -from typing import Any, Iterator, List, Optional +from typing import Any, Iterator, Optional, Tuple from langchain_core.runnables import RunnableConfig from typing_extensions import Self @@ -362,14 +362,11 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): """ # construct query SELECT = "SELECT thread_id, thread_ts, parent_ts, checkpoint, metadata FROM checkpoints " - WHERE = search_where( - metadata_filter, [] if before is None else ["thread_ts < ?"] - ) + WHERE, params = search_where(metadata_filter, before) ORDER_BY = "ORDER BY thread_ts DESC " LIMIT = f"LIMIT {limit}" if limit else "" query = f"{SELECT}{WHERE}{ORDER_BY}{LIMIT}" - params = () if before is None else (before["configurable"]["thread_ts"],) # execute query with self.cursor(transaction=False) as cur: @@ -439,44 +436,87 @@ class SqliteSaver(BaseCheckpointSaver, AbstractContextManager): } -def search_where(metadata_query: CheckpointMetadata, predicates: List[str] = []) -> str: - """Return WHERE clause for (a)search() given metadata query and - predicates. +def search_where( + metadata_filter: CheckpointMetadata, + before: Optional[RunnableConfig] = None, +) -> Tuple[str, Tuple[Any, ...]]: + """Return WHERE clause predicates for (a)search() given metadata filter + and `before` config. - This method returns the operator as well (=, IS). + This method returns a tuple of a string and a tuple of values. The string + is the parametered WHERE clause predicate (including the WHERE keyword): + "WHERE column1 = ? AND column2 IS ?". The tuple of values contains the + values for each of the corresponding parameters. + """ + where = "WHERE " + param_values = () + + # construct predicate for metadata filter + metadata_predicate, metadata_values = _metadata_predicate(metadata_filter) + if metadata_predicate != "": + where += metadata_predicate + param_values += metadata_values + + # construct predicate for `before` + if before is not None: + if metadata_predicate != "": + where += "AND thread_ts < ? " + else: + where += "thread_ts < ? " + + param_values += (before["configurable"]["thread_ts"],) + + if where == "WHERE ": + # no predicates, return an empty WHERE clause string + return ("", ()) + else: + return (where, param_values) + + +def _metadata_predicate( + metadata_filter: CheckpointMetadata, +) -> Tuple[str, Tuple[Any, ...]]: + """Return WHERE clause predicates for (a)search() given metadata filter. + + This method returns a tuple of a string and a tuple of values. The string + is the parametered WHERE clause predicate (excluding the WHERE keyword): + "column1 = ? AND column2 IS ?". The tuple of values contains the values + for each of the corresponding parameters. """ - def _where_value(query_value: Any) -> str: + def _where_value(query_value: Any) -> Tuple[str, Any]: + """Return tuple of operator and value for WHERE clause predicate.""" if query_value is None: - return "IS NULL" - elif isinstance(query_value, str): - return f"= '{query_value}'" - elif isinstance(query_value, int) or isinstance(query_value, float): - return f"= {query_value}" + return ("IS ?", None) + elif ( + isinstance(query_value, str) + or isinstance(query_value, int) + or isinstance(query_value, float) + ): + return ("= ?", query_value) elif isinstance(query_value, bool): - return f"= {1 if query_value else 0}" + return ("= ?", 1 if query_value else 0) elif isinstance(query_value, dict) or isinstance(query_value, list): # query value for JSON object cannot have trailing space after separators (, :) # SQLite json_extract() returns JSON string without whitespace - return f"= '{json.dumps(query_value, separators=(',', ':'))}'" + return ("= ?", json.dumps(query_value, separators=(",", ":"))) else: - return f"= '{str(query_value)}'" + return ("= ?", str(query_value)) - where = "WHERE " + predicate = "" + param_values = () # process metadata query - for query_key, query_value in metadata_query.items(): - where += f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {_where_value(query_value)} AND " + for query_key, query_value in metadata_filter.items(): + operator, param_value = _where_value(query_value) + predicate += ( + f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator} AND " + ) + param_values += (param_value,) - # process predicates - for predicate in predicates: - where += f"{predicate} AND " - - if where == "WHERE ": - # there are no query key/value pairs or predicates - return "" - else: + if predicate != "": # remove trailing AND - where = where[:-4] - # where clause contains an extra trailing space - return where + predicate = predicate[:-4] + + # predicate contains an extra trailing space + return (predicate, param_values) diff --git a/tests/checkpoint/test_aiosqlite.py b/tests/checkpoint/test_aiosqlite.py index 541228c7c..ef06efdcd 100644 --- a/tests/checkpoint/test_aiosqlite.py +++ b/tests/checkpoint/test_aiosqlite.py @@ -5,7 +5,7 @@ from langgraph.checkpoint.aiosqlite import AsyncSqliteSaver from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata -class TestMemorySaver: +class TestAsyncSqliteSaver: @pytest.fixture(autouse=True) def setup(self): self.sqlite_saver = AsyncSqliteSaver.from_conn_string(":memory:") diff --git a/tests/checkpoint/test_sqlite.py b/tests/checkpoint/test_sqlite.py index ee046c606..c64b5ceea 100644 --- a/tests/checkpoint/test_sqlite.py +++ b/tests/checkpoint/test_sqlite.py @@ -2,10 +2,10 @@ import pytest from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import Checkpoint, CheckpointMetadata -from langgraph.checkpoint.sqlite import SqliteSaver, search_where +from langgraph.checkpoint.sqlite import SqliteSaver, _metadata_predicate, search_where -class TestMemorySaver: +class TestSqliteSaver: @pytest.fixture(autouse=True) def setup(self): self.sqlite_saver = SqliteSaver.from_conn_string(":memory:") @@ -78,12 +78,34 @@ class TestMemorySaver: # TODO: test before and limit params - def test_create_where(self): + def test_search_where(self): # call method / assertions - expected_where_1 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'input' AND json_extract(CAST(metadata AS TEXT), '$.step') = 2 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{}' AND json_extract(CAST(metadata AS TEXT), '$.score') = 1 AND thread_ts < ? " - expected_where_2 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = 'loop' AND json_extract(CAST(metadata AS TEXT), '$.step') = 1 AND json_extract(CAST(metadata AS TEXT), '$.writes') = '{\"foo\":\"bar\"}' AND json_extract(CAST(metadata AS TEXT), '$.score') IS NULL " - expected_where_3 = "" + expected_predicate_1 = "WHERE json_extract(CAST(metadata AS TEXT), '$.source') = ? AND json_extract(CAST(metadata AS TEXT), '$.step') = ? AND json_extract(CAST(metadata AS TEXT), '$.writes') = ? AND json_extract(CAST(metadata AS TEXT), '$.score') = ? AND thread_ts < ? " + expected_param_values_1 = ("input", 2, "{}", 1, "1") + assert search_where(self.metadata_1, self.config_1) == ( + expected_predicate_1, + expected_param_values_1, + ) - assert search_where(self.metadata_1, ["thread_ts < ?"]) == expected_where_1 - assert search_where(self.metadata_2) == expected_where_2 - assert search_where(self.metadata_3) == expected_where_3 + def test_metadata_predicate(self): + # call method / assertions + expected_predicate_1 = "json_extract(CAST(metadata AS TEXT), '$.source') = ? AND json_extract(CAST(metadata AS TEXT), '$.step') = ? AND json_extract(CAST(metadata AS TEXT), '$.writes') = ? AND json_extract(CAST(metadata AS TEXT), '$.score') = ? " + expected_predicate_2 = "json_extract(CAST(metadata AS TEXT), '$.source') = ? AND json_extract(CAST(metadata AS TEXT), '$.step') = ? AND json_extract(CAST(metadata AS TEXT), '$.writes') = ? AND json_extract(CAST(metadata AS TEXT), '$.score') IS ? " + expected_predicate_3 = "" + + expected_param_values_1 = ("input", 2, "{}", 1) + expected_param_values_2 = ("loop", 1, '{"foo":"bar"}', None) + expected_param_values_3 = () + + assert _metadata_predicate(self.metadata_1) == ( + expected_predicate_1, + expected_param_values_1, + ) + assert _metadata_predicate(self.metadata_2) == ( + expected_predicate_2, + expected_param_values_2, + ) + assert _metadata_predicate(self.metadata_3) == ( + expected_predicate_3, + expected_param_values_3, + ) From 306260185ad8ca6eed30a1ab4e26dcc76165595e Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 14:46:07 -0700 Subject: [PATCH 14/15] Remove commented out line. --- langgraph/checkpoint/aiosqlite.py | 1 - 1 file changed, 1 deletion(-) diff --git a/langgraph/checkpoint/aiosqlite.py b/langgraph/checkpoint/aiosqlite.py index 77bb9d4be..39e4e467e 100644 --- a/langgraph/checkpoint/aiosqlite.py +++ b/langgraph/checkpoint/aiosqlite.py @@ -286,7 +286,6 @@ class AsyncSqliteSaver(BaseCheckpointSaver, AbstractAsyncContextManager): LIMIT = f"LIMIT {limit}" if limit else "" query = f"{SELECT}{WHERE}{ORDER_BY}{LIMIT}" - # params = () if before is None else (str(before["configurable"]["thread_ts"]),) # execute query async with self.conn.execute(query, params) as cursor: From 3f325e7b0ce56ca22193695dd7ba8dd5ec81eed0 Mon Sep 17 00:00:00 2001 From: Andrew Nguonly Date: Tue, 14 May 2024 14:48:14 -0700 Subject: [PATCH 15/15] Rename parameter for asearch() in BaseCheckpointSaver class. --- langgraph/checkpoint/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/langgraph/checkpoint/base.py b/langgraph/checkpoint/base.py index 19ced7cfa..5a9dc99ce 100644 --- a/langgraph/checkpoint/base.py +++ b/langgraph/checkpoint/base.py @@ -189,7 +189,7 @@ class BaseCheckpointSaver(ABC): def asearch( self, - metadata: CheckpointMetadata, + metadata_filter: CheckpointMetadata, *, before: Optional[RunnableConfig] = None, limit: Optional[int] = None,