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 = ( + "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" + "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",