From f1ab1accaf27ef516402ae613f0227e8c587a983 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Wed, 16 Sep 2026 22:34:04 -0400 Subject: [PATCH] Add LangGraph SQLite checkpointing and cron_jobs migration Replaces per-request history reconstruction with a persistent AsyncSqliteSaver checkpointer keyed by conversation ID, so agent state (tool calls, intermediate steps) carries forward between turns without manual message list building. Also includes the missing migration for the CronJob model added in bb4619a. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 4 + app.py | 29 +++++-- blueprints/conversation/__init__.py | 57 +++++------- blueprints/conversation/agents.py | 47 +++++++++- blueprints/imessage/__init__.py | 24 +++--- blueprints/whatsapp/__init__.py | 34 +++----- docker-compose.yml | 2 + .../models/12_20260916223332_add_cron_jobs.py | 86 +++++++++++++++++++ pyproject.toml | 2 + 9 files changed, 202 insertions(+), 83 deletions(-) create mode 100644 migrations/models/12_20260916223332_add_cron_jobs.py diff --git a/.env.example b/.env.example index 67b7109..006407d 100644 --- a/.env.example +++ b/.env.example @@ -149,3 +149,7 @@ OBSIDIAN_DEVICE_NAME=simbarag OBSIDIAN_CONTINUOUS_SYNC=false # Local path to Obsidian vault (where files are synced) OBSIDIAN_VAULT_PATH=/app/data/obsidian + +# LangGraph checkpoint store (SQLite). Must be on a persistent volume. +# Both blue and green containers share this volume via docker-compose. +CHECKPOINT_DB_PATH=/app/checkpoints/checkpoints.db diff --git a/app.py b/app.py index dcce030..032877c 100644 --- a/app.py +++ b/app.py @@ -28,6 +28,7 @@ import blueprints.cron import blueprints.users.models from config.db import TORTOISE_CONFIG from metrics import render_metrics +from blueprints.conversation.agents import build_agent # Load environment variables load_dotenv() @@ -95,19 +96,29 @@ async def lifespan(): await Tortoise.init(config=TORTOISE_CONFIG) logging.info("Tortoise ORM initialized successfully") - watcher_task = None - if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true": - watcher_task = asyncio.create_task(_obsidian_sync_loop()) + import aiosqlite + from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver - from blueprints.scheduled_messages.scheduler import scheduled_messages_loop + checkpoint_path = os.getenv("CHECKPOINT_DB_PATH", "/app/checkpoints/checkpoints.db") + async with aiosqlite.connect(checkpoint_path) as conn: + checkpointer = AsyncSqliteSaver(conn) + await checkpointer.setup() + build_agent(checkpointer) + logging.info(f"Agent checkpointer ready at {checkpoint_path}") - scheduler_task = asyncio.create_task(scheduled_messages_loop()) + watcher_task = None + if os.getenv("OBSIDIAN_CONTINUOUS_SYNC") == "true": + watcher_task = asyncio.create_task(_obsidian_sync_loop()) - yield + from blueprints.scheduled_messages.scheduler import scheduled_messages_loop - scheduler_task.cancel() - if watcher_task is not None: - watcher_task.cancel() + scheduler_task = asyncio.create_task(scheduled_messages_loop()) + + yield + + scheduler_task.cancel() + if watcher_task is not None: + watcher_task.cancel() logging.info("Closing Tortoise ORM connections...") await Tortoise.close_connections() diff --git a/blueprints/conversation/__init__.py b/blueprints/conversation/__init__.py index 0eb932a..eff547b 100644 --- a/blueprints/conversation/__init__.py +++ b/blueprints/conversation/__init__.py @@ -34,7 +34,6 @@ from .models import ( PydListConversation, Speaker, ) -from .prompts import SIMBA_SYSTEM_PROMPT TTFT_TIMEOUT = float(os.getenv("TTFT_TIMEOUT_SECONDS", "30")) @@ -102,35 +101,20 @@ def _persist_in_background(coro) -> asyncio.Task: return task -_SYSTEM_PROMPT = SIMBA_SYSTEM_PROMPT - - async def _get_owned_conversation(conversation_id, user_id) -> Conversation | None: """Fetch a conversation only if it belongs to the given user.""" return await Conversation.get_or_none(id=conversation_id, user_id=user_id) -def _build_messages_payload( - conversation, +def _build_user_message( query_text: str, image_block: dict | None = None, - system_prompt: str | None = None, -) -> list: - recent_messages = ( - conversation.messages[-20:] - if len(conversation.messages) > 20 - else conversation.messages - ) - messages_payload = [{"role": "system", "content": system_prompt or _SYSTEM_PROMPT}] - for msg in recent_messages[:-1]: # Exclude the message we just added - role = "user" if msg.speaker == Speaker.USER else "assistant" - text = msg.text - if msg.image_key and role == "user": - text = f"[User sent an image]\n{text}" - messages_payload.append({"role": role, "content": text}) +) -> dict: + """Build the single new user message for this turn. - # Attach the image directly to the current message so the (multimodal) - # agent model sees it in one call, instead of a separate describe pass. + History is restored by the LangGraph checkpointer; only the current + message is passed to avoid duplicating prior turns in checkpoint state. + """ if image_block: content = [ {"type": "text", "text": query_text or "The user sent this image."}, @@ -138,8 +122,7 @@ def _build_messages_payload( ] else: content = query_text - messages_payload.append({"role": "user", "content": content}) - return messages_payload + return {"role": "user", "content": content} @conversation_blueprint.post("/query") @@ -153,7 +136,6 @@ async def query(): conversation = await _get_owned_conversation(conversation_id, current_user_uuid) if conversation is None: return jsonify({"error": "conversation not found"}), 404 - await conversation.fetch_related("messages") await add_message_to_conversation( conversation=conversation, message=query, @@ -163,12 +145,14 @@ async def query(): with time_response(stream=False): system_prompt = await build_system_prompt_with_memories(str(user.id)) - messages_payload = _build_messages_payload( - conversation, query, system_prompt=system_prompt - ) - payload = {"messages": messages_payload} + payload = {"messages": [_build_user_message(query)]} agent_config = { - "configurable": {"user_id": str(user.id), "is_admin": user.is_admin()} + "configurable": { + "user_id": str(user.id), + "is_admin": user.is_admin(), + "thread_id": str(conversation.id), + "system_prompt": system_prompt, + } } response = await main_agent.ainvoke(payload, config=agent_config) @@ -236,7 +220,6 @@ async def stream_query(): conversation = await _get_owned_conversation(conversation_id, current_user_uuid) if conversation is None: return jsonify({"error": "conversation not found"}), 404 - await conversation.fetch_related("messages") await add_message_to_conversation( conversation=conversation, message=query_text or "", @@ -256,12 +239,14 @@ async def stream_query(): query_text = f"{query_text or ''}\n\n[An image was sent but could not be loaded.]".strip() system_prompt = await build_system_prompt_with_memories(str(user.id)) - messages_payload = _build_messages_payload( - conversation, query_text or "", image_block, system_prompt=system_prompt - ) - payload = {"messages": messages_payload} + payload = {"messages": [_build_user_message(query_text or "", image_block)]} agent_config = { - "configurable": {"user_id": str(user.id), "is_admin": user.is_admin()} + "configurable": { + "user_id": str(user.id), + "is_admin": user.is_admin(), + "thread_id": str(conversation.id), + "system_prompt": system_prompt, + } } async def event_generator(): diff --git a/blueprints/conversation/agents.py b/blueprints/conversation/agents.py index 8e7a2aa..1f4a562 100644 --- a/blueprints/conversation/agents.py +++ b/blueprints/conversation/agents.py @@ -6,11 +6,13 @@ from dotenv import load_dotenv from langchain.agents import create_agent from langchain.chat_models import BaseChatModel from langchain.tools import tool -from langchain_core.runnables import RunnableConfig +from langchain_core.messages import SystemMessage +from langchain_core.runnables import RunnableConfig, RunnableLambda from langchain_openai import ChatOpenAI from tavily import AsyncTavilyClient from blueprints.conversation.memory import save_memory +from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT from blueprints.rag.logic import query_vector_store from utils.google_service import GoogleService from utils.mealie_service import MealieService @@ -1228,5 +1230,44 @@ if google_calendar_enabled: if google_gmail_enabled: tools.extend([gmail_search_emails, gmail_get_email]) -# Llama 3.1 supports native function calling via OpenAI-compatible API -main_agent = create_agent(model=model_with_fallback, tools=tools) + +# Inject the caller-provided system prompt (with user memories) before every +# model call. Runs as a RunnableLambda so it receives the runtime config and +# can read the per-request "system_prompt" key without it being stored in the +# checkpoint state. +@RunnableLambda +def _inject_system_prompt(state: dict, config: RunnableConfig = None) -> list: + from langchain_core.messages import trim_messages + + prompt = ( + (config or {}).get("configurable", {}).get("system_prompt", SIMBA_SYSTEM_PROMPT) + ) + messages = [m for m in state["messages"] if not isinstance(m, SystemMessage)] + # Trim to stay within the 32k context slot (keep most recent messages). + trimmed = trim_messages( + messages, + max_tokens=24_000, + strategy="last", + token_counter=model_with_fallback, + include_system=False, + allow_partial=False, + ) + return [SystemMessage(content=prompt)] + trimmed + + +# Llama 3.1 supports native function calling via OpenAI-compatible API. +# main_agent is compiled lazily by build_agent() once the checkpointer is +# ready (the async SQLite connection must be created inside the event loop). +main_agent = None + + +def build_agent(checkpointer=None): + """Compile the LangGraph agent, optionally with a checkpointer.""" + global main_agent + main_agent = create_agent( + model=model_with_fallback, + tools=tools, + state_modifier=_inject_system_prompt, + checkpointer=checkpointer, + ) + return main_agent diff --git a/blueprints/imessage/__init__.py b/blueprints/imessage/__init__.py index 5c2816b..edf7e18 100644 --- a/blueprints/imessage/__init__.py +++ b/blueprints/imessage/__init__.py @@ -15,7 +15,7 @@ from blueprints.conversation.logic import ( add_message_to_conversation, persist_agent_tool_messages, ) -from blueprints.conversation.models import Conversation, ConversationMessage, Speaker +from blueprints.conversation.models import Conversation, ConversationMessage from blueprints.conversation.agents import main_agent from blueprints.conversation.memory import build_system_prompt_with_memories from utils.strip_markdown import extract_agent_text, strip_markdown @@ -277,7 +277,6 @@ async def webhook(): f"New iMessage conversation {conversation.id} for {user.username}" ) - await conversation.fetch_related("messages") except Exception as e: logger.error(f"Failed to get/create conversation for {user.username}: {e}") await send_imessage( @@ -337,20 +336,12 @@ async def _process_and_reply( response_text = "Sorry, I'm having trouble thinking right now." try: - messages = await conversation.messages.all() - recent_messages = list(messages)[-20:] - system_prompt = await build_system_prompt_with_memories(str(user.id)) system_prompt += ( "\n\nIMPORTANT: You are replying via iMessage. Use plain text only. " "Do not use markdown tables, headers, bullet points, bold, italics, " "or any other markdown formatting. Write in natural prose." ) - messages_payload = [{"role": "system", "content": system_prompt}] - - for msg in recent_messages[:-1]: - role = "user" if msg.speaker == Speaker.USER else "assistant" - messages_payload.append({"role": role, "content": msg.text}) if image_block: user_content = [ @@ -359,14 +350,19 @@ async def _process_and_reply( ] else: user_content = content - messages_payload.append({"role": "user", "content": user_content}) agent_config = { - "configurable": {"user_id": str(user.id), "is_admin": user.is_admin()} + "configurable": { + "user_id": str(user.id), + "is_admin": user.is_admin(), + "thread_id": str(conversation.id), + "system_prompt": system_prompt, + } } - logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages") + logger.info(f"Invoking LangChain agent for conversation {conversation.id}") response = await main_agent.ainvoke( - {"messages": messages_payload}, config=agent_config + {"messages": [{"role": "user", "content": user_content}]}, + config=agent_config, ) response_text = extract_agent_text(response.get("messages", [])[-1].content) await persist_agent_tool_messages( diff --git a/blueprints/whatsapp/__init__.py b/blueprints/whatsapp/__init__.py index e438eb4..8d54cc4 100644 --- a/blueprints/whatsapp/__init__.py +++ b/blueprints/whatsapp/__init__.py @@ -13,7 +13,6 @@ from blueprints.conversation.logic import ( add_message_to_conversation, persist_agent_tool_messages, ) -from blueprints.conversation.models import Speaker from blueprints.conversation.agents import main_agent from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT from utils.strip_markdown import extract_agent_text @@ -166,7 +165,6 @@ async def webhook(): # Get or create a conversation for this user try: conversation = await get_conversation_for_channel(user=user, channel="whatsapp") - await conversation.fetch_related("messages") except Exception as e: logger.error(f"Failed to get conversation for user {user.username}: {e}") return _twiml_response("Sorry, something went wrong. Please try again later.") @@ -179,26 +177,20 @@ async def webhook(): user=user, ) - # Build messages payload for LangChain agent with system prompt and conversation history try: - # Get last 20 messages for conversation history - messages = await conversation.messages.all() - recent_messages = list(messages)[-20:] - - # Build messages payload - messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}] - - # Add recent conversation history (exclude the message we just added) - for msg in recent_messages[:-1]: - role = "user" if msg.speaker == Speaker.USER else "assistant" - messages_payload.append({"role": role, "content": msg.text}) - - # Add current query - messages_payload.append({"role": "user", "content": body}) - - # Invoke LangChain agent - logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages") - response = await main_agent.ainvoke({"messages": messages_payload}) + agent_config = { + "configurable": { + "user_id": str(user.id), + "is_admin": user.is_admin(), + "thread_id": str(conversation.id), + "system_prompt": SIMBA_SYSTEM_PROMPT, + } + } + logger.info(f"Invoking LangChain agent for conversation {conversation.id}") + response = await main_agent.ainvoke( + {"messages": [{"role": "user", "content": body}]}, + config=agent_config, + ) response_text = extract_agent_text(response.get("messages", [])[-1].content) await persist_agent_tool_messages( conversation, user, response.get("messages", []) diff --git a/docker-compose.yml b/docker-compose.yml index 2dc04fc..94cdf42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,7 @@ x-raggr-common: &raggr-common volumes: - ./obvault:/app/data/obsidian - ./token.json:/app/config/google-token.json:ro + - raggr-checkpoints:/app/checkpoints healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"] interval: 5s @@ -124,3 +125,4 @@ services: volumes: postgres_data: simbakit-data: + raggr-checkpoints: diff --git a/migrations/models/12_20260916223332_add_cron_jobs.py b/migrations/models/12_20260916223332_add_cron_jobs.py new file mode 100644 index 0000000..a0452c7 --- /dev/null +++ b/migrations/models/12_20260916223332_add_cron_jobs.py @@ -0,0 +1,86 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "cron_jobs" ( + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(255) NOT NULL, + "description" TEXT, + "prompt" TEXT NOT NULL, + "recipient" VARCHAR(255) NOT NULL, + "channel" VARCHAR(20) NOT NULL, + "enabled" BOOL NOT NULL DEFAULT True, + "last_run_at" TIMESTAMPTZ, + "last_status" VARCHAR(50), + "last_error" TEXT, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +COMMENT ON COLUMN "cron_jobs"."channel" IS 'IMESSAGE: imessage\nEMAIL: email';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + DROP TABLE IF EXISTS "cron_jobs";""" + + +MODELS_STATE = ( + "eJztXW1v27YW/iuCPnVAFuSlybbgYoCdqK3X2C5iZ1tXFwItMbEWidQoqalR9L9fkpZsia" + "Jky69Syg9bapKHIh8ekec8PKS+6R62oRscX2P0BZIAhA5G+pX2TUfAg/Qf0vwjTQe+v8hl" + "CSEYu1zASpXkOWAchARYIc18AG4AaZINA4s4fvwwFLkuS8QWLeigx0VShJz/ImiG+BGGE0" + "hoxqfPNNlBNvwKg+Sn/2Q+ONC1M+12bPZsnm6GU5+n3d93bt7wkuxxY9PCbuShRWl/Gk4w" + "mhePIsc+ZjIs7xEiSEAI7VQ3WCvjbidJsxbThJBEcN5Ue5FgwwcQuQwM/X8PEbIYBhp/Ev" + "vf69/1CvBQqBm0DgoZFt++z3q16DNP1dmjrt+17l6dX/7Ee4mD8JHwTI6I/p0LghDMRDmu" + "CyD53xyU1xNA5FAm5QUwaUPXgTFJWOC40KEEyASg9VDTPfDVdCF6DCf059nFRQmMf7buOJ" + "K0FIcSU72eaX0vzjqb5TFIFxBaBLIumyDMA3lDc0LHg3Iws5ICpHYsepz8o6YA0z7YfeRO" + "45egBN9hp2sMhq3uB9YTLwj+czlEraHBcs546lRIfXUpDMW8Eu2vzvCdxn5q//R7hqj783" + "LDf3TWJhCF2ET42QR26n1NUhNgMgMb+faaA5uVVAN70IGNG596YScAIehWmfZSImvNfHGr" + "Kg2f/gzHG6wXwsx3ssrEd1I8752I014UQGJWW4dTIhssxutgudmrsP7SywyYhyfpysvAyI" + "P3BhPoPKL3cMoh7NB2AGTJltzYdLuPq6kraIvURSsIeJ7bdGmloL2jfYLh7GVsDa5bN4bO" + "MRwD6+kZENssANODQQAeYZAHtB1Lvnl/B925gSvHMm0Gd2c11nNuLsKWY4XPcAqjDHr5LO" + "/ME1MAov2242ezJ5Wgs8SXSIG4mkthpgdSuRaNdi1C+FViOA1pqhzKpHxTXIsyg8j4e5ix" + "hZKF9FW39fdPGXvott97mxRPLbzXt/228jNeojma9zMCH4InmTHA7FEDRV7OFMiMbkr8wK" + "+Ofj8w7q40tqKP0KDTbbeutMDxxmCEhv3+LUUDY25DV7VeT1exXk+LrdfTnPXqeHSVMZ/g" + "tIoXkBHamx+wTQLk4vRsBSRpqUIoeV4Wy3jNNqmPZLuV2KS8ZCNR3Q2tlDaLqhklEtFtWi" + "gNdbgsgeTe0PESOfPaorjUA5OoS1VPbJd+B/Nwu9DDZKpL3I1UbqmXwd1MjxV0lHfxArwL" + "+pAQokoORkpE+RjKx/ihfAy1l/EiBja3l3EwFr5ek2FTaPgaW4Eb8PC7tv6K7L7lFp+y9J" + "pv6bFxrBqmkpZpiq23B07BB0HwjIlEM9sOAmQqBzMtJYA5noYzb6p2DE0JeG1m96ZXdwpZ" + "u9Nr3X2Um85J+bTetj8OjZaALvSAUymqYC6wHRVd/sZvU0FPT1YjZctY2Rwt+zwBYUCndB" + "NF3riIEpeDKRHdCpm4X1TPVwH1vBjTcwnTHZOs1SGViCpIk/fWhIh1TzaXYuxCgEre+bSs" + "OKPGWyS7WJ+qWj8VptR+/zYzpbY7Ittw320bdD7gMNNCzsy67PSGUnAnHrCo2fUEJVTtkg" + "lVkG2gxp5erjKzik5namK9FDUWO7ZlBtH4X2hJCIBiQEW5BoK5E0OKuuQT0yf4i2NXm1Jz" + "gvuzTnUXW8DdwLwXthJXmVYviqfVi9y06trANx8JjnxJHNcfg35PDqkgJgB6j2gvP9mOFR" + "5prhOEn3cG76fPu3GdWM/LiV6R0xVsVVaBInpfJB+oiN4XOrDzaM4c0bYsFDZ3Qms78bB1" + "dvLzcbDZ8JTFpu/6WGT3meun+StBMbOPgWXhiIltBIjB6mrNqmowJIE1gXZEfTFzOzHkg6" + "Q+FUAuUvlcY3QJl28kPFgxmc81dzU2X+fVafFw0r8hYNyyBpCtxSEAx7qA0ioyIzRCgxDT" + "GV3zAQmgrfFGabzgsxNOtPOTn20w1Qhk5WljjrVbBz0FWoi16wnBHrhpj9ADJtoX6k7RPw" + "EExJpoFvDB2KEeMZ2keMvUrsOWTOcNdh0S6ksG6PKARimwjaB1d+Iur8E8bEo6HHwD52Il" + "fvyihB+/yPPjD3QWYTYj1WPJOlWMpijXTEh3opvU5C4EtOSwCm4inPsOJ2POYlU3NJFppg" + "PaEIcz6XYplTDG9tSseoorI9SQaP59vxYco0noSTaLlwCbCClg5eGr3MqmT6RNq2i5SUQb" + "ArK64kSRiuuyxfCr79Da1hjYrGQzB7YhA7nSYh2TehVjgbNSKhw4ASSPYeWI4Obyo0dCZH" + "BWSeoUHJzBuIhYTI3BEn4xw4yvyjPGMowmfHAeI8L5YY2xfJ1u6wNLRpDTXUER61ihhhQH" + "OZci0GZ0I23jjIWEyCJTnyqalgRuHqXq0AIYhvTxwdEIMXYzqYaqcxgFx9og8n1MwkDzqI" + "o5vguTEoHmQ8LPdiuOsiYcZay01emgnKBiKhPGwgl8F0zNqgHnopzyHVI3LfjmhCp2FTgz" + "Qk1h1/YFJpuf82B2UAFxkJERsHR2Z5v89tv5BlDyu6V+Pjt9/cvrX88vX/9Ki/CmzFN+KQ" + "E3H1TKMVjnIElOUCmjoIyFR0qMxA4p5rRyNTQF3X0TWw51tKk98kWiu6XR5hm5PUaazxf+" + "GgeauyAITUgIlgTwFqtsVqohy7y6RkDxhSq6VA2sukZAXSNQ32sEBGJjG/GpzcIyGzM1RZ" + "Y5Y+W2AMSA1jaYV9YgSHZOH6eQKWKQs+AtI5GFgVvOIw9p9lOgMTnNJ5hNOgGPPGWVQE67" + "wjRRnOeRK9fAeOQug5n+F2jMpp0Js7WXynj+0SyRVmbRuqA9D42lM2HMHD/QCiMCNda7J9" + "oQFuYazNhjzaG+H/Qgrd+dVcwEuNWs8bseaXHFIteEReYeDVfadcK18tJbsO3q5SHVyJRb" + "aSuYD0kSiRzJ3pNCrlAmuj/K8GQD03zLhGGCQcFucCGAObkfEj1WPbQiRjyZ8TIhMWIKQS" + "wS/yGx5K9kjML6M7RYgZqkDzxJKyblZTIpKg7rhcVh7TqooflRWLkjrxI/WnYsttiRlh/K" + "Vfc4NtrPJNByfEd6Z3fx/ntGqCm7w/uIyC/7huHyb8Zs+j3D7YGqsxV30HprXGnJdXMjZH" + "RbndsrbX4/4uG/dqium9/FPvEBzgW/yBi7xXpZ3Z0QZZvpUDTEgVjJNSza9Fnxc2Bz6T1e" + "LRdbdbNNkuMPRu+m03ubt0D0OOdK8yGy6XiO0MDoDa+0gE6UI/SGzvrGzRXfWYD2CF23et" + "fGLU+yWF/d+MrMwy8H1CyJCIEx/usMU7aGPQ7V3fzBx71YgYVBYskUWIzoSnxDh+TjlWbT" + "EZmO0F+G8Z79fIbwif3u9nvDdyzBo2vdxJ3WY3CiAJpUGWWrdWmwXEZOXcsqXMvKNu8Sd6" + "yKGZQTbMiyrWLmFCGoYubUwBYyvclrN55W/TyjKKj43hQmW6B8mx9Fl1OROvG91wSjP/BY" + "l9C8SdZR6QffaSHzXzxWpG7zSd2q56nUMar8kdNUyyrY1YKYsqqlVrVPsOdXom0XEk3R0X" + "1jqjZy1EZOYzdy1vxqz0G+19OYU5QkQmu4t4KoCkmrQ9xw2eZDWQT4ZrsOh96V28HHbNTp" + "YsWUKkJNMaU/3sBW/XbNLrmqFiSONdElVFWcc1TGVIFFmdrQVMX3/Kx4WicesEPGvG7njE" + "QxK8W+WiSlU4qtmJSI8k4XYdj01agAYly8mQDu5IPGhXGDxd8zLI4b3Nu3DHdmD27tW4YH" + "XV6+/x9WSMI2" +) diff --git a/pyproject.toml b/pyproject.toml index dbd467c..3a0557f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ dependencies = [ "langchain-openai>=1.1.6", "langchain>=1.2.0", "langchain-community>=0.4.1", + "langgraph-checkpoint-sqlite>=2.0.0", + "aiosqlite>=0.20.0", "jq>=1.10.0", "tavily-python>=0.7.17", "ynab>=1.3.0",