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 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 22:34:04 -04:00
parent bb4619aefe
commit f1ab1accaf
9 changed files with 202 additions and 83 deletions
+4
View File
@@ -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
+20 -9
View File
@@ -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()
+21 -36
View File
@@ -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():
+44 -3
View File
@@ -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
+10 -14
View File
@@ -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(
+13 -21
View File
@@ -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", [])
+2
View File
@@ -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:
@@ -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 = (
"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"
"XV6+/x9WSMI2"
)
+2
View File
@@ -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",