diff --git a/examples/chatbots/information-gather-prompting.ipynb b/examples/chatbots/information-gather-prompting.ipynb index bc1248cda..ad4cdfe35 100644 --- a/examples/chatbots/information-gather-prompting.ipynb +++ b/examples/chatbots/information-gather-prompting.ipynb @@ -42,7 +42,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 21, "id": "5f795b78-004d-40ca-95d6-069f67e4f9c9", "metadata": {}, "outputs": [], @@ -77,7 +77,11 @@ "llm = ChatOpenAI(temperature=0)\n", "llm_with_tool = llm.bind_tools([PromptInstructions])\n", "\n", - "chain = get_messages_info | llm_with_tool" + "\n", + "def info_chain(state):\n", + " messages = get_messages_info(state['messages'])\n", + " response = llm_with_tool.invoke(messages)\n", + " return {\"messages\": [response]}" ] }, { @@ -93,7 +97,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 22, "id": "ca9a0234-bbeb-4bff-8276-8dde499c3390", "metadata": {}, "outputs": [], @@ -121,7 +125,10 @@ " return [SystemMessage(content=prompt_system.format(reqs=tool_call))] + other_msgs\n", "\n", "\n", - "prompt_gen_chain = get_prompt_messages | llm" + "def prompt_gen_chain(state):\n", + " messages = get_prompt_messages(state['messages'])\n", + " response = llm.invoke(messages)\n", + " return {\"messages\": [response]}" ] }, { @@ -140,7 +147,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 23, "id": "74f29e15-20e2-420c-a450-84e929f16e4e", "metadata": {}, "outputs": [], @@ -150,7 +157,8 @@ "from langgraph.graph import END\n", "\n", "\n", - "def get_state(messages) -> Literal[\"add_tool_message\", \"info\", \"__end__\"]:\n", + "def get_state(state) -> Literal[\"add_tool_message\", \"info\", \"__end__\"]:\n", + " messages = state['messages']\n", " if isinstance(messages[-1], AIMessage) and messages[-1].tool_calls:\n", " return \"add_tool_message\"\n", " elif not isinstance(messages[-1], HumanMessage):\n", @@ -171,7 +179,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 28, "id": "59d9d6b4-dce4-43cc-9a1a-61a7912ed5b8", "metadata": {}, "outputs": [], @@ -187,15 +195,15 @@ "\n", "memory = MemorySaver()\n", "workflow = StateGraph(State)\n", - "workflow.add_node(\"info\", chain)\n", + "workflow.add_node(\"info\", info_chain)\n", "workflow.add_node(\"prompt\", prompt_gen_chain)\n", "\n", "\n", "@workflow.add_node\n", - "def add_tool_message(state: list):\n", - " return ToolMessage(\n", - " content=\"Prompt generated!\", tool_call_id=state[-1].tool_calls[0][\"id\"]\n", - " )\n", + "def add_tool_message(state: State):\n", + " return {\"messages\": [ToolMessage(\n", + " content=\"Prompt generated!\", tool_call_id=state['messages'][-1].tool_calls[0][\"id\"]\n", + " )]}\n", "\n", "\n", "workflow.add_conditional_edges(\"info\", get_state)\n", @@ -207,13 +215,13 @@ }, { "cell_type": "code", - "execution_count": 38, + "execution_count": 29, "id": "1b1613e0", "metadata": {}, "outputs": [ { "data": { - "image/jpeg": "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", + "image/jpeg": "/9j/4AAQSkZJRgABAQAAAQABAAD/4gHYSUNDX1BST0ZJTEUAAQEAAAHIAAAAAAQwAABtbnRyUkdCIFhZWiAH4AABAAEAAAAAAABhY3NwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAA9tYAAQAAAADTLQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAlkZXNjAAAA8AAAACRyWFlaAAABFAAAABRnWFlaAAABKAAAABRiWFlaAAABPAAAABR3dHB0AAABUAAAABRyVFJDAAABZAAAAChnVFJDAAABZAAAAChiVFJDAAABZAAAAChjcHJ0AAABjAAAADxtbHVjAAAAAAAAAAEAAAAMZW5VUwAAAAgAAAAcAHMAUgBHAEJYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9YWVogAAAAAAAA9tYAAQAAAADTLXBhcmEAAAAAAAQAAAACZmYAAPKnAAANWQAAE9AAAApbAAAAAAAAAABtbHVjAAAAAAAAAAEAAAAMZW5VUwAAACAAAAAcAEcAbwBvAGcAbABlACAASQBuAGMALgAgADIAMAAxADb/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAGDAMgDASIAAhEBAxEB/8QAHQABAAICAwEBAAAAAAAAAAAAAAYHBQgCAwQBCf/EAFQQAAEDBAADAgcHEQYDBwUAAAEAAgMEBQYRBxIhEzEIFBYiQVFWFRdxgZOU0QkjMjY4U1VhdHWSlaG00tPUM0JUkbKzJTQ3JENEUmKCxCaDoqOx/8QAGwEBAAMBAQEBAAAAAAAAAAAAAAECBAMFBgf/xAA2EQEAAQMABgcGBgIDAAAAAAAAAQIDERIhMVFhkQQTFFJxodEFU4GiseEVIzIzweJBQkNi8P/aAAwDAQACEQMRAD8A/VNERAREQEREBERAREQERYu+3o2pkEVPB45capxjpqUO5Q52tlz3aPJG0dXO0ddAA5xa02ppmqcQMm5wa0kkADqSfQsbJk1nieWvu1Cxw7w6pYD/AP1YxmEU9ye2fIJTfanYd2U7dUsRHojh2W636Xczv/V0CyEeJWOJgYyzW9jR3NbSxgD9i7YtU7ZmfD7+kJ1PvlVZPwxQfOmfSnlVZPwxQfOmfSvvktZfwRQfNmfQnktZfwRQfNmfQn5PHyTqfPKqyfhig+dM+lPKqyfhig+dM+lffJay/gig+bM+hPJay/gig+bM+hPyePkanzyqsn4YoPnTPpTyqsn4YoPnTPpX3yWsv4IoPmzPoTyWsv4IoPmzPoT8nj5Gp201/tdZII6e5Uk8hOg2OdrifiBXvWGqcMx+siMVRYrbPGe9klHG4f5ELweS8+OAT49NIyJg260TSl1PKPUwu2YnerlIZ62+kNG1VqpmYnjs5/ZGpKEXitF2gvVBHVQB7Gu210UreV8bwdOY4ehwOwQvauExNM4lAiIoBERAREQEREBERAREQFGMc1dcmv8AdJNONPMLZTd/mRsa10nwF0jnb13hjN92hJ1GMNHilfktC7Ykiub5xsa5mSsZIHD1jbnN+FpWi3+iuY24/mFo2Sk6LG5DktoxG1S3O+3WisttiLWyVlwqGQQsJOmgveQBskAde8qIjwhOFh3riXh513/8epf5izqphkF9osXsNyvNyl7C3W6mlrKmXlLuSKNhe92h1OgCdBUXxA8J+40fAzIM3x3Cchop6WGllonX2iiZDNFO7zZ28s/nMDe8b5gXs23RVgzcacAyWlqrXY8txTKbvVQSR0tkhvdK91c/kOodBzuju49DoE9FQ8PA7PL7w44oY5bsflwjHbnbqZtjxW6XmOvZBWxyGSUwvY5wggfyxtDObQOzytHRBfV24s1doxa23eTh9mFRV10r4vcWkpKeasg5SfPlLZzE1pA2D2nXYHf0WBq/CfxWDGsSvNPbr7cG5LcJrTSUNNRDxuKsiEnPBNG54LHB0TmekA6JIbtwinE2yZvxP8ibnd+HFZV2Kjmq23jB33qkBqHujjFNO9wkEUsbHCX6252/ODuU60o7ww4JZljc3D6CsxentFLY83u92mho62GWnp6KppqgwmPqHOa187YtcodthPKG6KCcN4/ZFJx0s+IeQN+itdbYW3GVj46TxmmkdUsj7WQiqLexjaSHBvM7mPQOCvRU3nNhyyw8ebJnFhxt2U22awS2Gsp4K2GmlpHGpjmZN9dc0PZoOBDSXd3QqSv8ILhbG9zH8SsQa9p0WuvtKCD6v7RBP0UAd4QfC1ji13ErEGuB0Qb9S7H/AOxTqmqYa2miqKeVk9PKwSRyxODmvaRsOBHQgjrsII7TatHECopWabDd6M1vIN/20LmRyO9XnMkhH/sUnUZqW+OcSKDlB5bfa53SHXTc8sQj6/BTy9PgUmWi7/rM7ca/48sJkREWdAiIgIiICIiAiIgIiICwF7t1RSXKK+W6Ht6qOPsKqmB06qgBLg1p7u0a4kt30PM9p1zczc+ivRVNE5TGp4LddLfkVEZaaRlVDvlex7SHRu7+V7HAFjh6WuAI9IXd7m0n+Fg+TH0LHXbELXeKsVcsL6evAA8co5n08xA7gXsILgOvmu2Op6dSvD5DyjozJ78xo7h4zG79royf2rro2qtlWPGPT0g1JCygponhzKeJjh3OawAhd6i3kRP7U375eL+UnkRP7U375eL+UnV2+/5SnEb0pRUT4S1bfeE3A3LMtseUXZ11tdPHJAKp8T4tmVjDzNDAT0cfSrBsuJ1dws1BVS5Tfe1np45Hcs0QGy0E6+t/jTq7ff8AKTEb01Xm9zaQ/wDhYfkx9Cj/AJET+1N++Xi/lJ5ET+1N++Xi/lJ1dvv+UmI3pB7m0Y/8LB8mPoXivOQ0tlMVOB4zcJh/2a3wkdrL6Ngeho6bcdNb6Ssb5DOeOWbI79Mz0t8bEe/jY1p/asrZcbtuPNkFDSiJ8uu0me50ksuu7nkcS53xkpi1Trmc/wDt/wBkanVjlmltkVTU1jmS3SukE9U+LfIHcoa1jN9eRrQAO7fV2gXFZhEXGqqa5zKBERVBERAREQEREBERAREQEREBERAREQUV4cn3KXEL8ki/eIlcWL/a1afySH/QFTvhyfcpcQvySL94iVxYv9rVp/JIf9AQZNERAREQEREBERAREQEREBERAREQEREBERAREQEREFFeHJ9ylxC/JIv3iJXFi/2tWn8kh/0BU74cn3KXEL8ki/eIlcWL/a1afySH/QEGTREQEREBERAREQEREBERAREQEREBERAREQEREBFFrtldc+4T0NjoqeskpnclTU1k7ooo3kAhjeVri92iCe4DY6k7A8Pu5mH+Bsfzqb+WtVPRrkxnVHxhOH5o/VIuBR4ccXxmFupiyxZZzVMhaPNirgfrzT6ufYk6nqXP10aro+pYcFprXaL9xPrmOjdcmOtFtadgPga9r5pPUQZGMaPUYn+tX/4QPCm8eELw3q8SvNPZqNr5Y6mmroppXyUszD0e0Fmjtpe0j1PPd3qW4Ta79w/xCz43Z7ZY4LZaqWOkp2Gqm3ysaBtx7Pq495PpJJ9Kt2WvfHODC0UUJF8zDY3RWTXp1Uzfy1l8fyWW41clvuNIyhubI+2EcUplilj6AuY8taTokAggEbHoIJpV0euiNLVPhJhn0XwkAEk6AUEzDjLZMZw6pyK2U9dm0ENWKDxTE4m3Cd1QTrs+VjtAg6B2RrfVZkJ4ig9Vcc9n4iWH3Ot1m8gJ6MyXGetfLHc4Zi2QtayMeZrfZb5uo2/1Bd3DnIMrvk+UxZTYI7IKG9VFLapoZA5ldQAjsZyOYkOIJDgQBsdEEyREQEREBERAREQEREBERAREQEREFfYodvvpPf7r1fX/AN+lkxeKB12daxW0xubYBUuohK3thEXFokLN75S4Ec2tbBCxmJ/ZX3871f8AuFUnxFym/YhxR4r1FHc2OfRcOnXq2vfQU3a0UrXVLQ1svZ88jOeHtOSRzm8z3dNdF696cVytVtbFLFUOWWO6Xqts9HebfV3eiAdVUEFUx89OD3GSMHmb8YCpSwZNnNBl+AW+65e66U+c2SsmcG22nh9y6qOCKVklPppLm6kcOWUv6gHfoVd8KbnkeBcDbFUWu9ipyDNswns0dxraCnPiDn11WJqg8jGmZzmwucBI4gOLQNNHKs+kq3EVe8RLlmFtzPEjhVnt94uUzamGpFyqTBFBSl9OZJNjq4jlADRvq4HRAWH4cZJktm4r5Dw/yS+eVPi1qpb1Q3WSkip5xHLJLE+GVsQawkOi5mkNB07rvW1YY/6kWD8313+umXaic58KvpKYdlLw+ubeJ10yisy+6VtnqqIUUGLPbGKCAEM55CNbe8lmwSQQHuHUHSy2D8P8b4aWJllxWyUVhtbXmXxaiiDGueQAXuPe5xAA5js6A69FIEXlIFW19o48X4y2vKLnnj7barrQNx+kxark1BVVrpTI2aLb+kvKOXQadj0qyVXnHaKxUeAy5FfcYqMt8maiO9UNuo2PfOaqMkRvjDeuxznqQQBsnuQWGi8lorJbjaqKqqKWSgnngZLJSykF8LnNBLHEdNgnR+BEHrREQEREBERAREQEREBERARF5brdKWyWusuNdM2moqSF9RPM7eo42NLnOOvUASghGJ/ZX3871f8AuFYPKuD1my+9ZHc6ypr4qi+447GKlsEjA1lMXSuL2AsJEm5n9SSOg83v31YHnlvySwOyix0V0uOL3uolrKOrjt8okG3Frw6Et5+UuaXNeAQQ70a6yDyzp/wVfv1JV/y17VVE3Z0qYzErTEzOYY33rbV7s4Vc/GKzt8SppqShbzs5ZGSxMicZRy7J5WAjlLeu+/uWIZwFxtvDKDCHTXF1vpq2S40tcJwyspal1S+pbLHI1oDXMkkPKdd3Q767lPlnT/gq/fqSr/lp5Z0/4Kv36kq/5ap1FfdNGdzEcPeE9u4fV92ubbldcgvt17NtZd71O2WokZGCI4xyMYxrG8ziA1o6uJOyo3x040W3gHUWfLrtabrd7fDTVcEkdpiZI+LnfThr38zmhrOYBvNvvc0a6qeNzGnc4AWu+gk662WqA/21F8vzLG7PebHJnFHPRWW/GTHqKCuoHSx1MtRylwnAaREx3ZtjaH6Li922hreZNGbVMzXGNU+cYIiY2tbr19VosdO4+5PDq4Vw9BrLmyn9P/pjk9C3B4JcSZ+MHC6xZjUWKpxs3dkk8Vuq3l8jYRK9sUmy1uxJG1kgIGtSDRcNE/nLx2+p63/GeNdjt+H0lwrMCyO5RQNrqWlfWPsjJJGtk7cDr2cYcXB7nAFrdOcCCTv5FguacM8W4f4zw8r7dcLJZnx0VzOUvkkqpKEFrQYnxho52N5tAgN01oGtaXkKrXUIzqjzG55Rh8eLXigtlrpK/wAZyGKcB89RScjwyKNpY4AOeDt22kcvQnRC52/ifFXZ/fsVdjmQ0ktqphVi61FAW0FYzTNiCbfnuBeW8uh1Y70d8W4Q0uM8RMtuvFq32nI7Te6+lZY5KfI6V1K+KGJwk+txOGw1xcCSCQS30EFBbqIiAiIgIiICIiAiIgIiICIoZnfECvxWvxqktOKXTK33i4CilmtpYIaCMH65LM9xAaGgO0P7xaW7BI2HfxS4i0nCrCLhktbb7jdYqXkYyitVM6eonke8MjY1o9bnNGzodVjbZiV6u3EamzafJ7vTWOSzsp4cPmhjjihleQ+SSbWy6QaYAO9pDgHEOIXtwjhtDhV+ym8e7l5vNXkFYKqRt0rDLFStAIZDBHoNjY0EjoNnpsnQUxQcY42QxtYxoYxoDWtaNAAdwAXJEQEREBcZImStDXsa9oIdpw2Ng7B+IgH4lyRBUN3fdeAlv4g5tdb3kee2KoqI7hT2CCljmntjCeWYQnbeaJoIdynQa2M95LnG0rPdIb5aKG407ZWU9ZAyojbUROikDXtDgHMcA5rtHq0gEHoV7FBrnw+8T4iTZ/R3K+1FdHaX0T8egrQKKtLSXRns3+a2QEuAO2jb9n07Ccoopw1zepzzDrdeLjj1xxK4VPOyWz3ZobPE9ji14Gvsm7aS12hzN0dDalaAiIgIiICIiAiIgIiICIiCC3a/ZXWcTqfGYMWPkZLa5Jq7JvdDsXsmcS1kMLG+dzjWy7Y0HAggjTvZws4X2Pg9hlJjOPiq9z4HvlMlbUOnmlke4ukke53pc4knQA2ToBRfwYrXhNm4P22l4e3itvuLtqqt0FbcGFszpDUSGUEGOM6EhcB5o6AdT3m1kBERAREQEREBERAREQQzOuE1h4hX/Fb1czXQ3TGa3x231FDWSQFrjoPY8NOnMeGgOB7wNb0SDx4eZPlt+umVUeU4o3HYrbcDBbayGsbURXKmI5mStGg5pA5Q4Ed5Ou4gTVVTxZteE13ErhXUZNeK23X+ludQ/H6WmYTHWTmHUjJSI3aAZ1G3M6+k9yC1kREBEXCSVkQBe9rAf/MdIOaLp8cg+/x/phPHIPv8f6YU4kdyLp8cg+/x/phPHIPv8f6YTEjuRdPjkH3+P9MJ45B9/j/TCYkdy1q8LHwwLj4Lt5sULsCOSWm7U73x3EXbxUNmY7T4izsH9zTG4HY3zEa83Z2P8cg+/wAf6YVOeFpwco+PfBO9Y/E+F15ph7oWp5cNiqjB5W79Ae0ujJ9HPv0JiRrrwC+qD02ZZ1i/DzFeC9LYaW6V4h7O2XZjYqVjnF884ibSsB5W9pIRsb0evXa3zX5+/UwuBYslHeeJd8gbBV1Bfa7VHOOVzGNdqeXR7tuAjB6Eckg7it/PHIPv8f6YTEjuRdPjkH3+P9MJ45B9/j/TCYkdyLp8cg+/x/phPHIPv8f6YTEjuRdPjkH3+P8ATCeOQff4/wBMJiR3IutlTFI7lZKxzj6A4ErsUbAREQFXfEe7eIZ3w7p/IHyr8br5me7fYdp7gai32/N2T+Tn+w3zM+E9ysRQzNrXm1dluGVGM3iit1gpauV+QUtSwGSsgMeo2RExu0Q/qdOZ09J7kEzREQeW6VvubbKyr5ebsIXy8vr5Wk6/Yq9teJ2q/W6kuV5t9JeLlVQsmmqa2BsztuaCWt5h5rB3Bo0ND17KnGVfaxePyOb/AEFR7GftctX5JF/oC9Lo8zRbmqmcTlbZDxe99i3s3aPmEX8Ke99i3s3aPmEX8KwNp47YNe8y8laS+bvhmlp2QTUk8TJZYt9pHHK9gjkc3ldsNcT0PqUN46+E/j3DKxZFS2i50tbmFsbEBRS0s81PHI97QI5ZIwGMcWuJDXPa7u6Lr2i5EZ055ozO9aHvfYt7N2j5hF/CnvfYt7N2j5hF/CoPxm8IfGOFdqyCkN1gfldFapq6noDTT1DGvEbjCJzE3UTXuAHnuZsHofSp/h14myHELHdalsbKiuoYKqRsQIYHPja4hoJJ1s9Nkp19zONKeZmd7o977FvZu0fMIv4U977FvZu0fMIv4VDc649WvAuK2O4bXUVdILrQz1j6ymoamo7IsexkbQ2KJ3NzFzuZ2/M5W82udpWSyLj3gWKZLJYbrkMVLcoXxR1A7CV8NM6TXZtmmawxwl2xoSOb3j1p19zvzzMzvSD3vsW9m7R8wi/hT3vsW9m7R8wi/hWBzHjvguA3x1ovt/ZR18cbJZ2Np5pmUrHnTHTvjY5sLT6DIWjXXuS3cQK2v423TEBHSutFNj1Jd4qhgcZXSS1E8ZBdzcpZyxNI0N7J6npp19zvzzMzvZ73vsW9m7R8wi/hT3vsW9m7R8wi/hWcmmZTwySyHljY0ucdb0B1Kqum8KbhhWNoXQ5K6RlfF2tE8W2r5avu2yE9lqWQcwBjZt4OwWgghTN+5G2ueZmd6ce99i3s3aPmEX8Ke99i3s3aPmEX8Kw8XGvCZcKkywX6JtijnNI+aSKRkrZw7lMBhLRJ2u+nZ8vN+JeOLwgcAlxiuv8A5Qsjt9DPHS1LJqaeOpimk12cZp3MEvM7fmt5Nnrreio6+5355mZ3pJ732Lezdo+YRfwp732Lezdo+YRfwqM3Dwg8CtdjtN2qb3Iyluz5mUUbbfUuqJTCdTfWBGZAGHo4loDfTpdmWcfcEweShZer46kFZSMrontoqiVjad+w2WR7Iy2Np0ery3uPqTr7nfnmZnekXvfYt7N2j5hF/CnvfYt7N2j5hF/CsJmvHDCOHs1JFfL9HTyVNN47G2nglqSKfeu3f2THckW/+8dpv41xvHHLC7BZLFdK+6yww3ynFXQQR0NRLUzQlodz9gyN0gaA5uyWgDY3radfc788zM72d8gMYAPLjtqYT/eZRxtI67GiBsdQCs3gVfPUUdyoZ531Jtla6kZNK4ukczs2SM5nHq4gSBuzsnl2STsnwYzk9qzKxUd6slfDc7XVs54KqB22vAJB+Agggg9QQQeoXbw9/wCcy788f/EplW5VVctVaU5xj6pzmNaYoiLylRVTxZteE13ErhXUZNeK23X+ludQ/H6WmYTHWTmHUjJSI3aAZ1G3M6+k9ytZV3xHu3iGd8O6fyB8q/G6+Znu32Hae4Got9vzdk/k5/sN8zPhPcgsRERBi8q+1i8fkc3+gqPYz9rlq/JIv9AUkyOF9Rj10ijaXSPpZWtaPSSwgKNYu9smM2hzTtrqOEg+scgXo2f2Z8f4W/w1JFBmF/v+DXLIbRndfllszCOrvJkgmFnoabtJYmmliaezkaGyRntI2vcG9oXuHUL5l1HkGO8G+JvDmXB8muOQ3C9VdfT3O22uSppbjFNWNmZMZmbAeGaa5h84cgABW5SKuhxVarZWL3hFLx5x2pw3Ir5XZf45W2m7We2vq4aiOWgbCyB72/2bonMcA12tg+bvfXYjh1SzUPD7GKaphkp6iG10sckUrS17HCJoLXA9QQRogqQqFXzglw+ya61Fzu+E2C53GpcHTVdXbopJZDoDbnFuz0AHxKcYEN4ty1+K8ZMAzFthu98s1Jb7nbqr3Eon1c0Ek3i7onOjZt3KexeOYDQOt62q2yS336wYfxjwBuE3y9XfMrpcKq03CloTJQyx1rGiN81R9jCYO4iTRAjbre1s9juM2nELTDa7HbaS0W2EuMdJRQtiiYXEudprQANkk/GskmjkarG237hVR8U8ZrcRvuYV+VRMdbblbaF1RT1hdQR03Yzy90IY+N2zIQOV2xvuUo4Y4ffuHPFvGKS6W6trqabAbbY33iliMtNHWUjpnStlePsOYPBaXdHHpva2BXgv1gtmU2motd4oKa6W2oAE1JVxCSKQAhw5mnodEA/EmiO27MdJaqxjGlz3QvAa0bJPKei1twXD75SYj4McM9kuEM1oe83GOSke11F/w2dn14EfW/Pc1vna6kDvKuS0cDOHVgudNcbbg2P0FfTPEsFTTW2JkkTx3Oa4N2D+MKcKcZ2jT7KuG2Rz1l+vRseQVNrtfEupu09BaJJqStqaKS3ww+M0rmOY95Y9ziOR3naeN94UomwjCbjhV+vpxHiaZqmuoozV1RrZ7y19OS+nqoGTyPla2J0j/wC7v7LzXDv2ZRV0RqZdqnKbpgVhveRWvOI86ttRc2Y7kNlsgdWeL8zRCK+laC1vbgN5mOYB9b3th6nlnfvhZfFUW7MrTlokrsUpm2+14gJI6KS5yxSCrbVzRuAaGv7MBsrxHyc32R79sUTR4jSurjyrDqXHprfZL1B7u8PqCx3qSsxitr/EpYWyRnkbA0kPbzSFzJOVpBjcHO2dZU4lbbTkOFZNbY8zyjhzNhdFZLfc8Oqa2KrhfTvcW9vDTPZIWSNdvZB5XtIIHeth854KYbxHu0Nzv9qlqq+KHxYTwV1RSudFsns3dlI3nbtzjp2x1KldkslBjdoo7Xa6OG326jibDT0tOwMjiY0aDWgdwUaIjnCXH7XjmCW+C0Wu6WakndLWGjvUr5axkksjpJDK573u53Oc5x2496kXD3/nMu/PH/xKZe5ePh6wifKZO9kl3cWn16p4Gn9rSPiXWdVqvwj6wmNkpeiIvMQKGZta82rstwyoxm8UVusFLVyvyClqWAyVkBj1GyImN2iH9TpzOnpPcpmqp4s2vCa7iVwrqMmvFbbr/S3Oofj9LTMJjrJzDqRkpEbtAM6jbmdfSe5BayIiAonVcP2du99svVyscL3F7qWjEDoQ49SWtlify7PXTSBsk66qWIutFyq3+mUxOEO8gK/2zvfyND/TJ5AV/tne/kaH+mUxRde03OHKPROZQ7yAr/bO9/I0P9MnkBX+2d7+Rof6ZTFE7Tc4co9DMod5AV/tne/kaH+mTyAr/bO9/I0P9MpiidpucOUehmUO8gK/2zvfyND/AEyeQFf7Z3v5Gh/plMUTtNzhyj0My158GS75Lxp4QW7K71lVfSXCpqquB8VBTUjYg2KokjboOhcdkMBPXv33K1fICv8AbO9/I0P9Mqo8AX7mex/nC5fvsy2ITtNzhyj0Myh3kBX+2d7+Rof6ZPICv9s738jQ/wBMpiidpucOUehmUO8gK/2zvfyND/TJ5AV/tne/kaH+mUxRO03OHKPQzKHeQFf7Z3v5Gh/pk8gK/wBs738jQ/0ymKJ2m5w5R6GZRBmA1eyJcuvc0Z72clIzfxtgBHxFSW12uls1BDRUUIgpohprASe87JJPUkkkknZJJJJJXqRc671dyMVTq+EfREzkREXFAq74j3bxDO+HdP5A+VfjdfMz3b7DtPcDUW+35uyfyc/2G+ZnwnuViKGZta82rstwyoxm8UVusFLVyvyClqWAyVkBj1GyImN2iH9TpzOnpPcgmaIiAiIgIiICIiAiIgIiINd/AF+5nsf5wuX77MtiFrv4Av3M9j/OFy/fZlsQgIiICIiAiIgIiICIiAqp4s2vCa7iVwrqMmvFbbr/AEtzqH4/S0zCY6ycw6kZKRG7QDOo25nX0nuVrKu+I928Qzvh3T+QPlX43XzM92+w7T3A1Fvt+bsn8nP9hvmZ8J7kFiIiICIiAiIgIiICIiAiLRL6qNwQdkGJWjiZboi+rsgbbrlrqTSveTE/4GSvcP8A734kFy+AL9zPY/zhcv32ZbEL8dfqf3Befitx+tV0kbIyzYpJHeKmZnQdsx4NPHv1ukaHa9LY3r9ikBERAREQEREBERAREQFDM2tebV2W4ZUYzeKK3WClq5X5BS1LAZKyAx6jZETG7RD+p05nT0nuUzVU8WbXhNdxK4V1GTXitt1/pbnUPx+lpmEx1k5h1IyUiN2gGdRtzOvpPcgtZERAREQEREBcJZWQRvkke2ONgLnPcdBoHeSVzUa4mSOi4cZW9h05tpq3A/jEL10t0dZXTRvnCYjM4eE5rebizxi0WGnmon9YpbjXOpXyt6+cGCF5APQjm0dHqAei4+VGW+zln/XUv9KvfE0MiY1oDWhoAA7gua9HFqP+OOc+qcxuY3yoy32cs/66l/pVjMnkvuYY5c7FdsUstXbLlTSUlTC69S6fG9pa4f8AK9Oh7/QpKiYte7j5vUzwUZ4MXBa8eDPgtTYaG12i8V1ZVvqqy5vuckLpvRG3l8XdprGgDWz1Lj05tC4fKjLfZyz/AK6l/pVkkTFr3cfN6meDG+VGW+zln/XUv9KvrcoyoHcmO2rlHojvMjnfEDTAftXvkkbExz3uDGNBLnOOgB6yuQOxsdyYte7j5vVGeDJWK9w36iM8TJIJGPMU1POAJIZBrbHaJG+oOwSCCCCQQTkVDcHP/wBRZc3uHjcB0PX4tH1/YP8AIKZLDfoi3XNMbNXnGSdQiIuCBERAREQFXfEe7eIZ3w7p/IHyr8br5me7fYdp7gai32/N2T+Tn+w3zM+E9ysRQzNrXm1dluGVGM3iit1gpauV+QUtSwGSsgMeo2RExu0Q/qdOZ09J7kEzREQEREBERAUY4of9NMt/NFX/ALL1J1GOKH/TTLfzRV/7L1o6P+9R4x9VqdsOUf2DfgC1Vw7iTxEtvCfh1xIu2ZOv8F5uFHQ3KyT2ymhjMVRUdgHwviY14kaXNd1JadHzQtqo/sG/AFrtwJ8GaSyYVgr80uV+qLhYtVceNVVwilt1HVtc4ska2NvnFu+Zu3uDSegC0VZmdSrrs/EPNaWx8V86umSSVdnw67XmGix+Cip2MqoadjjG2aXk59BxbosLTpu3F29D0UeXcQMDu3D+fIsshyWkzGKaCejbboYG2+p8UfUxup3MHM6Mdm5hEhcdEHY7lbOPcMLLj1oye1hs1woMir6y4V8Fa5rmudU/2sY5WjzNdADs6PUlRnFfB1sWMXe3V8t7yC/G00stHaKe8VrZorZHIzkd2IDGknk8zmkLyG9NqMSKjxXM+KF6sfBi4TcQnB+dsMFbGLNSctLy0j5xLD5m+0IiIPOXM28kMAAavfPxMz2W1UVgZlPY3en4leSU98bb6cy1NGaR04c6It7Nr/OYNtaBtgOtEg29ZuB9isdr4eUEFXcXw4OSbc6SSMul3Tvg+vaYObzZHHzeXqB6OihPE/gDUV5tDMbqbox1fnsWT3WshqoopqFvib4HvhLgOjeWLTdPdtx7x0EYmIFe8cb7lTOHHG7A73kst4NnslLdqW8No4IZ5qeYyNfTTtYwR98R05jWktd6CFs/hlor7Hj1NS3K+1WRVQ2419bDDFI4HqG8sLGM0O77Hfr2ofZ/B+xq345llquFTdMjlyqPsbxdLxVCWrqWBhYxvO1rQ0MaTyhrQBtSrA8Ofg1hba3X68ZEGv5mVd7mjlna3la0MDmMYOUcvpG9kkk7VoiYkZPB/tky78qp/wB3Ypkobg/2yZd+VU/7uxTJculfu/CPpC1W0REWRUREQEREBVTxZteE13ErhXUZNeK23X+ludQ/H6WmYTHWTmHUjJSI3aAZ1G3M6+k9ytZV3xHu3iGd8O6fyB8q/G6+Znu32Hae4Got9vzdk/k5/sN8zPhPcgsRERAREQEREBY/IbQzILBcrXI/smVtNLTOfy83KHsLSdenvWQRWpqmmYqjbArxuUi1QsgvNFXUdfG0NlbFRTTxOd3F0cjGFrmnWx3EAjma09F88u7T6rj+q6r+WrERbe0W51zRPP7StqV35d2n1XH9V1X8tPLu0+q4/quq/lqxETtFruTz+xqVdZuK+MZFQMrrVXT3OieXNbU0dDUSxuLSQ4BzYyNggg/jC9vl3afVcf1XVfy1XXgCfcy2L8vuX77MtiU7Ra7k8/sald+Xdp9Vx/VdV/LXJmcWyR3KyO5SOPc1tqqiT8XZqwkTtFvuzz+yNSNYXaqmlFzuNXCaWe5TtmFM8guijbGxjWu105jylxHXXNrZ1tSVEWS5XNyrSknWIiLmgREQEREBQzNrXm1dluGVGM3iit1gpauV+QUtSwGSsgMeo2RExu0Q/qdOZ09J7lM1VPFm14TXcSuFdRk14rbdf6W51D8fpaZhMdZOYdSMlIjdoBnUbczr6T3ILWREQEREBERAREQEREBERBrt4An3Mti/L7l++zLYla6eADKyTwZ7M1j2udHcbi14B2WnxyU6PqOiD8YWxaAiIgIiICIiAiIgIiICrviPdvEM74d0/kD5V+N18zPdvsO09wNRb7fm7J/Jz/Yb5mfCe5WIoHnDMrqs8weDHMhtlstsU89TfLZVFpqa+kDGtHYgxuPmyPbsgs1zAE9QEE8REQEREBERAREQEREBERBrrnfg/wB/4d5PW8QeCM8FpvdQe1u+IVB5bXe9dSQ3YEM/fp40Ce/W3F064KeEDYeM1PV0ccNRj+XWzzLti90HZ1tC8dDtp1zs2Rp4Gjsb0TpWgqm40+DzaeK1TRX+3V0+JZ/axu15TbBqohPojlHQTRHZBY70EgEbOwtlFr/w28Ie62LKqXh1xjoYMYzaQ9nbrvCdWu/AHQfBIejJDsbido7I1ouDRJ8E8J3COI3F/KeG9nnrDkGPF7ah9RAI4J3RvDJmwku5nGN55XbaAdEt5m+cgtlERAREQEREBFT2N+FHhWScccq4Ysr4aS7WOBj2zVMwY2tlAe6qji6a+sNEZdt3MdyabyxOcfc/P7txWtuO3XhJfcer7B7sOp7zcaxkspEELiJGQMGg5zi3l5iQOVzXN2CCgymf57c3Y9ktHw3ZZ8pzq1djE60T17WCmfK4BrptHbdN5n8pLS4NIBB0vRaOFllObU2f3W1Urs8da4rdPWwyySRQgbL2wh/QAucRzcocQBv07zeO4Pj+I1d2qrLZqK11V2qXVlfNSwtY+qmd3veR1cepPX1n1lZxAREQEREBERAREQEREBERAREQaw/VCo75dOAs9lsGCV+YVtxqo2+O0VKypNpDHB7puz06Tmc0OjDmNAaHOJe08rX/AJecJs8vfATi5YcmbS1NLXWiqa+oo5WGN8kJ82WIh3dzMLm7PdvfoX7BZ5xZqp6ua243OyCGImOa58ge4vB0WxA+b07i8gj1D0qr7jQMvRJuctRdXHvdXTvm/Y4kD4B0X0PRvY129TFdyrRzwzP8J1RtbPY5kFBleP229WudtVbbjTR1dNM3ufG9oc0/GCFkVqOzG7XGxrGUMLWtGg0N0APUuXk9bf8ABxf5Lb+Ax735f7IzDbZFqT5PW3/Bxf5J5PW3/Bxf5J+Ax735f7GYbbKt/CI4vU3Azg/kOXTFjqmlg7OhgkPSaqf5sTdekcxBOv7rXH0KkfJ62/4OL/JcJMZtUzCySggkYe9rmbCfgMe9+X+xmH5k4XFnGQ51Bf8AGKC6X3J6avbcmT0lE6tl8ZbIJA9zOVwcecbIcCD6QV+0fg5Xu8X3hDYpb7gfvcXGGMwSWOOJkMDeX/vIYmkujjcdkMkAc07HnDle+pLS6px4tdZ6+rtRb1a2mmPZ7/HGdsd8bSrj4dcT3ZBUMtN5EUF2IPYTRbEdWACXaB+xeACS3Z2AXDucG+d0v2Td6NTNymdKI27+SdU7FiIiLwkCIiAiIgIiICIiAiIgIiIChfF2/wA1hwqp8WkMNXWyMoopGnRbznz3A+sMDyPxgKaKtePVM+TFLbUt/sqS5wySk+gPY+If/lK1bug0019Kt01bMwmNqoY42xRtYwcrGgNAHoAXJEX6U5ijmVcQ8fwqangu9eYKioaXx08MElRKWjvfyRtc4NH/AJiNfjUjVIZ9j01s4tVV9uVBlFfZbhbIKaGfF6ipbJTyxPeTHIyB7XFrg/YcdgHfdslZ79dVunNHn/hKwK7i3iVBDbJX3hkzbpA+poRSQy1DqljC0O5GxtcXEFw20Dfedaadd1RxQxalxOnyWS8QizVDxHDUBj3Okk5i3s2xgc5fsEcgbzdD06FQfGsQjsme4JJabNc6Czx2i5SSNrueR9PLNLBIWSyEu09xLzouPcddyi1FYrzjdws+QzY/cq+3WnKr5LNQ01K584iqHvENRHERt7R6276P2NrLN+9G2I5T/wBdfhrndsFn8NOJI4h3TKxTmJ9stldHTUkrYZIpHtMDHu7Rr+ocHucNabrXcp2q04SGqrcm4gXWe13C101wucEtM240roHyMFLEwuDXejbT8HcdHYVlrVYqqqozVOvM/WQXCftgwSU0hgqonCWCVveyRp213xEBc18kkbFG57yGsaCST6AtA2Txe9DI8ctd0a0R+OU0c5YDvlLmglvxHY+JZRRbhbRyUHDrHYZgWyeJRvc13Qt5hza+LelKV+W3qaabtVNOyJledoiIuKBERAREQEREBERAREQF4b5ZqbIrPWWysaXU1VE6J/KdOAI7wfQR3g+ggFe5FamqaZiqNsDWS92Wsxa6utdyAFQAXQzDo2pjB12jP2cze9pOu4gmFXLhThl5r562vxWz1lZO7nlnnoo3ve71kkbJW399x63ZNb3UV0pI6ymJ5g2QdWu9DmkdWuGzogghQCs4DUDnuNFfLlSMJ2I5Ozma38QLm83+ZK+wse17F2iI6TGJ8MwYiWvDuDOBvO3YdY3HQGzQRdw6D+6pHZbHbsct0dBaqGnt1DGSWU9LGI42knZ00dOpJKtn3gz7T1fzWL6E94M+09X81i+hbKfaXQKZzTVj4T6GjxVoisv3gz7T1fzWL6E94M+09X81i+hX/Fuh9/yn0NHip7IsRseXRQxXu0UV2jhcXRsrYGyhhPQkBwOlgveXwH2Msf6vi/hV/e8Gfaer+axfQnvBn2nq/msX0LnPtHoFU5qmJ+E+ho8VKY/w9xjFK19ZZcfttqqnxmJ01HSsieWEglpLQOmwDr8QU7wrDps6u3Ycjm2eneDW1Hc1+iD2DT6XO7na+xaTsglu7BtnAqz08jX3C43C6gd8Mj2xRn4RG1pPwF2lYVDQU1ro4qSjp4qSlhbyxwQMDGMHqDR0AWDpPte1RRNHRY178YiPDiYw72tDQAAAB0AHoX1EXyIIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiAiIgIiICIiD//2Q==", "text/plain": [ "" ] @@ -240,63 +248,105 @@ }, { "cell_type": "code", - "execution_count": 41, + "execution_count": 30, "id": "25793988-45a2-4e65-b33c-64e72aadb10e", "metadata": {}, "outputs": [ + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): hi\n" + ] + }, { "name": "stdout", "output_type": "stream", "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "Hello! How can I assist you today?\n", + "Hello! How can I assist you today?\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): rag prompt\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "Sure! I can help you with that. To create an extraction prompt, I need some information from you. Could you please provide the following details:\n", + "Sure! I can help you create a prompt template. To get started, could you please provide me with the following information:\n", "\n", "1. What is the objective of the prompt?\n", "2. What variables will be passed into the prompt template?\n", "3. Any constraints for what the output should NOT do?\n", "4. Any requirements that the output MUST adhere to?\n", "\n", - "Once I have this information, I can create the extraction prompt for you.\n", + "Once I have this information, I can assist you in creating the prompt template.\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): 1 rag, 2 none, 3 no, 4 no\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", - "\n", - "Great! To create an extraction prompt for filling out a CSAT (Customer Satisfaction) survey, I will need the following information:\n", - "\n", - "1. Objective: To gather feedback on customer satisfaction.\n", - "2. Variables: Customer name, Date of interaction, Service provided, Rating (scale of 1-5), Comments.\n", - "3. Constraints: The output should not include any personally identifiable information (PII) of the customer.\n", - "4. Requirements: The output must include a structured format with fields for each variable mentioned above.\n", - "\n", - "With this information, I will proceed to create the extraction prompt template for filling out a CSAT survey. Let's get started!\n", "Tool Calls:\n", - " PromptInstructions (call_aU48Bjo7X29tXfRtCcrXkrqq)\n", - " Call ID: call_aU48Bjo7X29tXfRtCcrXkrqq\n", + " PromptInstructions (call_7qkSORledsemoCnK8A3RKvAb)\n", + " Call ID: call_7qkSORledsemoCnK8A3RKvAb\n", " Args:\n", - " objective: To gather feedback on customer satisfaction.\n", - " variables: ['Customer name', 'Date of interaction', 'Service provided', 'Rating (scale of 1-5)', 'Comments']\n", - " constraints: ['The output should not include any personally identifiable information (PII) of the customer.']\n", - " requirements: ['The output must include a structured format with fields for each variable mentioned above.']\n", + " objective: rag\n", + " variables: ['none']\n", + " constraints: ['no']\n", + " requirements: ['no']\n", "=================================\u001b[1m Tool Message \u001b[0m=================================\n", "\n", "Prompt generated!\n", "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "Please provide feedback on your recent interaction with our service. Your input is valuable to us in improving our services.\n", - "\n", - "Customer name: \n", - "Date of interaction: \n", - "Service provided: \n", - "Rating (scale of 1-5): \n", - "Comments: \n", - "\n", - "Please note that the output should not include any personally identifiable information (PII) of the customer. Your feedback will be kept confidential and used for internal evaluation purposes only. Thank you for taking the time to share your thoughts with us.\n", - "Done!\n", + "Please write a response using the RAG (Red, Amber, Green) rating system.\n", + "Done!\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): red\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "==================================\u001b[1m Ai Message \u001b[0m==================================\n", "\n", - "I'm glad you found it helpful! If you need any more assistance or have any other requests, feel free to let me know. Have a great day!\n", + "Thank you for providing the response. If you need any more assistance, feel free to ask!\n" + ] + }, + { + "name": "stdin", + "output_type": "stream", + "text": [ + "User (q/Q to quit): q\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ "AI: Byebye\n" ] } @@ -312,9 +362,9 @@ " break\n", " output = None\n", " for output in graph.stream(\n", - " [HumanMessage(content=user)], config=config, stream_mode=\"updates\"\n", + " {\"messages\": [HumanMessage(content=user)]}, config=config, stream_mode=\"updates\"\n", " ):\n", - " last_message = next(iter(output.values()))\n", + " last_message = next(iter(output.values()))['messages'][-1]\n", " last_message.pretty_print()\n", "\n", " if output and \"prompt\" in output:\n", @@ -344,7 +394,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.2" + "version": "3.11.1" } }, "nbformat": 4, diff --git a/examples/memory/manage-conversation-history.ipynb b/examples/memory/manage-conversation-history.ipynb index 066d38ad6..559112a4a 100644 --- a/examples/memory/manage-conversation-history.ipynb +++ b/examples/memory/manage-conversation-history.ipynb @@ -268,8 +268,8 @@ "\n", "\n", "def filter_messages(messages: list):\n", - " # This is very simple helper function which only ever uses the last two messages\n", - " return messages[-2:]\n", + " # This is very simple helper function which only ever uses the last message\n", + " return messages[-1:]\n", "\n", "\n", "# Define the function that calls the model\n", @@ -372,9 +372,9 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3 (ipykernel)", + "display_name": "langgraph-example-dev", "language": "python", - "name": "python3" + "name": "langgraph-example-dev" }, "language_info": { "codemirror_mode": { @@ -386,7 +386,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.1" + "version": "3.11.9" } }, "nbformat": 4, diff --git a/examples/rag/langgraph_agentic_rag.ipynb b/examples/rag/langgraph_agentic_rag.ipynb index 486c6caed..73d02f14a 100644 --- a/examples/rag/langgraph_agentic_rag.ipynb +++ b/examples/rag/langgraph_agentic_rag.ipynb @@ -20,7 +20,10 @@ "id": "969fb438", "metadata": {}, "outputs": [], - "source": ["%%capture --no-stderr\n%pip install -U --quiet langchain-community tiktoken langchain-openai langchainhub chromadb langchain langgraph langchain-text-splitters"] + "source": [ + "%%capture --no-stderr\n", + "%pip install -U --quiet langchain-community tiktoken langchain-openai langchainhub chromadb langchain langgraph langchain-text-splitters" + ] }, { "cell_type": "code", @@ -28,7 +31,22 @@ "id": "e4958a8c", "metadata": {}, "outputs": [], - "source": ["import getpass\nimport os\n\n\ndef _set_env(key: str):\n if key not in os.environ:\n os.environ[key] = getpass.getpass(f\"{key}:\")\n\n\n_set_env(\"OPENAI_API_KEY\")\n\n# (Optional) For tracing\nos.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n_set_env(\"LANGCHAIN_API_KEY\")"] + "source": [ + "import getpass\n", + "import os\n", + "\n", + "\n", + "def _set_env(key: str):\n", + " if key not in os.environ:\n", + " os.environ[key] = getpass.getpass(f\"{key}:\")\n", + "\n", + "\n", + "_set_env(\"OPENAI_API_KEY\")\n", + "\n", + "# (Optional) For tracing\n", + "os.environ[\"LANGCHAIN_TRACING_V2\"] = \"true\"\n", + "_set_env(\"LANGCHAIN_API_KEY\")" + ] }, { "cell_type": "markdown", @@ -46,7 +64,34 @@ "id": "e50c9efe-4abe-42fa-b35a-05eeeede9ec6", "metadata": {}, "outputs": [], - "source": ["from langchain_community.document_loaders import WebBaseLoader\nfrom langchain_community.vectorstores import Chroma\nfrom langchain_openai import OpenAIEmbeddings\nfrom langchain_text_splitters import RecursiveCharacterTextSplitter\n\nurls = [\n \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n]\n\ndocs = [WebBaseLoader(url).load() for url in urls]\ndocs_list = [item for sublist in docs for item in sublist]\n\ntext_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n chunk_size=100, chunk_overlap=50\n)\ndoc_splits = text_splitter.split_documents(docs_list)\n\n# Add to vectorDB\nvectorstore = Chroma.from_documents(\n documents=doc_splits,\n collection_name=\"rag-chroma\",\n embedding=OpenAIEmbeddings(),\n)\nretriever = vectorstore.as_retriever()"] + "source": [ + "from langchain_community.document_loaders import WebBaseLoader\n", + "from langchain_community.vectorstores import Chroma\n", + "from langchain_openai import OpenAIEmbeddings\n", + "from langchain_text_splitters import RecursiveCharacterTextSplitter\n", + "\n", + "urls = [\n", + " \"https://lilianweng.github.io/posts/2023-06-23-agent/\",\n", + " \"https://lilianweng.github.io/posts/2023-03-15-prompt-engineering/\",\n", + " \"https://lilianweng.github.io/posts/2023-10-25-adv-attack-llm/\",\n", + "]\n", + "\n", + "docs = [WebBaseLoader(url).load() for url in urls]\n", + "docs_list = [item for sublist in docs for item in sublist]\n", + "\n", + "text_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(\n", + " chunk_size=100, chunk_overlap=50\n", + ")\n", + "doc_splits = text_splitter.split_documents(docs_list)\n", + "\n", + "# Add to vectorDB\n", + "vectorstore = Chroma.from_documents(\n", + " documents=doc_splits,\n", + " collection_name=\"rag-chroma\",\n", + " embedding=OpenAIEmbeddings(),\n", + ")\n", + "retriever = vectorstore.as_retriever()" + ] }, { "cell_type": "markdown", @@ -62,7 +107,17 @@ "id": "0b97bdd8-d7e3-444d-ac96-5ef4725f9048", "metadata": {}, "outputs": [], - "source": ["from langchain.tools.retriever import create_retriever_tool\n\nretriever_tool = create_retriever_tool(\n retriever,\n \"retrieve_blog_posts\",\n \"Search and return information about Lilian Weng blog posts on LLM agents, prompt engineering, and adversarial attacks on LLMs.\",\n)\n\ntools = [retriever_tool]"] + "source": [ + "from langchain.tools.retriever import create_retriever_tool\n", + "\n", + "retriever_tool = create_retriever_tool(\n", + " retriever,\n", + " \"retrieve_blog_posts\",\n", + " \"Search and return information about Lilian Weng blog posts on LLM agents, prompt engineering, and adversarial attacks on LLMs.\",\n", + ")\n", + "\n", + "tools = [retriever_tool]" + ] }, { "cell_type": "markdown", @@ -86,7 +141,19 @@ "id": "0e378706-47d5-425a-8ba0-57b9acffbd0c", "metadata": {}, "outputs": [], - "source": ["from typing import Annotated, Sequence, TypedDict\n\nfrom langchain_core.messages import BaseMessage\n\nfrom langgraph.graph.message import add_messages\n\n\nclass AgentState(TypedDict):\n # The add_messages function defines how an update should be processed\n # Default is to replace. add_messages says \"append\"\n messages: Annotated[Sequence[BaseMessage], add_messages]"] + "source": [ + "from typing import Annotated, Sequence, TypedDict\n", + "\n", + "from langchain_core.messages import BaseMessage\n", + "\n", + "from langgraph.graph.message import add_messages\n", + "\n", + "\n", + "class AgentState(TypedDict):\n", + " # The add_messages function defines how an update should be processed\n", + " # Default is to replace. add_messages says \"append\"\n", + " messages: Annotated[Sequence[BaseMessage], add_messages]" + ] }, { "attachments": { @@ -129,7 +196,173 @@ ] } ], - "source": ["from typing import Annotated, Literal, Sequence, TypedDict\n\nfrom langchain import hub\nfrom langchain_core.messages import BaseMessage, HumanMessage\nfrom langchain_core.output_parsers import StrOutputParser\nfrom langchain_core.prompts import PromptTemplate\nfrom langchain_core.pydantic_v1 import BaseModel, Field\nfrom langchain_openai import ChatOpenAI\n\nfrom langgraph.prebuilt import tools_condition\n\n### Edges\n\n\ndef grade_documents(state) -> Literal[\"generate\", \"rewrite\"]:\n \"\"\"\n Determines whether the retrieved documents are relevant to the question.\n\n Args:\n state (messages): The current state\n\n Returns:\n str: A decision for whether the documents are relevant or not\n \"\"\"\n\n print(\"---CHECK RELEVANCE---\")\n\n # Data model\n class grade(BaseModel):\n \"\"\"Binary score for relevance check.\"\"\"\n\n binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n\n # LLM\n model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n\n # LLM with tool and validation\n llm_with_tool = model.with_structured_output(grade)\n\n # Prompt\n prompt = PromptTemplate(\n template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n Here is the retrieved document: \\n\\n {context} \\n\\n\n Here is the user question: {question} \\n\n If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\",\n input_variables=[\"context\", \"question\"],\n )\n\n # Chain\n chain = prompt | llm_with_tool\n\n messages = state[\"messages\"]\n last_message = messages[-1]\n\n question = messages[0].content\n docs = last_message.content\n\n scored_result = chain.invoke({\"question\": question, \"context\": docs})\n\n score = scored_result.binary_score\n\n if score == \"yes\":\n print(\"---DECISION: DOCS RELEVANT---\")\n return \"generate\"\n\n else:\n print(\"---DECISION: DOCS NOT RELEVANT---\")\n print(score)\n return \"rewrite\"\n\n\n### Nodes\n\n\ndef agent(state):\n \"\"\"\n Invokes the agent model to generate a response based on the current state. Given\n the question, it will decide to retrieve using the retriever tool, or simply end.\n\n Args:\n state (messages): The current state\n\n Returns:\n dict: The updated state with the agent response appended to messages\n \"\"\"\n print(\"---CALL AGENT---\")\n messages = state[\"messages\"]\n model = ChatOpenAI(temperature=0, streaming=True, model=\"gpt-4-turbo\")\n model = model.bind_tools(tools)\n response = model.invoke(messages)\n # We return a list, because this will get added to the existing list\n return {\"messages\": [response]}\n\n\ndef rewrite(state):\n \"\"\"\n Transform the query to produce a better question.\n\n Args:\n state (messages): The current state\n\n Returns:\n dict: The updated state with re-phrased question\n \"\"\"\n\n print(\"---TRANSFORM QUERY---\")\n messages = state[\"messages\"]\n question = messages[0].content\n\n msg = [\n HumanMessage(\n content=f\"\"\" \\n \n Look at the input and try to reason about the underlying semantic intent / meaning. \\n \n Here is the initial question:\n \\n ------- \\n\n {question} \n \\n ------- \\n\n Formulate an improved question: \"\"\",\n )\n ]\n\n # Grader\n model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n response = model.invoke(msg)\n return {\"messages\": [response]}\n\n\ndef generate(state):\n \"\"\"\n Generate answer\n\n Args:\n state (messages): The current state\n\n Returns:\n dict: The updated state with re-phrased question\n \"\"\"\n print(\"---GENERATE---\")\n messages = state[\"messages\"]\n question = messages[0].content\n last_message = messages[-1]\n\n question = messages[0].content\n docs = last_message.content\n\n # Prompt\n prompt = hub.pull(\"rlm/rag-prompt\")\n\n # LLM\n llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0, streaming=True)\n\n # Post-processing\n def format_docs(docs):\n return \"\\n\\n\".join(doc.page_content for doc in docs)\n\n # Chain\n rag_chain = prompt | llm | StrOutputParser()\n\n # Run\n response = rag_chain.invoke({\"context\": docs, \"question\": question})\n return {\"messages\": [response]}\n\n\nprint(\"*\" * 20 + \"Prompt[rlm/rag-prompt]\" + \"*\" * 20)\nprompt = hub.pull(\"rlm/rag-prompt\").pretty_print() # Show what the prompt looks like"] + "source": [ + "from typing import Annotated, Literal, Sequence, TypedDict\n", + "\n", + "from langchain import hub\n", + "from langchain_core.messages import BaseMessage, HumanMessage\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.prompts import PromptTemplate\n", + "from langchain_core.pydantic_v1 import BaseModel, Field\n", + "from langchain_openai import ChatOpenAI\n", + "\n", + "from langgraph.prebuilt import tools_condition\n", + "\n", + "### Edges\n", + "\n", + "\n", + "def grade_documents(state) -> Literal[\"generate\", \"rewrite\"]:\n", + " \"\"\"\n", + " Determines whether the retrieved documents are relevant to the question.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " str: A decision for whether the documents are relevant or not\n", + " \"\"\"\n", + "\n", + " print(\"---CHECK RELEVANCE---\")\n", + "\n", + " # Data model\n", + " class grade(BaseModel):\n", + " \"\"\"Binary score for relevance check.\"\"\"\n", + "\n", + " binary_score: str = Field(description=\"Relevance score 'yes' or 'no'\")\n", + "\n", + " # LLM\n", + " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n", + "\n", + " # LLM with tool and validation\n", + " llm_with_tool = model.with_structured_output(grade)\n", + "\n", + " # Prompt\n", + " prompt = PromptTemplate(\n", + " template=\"\"\"You are a grader assessing relevance of a retrieved document to a user question. \\n \n", + " Here is the retrieved document: \\n\\n {context} \\n\\n\n", + " Here is the user question: {question} \\n\n", + " If the document contains keyword(s) or semantic meaning related to the user question, grade it as relevant. \\n\n", + " Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.\"\"\",\n", + " input_variables=[\"context\", \"question\"],\n", + " )\n", + "\n", + " # Chain\n", + " chain = prompt | llm_with_tool\n", + "\n", + " messages = state[\"messages\"]\n", + " last_message = messages[-1]\n", + "\n", + " question = messages[0].content\n", + " docs = last_message.content\n", + "\n", + " scored_result = chain.invoke({\"question\": question, \"context\": docs})\n", + "\n", + " score = scored_result.binary_score\n", + "\n", + " if score == \"yes\":\n", + " print(\"---DECISION: DOCS RELEVANT---\")\n", + " return \"generate\"\n", + "\n", + " else:\n", + " print(\"---DECISION: DOCS NOT RELEVANT---\")\n", + " print(score)\n", + " return \"rewrite\"\n", + "\n", + "\n", + "### Nodes\n", + "\n", + "\n", + "def agent(state):\n", + " \"\"\"\n", + " Invokes the agent model to generate a response based on the current state. Given\n", + " the question, it will decide to retrieve using the retriever tool, or simply end.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with the agent response appended to messages\n", + " \"\"\"\n", + " print(\"---CALL AGENT---\")\n", + " messages = state[\"messages\"]\n", + " model = ChatOpenAI(temperature=0, streaming=True, model=\"gpt-4-turbo\")\n", + " model = model.bind_tools(tools)\n", + " response = model.invoke(messages)\n", + " # We return a list, because this will get added to the existing list\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "def rewrite(state):\n", + " \"\"\"\n", + " Transform the query to produce a better question.\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with re-phrased question\n", + " \"\"\"\n", + "\n", + " print(\"---TRANSFORM QUERY---\")\n", + " messages = state[\"messages\"]\n", + " question = messages[0].content\n", + "\n", + " msg = [\n", + " HumanMessage(\n", + " content=f\"\"\" \\n \n", + " Look at the input and try to reason about the underlying semantic intent / meaning. \\n \n", + " Here is the initial question:\n", + " \\n ------- \\n\n", + " {question} \n", + " \\n ------- \\n\n", + " Formulate an improved question: \"\"\",\n", + " )\n", + " ]\n", + "\n", + " # Grader\n", + " model = ChatOpenAI(temperature=0, model=\"gpt-4-0125-preview\", streaming=True)\n", + " response = model.invoke(msg)\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "def generate(state):\n", + " \"\"\"\n", + " Generate answer\n", + "\n", + " Args:\n", + " state (messages): The current state\n", + "\n", + " Returns:\n", + " dict: The updated state with re-phrased question\n", + " \"\"\"\n", + " print(\"---GENERATE---\")\n", + " messages = state[\"messages\"]\n", + " question = messages[0].content\n", + " last_message = messages[-1]\n", + "\n", + " docs = last_message.content\n", + "\n", + " # Prompt\n", + " prompt = hub.pull(\"rlm/rag-prompt\")\n", + "\n", + " # LLM\n", + " llm = ChatOpenAI(model_name=\"gpt-3.5-turbo\", temperature=0, streaming=True)\n", + "\n", + " # Post-processing\n", + " def format_docs(docs):\n", + " return \"\\n\\n\".join(doc.page_content for doc in docs)\n", + "\n", + " # Chain\n", + " rag_chain = prompt | llm | StrOutputParser()\n", + "\n", + " # Run\n", + " response = rag_chain.invoke({\"context\": docs, \"question\": question})\n", + " return {\"messages\": [response]}\n", + "\n", + "\n", + "print(\"*\" * 20 + \"Prompt[rlm/rag-prompt]\" + \"*\" * 20)\n", + "prompt = hub.pull(\"rlm/rag-prompt\").pretty_print() # Show what the prompt looks like" + ] }, { "cell_type": "markdown", @@ -150,7 +383,48 @@ "id": "8718a37f-83c2-4f16-9850-e61e0f49c3d4", "metadata": {}, "outputs": [], - "source": ["from langgraph.graph import END, StateGraph, START\nfrom langgraph.prebuilt import ToolNode\n\n# Define a new graph\nworkflow = StateGraph(AgentState)\n\n# Define the nodes we will cycle between\nworkflow.add_node(\"agent\", agent) # agent\nretrieve = ToolNode([retriever_tool])\nworkflow.add_node(\"retrieve\", retrieve) # retrieval\nworkflow.add_node(\"rewrite\", rewrite) # Re-writing the question\nworkflow.add_node(\n \"generate\", generate\n) # Generating a response after we know the documents are relevant\n# Call agent node to decide to retrieve or not\nworkflow.add_edge(START, \"agent\")\n\n# Decide whether to retrieve\nworkflow.add_conditional_edges(\n \"agent\",\n # Assess agent decision\n tools_condition,\n {\n # Translate the condition outputs to nodes in our graph\n \"tools\": \"retrieve\",\n END: END,\n },\n)\n\n# Edges taken after the `action` node is called.\nworkflow.add_conditional_edges(\n \"retrieve\",\n # Assess agent decision\n grade_documents,\n)\nworkflow.add_edge(\"generate\", END)\nworkflow.add_edge(\"rewrite\", \"agent\")\n\n# Compile\ngraph = workflow.compile()"] + "source": [ + "from langgraph.graph import END, StateGraph, START\n", + "from langgraph.prebuilt import ToolNode\n", + "\n", + "# Define a new graph\n", + "workflow = StateGraph(AgentState)\n", + "\n", + "# Define the nodes we will cycle between\n", + "workflow.add_node(\"agent\", agent) # agent\n", + "retrieve = ToolNode([retriever_tool])\n", + "workflow.add_node(\"retrieve\", retrieve) # retrieval\n", + "workflow.add_node(\"rewrite\", rewrite) # Re-writing the question\n", + "workflow.add_node(\n", + " \"generate\", generate\n", + ") # Generating a response after we know the documents are relevant\n", + "# Call agent node to decide to retrieve or not\n", + "workflow.add_edge(START, \"agent\")\n", + "\n", + "# Decide whether to retrieve\n", + "workflow.add_conditional_edges(\n", + " \"agent\",\n", + " # Assess agent decision\n", + " tools_condition,\n", + " {\n", + " # Translate the condition outputs to nodes in our graph\n", + " \"tools\": \"retrieve\",\n", + " END: END,\n", + " },\n", + ")\n", + "\n", + "# Edges taken after the `action` node is called.\n", + "workflow.add_conditional_edges(\n", + " \"retrieve\",\n", + " # Assess agent decision\n", + " grade_documents,\n", + ")\n", + "workflow.add_edge(\"generate\", END)\n", + "workflow.add_edge(\"rewrite\", \"agent\")\n", + "\n", + "# Compile\n", + "graph = workflow.compile()" + ] }, { "cell_type": "code", @@ -169,7 +443,15 @@ "output_type": "display_data" } ], - "source": ["from IPython.display import Image, display\n\ntry:\n display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\nexcept Exception:\n # This requires some extra dependencies and is optional\n pass"] + "source": [ + "from IPython.display import Image, display\n", + "\n", + "try:\n", + " display(Image(graph.get_graph(xray=True).draw_mermaid_png()))\n", + "except Exception:\n", + " # This requires some extra dependencies and is optional\n", + " pass" + ] }, { "cell_type": "code", @@ -203,7 +485,21 @@ ] } ], - "source": ["import pprint\n\ninputs = {\n \"messages\": [\n (\"user\", \"What does Lilian Weng say about the types of agent memory?\"),\n ]\n}\nfor output in graph.stream(inputs):\n for key, value in output.items():\n pprint.pprint(f\"Output from node '{key}':\")\n pprint.pprint(\"---\")\n pprint.pprint(value, indent=2, width=80, depth=None)\n pprint.pprint(\"\\n---\\n\")"] + "source": [ + "import pprint\n", + "\n", + "inputs = {\n", + " \"messages\": [\n", + " (\"user\", \"What does Lilian Weng say about the types of agent memory?\"),\n", + " ]\n", + "}\n", + "for output in graph.stream(inputs):\n", + " for key, value in output.items():\n", + " pprint.pprint(f\"Output from node '{key}':\")\n", + " pprint.pprint(\"---\")\n", + " pprint.pprint(value, indent=2, width=80, depth=None)\n", + " pprint.pprint(\"\\n---\\n\")" + ] }, { "cell_type": "code", @@ -211,7 +507,7 @@ "id": "189333cc-5d34-4869-9f9b-741210e1096f", "metadata": {}, "outputs": [], - "source": [""] + "source": [] } ], "metadata": { diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py index 1da25b35f..433bc231d 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/__init__.py @@ -318,16 +318,6 @@ class PostgresSaver(BasePostgresSaver): task_id (str): Identifier for the task creating the writes. """ with self._cursor(pipeline=True) as cur: - cur.execute( - self.DELETE_WRITES_SQL, - ( - config["configurable"]["thread_id"], - config["configurable"]["checkpoint_ns"], - config["configurable"]["checkpoint_id"], - task_id, - len(writes), - ), - ) cur.executemany( self.UPSERT_CHECKPOINT_WRITES_SQL, self._dump_writes( diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py index 7be1f36dc..79ae1ddf7 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/aio.py @@ -274,16 +274,6 @@ class AsyncPostgresSaver(BasePostgresSaver): task_id (str): Identifier for the task creating the writes. """ async with self._cursor(pipeline=True) as cur: - await cur.execute( - self.DELETE_WRITES_SQL, - ( - config["configurable"]["thread_id"], - config["configurable"]["checkpoint_ns"], - config["configurable"]["checkpoint_id"], - task_id, - len(writes), - ), - ) await cur.executemany( self.UPSERT_CHECKPOINT_WRITES_SQL, await asyncio.to_thread( diff --git a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py index 99fb0bbba..4b665fa64 100644 --- a/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py +++ b/libs/checkpoint-postgres/langgraph/checkpoint/postgres/base.py @@ -6,6 +6,7 @@ from langchain_core.runnables import RunnableConfig from psycopg.types.json import Jsonb from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, Checkpoint, EmptyChannelError, @@ -105,15 +106,6 @@ UPSERT_CHECKPOINT_WRITES_SQL = """ ON CONFLICT (thread_id, checkpoint_ns, checkpoint_id, task_id, idx) DO NOTHING """ -DELETE_WRITES_SQL = """ - DELETE FROM checkpoint_writes - WHERE thread_id = %s - AND checkpoint_ns = %s - AND checkpoint_id = %s - AND task_id = %s - AND idx >= %s -""" - class BasePostgresSaver(BaseCheckpointSaver): SELECT_SQL = SELECT_SQL @@ -121,7 +113,6 @@ class BasePostgresSaver(BaseCheckpointSaver): UPSERT_CHECKPOINT_BLOBS_SQL = UPSERT_CHECKPOINT_BLOBS_SQL UPSERT_CHECKPOINTS_SQL = UPSERT_CHECKPOINTS_SQL UPSERT_CHECKPOINT_WRITES_SQL = UPSERT_CHECKPOINT_WRITES_SQL - DELETE_WRITES_SQL = DELETE_WRITES_SQL jsonplus_serde = JsonPlusSerializer() @@ -210,7 +201,7 @@ class BasePostgresSaver(BaseCheckpointSaver): checkpoint_ns, checkpoint_id, task_id, - idx, + WRITES_IDX_MAP.get(channel, idx), channel, *self.serde.dumps_typed(value), ) diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py index d67454034..4767bfb70 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/__init__.py @@ -1,12 +1,13 @@ import sqlite3 import threading -from contextlib import contextmanager +from contextlib import closing, contextmanager from hashlib import md5 from typing import Any, AsyncIterator, Dict, Iterator, Optional, Sequence, Tuple from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, ChannelVersions, Checkpoint, @@ -318,9 +319,7 @@ class SqliteSaver(BaseCheckpointSaver): ORDER BY checkpoint_id DESC""" if limit: query += f" LIMIT {limit}" - with self.cursor(transaction=False) as cur, self.cursor( - transaction=False - ) as writes_cur: + with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur: cur.execute(query, param_values) for ( thread_id, @@ -331,13 +330,9 @@ class SqliteSaver(BaseCheckpointSaver): checkpoint, metadata, ) in cur: - writes_cur.execute( + wcur.execute( "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", - ( - thread_id, - checkpoint_ns, - checkpoint_id, - ), + (thread_id, checkpoint_ns, checkpoint_id), ) yield CheckpointTuple( { @@ -362,7 +357,7 @@ class SqliteSaver(BaseCheckpointSaver): ), [ (task_id, channel, self.serde.loads_typed((type, value))) - for task_id, channel, type, value in writes_cur + for task_id, channel, type, value in wcur ], ) @@ -438,25 +433,15 @@ class SqliteSaver(BaseCheckpointSaver): task_id (str): Identifier for the task creating the writes. """ with self.lock, self.cursor() as cur: - cur.execute( - "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? AND task_id = ? AND idx >= ?", - ( - str(config["configurable"]["thread_id"]), - str(config["configurable"]["checkpoint_ns"]), - str(config["configurable"]["checkpoint_id"]), - task_id, - len(writes), - ), - ) cur.executemany( - "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [ ( str(config["configurable"]["thread_id"]), str(config["configurable"]["checkpoint_ns"]), str(config["configurable"]["checkpoint_id"]), task_id, - idx, + WRITES_IDX_MAP.get(channel, idx), channel, *self.serde.dumps_typed(value), ) diff --git a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py index 364adf063..85869d3f0 100644 --- a/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py +++ b/libs/checkpoint-sqlite/langgraph/checkpoint/sqlite/aio.py @@ -16,6 +16,7 @@ import aiosqlite from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, ChannelVersions, Checkpoint, @@ -329,14 +330,14 @@ class AsyncSqliteSaver(BaseCheckpointSaver): AsyncIterator[CheckpointTuple]: An asynchronous iterator of matching checkpoint tuples. """ await self.setup() - where, param_values = search_where(config, filter, before) + where, params = search_where(config, filter, before) query = f"""SELECT thread_id, checkpoint_ns, checkpoint_id, parent_checkpoint_id, type, checkpoint, metadata FROM checkpoints {where} ORDER BY checkpoint_id DESC""" if limit: query += f" LIMIT {limit}" - async with self.conn.execute(query, param_values) as cursor: + async with self.conn.execute(query, params) as cur, self.conn.cursor() as wcur: async for ( thread_id, checkpoint_ns, @@ -345,14 +346,10 @@ class AsyncSqliteSaver(BaseCheckpointSaver): type, checkpoint, metadata, - ) in cursor: - writes_cur = await self.conn.execute( + ) in cur: + await wcur.execute( "SELECT task_id, channel, type, value FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ?", - ( - thread_id, - checkpoint_ns, - checkpoint_id, - ), + (thread_id, checkpoint_ns, checkpoint_id), ) yield CheckpointTuple( { @@ -377,7 +374,7 @@ class AsyncSqliteSaver(BaseCheckpointSaver): ), [ (task_id, channel, self.serde.loads_typed((type, value))) - async for task_id, channel, type, value in writes_cur + async for task_id, channel, type, value in wcur ], ) @@ -445,25 +442,15 @@ class AsyncSqliteSaver(BaseCheckpointSaver): """ await self.setup() async with self.conn.cursor() as cur: - await cur.execute( - "DELETE FROM writes WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? AND task_id = ? AND idx >= ?", - ( - str(config["configurable"]["thread_id"]), - str(config["configurable"]["checkpoint_ns"]), - str(config["configurable"]["checkpoint_id"]), - task_id, - len(writes), - ), - ) await cur.executemany( - "INSERT OR REPLACE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO writes (thread_id, checkpoint_ns, checkpoint_id, task_id, idx, channel, type, value) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [ ( str(config["configurable"]["thread_id"]), str(config["configurable"]["checkpoint_ns"]), str(config["configurable"]["checkpoint_id"]), task_id, - idx, + WRITES_IDX_MAP.get(channel, idx), channel, *self.serde.dumps_typed(value), ) diff --git a/libs/checkpoint/langgraph/checkpoint/base/__init__.py b/libs/checkpoint/langgraph/checkpoint/base/__init__.py index 86c8b0eec..21d021ad5 100644 --- a/libs/checkpoint/langgraph/checkpoint/base/__init__.py +++ b/libs/checkpoint/langgraph/checkpoint/base/__init__.py @@ -22,6 +22,7 @@ from langgraph.checkpoint.base.id import uuid6 from langgraph.checkpoint.serde.base import SerializerProtocol, maybe_add_typed_methods from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer from langgraph.checkpoint.serde.types import ( + ERROR, ChannelProtocol, SendProtocol, ) @@ -98,6 +99,7 @@ class Checkpoint(TypedDict): Cleared by the next checkpoint.""" current_tasks: Dict[str, TaskInfo] """Map from task ID to task info.""" + # TODO remove this def empty_checkpoint() -> Checkpoint: @@ -140,6 +142,8 @@ def create_checkpoint( else: values: dict[str, Any] = {} for k, v in channels.items(): + if k not in checkpoint["channel_versions"]: + continue try: values[k] = v.checkpoint() except EmptyChannelError: @@ -437,3 +441,13 @@ def get_checkpoint_id(config: RunnableConfig) -> Optional[str]: return config["configurable"].get( "checkpoint_id", config["configurable"].get("thread_ts") ) + + +""" +Mapping from error type to error index. +Regular writes just map to their index in the list of writes being saved. +Special writes (e.g. errors) map to negative indices, to avoid those writes from +saving regular writes. +Each Checkpointer implementation should use this mapping in put_writes. +""" +WRITES_IDX_MAP = {ERROR: -1} diff --git a/libs/checkpoint/langgraph/checkpoint/memory/__init__.py b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py index 6b3714f05..132b05c15 100644 --- a/libs/checkpoint/langgraph/checkpoint/memory/__init__.py +++ b/libs/checkpoint/langgraph/checkpoint/memory/__init__.py @@ -8,6 +8,7 @@ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Tuple from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.base import ( + WRITES_IDX_MAP, BaseCheckpointSaver, ChannelVersions, Checkpoint, @@ -52,6 +53,9 @@ class MemorySaver( # thread ID -> checkpoint NS -> checkpoint ID -> checkpoint mapping storage: defaultdict[str, dict[str, dict[str, tuple[bytes, bytes, Optional[str]]]]] + writes: defaultdict[ + tuple[str, str, str], dict[tuple[str, int], tuple[str, str, bytes]] + ] def __init__( self, @@ -60,7 +64,7 @@ class MemorySaver( ) -> None: super().__init__(serde=serde) self.storage = defaultdict(lambda: defaultdict(dict)) - self.writes = defaultdict(list) + self.writes = defaultdict(dict) def __enter__(self) -> "MemorySaver": return self @@ -103,7 +107,7 @@ class MemorySaver( if checkpoint_id := get_checkpoint_id(config): if saved := self.storage[thread_id][checkpoint_ns].get(checkpoint_id): checkpoint, metadata, parent_checkpoint_id = saved - writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)] + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() return CheckpointTuple( config=config, checkpoint=self.serde.loads_typed(checkpoint), @@ -125,7 +129,7 @@ class MemorySaver( if checkpoints := self.storage[thread_id][checkpoint_ns]: checkpoint_id = max(checkpoints.keys()) checkpoint, metadata, parent_checkpoint_id = checkpoints[checkpoint_id] - writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)] + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() return CheckpointTuple( config={ "configurable": { @@ -206,7 +210,8 @@ class MemorySaver( elif limit is not None: limit -= 1 - writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)] + writes = self.writes[(thread_id, checkpoint_ns, checkpoint_id)].values() + yield CheckpointTuple( config={ "configurable": { @@ -216,9 +221,6 @@ class MemorySaver( } }, checkpoint=self.serde.loads_typed(checkpoint), - pending_writes=[ - (id, c, self.serde.loads_typed(v)) for id, c, v in writes - ], metadata=metadata, parent_config={ "configurable": { @@ -229,6 +231,9 @@ class MemorySaver( } if parent_checkpoint_id else None, + pending_writes=[ + (id, c, self.serde.loads_typed(v)) for id, c, v in writes + ], ) def put( @@ -293,11 +298,10 @@ class MemorySaver( thread_id = config["configurable"]["thread_id"] checkpoint_ns = config["configurable"]["checkpoint_ns"] checkpoint_id = config["configurable"]["checkpoint_id"] - key = (thread_id, checkpoint_ns, checkpoint_id) - self.writes[key] = [w for w in self.writes[key] if w[0] != task_id] - self.writes[key].extend( - [(task_id, c, self.serde.dumps_typed(v)) for c, v in writes] - ) + outer_key = (thread_id, checkpoint_ns, checkpoint_id) + for idx, (c, v) in enumerate(writes): + inner_key = (task_id, WRITES_IDX_MAP.get(c, idx)) + self.writes[outer_key][inner_key] = (task_id, c, self.serde.dumps_typed(v)) async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]: """Asynchronous version of get_tuple. diff --git a/libs/checkpoint/langgraph/checkpoint/serde/types.py b/libs/checkpoint/langgraph/checkpoint/serde/types.py index cc5c1fa8b..71588cfa0 100644 --- a/libs/checkpoint/langgraph/checkpoint/serde/types.py +++ b/libs/checkpoint/langgraph/checkpoint/serde/types.py @@ -12,6 +12,8 @@ from typing import ( from langchain_core.runnables import RunnableConfig from typing_extensions import Self +ERROR = "__error__" + Value = TypeVar("Value") Update = TypeVar("Update") C = TypeVar("C") diff --git a/libs/langgraph/langgraph/channels/base.py b/libs/langgraph/langgraph/channels/base.py index fe47f0d8f..885698743 100644 --- a/libs/langgraph/langgraph/channels/base.py +++ b/libs/langgraph/langgraph/channels/base.py @@ -2,9 +2,9 @@ from abc import ABC, abstractmethod from contextlib import asynccontextmanager, contextmanager from typing import ( Any, - AsyncGenerator, - Generator, + AsyncIterator, Generic, + Iterator, Optional, Sequence, TypeVar, @@ -21,6 +21,8 @@ C = TypeVar("C") class BaseChannel(Generic[Value, Update, C], ABC): + key: str = "" + @property @abstractmethod def ValueType(self) -> Any: @@ -43,19 +45,35 @@ class BaseChannel(Generic[Value, Update, C], ABC): @abstractmethod def from_checkpoint( self, checkpoint: Optional[C], config: RunnableConfig - ) -> Generator[Self, None, None]: + ) -> Iterator[Self]: """Return a new identical channel, optionally initialized from a checkpoint. If the checkpoint contains complex data structures, they should be copied.""" + @contextmanager + def from_checkpoint_named( + self, checkpoint: Optional[C], config: RunnableConfig + ) -> Iterator[Self]: + with self.from_checkpoint(checkpoint, config) as value: + value.key = self.key + yield value + @asynccontextmanager async def afrom_checkpoint( self, checkpoint: Optional[C], config: RunnableConfig - ) -> AsyncGenerator[Self, None]: + ) -> AsyncIterator[Self]: """Return a new identical channel, optionally initialized from a checkpoint. If the checkpoint contains complex data structures, they should be copied.""" with self.from_checkpoint(checkpoint, config) as value: yield value + @asynccontextmanager + async def afrom_checkpoint_named( + self, checkpoint: Optional[C], config: RunnableConfig + ) -> AsyncIterator[Self]: + async with self.afrom_checkpoint(checkpoint, config) as value: + value.key = self.key + yield value + # state methods @abstractmethod diff --git a/libs/langgraph/langgraph/channels/context.py b/libs/langgraph/langgraph/channels/context.py index 914de9348..b48260b40 100644 --- a/libs/langgraph/langgraph/channels/context.py +++ b/libs/langgraph/langgraph/channels/context.py @@ -112,7 +112,9 @@ class Context(Generic[Value], BaseChannel[Value, None, None]): def update(self, values: Sequence[None]) -> bool: if values: - raise InvalidUpdateError("Context channel does not accept writes.") + raise InvalidUpdateError( + f"At key '{self.key}': Context channel does not accept writes." + ) return False def get(self) -> Value: diff --git a/libs/langgraph/langgraph/channels/dynamic_barrier_value.py b/libs/langgraph/langgraph/channels/dynamic_barrier_value.py index bb0d447fa..64406b8f8 100644 --- a/libs/langgraph/langgraph/channels/dynamic_barrier_value.py +++ b/libs/langgraph/langgraph/channels/dynamic_barrier_value.py @@ -69,7 +69,7 @@ class DynamicBarrierValue( if wait_for_names := [v for v in values if isinstance(v, WaitForNames)]: if len(wait_for_names) > 1: raise InvalidUpdateError( - "Received multiple WaitForNames updates in the same step." + f"At key '{self.key}': Received multiple WaitForNames updates in the same step." ) self.names = wait_for_names[0].names return True diff --git a/libs/langgraph/langgraph/channels/ephemeral_value.py b/libs/langgraph/langgraph/channels/ephemeral_value.py index 15e11550d..4e7f2ed63 100644 --- a/libs/langgraph/langgraph/channels/ephemeral_value.py +++ b/libs/langgraph/langgraph/channels/ephemeral_value.py @@ -58,7 +58,7 @@ class EphemeralValue(Generic[Value], BaseChannel[Value, Value, Value]): return False if len(values) != 1 and self.guard: raise InvalidUpdateError( - "EphemeralValue can only receive one value per step." + f"At key '{self.key}': EphemeralValue(guard=True) can receive only one value per step. Use guard=False if you want to store any one of multiple values." ) self.value = values[-1] diff --git a/libs/langgraph/langgraph/channels/last_value.py b/libs/langgraph/langgraph/channels/last_value.py index a207ebce3..e74580d6a 100644 --- a/libs/langgraph/langgraph/channels/last_value.py +++ b/libs/langgraph/langgraph/channels/last_value.py @@ -52,7 +52,9 @@ class LastValue(Generic[Value], BaseChannel[Value, Value, Value]): if len(values) == 0: return False if len(values) != 1: - raise InvalidUpdateError("LastValue can only receive one value per step.") + raise InvalidUpdateError( + f"At key '{self.key}': Can receive only one value per step. Use an Annotated key to handle multiple values." + ) self.value = values[-1] return True diff --git a/libs/langgraph/langgraph/channels/named_barrier_value.py b/libs/langgraph/langgraph/channels/named_barrier_value.py index bdfd4660b..023f54e6c 100644 --- a/libs/langgraph/langgraph/channels/named_barrier_value.py +++ b/libs/langgraph/langgraph/channels/named_barrier_value.py @@ -53,7 +53,9 @@ class NamedBarrierValue(Generic[Value], BaseChannel[Value, Value, set[Value]]): self.seen.add(value) updated = True else: - raise InvalidUpdateError(f"Value {value} not in {self.names}") + raise InvalidUpdateError( + f"At key '{self.key}': Value {value} not in {self.names}" + ) return updated def get(self) -> Value: diff --git a/libs/langgraph/langgraph/channels/untracked_value.py b/libs/langgraph/langgraph/channels/untracked_value.py index 989bba35e..a112b0e81 100644 --- a/libs/langgraph/langgraph/channels/untracked_value.py +++ b/libs/langgraph/langgraph/channels/untracked_value.py @@ -49,7 +49,7 @@ class UntrackedValue(Generic[Value], BaseChannel[Value, Value, Value]): return False if len(values) != 1 and self.guard: raise InvalidUpdateError( - "UntrackedValue can only receive one value per step." + f"At key '{self.key}': UntrackedValue(guard=True) can receive only one value per step. Use guard=False if you want to store any one of multiple values." ) self.value = values[-1] diff --git a/libs/langgraph/langgraph/errors.py b/libs/langgraph/langgraph/errors.py index 00dc36e58..27a7689fa 100644 --- a/libs/langgraph/langgraph/errors.py +++ b/libs/langgraph/langgraph/errors.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Sequence from langgraph.checkpoint.base import EmptyChannelError from langgraph.constants import Interrupt @@ -32,7 +32,7 @@ class InvalidUpdateError(Exception): class GraphInterrupt(Exception): """Raised when a subgraph is interrupted.""" - def __init__(self, interrupts: list[Interrupt]) -> None: + def __init__(self, interrupts: Sequence[Interrupt] = ()) -> None: super().__init__(interrupts) diff --git a/libs/langgraph/langgraph/graph/graph.py b/libs/langgraph/langgraph/graph/graph.py index 96ccda3b2..3ecbe726f 100644 --- a/libs/langgraph/langgraph/graph/graph.py +++ b/libs/langgraph/langgraph/graph/graph.py @@ -198,12 +198,14 @@ class Graph: raise ValueError("END cannot be a start node") if end_key == START: raise ValueError("START cannot be an end node") - if not self.support_multiple_edges and start_key in set( + + # run this validation only for non-StateGraph graphs + if not hasattr(self, "channels") and start_key in set( start for start, _ in self.edges ): raise ValueError( f"Already found path for node '{start_key}'.\n" - "For multiple edges, use StateGraph with an annotated state key." + "For multiple edges, use StateGraph with an Annotated state key." ) self.edges.add((start_key, end_key)) diff --git a/libs/langgraph/langgraph/graph/state.py b/libs/langgraph/langgraph/graph/state.py index 259d51b62..3f2b72d64 100644 --- a/libs/langgraph/langgraph/graph/state.py +++ b/libs/langgraph/langgraph/graph/state.py @@ -1,3 +1,4 @@ +import inspect import logging import typing import warnings @@ -196,10 +197,6 @@ class StateGraph(Graph): ) else: self.managed[key] = managed - if any( - isinstance(c, BinaryOperatorAggregate) for c in self.channels.values() - ): - self.support_multiple_edges = True @overload def add_node( @@ -338,10 +335,13 @@ class StateGraph(Graph): hints := get_type_hints(action.__call__) or get_type_hints(action) ): if input is None: - input_hint = hints[list(hints.keys())[0]] - if isinstance(input_hint, type) and get_type_hints(input_hint): - input = input_hint - except TypeError: + first_parameter_name = next( + iter(inspect.signature(action).parameters.keys()) + ) + if input_hint := hints.get(first_parameter_name): + if isinstance(input_hint, type) and get_type_hints(input_hint): + input = input_hint + except (TypeError, StopIteration): pass if input is not None: self._add_schema(input) @@ -727,10 +727,15 @@ def _get_channel( else: raise ValueError(f"This {annotation} not allowed in this position") elif channel := _is_field_channel(annotation): + channel.key = name return channel elif channel := _is_field_binop(annotation): + channel.key = name return channel - return LastValue(annotation) + + fallback = LastValue(annotation) + fallback.key = name + return fallback def _is_field_channel(typ: Type[Any]) -> Optional[BaseChannel]: diff --git a/libs/langgraph/langgraph/pregel/__init__.py b/libs/langgraph/langgraph/pregel/__init__.py index 4ef53b40f..0096f7f8b 100644 --- a/libs/langgraph/langgraph/pregel/__init__.py +++ b/libs/langgraph/langgraph/pregel/__init__.py @@ -71,23 +71,14 @@ from langgraph.constants import ( ) from langgraph.errors import GraphInterrupt, GraphRecursionError, InvalidUpdateError from langgraph.managed.base import ManagedValueSpec -from langgraph.pregel.algo import ( - apply_writes, - local_read, - prepare_next_tasks, - should_interrupt, -) +from langgraph.pregel.algo import apply_writes, local_read, prepare_next_tasks from langgraph.pregel.debug import ( - map_debug_task_results, print_step_checkpoint, print_step_tasks, print_step_writes, tasks_w_writes, ) -from langgraph.pregel.io import ( - map_output_updates, - read_channels, -) +from langgraph.pregel.io import read_channels from langgraph.pregel.loop import AsyncPregelLoop, SyncPregelLoop from langgraph.pregel.manager import AsyncChannelsManager, ChannelsManager from langgraph.pregel.read import PregelNode @@ -503,6 +494,7 @@ class Pregel( config=config, metadata=None, created_at=None, + parent_config=None, tasks=(), ) @@ -578,6 +570,7 @@ class Pregel( config=config, metadata=None, created_at=None, + parent_config=None, tasks=(), ) @@ -723,7 +716,7 @@ class Pregel( # update channels with ChannelsManager(self.channels, checkpoint, config) as ( channels, - managed, + _, ): # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() @@ -759,31 +752,6 @@ class Pregel( checkpoint, channels, [task], self.checkpointer.get_next_version ), "Can't write to SharedValues from update_state" checkpoint = create_checkpoint(checkpoint, channels, step + 1) - # check interrupt before - if tasks := should_interrupt( - checkpoint, - self.interrupt_before_nodes, - prepare_next_tasks( - checkpoint, - self.nodes, - channels, - managed, - config, - step + 2, - for_execution=False, - ), - ): - for t in tasks: - self.checkpointer.put_writes( - { - "configurable": { - **checkpoint_config["configurable"], - "checkpoint_id": checkpoint["id"], - } - }, - [(INTERRUPT, Interrupt("before"))], - t.id, - ) return self.checkpointer.put( checkpoint_config, checkpoint, @@ -866,7 +834,7 @@ class Pregel( # update channels, acting as the chosen node async with AsyncChannelsManager(self.channels, checkpoint, config) as ( channels, - managed, + _, ): # create task to run all writers of the chosen node writers = self.nodes[as_node].get_writers() @@ -902,35 +870,6 @@ class Pregel( checkpoint, channels, [task], self.checkpointer.get_next_version ), "Can't write to SharedValues from update_state" checkpoint = create_checkpoint(checkpoint, channels, step + 1) - # check interrupt before - if tasks := should_interrupt( - checkpoint, - self.interrupt_before_nodes, - prepare_next_tasks( - checkpoint, - self.nodes, - channels, - managed, - config, - step + 2, - for_execution=False, - ), - ): - await asyncio.gather( - *( - self.checkpointer.aput_writes( - { - "configurable": { - **checkpoint_config["configurable"], - "checkpoint_id": checkpoint["id"], - } - }, - [(INTERRUPT, Interrupt("before"))], - t.id, - ) - for t in tasks - ) - ) return await self.checkpointer.aput( checkpoint_config, checkpoint, @@ -1111,6 +1050,7 @@ class Pregel( nodes=self.nodes, specs=self.channels, output_keys=output_keys, + stream_keys=self.stream_channels_asis, ) as loop: # Similarly to Bulk Synchronous Parallel / Pregel model # computation proceeds in steps, while there are channel updates @@ -1119,7 +1059,6 @@ class Pregel( # with channel updates applied only at the transition between steps while loop.tick( input_keys=self.input_channels, - stream_keys=self.stream_channels_asis, interrupt_before=interrupt_before, interrupt_after=interrupt_after, manager=run_manager, @@ -1188,26 +1127,17 @@ class Pregel( else: # save task writes to checkpointer loop.put_writes(task.id, task.writes) - # yield updates output for the finished task - if "updates" in stream_modes: - yield from _with_mode( - "updates", - isinstance(stream_mode, list), - map_output_updates(output_keys, [task]), - ) - if "debug" in stream_modes: - yield from _with_mode( - "debug", - isinstance(stream_mode, list), - map_debug_task_results( - loop.step, - [task], - self.stream_channels_list, - ), - ) else: # remove references to loop vars del fut, task + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload if _should_stop_others(done): break @@ -1222,21 +1152,21 @@ class Pregel( [w for t in loop.tasks for w in t.writes], self.stream_channels_list, ) - # emit output - while loop.stream: - mode, payload = loop.stream.popleft() - if mode in stream_modes: - if isinstance(stream_mode, list): - yield (mode, payload) - else: - yield payload - # handle exit - if loop.status == "out_of_steps": - raise GraphRecursionError( - f"Recursion limit of {config['recursion_limit']} reached " - "without hitting a stop condition. You can increase the " - "limit by setting the `recursion_limit` config key." - ) + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload + # handle exit + if loop.status == "out_of_steps": + raise GraphRecursionError( + f"Recursion limit of {config['recursion_limit']} reached " + "without hitting a stop condition. You can increase the " + "limit by setting the `recursion_limit` config key." + ) # set final channel values as run output run_manager.on_chain_end(loop.output) except BaseException as e: @@ -1368,6 +1298,7 @@ class Pregel( nodes=self.nodes, specs=self.channels, output_keys=output_keys, + stream_keys=self.stream_channels_asis, ) as loop: aioloop = asyncio.get_event_loop() # Similarly to Bulk Synchronous Parallel / Pregel model @@ -1377,7 +1308,6 @@ class Pregel( # with channel updates applied only at the transition between steps while loop.tick( input_keys=self.input_channels, - stream_keys=self.stream_channels_asis, interrupt_before=interrupt_before, interrupt_after=interrupt_after, manager=run_manager, @@ -1448,28 +1378,17 @@ class Pregel( else: # save task writes to checkpointer loop.put_writes(task.id, task.writes) - # yield updates output for the finished task - if "updates" in stream_modes: - for chunk in _with_mode( - "updates", - isinstance(stream_mode, list), - map_output_updates(output_keys, [task]), - ): - yield chunk - if "debug" in stream_modes: - for chunk in _with_mode( - "debug", - isinstance(stream_mode, list), - map_debug_task_results( - loop.step, - [task], - self.stream_channels_list, - ), - ): - yield chunk else: # remove references to loop vars del fut, task + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload if _should_stop_others(done): break @@ -1484,21 +1403,21 @@ class Pregel( [w for t in loop.tasks for w in t.writes], self.stream_channels_list, ) - # emit output - while loop.stream: - mode, payload = loop.stream.popleft() - if mode in stream_modes: - if isinstance(stream_mode, list): - yield (mode, payload) - else: - yield payload - # handle exit - if loop.status == "out_of_steps": - raise GraphRecursionError( - f"Recursion limit of {config['recursion_limit']} reached " - "without hitting a stop condition. You can increase the " - "limit by setting the `recursion_limit` config key." - ) + # emit output + while loop.stream: + mode, payload = loop.stream.popleft() + if mode in stream_modes: + if isinstance(stream_mode, list): + yield (mode, payload) + else: + yield payload + # handle exit + if loop.status == "out_of_steps": + raise GraphRecursionError( + f"Recursion limit of {config['recursion_limit']} reached " + "without hitting a stop condition. You can increase the " + "limit by setting the `recursion_limit` config key." + ) # set final channel values as run output await run_manager.on_chain_end(loop.output) except BaseException as e: diff --git a/libs/langgraph/langgraph/pregel/algo.py b/libs/langgraph/langgraph/pregel/algo.py index 846afd9e0..27777bbab 100644 --- a/libs/langgraph/langgraph/pregel/algo.py +++ b/libs/langgraph/langgraph/pregel/algo.py @@ -198,12 +198,7 @@ def apply_writes( updated_channels: set[str] = set() for chan, vals in pending_writes_by_channel.items(): if chan in channels: - try: - updated = channels[chan].update(vals) - except InvalidUpdateError as e: - raise InvalidUpdateError( - f"Invalid update for channel {chan} with values {vals}" - ) from e + updated = channels[chan].update(vals) if updated and get_next_version is not None: checkpoint["channel_versions"][chan] = get_next_version( max_version, channels[chan] diff --git a/libs/langgraph/langgraph/pregel/debug.py b/libs/langgraph/langgraph/pregel/debug.py index 5102cd8c5..72b6cdbe5 100644 --- a/libs/langgraph/langgraph/pregel/debug.py +++ b/libs/langgraph/langgraph/pregel/debug.py @@ -1,5 +1,6 @@ import json from collections import defaultdict +from dataclasses import asdict from datetime import datetime, timezone from pprint import pformat from typing import Any, Iterator, Literal, Mapping, Optional, Sequence, TypedDict, Union @@ -25,6 +26,8 @@ class TaskPayload(TypedDict): class TaskResultPayload(TypedDict): id: str name: str + error: Optional[str] + interrupts: list[dict] result: list[tuple[str, Any]] @@ -97,11 +100,14 @@ def map_debug_tasks( def map_debug_task_results( step: int, - tasks: list[PregelExecutableTask], - stream_channels_list: Sequence[str], + tasks: list[tuple[PregelExecutableTask, Sequence[tuple[str, Any]]]], + stream_keys: Union[str, Sequence[str]], ) -> Iterator[DebugOutputTaskResult]: + stream_channels_list = ( + [stream_keys] if isinstance(stream_keys, str) else stream_keys + ) ts = datetime.now(timezone.utc).isoformat() - for name, _, _, writes, config, _, _, _ in tasks: + for (name, _, _, _, config, _, _, _), writes in tasks: if config is not None and TAG_HIDDEN in config.get("tags", []): continue @@ -116,7 +122,9 @@ def map_debug_task_results( "payload": { "id": str(uuid5(TASK_NAMESPACE, json.dumps((name, step, metadata)))), "name": name, + "error": next((w[1] for w in writes if w[0] == ERROR), None), "result": [w for w in writes if w[0] in stream_channels_list], + "interrupts": [asdict(w[1]) for w in writes if w[0] == INTERRUPT], }, } @@ -150,7 +158,7 @@ def map_debug_checkpoint( else { "id": t.id, "name": t.name, - "interrupts": t.interrupts, + "interrupts": tuple(asdict(i) for i in t.interrupts), } for t in tasks_w_writes(tasks, pending_writes) ], diff --git a/libs/langgraph/langgraph/pregel/io.py b/libs/langgraph/langgraph/pregel/io.py index 53e77557e..6c9205248 100644 --- a/libs/langgraph/langgraph/pregel/io.py +++ b/libs/langgraph/langgraph/pregel/io.py @@ -3,7 +3,7 @@ from typing import Any, Iterator, Mapping, Optional, Sequence, TypeVar, Union from langchain_core.runnables.utils import AddableDict from langgraph.channels.base import BaseChannel, EmptyChannelError -from langgraph.constants import TAG_HIDDEN +from langgraph.constants import ERROR, INTERRUPT, TAG_HIDDEN from langgraph.pregel.log import logger from langgraph.pregel.types import PregelExecutableTask @@ -95,19 +95,22 @@ class AddableUpdatesDict(AddableDict): def map_output_updates( output_channels: Union[str, Sequence[str]], - tasks: list[PregelExecutableTask], + tasks: list[tuple[PregelExecutableTask, Sequence[tuple[str, Any]]]], ) -> Iterator[dict[str, Union[Any, dict[str, Any]]]]: """Map pending writes (a sequence of tuples (channel, value)) to output chunk.""" output_tasks = [ - t for t in tasks if not t.config or TAG_HIDDEN not in t.config.get("tags") + (t, ww) + for t, ww in tasks + if (not t.config or TAG_HIDDEN not in t.config.get("tags")) + and all(k not in (ERROR, INTERRUPT) for k, _ in ww) ] if not output_tasks: return if isinstance(output_channels, str): updated = [ (task.name, value) - for task in output_tasks - for chan, value in task.writes + for task, writes in output_tasks + for chan, value in writes if chan == output_channels ] else: @@ -116,10 +119,10 @@ def map_output_updates( task.name, {chan: value for chan, value in task.writes if chan in output_channels}, ) - for task in output_tasks - if any(chan in output_channels for chan, _ in task.writes) + for task, writes in output_tasks + if any(chan in output_channels for chan, _ in writes) ] - grouped = {t.name: [] for t in output_tasks} + grouped = {t.name: [] for t, _ in output_tasks} for node, value in updated: grouped[node].append(value) for node, value in grouped.items(): diff --git a/libs/langgraph/langgraph/pregel/loop.py b/libs/langgraph/langgraph/pregel/loop.py index aa256b2f7..a501232a9 100644 --- a/libs/langgraph/langgraph/pregel/loop.py +++ b/libs/langgraph/langgraph/pregel/loop.py @@ -41,7 +41,6 @@ from langgraph.constants import ( ERROR, INPUT, INTERRUPT, - Interrupt, ) from langgraph.errors import EmptyInputError, GraphInterrupt from langgraph.managed.base import ( @@ -56,7 +55,11 @@ from langgraph.pregel.algo import ( prepare_next_tasks, should_interrupt, ) -from langgraph.pregel.debug import map_debug_checkpoint, map_debug_tasks +from langgraph.pregel.debug import ( + map_debug_checkpoint, + map_debug_task_results, + map_debug_tasks, +) from langgraph.pregel.executor import ( AsyncBackgroundExecutor, BackgroundExecutor, @@ -90,6 +93,7 @@ class PregelLoop: nodes: Mapping[str, PregelNode] specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]] output_keys: Union[str, Sequence[str]] + stream_keys: Union[str, Sequence[str]] is_nested: bool checkpointer_get_next_version: Callable[[Optional[V]], V] @@ -138,6 +142,7 @@ class PregelLoop: nodes: Mapping[str, PregelNode], specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]], output_keys: Union[str, Sequence[str]], + stream_keys: Union[str, Sequence[str]], ) -> None: self.stream = deque() self.input = input @@ -147,6 +152,7 @@ class PregelLoop: self.nodes = nodes self.specs = specs self.output_keys = output_keys + self.stream_keys = stream_keys self.is_nested = CONFIG_KEY_READ in self.config.get("configurable", {}) def mark_tasks_scheduled(self, tasks: Sequence[PregelExecutableTask]) -> None: @@ -155,6 +161,8 @@ class PregelLoop: def put_writes(self, task_id: str, writes: Sequence[tuple[str, Any]]) -> None: """Put writes for a task, to be read by the next tick.""" + if not writes: + return self.checkpoint_pending_writes.extend((task_id, k, v) for k, v in writes) if self.checkpointer_put_writes is not None: self.submit( @@ -172,12 +180,22 @@ class PregelLoop: writes, task_id, ) + if task := next((t for t in self.tasks if t.id == task_id), None): + self.stream.extend( + ("updates", v) + for v in map_output_updates(self.output_keys, [(task, writes)]) + ) + self.stream.extend( + ("debug", v) + for v in map_debug_task_results( + self.step, [(task, writes)], self.stream_keys + ) + ) def tick( self, *, input_keys: Union[str, Sequence[str]], - stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, interrupt_after: Sequence[str] = EMPTY_SEQ, interrupt_before: Sequence[str] = EMPTY_SEQ, manager: Union[None, AsyncParentRunManager, ParentRunManager] = None, @@ -213,17 +231,18 @@ class PregelLoop: self._put_checkpoint( { "source": "loop", - "writes": single(map_output_updates(self.output_keys, self.tasks)), + "writes": single( + map_output_updates( + self.output_keys, [(t, t.writes) for t in self.tasks] + ) + ), } ) # after execution, check if we should interrupt - if tasks := should_interrupt(self.checkpoint, interrupt_after, self.tasks): + if should_interrupt(self.checkpoint, interrupt_after, self.tasks): self.status = "interrupt_after" - interrupts = [(t.id, Interrupt("after")) for t in tasks] - for tid, interrupt in interrupts: - self.put_writes(tid, [(INTERRUPT, interrupt)]) if self.is_nested: - raise GraphInterrupt([i[1] for i in interrupts]) + raise GraphInterrupt() else: return False else: @@ -256,7 +275,7 @@ class PregelLoop: self.step - 1, # printing checkpoint for previous step self.checkpoint_config, self.channels, - stream_keys, + self.stream_keys, self.checkpoint_metadata, self.checkpoint, self.tasks, @@ -281,20 +300,16 @@ class PregelLoop: if all(task.writes for task in self.tasks): return self.tick( input_keys=input_keys, - stream_keys=stream_keys, interrupt_after=interrupt_after, interrupt_before=interrupt_before, manager=manager, ) # before execution, check if we should interrupt - if tasks := should_interrupt(self.checkpoint, interrupt_before, self.tasks): + if should_interrupt(self.checkpoint, interrupt_before, self.tasks): self.status = "interrupt_before" - interrupts = [(t.id, Interrupt("before")) for t in tasks] - for tid, interrupt in interrupts: - self.put_writes(tid, [(INTERRUPT, interrupt)]) if self.is_nested: - raise GraphInterrupt([i[1] for i in interrupts]) + raise GraphInterrupt() else: return False @@ -435,6 +450,7 @@ class SyncPregelLoop(PregelLoop, ContextManager): nodes: Mapping[str, PregelNode], specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]], output_keys: Union[str, Sequence[str]] = EMPTY_SEQ, + stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, ) -> None: super().__init__( input, @@ -444,6 +460,7 @@ class SyncPregelLoop(PregelLoop, ContextManager): nodes=nodes, specs=specs, output_keys=output_keys, + stream_keys=stream_keys, ) self.stack = ExitStack() if checkpointer: @@ -522,6 +539,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): nodes: Mapping[str, PregelNode], specs: Mapping[str, Union[BaseChannel, ManagedValueSpec]], output_keys: Union[str, Sequence[str]] = EMPTY_SEQ, + stream_keys: Union[str, Sequence[str]] = EMPTY_SEQ, ) -> None: super().__init__( input, @@ -531,6 +549,7 @@ class AsyncPregelLoop(PregelLoop, AsyncContextManager): nodes=nodes, specs=specs, output_keys=output_keys, + stream_keys=stream_keys, ) self.store = AsyncBatchedStore(self.store) if self.store else None self.stack = AsyncExitStack() diff --git a/libs/langgraph/langgraph/pregel/manager.py b/libs/langgraph/langgraph/pregel/manager.py index 437019113..849395c50 100644 --- a/libs/langgraph/langgraph/pregel/manager.py +++ b/libs/langgraph/langgraph/pregel/manager.py @@ -41,7 +41,7 @@ def ChannelsManager( yield ( { k: stack.enter_context( - v.from_checkpoint(checkpoint["channel_values"].get(k), config) + v.from_checkpoint_named(checkpoint["channel_values"].get(k), config) ) for k, v in channel_specs.items() }, @@ -95,7 +95,9 @@ async def AsyncChannelsManager( # channels: enter each channel with checkpoint { k: await stack.enter_async_context( - v.afrom_checkpoint(checkpoint["channel_values"].get(k), config) + v.afrom_checkpoint_named( + checkpoint["channel_values"].get(k), config + ) ) for k, v in channel_specs.items() }, diff --git a/libs/langgraph/pyproject.toml b/libs/langgraph/pyproject.toml index 1df951ee4..1c3b20e9b 100644 --- a/libs/langgraph/pyproject.toml +++ b/libs/langgraph/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph" -version = "0.2.9" +version = "0.2.11" description = "Building stateful, multi-actor applications with LLMs" authors = [] license = "MIT" diff --git a/libs/langgraph/tests/any_str.py b/libs/langgraph/tests/any_str.py index 9aa030d3b..a98962cdc 100644 --- a/libs/langgraph/tests/any_str.py +++ b/libs/langgraph/tests/any_str.py @@ -1,3 +1,6 @@ +from typing import Any, Sequence + + class AnyStr(str): def __init__(self) -> None: super().__init__() @@ -9,6 +12,17 @@ class AnyStr(str): return hash(str(self)) +class AnyVersion: + def __init__(self) -> None: + super().__init__() + + def __eq__(self, other: object) -> bool: + return isinstance(other, (str, int, float)) + + def __hash__(self) -> int: + return hash(str(self)) + + class ExceptionLike: def __init__(self, exc: Exception) -> None: self.exc = exc @@ -22,3 +36,24 @@ class ExceptionLike: def __hash__(self) -> int: return hash((self.exc.__class__, str(self.exc))) + + def __repr__(self) -> str: + return str(self.exc) + + +class UnsortedSequence: + def __init__(self, *values: Any) -> None: + self.seq = values + + def __eq__(self, value: object) -> bool: + return ( + isinstance(value, Sequence) + and len(self.seq) == len(value) + and all(a in value for a in self.seq) + ) + + def __hash__(self) -> int: + return hash(frozenset(self.seq)) + + def __repr__(self) -> str: + return repr(self.seq) diff --git a/libs/langgraph/tests/test_pregel.py b/libs/langgraph/tests/test_pregel.py index 69842eb9a..2474f6790 100644 --- a/libs/langgraph/tests/test_pregel.py +++ b/libs/langgraph/tests/test_pregel.py @@ -72,7 +72,7 @@ from langgraph.pregel import ( from langgraph.pregel.retry import RetryPolicy from langgraph.pregel.types import PregelTask from langgraph.store.memory import MemoryStore -from tests.any_str import AnyStr, ExceptionLike +from tests.any_str import AnyStr, AnyVersion, ExceptionLike, UnsortedSequence from tests.fake_tracer import FakeTracer from tests.memory_assert import ( MemorySaverAssertCheckpointMetadata, @@ -203,6 +203,21 @@ def test_graph_validation() -> None: with pytest.raises(ValueError, match="Invalid reducer"): StateGraph(BadReducerState) + def node_b(state: State) -> State: + return {"hello": "world"} + + builder = StateGraph(State) + builder.add_node("a", node_b) + builder.add_node("b", node_b) + builder.add_node("c", node_b) + builder.set_entry_point("a") + builder.add_edge("a", "b") + builder.add_edge("a", "c") + graph = builder.compile() + + with pytest.raises(InvalidUpdateError, match="At key 'hello'"): + graph.invoke({"hello": "there"}) + def test_checkpoint_errors() -> None: class FaultyGetCheckpointer(MemorySaver): @@ -774,7 +789,7 @@ def test_invoke_two_processes_in_out_interrupt( ), ] - # forking from any previous checkpoint w/out forking should do nothing + # re-running from any previous checkpoint w/out forking should do nothing assert [c for c in app.stream(None, history[0].config, stream_mode="updates")] == [] assert [c for c in app.stream(None, history[1].config, stream_mode="updates")] == [] assert [c for c in app.stream(None, history[2].config, stream_mode="updates")] == [] @@ -1038,6 +1053,8 @@ def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "2687f72c-e3a8-5f6f-9afa-047cbf24e923", "name": "one", "result": [("inbox", 3)], + "error": None, + "interrupts": [], }, }, { @@ -1048,6 +1065,8 @@ def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "18f52f6a-828d-58a1-a501-53cc0c7af33e", "name": "two", "result": [("output", 13)], + "error": None, + "interrupts": [], }, }, { @@ -1069,6 +1088,8 @@ def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "871d6e74-7bb3-565f-a4fe-cef4b8f19b62", "name": "two", "result": [("output", 4)], + "error": None, + "interrupts": [], }, }, ] @@ -1196,6 +1217,21 @@ def test_invoke_two_processes_two_in_two_out_invalid(mocker: MockerFixture) -> N # LastValue channels can only be updated once per iteration app.invoke(2) + class State(TypedDict): + hello: str + + def my_node(input: State) -> State: + return {"hello": "world"} + + builder = StateGraph(State) + builder.add_node("one", my_node) + builder.add_node("two", my_node) + builder.set_conditional_entry_point(lambda _: ["one", "two"]) + + graph = builder.compile() + with pytest.raises(InvalidUpdateError, match="At key 'hello'"): + graph.invoke({"hello": "there"}, debug=True) + def test_invoke_two_processes_two_in_two_out_valid(mocker: MockerFixture) -> None: add_one = mocker.Mock(side_effect=lambda x: x + 1) @@ -1380,6 +1416,147 @@ def test_pending_writes_resume( # both the pending write and the new write were applied, 1 + 2 + 3 = 6 assert graph.invoke(None, thread1) == {"value": 6} + # check all final checkpoints + checkpoints = [c for c in checkpointer.list(thread1)] + # we should have 3 + assert len(checkpoints) == 3 + # the last one not too interesting for this test + assert checkpoints[0] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "one": { + "start:one": AnyVersion(), + }, + "two": { + "start:two": AnyVersion(), + }, + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + "__interrupt__": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + }, + "channel_versions": { + "one": AnyVersion(), + "two": AnyVersion(), + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": {"one": "one", "two": "two", "value": 6}, + }, + metadata={ + "step": 1, + "source": "loop", + "writes": {"one": {"value": 2}, "two": {"value": 3}}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[1].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=[], + ) + # the previous one we assert that pending writes contains both + # - original error + # - successful writes from resuming after preventing error + assert checkpoints[1] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": { + "value": 1, + "start:one": "__start__", + "start:two": "__start__", + }, + }, + metadata={"step": 0, "source": "loop", "writes": None}, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=UnsortedSequence( + (AnyStr(), "one", "one"), + (AnyStr(), "value", 2), + (AnyStr(), "__error__", ExceptionLike(ConnectionError("I'm not good"))), + (AnyStr(), "two", "two"), + (AnyStr(), "value", 3), + ), + ) + assert checkpoints[2] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": {"__input__": {}}, + "channel_versions": { + "__start__": AnyVersion(), + }, + "channel_values": {"__start__": {"value": 1}}, + }, + metadata={"step": -1, "source": "input", "writes": {"value": 1}}, + parent_config=None, + pending_writes=UnsortedSequence( + (AnyStr(), "value", 1), + (AnyStr(), "start:one", "__start__"), + (AnyStr(), "start:two", "__start__"), + ), + ) + def test_cond_edge_after_send() -> None: class Node: @@ -1749,7 +1926,6 @@ def test_channel_enter_exit_timing(mocker: MockerFixture) -> None: assert cleanup.call_count == 0 for i, chunk in enumerate(app.stream(2)): assert setup.call_count == 1, "Expected setup to be called once" - assert cleanup.call_count == 0, "Expected cleanup to not be called yet" if i == 0: assert chunk == {"inbox": [3]} elif i == 1: @@ -2205,7 +2381,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2251,7 +2427,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: "input": "what is weather in sf", }, }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -2412,7 +2588,7 @@ def test_conditional_graph(snapshot: SnapshotAssertion) -> None: ), }, }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3013,7 +3189,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3053,7 +3229,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3162,7 +3338,7 @@ def test_conditional_state_graph( values={ "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "agent", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3187,7 +3363,7 @@ def test_conditional_state_graph( ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -3240,7 +3416,7 @@ def test_conditional_state_graph( ) ], }, - tasks=(PregelTask(AnyStr(), "agent", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "agent"),), next=("agent",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4842,13 +5018,7 @@ def test_message_graph( id="ai1", ), ], - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4893,7 +5063,7 @@ def test_message_graph( ], ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -4975,7 +5145,7 @@ def test_message_graph( id="ai2", ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5567,13 +5737,7 @@ def test_root_graph( id="ai1", ), ], - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5618,7 +5782,7 @@ def test_root_graph( ], ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -5700,7 +5864,7 @@ def test_root_graph( id="ai2", ), ], - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], @@ -6022,6 +6186,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "592f3430-c17c-5d1c-831f-fecebb2c05bf", "name": "rewrite_query", "result": [("query", "query: what is weather in sf")], + "error": None, + "interrupts": [], }, }, ), @@ -6076,6 +6242,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "96965ed0-2c10-52a1-86eb-081ba6de73b2", "name": "retriever_two", "result": [("docs", ["doc3", "doc4"])], + "error": None, + "interrupts": [], }, }, ), @@ -6093,6 +6261,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "7db5e9d8-e132-5079-ab99-ced15e67d48b", "name": "retriever_one", "result": [("docs", ["doc1", "doc2"])], + "error": None, + "interrupts": [], }, }, ), @@ -6132,6 +6302,8 @@ def test_in_one_fan_out_out_one_graph_state() -> None: "id": "8959fb57-d0f5-5725-9ac4-ec1c554fb0a0", "name": "qa", "result": [("answer", "doc1,doc2,doc3,doc4")], + "error": None, + "interrupts": [], }, }, ), @@ -6302,13 +6474,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: ] assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value ⛰️", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6342,13 +6508,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6382,13 +6542,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6399,13 +6553,7 @@ def test_start_branch_then(snapshot: SnapshotAssertion) -> None: tool_two.update_state(thread3, {"my_key": "key"}) # appends to my_key assert tool_two.get_state(thread3) == StateSnapshot( values={"my_key": "valuekey", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread3).config, created_at=tool_two.checkpointer.get_tuple(thread3).checkpoint["ts"], @@ -6549,6 +6697,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "id": "7b7b0713-e958-5d07-803c-c9910a7cc162", "name": "prepare", "result": [("my_key", " prepared")], + "error": None, + "interrupts": [], }, }, { @@ -6601,6 +6751,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "id": "dd9f2fa5-ccfa-5d12-81ec-942563056a08", "name": "tool_two_slow", "result": [("my_key", " slow")], + "error": None, + "interrupts": [], }, }, { @@ -6651,6 +6803,8 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "id": "9b590c54-15ef-54b1-83a7-140d27b0bc52", "name": "finish", "result": [("my_key", " finished")], + "error": None, + "interrupts": [], }, }, { @@ -6700,13 +6854,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6744,13 +6892,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: } assert tool_two.get_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=tool_two.checkpointer.get_tuple(thread2).config, created_at=tool_two.checkpointer.get_tuple(thread2).checkpoint["ts"], @@ -6797,7 +6939,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "my_key": "value prepared slow", "market": "DE", }, - tasks=(PregelTask(AnyStr(), "finish", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "finish"),), next=("finish",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -6816,7 +6958,7 @@ def test_branch_then(snapshot: SnapshotAssertion) -> None: "my_key": "value prepared slower", "market": "DE", }, - tasks=(PregelTask(AnyStr(), "finish", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "finish"),), next=("finish",), config=tool_two.checkpointer.get_tuple(thread1).config, created_at=tool_two.checkpointer.get_tuple(thread1).checkpoint["ts"], @@ -7073,7 +7215,7 @@ def test_in_one_fan_out_state_graph_waiting_edge(snapshot: SnapshotAssertion) -> "query": "analyzed: query: what is weather in sf", "docs": ["doc1", "doc2", "doc3", "doc4", "doc5"], }, - tasks=(PregelTask(AnyStr(), "qa", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "qa"),), next=("qa",), config=app_w_interrupt.checkpointer.get_tuple(config).config, created_at=app_w_interrupt.checkpointer.get_tuple(config).checkpoint["ts"], diff --git a/libs/langgraph/tests/test_pregel_async.py b/libs/langgraph/tests/test_pregel_async.py index 4293287d2..409448a9d 100644 --- a/libs/langgraph/tests/test_pregel_async.py +++ b/libs/langgraph/tests/test_pregel_async.py @@ -67,7 +67,7 @@ from langgraph.pregel import ( from langgraph.pregel.retry import RetryPolicy from langgraph.pregel.types import PregelTask from langgraph.store.memory import MemoryStore -from tests.any_str import AnyStr, ExceptionLike +from tests.any_str import AnyStr, AnyVersion, ExceptionLike, UnsortedSequence from tests.fake_tracer import FakeTracer from tests.memory_assert import ( MemorySaverAssertCheckpointMetadata, @@ -212,7 +212,15 @@ async def test_node_cancellation_on_other_node_exception() -> None: assert inner_task_cancelled -async def test_dynamic_interrupt(snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + "checkpointer_name", + ["memory", "sqlite_aio", "postgres_aio", "postgres_aio_pipe"], +) +async def test_dynamic_interrupt( + checkpointer_name: str, snapshot: SnapshotAssertion, request: pytest.FixtureRequest +) -> None: + checkpointer = request.getfixturevalue(f"checkpointer_{checkpointer_name}") + class State(TypedDict): my_key: Annotated[str, operator.add] market: str @@ -250,51 +258,49 @@ async def test_dynamic_interrupt(snapshot: SnapshotAssertion) -> None: "market": "US", } - async with AsyncSqliteSaver.from_conn_string(":memory:") as saver: - tool_two = tool_two_graph.compile(checkpointer=saver) + tool_two = tool_two_graph.compile(checkpointer=checkpointer) - # missing thread_id - with pytest.raises(ValueError, match="thread_id"): - await tool_two.ainvoke({"my_key": "value", "market": "DE"}) + # missing thread_id + with pytest.raises(ValueError, match="thread_id"): + await tool_two.ainvoke({"my_key": "value", "market": "DE"}) - thread1 = {"configurable": {"thread_id": "1"}} - # stop when about to enter node - assert await tool_two.ainvoke( - {"my_key": "value ⛰️", "market": "DE"}, thread1 - ) == { - "my_key": "value ⛰️", - "market": "DE", - } - assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [ - { - "source": "loop", - "step": 0, - "writes": None, - }, - { - "source": "input", - "step": -1, - "writes": {"my_key": "value ⛰️", "market": "DE"}, - }, - ] - tup = await tool_two.checkpointer.aget_tuple(thread1) - assert await tool_two.aget_state(thread1) == StateSnapshot( - values={"my_key": "value ⛰️", "market": "DE"}, - next=("tool_two",), - tasks=( - PregelTask( - AnyStr(), - "tool_two", - interrupts=(Interrupt("during", "Just because..."),), - ), + thread1 = {"configurable": {"thread_id": "1"}} + # stop when about to enter node + assert await tool_two.ainvoke({"my_key": "value ⛰️", "market": "DE"}, thread1) == { + "my_key": "value ⛰️", + "market": "DE", + } + assert [c.metadata async for c in tool_two.checkpointer.alist(thread1)] == [ + { + "source": "loop", + "step": 0, + "writes": None, + }, + { + "source": "input", + "step": -1, + "writes": {"my_key": "value ⛰️", "market": "DE"}, + }, + ] + tup = await tool_two.checkpointer.aget_tuple(thread1) + assert await tool_two.aget_state(thread1) == StateSnapshot( + values={"my_key": "value ⛰️", "market": "DE"}, + next=("tool_two",), + tasks=( + PregelTask( + AnyStr(), + "tool_two", + interrupts=(Interrupt("during", "Just because..."),), ), - config=tup.config, - created_at=tup.checkpoint["ts"], - metadata={"source": "loop", "step": 0, "writes": None}, - parent_config=[ - c async for c in tool_two.checkpointer.alist(thread1, limit=2) - ][-1].config, - ) + ), + config=tup.config, + created_at=tup.checkpoint["ts"], + metadata={"source": "loop", "step": 0, "writes": None}, + parent_config=[c async for c in tool_two.checkpointer.alist(thread1, limit=2)][ + -1 + ].config, + ) + # TODO use aget_state_history @pytest.mark.parametrize( @@ -1283,6 +1289,8 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "2687f72c-e3a8-5f6f-9afa-047cbf24e923", "name": "one", "result": [("inbox", 3)], + "error": None, + "interrupts": [], }, }, { @@ -1293,6 +1301,8 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "18f52f6a-828d-58a1-a501-53cc0c7af33e", "name": "two", "result": [("output", 13)], + "error": None, + "interrupts": [], }, }, { @@ -1314,6 +1324,8 @@ async def test_invoke_two_processes_in_dict_out(mocker: MockerFixture) -> None: "id": "871d6e74-7bb3-565f-a4fe-cef4b8f19b62", "name": "two", "result": [("output", 4)], + "error": None, + "interrupts": [], }, }, ] @@ -1608,6 +1620,10 @@ async def test_pending_writes_resume( error_write = next(w for w in checkpoint.pending_writes if w[1] == ERROR) assert error_write[0] != non_error_writes[0][0] + # TODO arguably this shouldn't even run the failed task again, + # and should require empty update_state (ie new checkpoint_id) + # in order to try again + # resume execution with pytest.raises(ValueError, match="I'm not good"): await graph.ainvoke(None, thread1) @@ -1626,6 +1642,147 @@ async def test_pending_writes_resume( # both the pending write and the new write were applied, 1 + 2 + 3 = 6 assert await graph.ainvoke(None, thread1) == {"value": 6} + # check all final checkpoints + checkpoints = [c async for c in checkpointer.alist(thread1)] + # we should have 3 + assert len(checkpoints) == 3 + # the last one not too interesting for this test + assert checkpoints[0] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "one": { + "start:one": AnyVersion(), + }, + "two": { + "start:two": AnyVersion(), + }, + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + "__interrupt__": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + }, + "channel_versions": { + "one": AnyVersion(), + "two": AnyVersion(), + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": {"one": "one", "two": "two", "value": 6}, + }, + metadata={ + "step": 1, + "source": "loop", + "writes": {"one": {"value": 2}, "two": {"value": 3}}, + }, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[1].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=[], + ) + # the previous one we assert that pending writes contains both + # - original error + # - successful writes from resuming after preventing error + assert checkpoints[1] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": { + "__input__": {}, + "__start__": { + "__start__": AnyVersion(), + }, + }, + "channel_versions": { + "value": AnyVersion(), + "__start__": AnyVersion(), + "start:one": AnyVersion(), + "start:two": AnyVersion(), + }, + "channel_values": { + "value": 1, + "start:one": "__start__", + "start:two": "__start__", + }, + }, + metadata={"step": 0, "source": "loop", "writes": None}, + parent_config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": checkpoints[2].config["configurable"]["checkpoint_id"], + } + }, + pending_writes=UnsortedSequence( + (AnyStr(), "one", "one"), + (AnyStr(), "value", 2), + (AnyStr(), "__error__", ExceptionLike(ValueError("I'm not good"))), + (AnyStr(), "two", "two"), + (AnyStr(), "value", 3), + ), + ) + assert checkpoints[2] == CheckpointTuple( + config={ + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + } + }, + checkpoint={ + "v": 1, + "id": AnyStr(), + "ts": AnyStr(), + "current_tasks": {}, + "pending_sends": [], + "versions_seen": {"__input__": {}}, + "channel_versions": { + "__start__": AnyVersion(), + }, + "channel_values": {"__start__": {"value": 1}}, + }, + metadata={"step": -1, "source": "input", "writes": {"value": 1}}, + parent_config=None, + pending_writes=UnsortedSequence( + (AnyStr(), "value", 1), + (AnyStr(), "start:one", "__start__"), + (AnyStr(), "start:two", "__start__"), + ), + ) + async def test_cond_edge_after_send() -> None: class Node: @@ -1983,7 +2140,6 @@ async def test_channel_enter_exit_timing(mocker: MockerFixture) -> None: assert setup_sync.call_count == 0, "Sync context manager should not be used" assert cleanup_sync.call_count == 0, "Sync context manager should not be used" assert setup_async.call_count == 1, "Expected setup to be called once" - assert cleanup_async.call_count == 0, "Expected cleanup to not be called yet" if i == 0: assert chunk == {"inbox": [3]} elif i == 1: @@ -2516,13 +2672,7 @@ async def test_conditional_graph() -> None: ), }, }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2572,13 +2722,7 @@ async def test_conditional_graph() -> None: "input": "what is weather in sf", }, }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -2750,13 +2894,7 @@ async def test_conditional_graph() -> None: ), }, }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3324,13 +3462,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, - tasks=( - PregelTask( - AnyStr(), - "tools", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -3374,7 +3506,7 @@ async def test_conditional_graph_state(mocker: MockerFixture) -> None: ), "intermediate_steps": [], }, - tasks=(PregelTask(AnyStr(), "tools", interrupts=(Interrupt("before"),)),), + tasks=(PregelTask(AnyStr(), "tools"),), next=("tools",), config=(await app_w_interrupt.checkpointer.aget_tuple(config)).config, created_at=(await app_w_interrupt.checkpointer.aget_tuple(config)).checkpoint[ @@ -4749,6 +4881,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "592f3430-c17c-5d1c-831f-fecebb2c05bf", "name": "rewrite_query", "result": [("query", "query: what is weather in sf")], + "error": None, + "interrupts": [], }, }, ), @@ -4803,6 +4937,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "96965ed0-2c10-52a1-86eb-081ba6de73b2", "name": "retriever_two", "result": [("docs", ["doc3", "doc4"])], + "error": None, + "interrupts": [], }, }, ), @@ -4820,6 +4956,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "7db5e9d8-e132-5079-ab99-ced15e67d48b", "name": "retriever_one", "result": [("docs", ["doc1", "doc2"])], + "error": None, + "interrupts": [], }, }, ), @@ -4859,6 +4997,8 @@ async def test_in_one_fan_out_out_one_graph_state() -> None: "id": "8959fb57-d0f5-5725-9ac4-ec1c554fb0a0", "name": "qa", "result": [("answer", "doc1,doc2,doc3,doc4")], + "error": None, + "interrupts": [], }, }, ), @@ -4949,13 +5089,7 @@ async def test_start_branch_then() -> None: ] assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -4997,13 +5131,7 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ @@ -5045,13 +5173,7 @@ async def test_start_branch_then() -> None: } assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "value", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -5066,13 +5188,7 @@ async def test_start_branch_then() -> None: await tool_two.aupdate_state(thread3, {"my_key": "key"}) # appends to my_key assert await tool_two.aget_state(thread3) == StateSnapshot( values={"my_key": "valuekey", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread3)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread3)).checkpoint[ @@ -5223,6 +5339,8 @@ async def test_branch_then() -> None: "id": "7b7b0713-e958-5d07-803c-c9910a7cc162", "name": "prepare", "result": [("my_key", " prepared")], + "error": None, + "interrupts": [], }, }, { @@ -5275,6 +5393,8 @@ async def test_branch_then() -> None: "id": "dd9f2fa5-ccfa-5d12-81ec-942563056a08", "name": "tool_two_slow", "result": [("my_key", " slow")], + "error": None, + "interrupts": [], }, }, { @@ -5325,6 +5445,8 @@ async def test_branch_then() -> None: "id": "9b590c54-15ef-54b1-83a7-140d27b0bc52", "name": "finish", "result": [("my_key", " finished")], + "error": None, + "interrupts": [], }, }, { @@ -5368,19 +5490,125 @@ async def test_branch_then() -> None: thread1 = {"configurable": {"thread_id": "1"}} # stop when about to enter node - assert await tool_two.ainvoke({"my_key": "value", "market": "DE"}, thread1) == { - "my_key": "value prepared", - "market": "DE", - } + assert [ + c + async for c in tool_two.astream( + {"my_key": "value", "market": "DE"}, thread1, stream_mode="debug" + ) + ] == [ + { + "type": "checkpoint", + "timestamp": AnyStr(), + "step": -1, + "payload": { + "config": { + "tags": [], + "metadata": {"thread_id": "1"}, + "callbacks": None, + "recursion_limit": 25, + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + }, + }, + "values": {"my_key": ""}, + "metadata": { + "source": "input", + "step": -1, + "writes": {"my_key": "value", "market": "DE"}, + }, + "next": ["__start__"], + "tasks": [{"id": AnyStr(), "name": "__start__", "interrupts": ()}], + }, + }, + { + "type": "checkpoint", + "timestamp": AnyStr(), + "step": 0, + "payload": { + "config": { + "tags": [], + "metadata": {"thread_id": "1"}, + "callbacks": None, + "recursion_limit": 25, + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + }, + }, + "values": { + "my_key": "value", + "market": "DE", + }, + "metadata": { + "source": "loop", + "step": 0, + "writes": None, + }, + "next": ["prepare"], + "tasks": [{"id": AnyStr(), "name": "prepare", "interrupts": ()}], + }, + }, + { + "type": "task", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": "ca572c3b-b805-5fc6-a19e-3d79f52dde70", + "name": "prepare", + "input": {"my_key": "value", "market": "DE"}, + "triggers": ["start:prepare"], + }, + }, + { + "type": "task_result", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "id": "ca572c3b-b805-5fc6-a19e-3d79f52dde70", + "name": "prepare", + "result": [("my_key", " prepared")], + "error": None, + "interrupts": [], + }, + }, + { + "type": "checkpoint", + "timestamp": AnyStr(), + "step": 1, + "payload": { + "config": { + "tags": [], + "metadata": {"thread_id": "1"}, + "callbacks": None, + "recursion_limit": 25, + "configurable": { + "thread_id": "1", + "checkpoint_ns": "", + "checkpoint_id": AnyStr(), + }, + }, + "values": { + "my_key": "value prepared", + "market": "DE", + }, + "metadata": { + "source": "loop", + "step": 1, + "writes": {"prepare": {"my_key": " prepared"}}, + }, + "next": ["tool_two_slow"], + "tasks": [ + {"id": AnyStr(), "name": "tool_two_slow", "interrupts": ()} + ], + }, + }, + ] assert await tool_two.aget_state(thread1) == StateSnapshot( values={"my_key": "value prepared", "market": "DE"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_slow", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_slow"),), next=("tool_two_slow",), config=(await tool_two.checkpointer.aget_tuple(thread1)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread1)).checkpoint[ @@ -5426,13 +5654,7 @@ async def test_branch_then() -> None: } assert await tool_two.aget_state(thread2) == StateSnapshot( values={"my_key": "value prepared", "market": "US"}, - tasks=( - PregelTask( - AnyStr(), - "tool_two_fast", - interrupts=(Interrupt("before"),), - ), - ), + tasks=(PregelTask(AnyStr(), "tool_two_fast"),), next=("tool_two_fast",), config=(await tool_two.checkpointer.aget_tuple(thread2)).config, created_at=(await tool_two.checkpointer.aget_tuple(thread2)).checkpoint[ diff --git a/libs/langgraph/tests/test_state.py b/libs/langgraph/tests/test_state.py index ecf35eb2a..4088e38c9 100644 --- a/libs/langgraph/tests/test_state.py +++ b/libs/langgraph/tests/test_state.py @@ -2,10 +2,11 @@ from typing import Annotated as Annotated2 from typing import Any import pytest +from langchain_core.runnables import RunnableConfig from pydantic.v1 import BaseModel from typing_extensions import Annotated, TypedDict -from langgraph.graph.state import _warn_invalid_state_schema +from langgraph.graph.state import StateGraph, _warn_invalid_state_schema class State(BaseModel): @@ -46,3 +47,42 @@ def test_doesnt_warn_valid_schema(schema: Any): # Assert the function does not raise a warning with pytest.warns(None): _warn_invalid_state_schema(schema) + + +def test_state_schema_with_type_hint(): + class InputState(TypedDict): + question: str + + class OutputState(TypedDict): + input_state: InputState + + def complete_hint(state: InputState) -> OutputState: + return {"input_state": state} + + def miss_first_hint(state, config: RunnableConfig) -> OutputState: + return {"input_state": state} + + def only_return_hint(state, config) -> OutputState: + return {"input_state": state} + + def miss_all_hint(state, config): + return {"input_state": state} + + graph = StateGraph(input=InputState, output=OutputState) + actions = [complete_hint, miss_first_hint, only_return_hint, miss_all_hint] + + for action in actions: + graph.add_node(action) + + graph.set_entry_point(actions[0].__name__) + for i in range(len(actions) - 1): + graph.add_edge(actions[i].__name__, actions[i + 1].__name__) + graph.set_finish_point(actions[-1].__name__) + + graph = graph.compile() + + input_state = InputState(question="Hello World!") + output_state = OutputState(input_state=input_state) + for i, c in enumerate(graph.stream(input_state, stream_mode="updates")): + node_name = actions[i].__name__ + assert c[node_name] == output_state