From bc3f98a291048ef96a23867d83538e10bcc70b68 Mon Sep 17 00:00:00 2001 From: Fahad Date: Sat, 14 Jun 2025 13:27:19 +0400 Subject: [PATCH] Make conversation timeout configuration (so that you're able to resume a discussion manually with another model with a gap of several hours in case you stepped away) --- .env.example | 6 ++++++ README.md | 2 +- docker-compose.yml | 1 + tests/test_conversation_memory.py | 3 ++- utils/conversation_memory.py | 26 +++++++++++++++++++++----- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 5556c12..fb94221 100644 --- a/.env.example +++ b/.env.example @@ -86,6 +86,12 @@ DEFAULT_THINKING_MODE_THINKDEEP=high # The Redis URL for conversation threading - typically managed by docker-compose # REDIS_URL=redis://redis:6379/0 +# Optional: Conversation timeout (hours) +# How long AI-to-AI conversation threads persist before expiring +# Longer timeouts use more Redis memory but allow resuming conversations later +# Defaults to 3 hours if not specified +CONVERSATION_TIMEOUT_HOURS=3 + # Optional: Logging level (DEBUG, INFO, WARNING, ERROR) # DEBUG: Shows detailed operational messages for troubleshooting (default) # INFO: Shows general operational messages diff --git a/README.md b/README.md index 85f645e..8f3fb56 100644 --- a/README.md +++ b/README.md @@ -470,7 +470,7 @@ This server enables **true AI collaboration** between Claude and multiple AI mod - **Asynchronous workflow**: Conversations don't need to be sequential - Claude can work on tasks between exchanges, then return to Gemini with additional context and progress updates - **Incremental updates**: Share only new information in each exchange while maintaining full conversation history - **Automatic 25K limit bypass**: Each exchange sends only incremental context, allowing unlimited total conversation size -- Up to 5 exchanges per conversation with 1-hour expiry +- Up to 5 exchanges per conversation with 3-hour expiry (configurable) - Thread-safe with Redis persistence across all tools **Cross-tool & Cross-Model Continuation Example:** diff --git a/docker-compose.yml b/docker-compose.yml index 8713b63..d33242d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,6 +40,7 @@ services: - CUSTOM_MODEL_NAME=${CUSTOM_MODEL_NAME:-llama3.2} - DEFAULT_MODEL=${DEFAULT_MODEL:-auto} - DEFAULT_THINKING_MODE_THINKDEEP=${DEFAULT_THINKING_MODE_THINKDEEP:-high} + - CONVERSATION_TIMEOUT_HOURS=${CONVERSATION_TIMEOUT_HOURS:-3} # Model usage restrictions - OPENAI_ALLOWED_MODELS=${OPENAI_ALLOWED_MODELS:-} - GOOGLE_ALLOWED_MODELS=${GOOGLE_ALLOWED_MODELS:-} diff --git a/tests/test_conversation_memory.py b/tests/test_conversation_memory.py index d2a2e83..a7a446f 100644 --- a/tests/test_conversation_memory.py +++ b/tests/test_conversation_memory.py @@ -12,6 +12,7 @@ import pytest from server import get_follow_up_instructions from utils.conversation_memory import ( + CONVERSATION_TIMEOUT_SECONDS, MAX_CONVERSATION_TURNS, ConversationTurn, ThreadContext, @@ -40,7 +41,7 @@ class TestConversationMemory: mock_client.setex.assert_called_once() call_args = mock_client.setex.call_args assert call_args[0][0] == f"thread:{thread_id}" # key - assert call_args[0][1] == 3600 # TTL + assert call_args[0][1] == CONVERSATION_TIMEOUT_SECONDS # TTL from configuration @patch("utils.conversation_memory.get_redis_client") def test_get_thread_valid(self, mock_redis): diff --git a/utils/conversation_memory.py b/utils/conversation_memory.py index 0ce6ee5..cbb95ea 100644 --- a/utils/conversation_memory.py +++ b/utils/conversation_memory.py @@ -59,6 +59,22 @@ logger = logging.getLogger(__name__) # Configuration constants MAX_CONVERSATION_TURNS = 10 # Maximum turns allowed per conversation thread +# Get conversation timeout from environment (in hours), default to 3 hours +try: + CONVERSATION_TIMEOUT_HOURS = int(os.getenv("CONVERSATION_TIMEOUT_HOURS", "3")) + if CONVERSATION_TIMEOUT_HOURS <= 0: + logger.warning( + f"Invalid CONVERSATION_TIMEOUT_HOURS value ({CONVERSATION_TIMEOUT_HOURS}), using default of 3 hours" + ) + CONVERSATION_TIMEOUT_HOURS = 3 +except ValueError: + logger.warning( + f"Invalid CONVERSATION_TIMEOUT_HOURS value ('{os.getenv('CONVERSATION_TIMEOUT_HOURS')}'), using default of 3 hours" + ) + CONVERSATION_TIMEOUT_HOURS = 3 + +CONVERSATION_TIMEOUT_SECONDS = CONVERSATION_TIMEOUT_HOURS * 3600 + class ConversationTurn(BaseModel): """ @@ -154,7 +170,7 @@ def create_thread(tool_name: str, initial_request: dict[str, Any], parent_thread str: UUID thread identifier that can be used for continuation Note: - - Thread expires after 1 hour (3600 seconds) + - Thread expires after the configured timeout (default: 3 hours) - Non-serializable parameters are filtered out automatically - Thread can be continued by any tool using the returned UUID - Parent thread creates a chain for conversation history traversal @@ -179,10 +195,10 @@ def create_thread(tool_name: str, initial_request: dict[str, Any], parent_thread initial_context=filtered_context, ) - # Store in Redis with 1 hour TTL to prevent indefinite accumulation + # Store in Redis with configurable TTL to prevent indefinite accumulation client = get_redis_client() key = f"thread:{thread_id}" - client.setex(key, 3600, context.model_dump_json()) + client.setex(key, CONVERSATION_TIMEOUT_SECONDS, context.model_dump_json()) logger.debug(f"[THREAD] Created new thread {thread_id} with parent {parent_thread_id}") @@ -261,7 +277,7 @@ def add_turn( - Redis connection failure Note: - - Refreshes thread TTL to 1 hour on successful update + - Refreshes thread TTL to configured timeout on successful update - Turn limits prevent runaway conversations - File references are preserved for cross-tool access - Model information enables cross-provider conversations @@ -297,7 +313,7 @@ def add_turn( try: client = get_redis_client() key = f"thread:{thread_id}" - client.setex(key, 3600, context.model_dump_json()) # Refresh TTL to 1 hour + client.setex(key, CONVERSATION_TIMEOUT_SECONDS, context.model_dump_json()) # Refresh TTL to configured timeout return True except Exception as e: logger.debug(f"[FLOW] Failed to save turn to Redis: {type(e).__name__}")