diff --git a/.env.example b/.env.example index 381729b..fc516a7 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,28 @@ -# Example .env file for Gemini MCP Server -# Copy this to .env and update with your actual values +# Gemini MCP Server Environment Configuration +# Copy this file to .env and fill in your values -# Your Gemini API key (required) -# Get one from: https://makersuite.google.com/app/apikey -GEMINI_API_KEY=your-gemini-api-key-here +# Required: Google Gemini API Key +# Get your API key from: https://makersuite.google.com/app/apikey +GEMINI_API_KEY=your_gemini_api_key_here -# Docker-specific environment variables (optional) -# These are set automatically by the Docker setup scripts -# You typically don't need to set these manually +# Optional: Redis connection URL for conversation memory +# Defaults to redis://localhost:6379/0 +# For Docker: redis://redis:6379/0 +REDIS_URL=redis://localhost:6379/0 -# WORKSPACE_ROOT: Used for Docker path translation -# Automatically set when using Docker wrapper scripts -# Example: /Users/username/my-project (macOS/Linux) -# Example: C:\Users\username\my-project (Windows) -# WORKSPACE_ROOT=/path/to/your/project +# Optional: Workspace root directory for file access +# This should be the HOST path that contains all files Claude might reference +# Defaults to $HOME for direct usage, auto-configured for Docker +WORKSPACE_ROOT=/Users/your-username -# MCP_PROJECT_ROOT: Restricts file access to a specific directory -# If not set, defaults to user's home directory -# Set this to limit file access to a specific project folder -# MCP_PROJECT_ROOT=/path/to/allowed/directory \ No newline at end of file +# Optional: Logging level (DEBUG, INFO, WARNING, ERROR) +# DEBUG: Shows detailed operational messages for troubleshooting +# INFO: Shows general operational messages (default) +# WARNING: Shows only warnings and errors +# ERROR: Shows only errors +LOG_LEVEL=INFO + +# Optional: Project root override for file sandboxing +# If set, overrides the default sandbox directory +# Use with caution - this controls which files the server can access +# MCP_PROJECT_ROOT=/path/to/specific/project \ No newline at end of file diff --git a/README.md b/README.md index a556ad1..a9b4f93 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ This server enables **true AI collaboration** between Claude and Gemini, where t - **Claude can respond** with additional information, files, or refined instructions - **Claude can work independently** between exchanges - implementing solutions, gathering data, or performing analysis - **Claude can return to Gemini** with progress updates and new context for further collaboration +- **Cross-tool continuation** - Start with one tool (e.g., `analyze`) and continue with another (e.g., `codereview`) using the same conversation thread - **Both AIs coordinate their approaches** - questioning assumptions, validating solutions, and building on each other's insights - Each conversation maintains full context while only sending incremental updates - Conversations are automatically managed with Redis for persistence @@ -208,12 +209,27 @@ This server enables **true AI collaboration** between Claude and Gemini, where t - **Coordinated problem-solving**: Each AI contributes their strengths to complex problems - **Context building**: Claude gathers information while Gemini provides deep analysis - **Approach validation**: AIs can verify and improve each other's solutions +- **Cross-tool continuation**: Seamlessly continue conversations across different tools while preserving all context - **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 - Thread-safe with Redis persistence across all tools +**Cross-tool continuation example:** +``` +1. Claude: "Use gemini to analyze /src/auth.py for security issues" + → Gemini analyzes and finds vulnerabilities, provides continuation_id + +2. Claude: "Use gemini to review the authentication logic thoroughly" + → Uses same continuation_id, Gemini sees previous analysis and files + → Provides detailed code review building on previous findings + +3. Claude: "Use gemini to help debug the auth test failures" + → Same continuation_id, full context from analysis + review + → Gemini provides targeted debugging with complete understanding +``` + ## Available Tools **Quick Tool Selection Guide:** @@ -837,6 +853,28 @@ Different tools use optimized temperature settings: - **`TEMPERATURE_BALANCED`**: `0.5` - Used for general chat (balanced creativity/accuracy) - **`TEMPERATURE_CREATIVE`**: `0.7` - Used for deep thinking and architecture (more creative) +### Logging Configuration +Control logging verbosity via the `LOG_LEVEL` environment variable: +- **`DEBUG`**: Shows detailed operational messages, tool execution flow, conversation threading +- **`INFO`**: Shows general operational messages (default) +- **`WARNING`**: Shows only warnings and errors +- **`ERROR`**: Shows only errors + +**Set in your .env file:** +```bash +LOG_LEVEL=DEBUG # For troubleshooting +LOG_LEVEL=INFO # For normal operation (default) +``` + +**For Docker:** +```bash +# In .env file +LOG_LEVEL=DEBUG + +# Or set directly when starting +LOG_LEVEL=DEBUG docker compose up +``` + ## File Path Requirements diff --git a/docker-compose.yml b/docker-compose.yml index 38c80af..a24ee78 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,11 +34,13 @@ services: # Use HOME not PWD: Claude needs access to any absolute file path, not just current project, # and Claude Code could be running from multiple locations at the same time - WORKSPACE_ROOT=${WORKSPACE_ROOT:-${HOME}} + - LOG_LEVEL=${LOG_LEVEL:-INFO} volumes: - ${HOME:-/tmp}:/workspace:ro stdin_open: true tty: true - command: ["sh", "-c", "while true; do sleep 86400; done"] + entrypoint: ["python"] + command: ["log_monitor.py"] volumes: redis_data: \ No newline at end of file diff --git a/server.py b/server.py index bacea30..eebdc18 100644 --- a/server.py +++ b/server.py @@ -47,8 +47,22 @@ from tools import ( ) # Configure logging for server operations -# Set to DEBUG level to capture detailed operational messages for troubleshooting -logging.basicConfig(level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +# Can be controlled via LOG_LEVEL environment variable (DEBUG, INFO, WARNING, ERROR) +log_level = os.getenv("LOG_LEVEL", "INFO").upper() + +# Configure both console and file logging +log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" +logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format=log_format) + +# Add file handler for Docker log monitoring +try: + file_handler = logging.FileHandler("/tmp/mcp_server.log") + file_handler.setLevel(getattr(logging, log_level, logging.INFO)) + file_handler.setFormatter(logging.Formatter(log_format)) + logging.getLogger().addHandler(file_handler) +except Exception as e: + print(f"Warning: Could not set up file logging: {e}") + logger = logging.getLogger(__name__) # Create the MCP server instance with a unique name identifier diff --git a/setup-docker.sh b/setup-docker.sh index 5d7e654..ce7aa3c 100755 --- a/setup-docker.sh +++ b/setup-docker.sh @@ -43,6 +43,14 @@ REDIS_URL=redis://redis:6379/0 # not just files within the current project directory. Additionally, Claude Code # could be running from multiple locations at the same time. WORKSPACE_ROOT=$HOME + +# Logging level (DEBUG, INFO, WARNING, ERROR) +# DEBUG: Shows detailed operational messages, conversation threading, tool execution flow +# INFO: Shows general operational messages (default) +# WARNING: Shows only warnings and errors +# ERROR: Shows only errors +# Uncomment and change to DEBUG if you need detailed troubleshooting information +LOG_LEVEL=INFO EOF echo "✅ Created .env file with Redis configuration" echo "" @@ -168,6 +176,17 @@ echo " }" echo "}" echo "===========================================" echo "" +echo "===== CLAUDE CODE CLI CONFIGURATION =====" +echo "# Add the MCP server via Claude Code CLI:" +echo "claude mcp add gemini -s user -- docker exec -i gemini-mcp-server python server.py" +echo "" +echo "# List your MCP servers to verify:" +echo "claude mcp list" +echo "" +echo "# Remove if needed:" +echo "claude mcp remove gemini" +echo "===========================================" +echo "" echo "📁 Config file locations:" echo " macOS: ~/Library/Application Support/Claude/claude_desktop_config.json" diff --git a/setup.py b/setup.py index 37b6745..fac6678 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ if readme_path.exists(): setup( name="gemini-mcp-server", - version="3.0.0", + version="3.1.0", description="Model Context Protocol server for Google Gemini", long_description=long_description, long_description_content_type="text/markdown", diff --git a/tests/test_claude_continuation.py b/tests/test_claude_continuation.py index a88da10..5db406b 100644 --- a/tests/test_claude_continuation.py +++ b/tests/test_claude_continuation.py @@ -241,7 +241,7 @@ class TestClaudeContinuationOffers: def test_max_turns_reached_no_continuation_offer(self): """Test that no continuation is offered when max turns would be exceeded""" # Mock MAX_CONVERSATION_TURNS to be 1 for this test - with patch("utils.conversation_memory.MAX_CONVERSATION_TURNS", 1): + with patch("tools.base.MAX_CONVERSATION_TURNS", 1): request = ContinuationRequest(prompt="Test prompt") # Check continuation opportunity diff --git a/tests/test_collaboration.py b/tests/test_collaboration.py index 6e7fe93..8d653c9 100644 --- a/tests/test_collaboration.py +++ b/tests/test_collaboration.py @@ -235,9 +235,9 @@ class TestCollaborationWorkflow: ) response = json.loads(result[0].text) - assert response["status"] == "requires_clarification", ( - "Should request clarification when asked about dependencies without package files" - ) + assert ( + response["status"] == "requires_clarification" + ), "Should request clarification when asked about dependencies without package files" clarification = json.loads(response["content"]) assert "package.json" in str(clarification["files_needed"]), "Should specifically request package.json" diff --git a/tests/test_conversation_history_bug.py b/tests/test_conversation_history_bug.py new file mode 100644 index 0000000..49d2737 --- /dev/null +++ b/tests/test_conversation_history_bug.py @@ -0,0 +1,251 @@ +""" +Test suite for conversation history bug fix + +This test verifies that the critical bug where conversation history +(including file context) was not included when using continuation_id +has been properly fixed. + +The bug was that tools with continuation_id would not see previous +conversation turns, causing issues like Gemini not seeing files that +Claude had shared in earlier turns. +""" + +import json +from unittest.mock import Mock, patch + +import pytest +from pydantic import Field + +from tools.base import BaseTool, ToolRequest +from utils.conversation_memory import ConversationTurn, ThreadContext + + +class FileContextRequest(ToolRequest): + """Test request with file support""" + + prompt: str = Field(..., description="Test prompt") + files: list[str] = Field(default_factory=list, description="Optional files") + + +class FileContextTool(BaseTool): + """Test tool for file context verification""" + + def get_name(self) -> str: + return "test_file_context" + + def get_description(self) -> str: + return "Test tool for file context" + + def get_input_schema(self) -> dict: + return { + "type": "object", + "properties": { + "prompt": {"type": "string"}, + "files": {"type": "array", "items": {"type": "string"}}, + "continuation_id": {"type": "string", "required": False}, + }, + } + + def get_system_prompt(self) -> str: + return "Test system prompt for file context" + + def get_request_model(self): + return FileContextRequest + + async def prepare_prompt(self, request) -> str: + # Simple prompt preparation that would normally read files + # For this test, we're focusing on whether conversation history is included + files_context = "" + if request.files: + files_context = f"\nFiles in current request: {', '.join(request.files)}" + + return f"System: {self.get_system_prompt()}\nUser: {request.prompt}{files_context}" + + +class TestConversationHistoryBugFix: + """Test that conversation history is properly included with continuation_id""" + + def setup_method(self): + self.tool = FileContextTool() + + @patch("tools.base.get_thread") + @patch("tools.base.add_turn") + async def test_conversation_history_included_with_continuation_id(self, mock_add_turn, mock_get_thread): + """Test that conversation history (including file context) is included when using continuation_id""" + + # Create a thread context with previous turns including files + thread_context = ThreadContext( + thread_id="test-history-id", + created_at="2023-01-01T00:00:00Z", + last_updated_at="2023-01-01T00:02:00Z", + tool_name="analyze", # Started with analyze tool + turns=[ + ConversationTurn( + role="assistant", + content="I've analyzed the authentication module and found several security issues.", + timestamp="2023-01-01T00:01:00Z", + tool_name="analyze", + files=["/src/auth.py", "/src/security.py"], # Files from analyze tool + ), + ConversationTurn( + role="assistant", + content="The code review shows these files have critical vulnerabilities.", + timestamp="2023-01-01T00:02:00Z", + tool_name="codereview", + files=["/src/auth.py", "/tests/test_auth.py"], # Files from codereview tool + ), + ], + initial_context={"question": "Analyze authentication security"}, + ) + + # Mock get_thread to return our test context + mock_get_thread.return_value = thread_context + # Mock add_turn to return success + mock_add_turn.return_value = True + + # Mock the model to capture what prompt it receives + captured_prompt = None + + with patch.object(self.tool, "create_model") as mock_create_model: + mock_model = Mock() + mock_response = Mock() + mock_response.candidates = [ + Mock( + content=Mock(parts=[Mock(text="Response with conversation context")]), + finish_reason="STOP", + ) + ] + + def capture_prompt(prompt): + nonlocal captured_prompt + captured_prompt = prompt + return mock_response + + mock_model.generate_content.side_effect = capture_prompt + mock_create_model.return_value = mock_model + + # Execute tool with continuation_id + arguments = { + "prompt": "What should we fix first?", + "continuation_id": "test-history-id", + "files": ["/src/utils.py"], # New file for this turn + } + response = await self.tool.execute(arguments) + + # Verify response succeeded + response_data = json.loads(response[0].text) + assert response_data["status"] == "success" + + # Verify get_thread was called for history reconstruction + mock_get_thread.assert_called_with("test-history-id") + + # Verify the prompt includes conversation history + assert captured_prompt is not None + + # Check that conversation history is included + assert "=== CONVERSATION HISTORY ===" in captured_prompt + assert "Turn 1 (Gemini using analyze)" in captured_prompt + assert "Turn 2 (Gemini using codereview)" in captured_prompt + + # Check that file context from previous turns is included + assert "📁 Files referenced: /src/auth.py, /src/security.py" in captured_prompt + assert "📁 Files referenced: /src/auth.py, /tests/test_auth.py" in captured_prompt + + # Check that previous turn content is included + assert "I've analyzed the authentication module and found several security issues." in captured_prompt + assert "The code review shows these files have critical vulnerabilities." in captured_prompt + + # Check that continuation instruction is included + assert "Continue this conversation by building on the previous context." in captured_prompt + + # Check that current request is still included + assert "What should we fix first?" in captured_prompt + assert "Files in current request: /src/utils.py" in captured_prompt + + @patch("tools.base.get_thread") + async def test_no_history_when_thread_not_found(self, mock_get_thread): + """Test graceful handling when thread is not found""" + + # Mock get_thread to return None (thread not found) + mock_get_thread.return_value = None + + captured_prompt = None + + with patch.object(self.tool, "create_model") as mock_create_model: + mock_model = Mock() + mock_response = Mock() + mock_response.candidates = [ + Mock( + content=Mock(parts=[Mock(text="Response without history")]), + finish_reason="STOP", + ) + ] + + def capture_prompt(prompt): + nonlocal captured_prompt + captured_prompt = prompt + return mock_response + + mock_model.generate_content.side_effect = capture_prompt + mock_create_model.return_value = mock_model + + # Execute tool with continuation_id for non-existent thread + arguments = {"prompt": "Test without history", "continuation_id": "non-existent-thread-id"} + response = await self.tool.execute(arguments) + + # Should still succeed but without history + response_data = json.loads(response[0].text) + assert response_data["status"] == "success" + + # Verify get_thread was called for non-existent thread + mock_get_thread.assert_called_with("non-existent-thread-id") + + # Verify the prompt does NOT include conversation history + assert captured_prompt is not None + assert "=== CONVERSATION HISTORY ===" not in captured_prompt + assert "Test without history" in captured_prompt + + async def test_no_history_for_new_conversations(self): + """Test that new conversations (no continuation_id) don't get history""" + + captured_prompt = None + + with patch.object(self.tool, "create_model") as mock_create_model: + mock_model = Mock() + mock_response = Mock() + mock_response.candidates = [ + Mock( + content=Mock(parts=[Mock(text="New conversation response")]), + finish_reason="STOP", + ) + ] + + def capture_prompt(prompt): + nonlocal captured_prompt + captured_prompt = prompt + return mock_response + + mock_model.generate_content.side_effect = capture_prompt + mock_create_model.return_value = mock_model + + # Execute tool without continuation_id (new conversation) + arguments = {"prompt": "Start new conversation", "files": ["/src/new_file.py"]} + response = await self.tool.execute(arguments) + + # Should succeed (may offer continuation for new conversations) + response_data = json.loads(response[0].text) + assert response_data["status"] in ["success", "continuation_available"] + + # Verify the prompt does NOT include conversation history + assert captured_prompt is not None + assert "=== CONVERSATION HISTORY ===" not in captured_prompt + assert "Start new conversation" in captured_prompt + assert "Files in current request: /src/new_file.py" in captured_prompt + + # Should include follow-up instructions for new conversation + # (This is the existing behavior for new conversations) + assert "If you'd like to ask a follow-up question" in captured_prompt + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_cross_tool_continuation.py b/tests/test_cross_tool_continuation.py new file mode 100644 index 0000000..ef3861a --- /dev/null +++ b/tests/test_cross_tool_continuation.py @@ -0,0 +1,381 @@ +""" +Test suite for cross-tool continuation functionality + +Tests that continuation IDs work properly across different tools, +allowing multi-turn conversations to span multiple tool types. +""" + +import json +from unittest.mock import Mock, patch + +import pytest +from pydantic import Field + +from tools.base import BaseTool, ToolRequest +from utils.conversation_memory import ConversationTurn, ThreadContext + + +class AnalysisRequest(ToolRequest): + """Test request for analysis tool""" + + code: str = Field(..., description="Code to analyze") + + +class ReviewRequest(ToolRequest): + """Test request for review tool""" + + findings: str = Field(..., description="Analysis findings to review") + files: list[str] = Field(default_factory=list, description="Optional files to review") + + +class MockAnalysisTool(BaseTool): + """Mock analysis tool for cross-tool testing""" + + def get_name(self) -> str: + return "test_analysis" + + def get_description(self) -> str: + return "Test analysis tool" + + def get_input_schema(self) -> dict: + return { + "type": "object", + "properties": { + "code": {"type": "string"}, + "continuation_id": {"type": "string", "required": False}, + }, + } + + def get_system_prompt(self) -> str: + return "Analyze the provided code" + + def get_request_model(self): + return AnalysisRequest + + async def prepare_prompt(self, request) -> str: + return f"System: {self.get_system_prompt()}\nCode: {request.code}" + + +class MockReviewTool(BaseTool): + """Mock review tool for cross-tool testing""" + + def get_name(self) -> str: + return "test_review" + + def get_description(self) -> str: + return "Test review tool" + + def get_input_schema(self) -> dict: + return { + "type": "object", + "properties": { + "findings": {"type": "string"}, + "continuation_id": {"type": "string", "required": False}, + }, + } + + def get_system_prompt(self) -> str: + return "Review the analysis findings" + + def get_request_model(self): + return ReviewRequest + + async def prepare_prompt(self, request) -> str: + return f"System: {self.get_system_prompt()}\nFindings: {request.findings}" + + +class TestCrossToolContinuation: + """Test cross-tool continuation functionality""" + + def setup_method(self): + self.analysis_tool = MockAnalysisTool() + self.review_tool = MockReviewTool() + + @patch("utils.conversation_memory.get_redis_client") + async def test_continuation_id_works_across_different_tools(self, mock_redis): + """Test that a continuation_id from one tool can be used with another tool""" + mock_client = Mock() + mock_redis.return_value = mock_client + + # Step 1: Analysis tool creates a conversation with follow-up + with patch.object(self.analysis_tool, "create_model") as mock_create_model: + mock_model = Mock() + mock_response = Mock() + mock_response.candidates = [ + Mock( + content=Mock( + parts=[ + Mock( + text="""Found potential security issues in authentication logic. + +```json +{ + "follow_up_question": "Would you like me to review these security findings in detail?", + "suggested_params": {"findings": "Authentication bypass vulnerability detected"}, + "ui_hint": "Security review recommended" +} +```""" + ) + ] + ), + finish_reason="STOP", + ) + ] + mock_model.generate_content.return_value = mock_response + mock_create_model.return_value = mock_model + + # Execute analysis tool + arguments = {"code": "function authenticate(user) { return true; }"} + response = await self.analysis_tool.execute(arguments) + response_data = json.loads(response[0].text) + + assert response_data["status"] == "requires_continuation" + continuation_id = response_data["follow_up_request"]["continuation_id"] + + # Step 2: Mock the existing thread context for the review tool + # The thread was created by analysis_tool but will be continued by review_tool + existing_context = ThreadContext( + thread_id=continuation_id, + created_at="2023-01-01T00:00:00Z", + last_updated_at="2023-01-01T00:01:00Z", + tool_name="test_analysis", # Original tool + turns=[ + ConversationTurn( + role="assistant", + content="Found potential security issues in authentication logic.", + timestamp="2023-01-01T00:00:30Z", + tool_name="test_analysis", # Original tool + follow_up_question="Would you like me to review these security findings in detail?", + ) + ], + initial_context={"code": "function authenticate(user) { return true; }"}, + ) + + # Mock the get call to return existing context for add_turn to work + def mock_get_side_effect(key): + if key.startswith("thread:"): + return existing_context.model_dump_json() + return None + + mock_client.get.side_effect = mock_get_side_effect + + # Step 3: Review tool uses the same continuation_id + with patch.object(self.review_tool, "create_model") as mock_create_model: + mock_model = Mock() + mock_response = Mock() + mock_response.candidates = [ + Mock( + content=Mock( + parts=[ + Mock( + text="Critical security vulnerability confirmed. The authentication function always returns true, bypassing all security checks." + ) + ] + ), + finish_reason="STOP", + ) + ] + mock_model.generate_content.return_value = mock_response + mock_create_model.return_value = mock_model + + # Execute review tool with the continuation_id from analysis tool + arguments = { + "findings": "Authentication bypass vulnerability detected", + "continuation_id": continuation_id, + } + response = await self.review_tool.execute(arguments) + response_data = json.loads(response[0].text) + + # Should successfully continue the conversation + assert response_data["status"] == "success" + assert "Critical security vulnerability confirmed" in response_data["content"] + + # Step 4: Verify the cross-tool continuation worked + # Should have at least 2 setex calls: 1 from analysis tool follow-up, 1 from review tool add_turn + setex_calls = mock_client.setex.call_args_list + assert len(setex_calls) >= 2 # Analysis tool creates thread + review tool adds turn + + # Get the final thread state from the last setex call + final_thread_data = setex_calls[-1][0][2] # Last setex call's data + final_context = json.loads(final_thread_data) + + assert final_context["thread_id"] == continuation_id + assert final_context["tool_name"] == "test_analysis" # Original tool name preserved + assert len(final_context["turns"]) == 2 # Original + new turn + + # Verify the new turn has the review tool's name + second_turn = final_context["turns"][1] + assert second_turn["role"] == "assistant" + assert second_turn["tool_name"] == "test_review" # New tool name + assert "Critical security vulnerability confirmed" in second_turn["content"] + + @patch("utils.conversation_memory.get_redis_client") + def test_cross_tool_conversation_history_includes_tool_names(self, mock_redis): + """Test that conversation history properly shows which tool was used for each turn""" + mock_client = Mock() + mock_redis.return_value = mock_client + + # Create a thread context with turns from different tools + thread_context = ThreadContext( + thread_id="12345678-1234-1234-1234-123456789012", + created_at="2023-01-01T00:00:00Z", + last_updated_at="2023-01-01T00:03:00Z", + tool_name="test_analysis", # Original tool + turns=[ + ConversationTurn( + role="assistant", + content="Analysis complete: Found 3 issues", + timestamp="2023-01-01T00:01:00Z", + tool_name="test_analysis", + ), + ConversationTurn( + role="assistant", + content="Review complete: 2 critical, 1 minor issue", + timestamp="2023-01-01T00:02:00Z", + tool_name="test_review", + ), + ConversationTurn( + role="assistant", + content="Deep analysis: Root cause identified", + timestamp="2023-01-01T00:03:00Z", + tool_name="test_thinkdeep", + ), + ], + initial_context={"code": "test code"}, + ) + + # Build conversation history + from utils.conversation_memory import build_conversation_history + + history = build_conversation_history(thread_context) + + # Verify tool names are included in the history + assert "Turn 1 (Gemini using test_analysis)" in history + assert "Turn 2 (Gemini using test_review)" in history + assert "Turn 3 (Gemini using test_thinkdeep)" in history + assert "Analysis complete: Found 3 issues" in history + assert "Review complete: 2 critical, 1 minor issue" in history + assert "Deep analysis: Root cause identified" in history + + @patch("utils.conversation_memory.get_redis_client") + @patch("utils.conversation_memory.get_thread") + async def test_cross_tool_conversation_with_files_context(self, mock_get_thread, mock_redis): + """Test that file context is preserved across tool switches""" + mock_client = Mock() + mock_redis.return_value = mock_client + + # Create existing context with files from analysis tool + existing_context = ThreadContext( + thread_id="test-thread-id", + created_at="2023-01-01T00:00:00Z", + last_updated_at="2023-01-01T00:01:00Z", + tool_name="test_analysis", + turns=[ + ConversationTurn( + role="assistant", + content="Analysis of auth.py complete", + timestamp="2023-01-01T00:01:00Z", + tool_name="test_analysis", + files=["/src/auth.py", "/src/utils.py"], + ) + ], + initial_context={"code": "authentication code", "files": ["/src/auth.py"]}, + ) + + # Mock get_thread to return the existing context + mock_get_thread.return_value = existing_context + + # Mock review tool response + with patch.object(self.review_tool, "create_model") as mock_create_model: + mock_model = Mock() + mock_response = Mock() + mock_response.candidates = [ + Mock( + content=Mock(parts=[Mock(text="Security review of auth.py shows vulnerabilities")]), + finish_reason="STOP", + ) + ] + mock_model.generate_content.return_value = mock_response + mock_create_model.return_value = mock_model + + # Execute review tool with additional files + arguments = { + "findings": "Auth vulnerabilities found", + "continuation_id": "test-thread-id", + "files": ["/src/security.py"], # Additional file for review + } + response = await self.review_tool.execute(arguments) + response_data = json.loads(response[0].text) + + assert response_data["status"] == "success" + + # Verify files from both tools are tracked in Redis calls + setex_calls = mock_client.setex.call_args_list + assert len(setex_calls) >= 1 # At least the add_turn call from review tool + + # Get the final thread state + final_thread_data = setex_calls[-1][0][2] + final_context = json.loads(final_thread_data) + + # Check that the new turn includes the review tool's files + review_turn = final_context["turns"][1] # Second turn (review tool) + assert review_turn["tool_name"] == "test_review" + assert review_turn["files"] == ["/src/security.py"] + + # Original turn's files should still be there + analysis_turn = final_context["turns"][0] # First turn (analysis tool) + assert analysis_turn["files"] == ["/src/auth.py", "/src/utils.py"] + + @patch("utils.conversation_memory.get_redis_client") + @patch("utils.conversation_memory.get_thread") + def test_thread_preserves_original_tool_name(self, mock_get_thread, mock_redis): + """Test that the thread's original tool_name is preserved even when other tools contribute""" + mock_client = Mock() + mock_redis.return_value = mock_client + + # Create existing thread from analysis tool + existing_context = ThreadContext( + thread_id="test-thread-id", + created_at="2023-01-01T00:00:00Z", + last_updated_at="2023-01-01T00:01:00Z", + tool_name="test_analysis", # Original tool + turns=[ + ConversationTurn( + role="assistant", + content="Initial analysis", + timestamp="2023-01-01T00:01:00Z", + tool_name="test_analysis", + ) + ], + initial_context={"code": "test"}, + ) + + # Mock get_thread to return the existing context + mock_get_thread.return_value = existing_context + + # Add turn from review tool + from utils.conversation_memory import add_turn + + success = add_turn( + "test-thread-id", + "assistant", + "Review completed", + tool_name="test_review", # Different tool + ) + + # Verify the add_turn succeeded (basic cross-tool functionality test) + assert success + + # Verify thread's original tool_name is preserved + setex_calls = mock_client.setex.call_args_list + updated_thread_data = setex_calls[-1][0][2] + updated_context = json.loads(updated_thread_data) + + assert updated_context["tool_name"] == "test_analysis" # Original preserved + assert len(updated_context["turns"]) == 2 + assert updated_context["turns"][0]["tool_name"] == "test_analysis" + assert updated_context["turns"][1]["tool_name"] == "test_review" + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_thinking_modes.py b/tests/test_thinking_modes.py index 135072a..c8d441e 100644 --- a/tests/test_thinking_modes.py +++ b/tests/test_thinking_modes.py @@ -32,9 +32,9 @@ class TestThinkingModes: ] for tool, expected_default in tools: - assert tool.get_default_thinking_mode() == expected_default, ( - f"{tool.__class__.__name__} should default to {expected_default}" - ) + assert ( + tool.get_default_thinking_mode() == expected_default + ), f"{tool.__class__.__name__} should default to {expected_default}" @pytest.mark.asyncio @patch("tools.base.BaseTool.create_model") diff --git a/tools/analyze.py b/tools/analyze.py index 140fe9f..ec9cb65 100644 --- a/tools/analyze.py +++ b/tools/analyze.py @@ -90,7 +90,7 @@ class AnalyzeTool(BaseTool): }, "continuation_id": { "type": "string", - "description": "Thread continuation ID for multi-turn conversations. Only provide this if continuing a previous conversation thread.", + "description": "Thread continuation ID for multi-turn conversations. Can be used to continue conversations across different tools. Only provide this if continuing a previous conversation thread.", }, }, "required": ["files", "question"], diff --git a/tools/base.py b/tools/base.py index 796b65b..851f093 100644 --- a/tools/base.py +++ b/tools/base.py @@ -14,7 +14,9 @@ Key responsibilities: """ import json +import logging import os +import re from abc import ABC, abstractmethod from typing import Any, Literal, Optional @@ -23,7 +25,15 @@ from google.genai import types from mcp.types import TextContent from pydantic import BaseModel, Field -from config import MCP_PROMPT_SIZE_LIMIT +from config import GEMINI_MODEL, MAX_CONTEXT_TOKENS, MCP_PROMPT_SIZE_LIMIT +from utils import check_token_limit +from utils.conversation_memory import ( + MAX_CONVERSATION_TURNS, + add_turn, + build_conversation_history, + create_thread, + get_thread, +) from utils.file_utils import read_file_content, translate_path_for_environment from .models import ClarificationRequest, ContinuationOffer, FollowUpRequest, ToolOutput @@ -52,7 +62,7 @@ class ToolRequest(BaseModel): ) continuation_id: Optional[str] = Field( None, - description="Thread continuation ID for multi-turn conversations. Only provide this if continuing a previous conversation thread.", + description="Thread continuation ID for multi-turn conversations. Can be used to continue conversations across different tools. Only provide this if continuing a previous conversation thread.", ) @@ -359,10 +369,15 @@ If any of these would strengthen your analysis, specify what Claude should searc List[TextContent]: Formatted response as MCP TextContent objects """ try: + # Set up logger for this tool execution + logger = logging.getLogger(f"tools.{self.name}") + logger.info(f"Starting {self.name} tool execution with arguments: {list(arguments.keys())}") + # Validate request using the tool's Pydantic model # This ensures all required fields are present and properly typed request_model = self.get_request_model() request = request_model(**arguments) + logger.debug(f"Request validation successful for {self.name}") # Validate file paths for security # This prevents path traversal attacks and ensures proper access control @@ -383,13 +398,13 @@ If any of these would strengthen your analysis, specify what Claude should searc continuation_id = getattr(request, "continuation_id", None) if not continuation_id: # Import here to avoid circular imports - import logging - from server import get_follow_up_instructions follow_up_instructions = get_follow_up_instructions(0) # New conversation, turn 0 prompt = f"{prompt}\n\n{follow_up_instructions}" - logging.debug(f"Added follow-up instructions for new {self.name} conversation") + + logger.debug(f"Added follow-up instructions for new {self.name} conversation") + # Also log to file for debugging MCP issues try: with open("/tmp/gemini_debug.log", "a") as f: @@ -397,13 +412,18 @@ If any of these would strengthen your analysis, specify what Claude should searc except Exception: pass else: - import logging + logger.debug(f"Continuing {self.name} conversation with thread {continuation_id}") - logging.debug(f"Continuing {self.name} conversation with thread {continuation_id}") + # Add conversation history when continuing a threaded conversation + thread_context = get_thread(continuation_id) + if thread_context: + conversation_history = build_conversation_history(thread_context) + prompt = f"{conversation_history}\n\n{prompt}" + logger.debug(f"Added conversation history to {self.name} prompt for thread {continuation_id}") + else: + logger.warning(f"Thread {continuation_id} not found for {self.name} - continuing without history") # Extract model configuration from request or use defaults - from config import GEMINI_MODEL - model_name = getattr(request, "model", None) or GEMINI_MODEL temperature = getattr(request, "temperature", None) if temperature is None: @@ -417,7 +437,10 @@ If any of these would strengthen your analysis, specify what Claude should searc model = self.create_model(model_name, temperature, thinking_mode) # Generate AI response using the configured model + logger.info(f"Sending request to Gemini API for {self.name}") + logger.debug(f"Prompt length: {len(prompt)} characters") response = model.generate_content(prompt) + logger.info(f"Received response from Gemini API for {self.name}") # Process the model's response if response.candidates and response.candidates[0].content.parts: @@ -425,11 +448,13 @@ If any of these would strengthen your analysis, specify what Claude should searc # Parse response to check for clarification requests or format output tool_output = self._parse_response(raw_text, request) + logger.info(f"Successfully completed {self.name} tool execution") else: # Handle cases where the model couldn't generate a response # This might happen due to safety filters or other constraints finish_reason = response.candidates[0].finish_reason if response.candidates else "Unknown" + logger.warning(f"Response blocked or incomplete for {self.name}. Finish reason: {finish_reason}") tool_output = ToolOutput( status="error", content=f"Response blocked or incomplete. Finish reason: {finish_reason}", @@ -442,6 +467,9 @@ If any of these would strengthen your analysis, specify what Claude should searc except Exception as e: # Catch all exceptions to prevent server crashes # Return error information in standardized format + logger = logging.getLogger(f"tools.{self.name}") + logger.error(f"Error in {self.name} tool execution: {str(e)}", exc_info=True) + error_output = ToolOutput( status="error", content=f"Error in {self.name}: {str(e)}", @@ -465,15 +493,14 @@ If any of these would strengthen your analysis, specify what Claude should searc """ # Check for follow-up questions in JSON blocks at the end of the response follow_up_question = self._extract_follow_up_question(raw_text) - - import logging + logger = logging.getLogger(f"tools.{self.name}") if follow_up_question: - logging.debug( + logger.debug( f"Found follow-up question in {self.name} response: {follow_up_question.get('follow_up_question', 'N/A')}" ) else: - logging.debug(f"No follow-up question found in {self.name} response") + logger.debug(f"No follow-up question found in {self.name} response") try: # Try to parse as JSON to check for clarification requests @@ -505,15 +532,27 @@ If any of these would strengthen your analysis, specify what Claude should searc # Check if we should offer Claude a continuation opportunity continuation_offer = self._check_continuation_opportunity(request) - import logging - if continuation_offer: - logging.debug( + logger.debug( f"Creating continuation offer for {self.name} with {continuation_offer['remaining_turns']} turns remaining" ) return self._create_continuation_offer_response(formatted_content, continuation_offer, request) else: - logging.debug(f"No continuation offer created for {self.name}") + logger.debug(f"No continuation offer created for {self.name}") + + # If this is a threaded conversation (has continuation_id), save the response + continuation_id = getattr(request, "continuation_id", None) + if continuation_id: + request_files = getattr(request, "files", []) or [] + success = add_turn( + continuation_id, + "assistant", + formatted_content, + files=request_files, + tool_name=self.name, + ) + if not success: + logging.warning(f"Failed to add turn to thread {continuation_id} for {self.name}") # Determine content type based on the formatted content content_type = ( @@ -539,8 +578,6 @@ If any of these would strengthen your analysis, specify what Claude should searc Returns: Dict with follow-up data if found, None otherwise """ - import re - # Look for JSON blocks that contain follow_up_question # Pattern handles optional leading whitespace and indentation json_pattern = r'```json\s*\n\s*(\{.*?"follow_up_question".*?\})\s*\n\s*```' @@ -573,8 +610,6 @@ If any of these would strengthen your analysis, specify what Claude should searc Returns: ToolOutput configured for conversation continuation """ - from utils.conversation_memory import add_turn, create_thread - # Create or get thread ID continuation_id = getattr(request, "continuation_id", None) @@ -617,9 +652,8 @@ If any of these would strengthen your analysis, specify what Claude should searc ) except Exception as e: # Threading failed, return normal response - import logging - - logging.warning(f"Follow-up threading failed in {self.name}: {str(e)}") + logger = logging.getLogger(f"tools.{self.name}") + logger.warning(f"Follow-up threading failed in {self.name}: {str(e)}") return ToolOutput( status="success", content=content, @@ -648,8 +682,6 @@ If any of these would strengthen your analysis, specify what Claude should searc def _remove_follow_up_json(self, text: str) -> str: """Remove follow-up JSON blocks from the response text""" - import re - # Remove JSON blocks containing follow_up_question pattern = r'```json\s*\n\s*\{.*?"follow_up_question".*?\}\s*\n\s*```' return re.sub(pattern, "", text, flags=re.DOTALL).strip() @@ -676,8 +708,6 @@ If any of these would strengthen your analysis, specify what Claude should searc # Only offer if we haven't reached conversation limits try: - from utils.conversation_memory import MAX_CONVERSATION_TURNS - # For new conversations, we have MAX_CONVERSATION_TURNS - 1 remaining # (since this response will be turn 1) remaining_turns = MAX_CONVERSATION_TURNS - 1 @@ -703,8 +733,6 @@ If any of these would strengthen your analysis, specify what Claude should searc Returns: ToolOutput configured with continuation offer """ - from utils.conversation_memory import create_thread - try: # Create new thread for potential continuation thread_id = create_thread( @@ -712,8 +740,6 @@ If any of these would strengthen your analysis, specify what Claude should searc ) # Add this response as the first turn (assistant turn) - from utils.conversation_memory import add_turn - request_files = getattr(request, "files", []) or [] add_turn(thread_id, "assistant", content, files=request_files, tool_name=self.name) @@ -743,9 +769,8 @@ If any of these would strengthen your analysis, specify what Claude should searc except Exception as e: # If threading fails, return normal response but log the error - import logging - - logging.warning(f"Conversation threading failed in {self.name}: {str(e)}") + logger = logging.getLogger(f"tools.{self.name}") + logger.warning(f"Conversation threading failed in {self.name}: {str(e)}") return ToolOutput( status="success", content=content, @@ -800,9 +825,6 @@ If any of these would strengthen your analysis, specify what Claude should searc Raises: ValueError: If text exceeds MAX_CONTEXT_TOKENS """ - from config import MAX_CONTEXT_TOKENS - from utils import check_token_limit - within_limit, estimated_tokens = check_token_limit(text) if not within_limit: raise ValueError( diff --git a/tools/chat.py b/tools/chat.py index e0fbedb..722bc38 100644 --- a/tools/chat.py +++ b/tools/chat.py @@ -75,7 +75,7 @@ class ChatTool(BaseTool): }, "continuation_id": { "type": "string", - "description": "Thread continuation ID for multi-turn conversations. Only provide this if continuing a previous conversation thread.", + "description": "Thread continuation ID for multi-turn conversations. Can be used to continue conversations across different tools. Only provide this if continuing a previous conversation thread.", }, }, "required": ["prompt"], diff --git a/tools/codereview.py b/tools/codereview.py index f300367..fa8558c 100644 --- a/tools/codereview.py +++ b/tools/codereview.py @@ -128,7 +128,7 @@ class CodeReviewTool(BaseTool): }, "continuation_id": { "type": "string", - "description": "Thread continuation ID for multi-turn conversations. Only provide this if continuing a previous conversation thread.", + "description": "Thread continuation ID for multi-turn conversations. Can be used to continue conversations across different tools. Only provide this if continuing a previous conversation thread.", }, }, "required": ["files", "context"], diff --git a/tools/debug.py b/tools/debug.py index 3d29d08..eedb48f 100644 --- a/tools/debug.py +++ b/tools/debug.py @@ -93,7 +93,7 @@ class DebugIssueTool(BaseTool): }, "continuation_id": { "type": "string", - "description": "Thread continuation ID for multi-turn conversations. Only provide this if continuing a previous conversation thread.", + "description": "Thread continuation ID for multi-turn conversations. Can be used to continue conversations across different tools. Only provide this if continuing a previous conversation thread.", }, }, "required": ["error_description"], diff --git a/tools/models.py b/tools/models.py index f08aa43..64ca054 100644 --- a/tools/models.py +++ b/tools/models.py @@ -10,7 +10,9 @@ from pydantic import BaseModel, Field class FollowUpRequest(BaseModel): """Request for follow-up conversation turn""" - continuation_id: str = Field(..., description="Thread continuation ID for multi-turn conversations") + continuation_id: str = Field( + ..., description="Thread continuation ID for multi-turn conversations across different tools" + ) question_to_user: str = Field(..., description="Follow-up question to ask Claude") suggested_tool_params: Optional[dict[str, Any]] = Field( None, description="Suggested parameters for the next tool call" @@ -23,7 +25,9 @@ class FollowUpRequest(BaseModel): class ContinuationOffer(BaseModel): """Offer for Claude to continue conversation when Gemini doesn't ask follow-up""" - continuation_id: str = Field(..., description="Thread continuation ID for multi-turn conversations") + continuation_id: str = Field( + ..., description="Thread continuation ID for multi-turn conversations across different tools" + ) message_to_user: str = Field(..., description="Message explaining continuation opportunity to Claude") suggested_tool_params: Optional[dict[str, Any]] = Field( None, description="Suggested parameters for continued tool usage" diff --git a/tools/precommit.py b/tools/precommit.py index 8da5556..3d1eb56 100644 --- a/tools/precommit.py +++ b/tools/precommit.py @@ -104,7 +104,7 @@ class Precommit(BaseTool): if "properties" in schema and "continuation_id" not in schema["properties"]: schema["properties"]["continuation_id"] = { "type": "string", - "description": "Thread continuation ID for multi-turn conversations. Only provide this if continuing a previous conversation thread.", + "description": "Thread continuation ID for multi-turn conversations. Can be used to continue conversations across different tools. Only provide this if continuing a previous conversation thread.", } return schema diff --git a/tools/thinkdeep.py b/tools/thinkdeep.py index e307e18..baf0e69 100644 --- a/tools/thinkdeep.py +++ b/tools/thinkdeep.py @@ -89,7 +89,7 @@ class ThinkDeepTool(BaseTool): }, "continuation_id": { "type": "string", - "description": "Thread continuation ID for multi-turn conversations. Only provide this if continuing a previous conversation thread.", + "description": "Thread continuation ID for multi-turn conversations. Can be used to continue conversations across different tools. Only provide this if continuing a previous conversation thread.", }, }, "required": ["current_analysis"], diff --git a/utils/conversation_memory.py b/utils/conversation_memory.py index 08aae41..256f5d1 100644 --- a/utils/conversation_memory.py +++ b/utils/conversation_memory.py @@ -2,15 +2,48 @@ Conversation Memory for AI-to-AI Multi-turn Discussions This module provides conversation persistence and context reconstruction for -stateless MCP environments. It enables multi-turn conversations between Claude -and Gemini by storing conversation state in Redis across independent request cycles. +stateless MCP (Model Context Protocol) environments. It enables multi-turn +conversations between Claude and Gemini by storing conversation state in Redis +across independent request cycles. + +ARCHITECTURE OVERVIEW: +The MCP protocol is inherently stateless - each tool request is independent +with no memory of previous interactions. This module bridges that gap by: + +1. Creating persistent conversation threads with unique UUIDs +2. Storing complete conversation context (turns, files, metadata) in Redis +3. Reconstructing conversation history when tools are called with continuation_id +4. Supporting cross-tool continuation - seamlessly switch between different tools + while maintaining full conversation context and file references + +CROSS-TOOL CONTINUATION: +A conversation started with one tool (e.g., 'analyze') can be continued with +any other tool (e.g., 'codereview', 'debug', 'chat') using the same continuation_id. +The second tool will have access to: +- All previous conversation turns and responses +- File context from previous tools (preserved in conversation history) +- Original thread metadata and timing information +- Accumulated knowledge from the entire conversation Key Features: -- UUID-based conversation thread identification -- Turn-by-turn conversation history storage -- Automatic turn limiting to prevent runaway conversations +- UUID-based conversation thread identification with security validation +- Turn-by-turn conversation history storage with tool attribution +- Cross-tool continuation support - switch tools while preserving context +- File context preservation - files shared in earlier turns remain accessible +- Automatic turn limiting (5 turns max) to prevent runaway conversations - Context reconstruction for stateless request continuity -- Redis-based persistence with automatic expiration +- Redis-based persistence with automatic expiration (1 hour TTL) +- Thread-safe operations for concurrent access +- Graceful degradation when Redis is unavailable + +USAGE EXAMPLE: +1. Tool A creates thread: create_thread("analyze", request_data) → returns UUID +2. Tool A adds response: add_turn(UUID, "assistant", response, files=[...], tool_name="analyze") +3. Tool B continues thread: get_thread(UUID) → retrieves full context +4. Tool B sees conversation history via build_conversation_history() +5. Tool B adds its response: add_turn(UUID, "assistant", response, tool_name="codereview") + +This enables true AI-to-AI collaboration across the entire tool ecosystem. """ import os @@ -25,7 +58,20 @@ MAX_CONVERSATION_TURNS = 5 # Maximum turns allowed per conversation thread class ConversationTurn(BaseModel): - """Single turn in a conversation""" + """ + Single turn in a conversation + + Represents one exchange in the AI-to-AI conversation, tracking both + the content and metadata needed for cross-tool continuation. + + Attributes: + role: "user" (Claude) or "assistant" (Gemini) + content: The actual message content/response + timestamp: ISO timestamp when this turn was created + follow_up_question: Optional follow-up question from Gemini to Claude + files: List of file paths referenced in this specific turn + tool_name: Which tool generated this turn (for cross-tool tracking) + """ role: str # "user" or "assistant" content: str @@ -36,18 +82,43 @@ class ConversationTurn(BaseModel): class ThreadContext(BaseModel): - """Complete conversation context""" + """ + Complete conversation context for a thread + + Contains all information needed to reconstruct a conversation state + across different tools and request cycles. This is the core data + structure that enables cross-tool continuation. + + Attributes: + thread_id: UUID identifying this conversation thread + created_at: ISO timestamp when thread was created + last_updated_at: ISO timestamp of last modification + tool_name: Name of the tool that initiated this thread + turns: List of all conversation turns in chronological order + initial_context: Original request data that started the conversation + """ thread_id: str created_at: str last_updated_at: str - tool_name: str + tool_name: str # Tool that created this thread (preserved for attribution) turns: list[ConversationTurn] - initial_context: dict[str, Any] + initial_context: dict[str, Any] # Original request parameters def get_redis_client(): - """Get Redis client from environment""" + """ + Get Redis client from environment configuration + + Creates a Redis client using the REDIS_URL environment variable. + Defaults to localhost:6379/0 if not specified. + + Returns: + redis.Redis: Configured Redis client with decode_responses=True + + Raises: + ValueError: If redis package is not installed + """ try: import redis @@ -58,11 +129,29 @@ def get_redis_client(): def create_thread(tool_name: str, initial_request: dict[str, Any]) -> str: - """Create new conversation thread and return thread ID""" + """ + Create new conversation thread and return thread ID + + Initializes a new conversation thread for AI-to-AI discussions. + This is called when a tool wants to enable follow-up conversations + or when Claude explicitly starts a multi-turn interaction. + + Args: + tool_name: Name of the tool creating this thread (e.g., "analyze", "chat") + initial_request: Original request parameters (will be filtered for serialization) + + Returns: + str: UUID thread identifier that can be used for continuation + + Note: + - Thread expires after 1 hour (3600 seconds) + - Non-serializable parameters are filtered out automatically + - Thread can be continued by any tool using the returned UUID + """ thread_id = str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() - # Filter out non-serializable parameters + # Filter out non-serializable parameters to avoid JSON encoding issues filtered_context = { k: v for k, v in initial_request.items() @@ -73,12 +162,12 @@ def create_thread(tool_name: str, initial_request: dict[str, Any]) -> str: thread_id=thread_id, created_at=now, last_updated_at=now, - tool_name=tool_name, - turns=[], + tool_name=tool_name, # Track which tool initiated this conversation + turns=[], # Empty initially, turns added via add_turn() initial_context=filtered_context, ) - # Store in Redis with 1 hour TTL + # Store in Redis with 1 hour TTL to prevent indefinite accumulation client = get_redis_client() key = f"thread:{thread_id}" client.setex(key, 3600, context.model_dump_json()) @@ -87,7 +176,25 @@ def create_thread(tool_name: str, initial_request: dict[str, Any]) -> str: def get_thread(thread_id: str) -> Optional[ThreadContext]: - """Retrieve thread context from Redis""" + """ + Retrieve thread context from Redis + + Fetches complete conversation context for cross-tool continuation. + This is the core function that enables tools to access conversation + history from previous interactions. + + Args: + thread_id: UUID of the conversation thread + + Returns: + ThreadContext: Complete conversation context if found + None: If thread doesn't exist, expired, or invalid UUID + + Security: + - Validates UUID format to prevent injection attacks + - Handles Redis connection failures gracefully + - No error information leakage on failure + """ if not thread_id or not _is_valid_uuid(thread_id): return None @@ -100,6 +207,7 @@ def get_thread(thread_id: str) -> Optional[ThreadContext]: return ThreadContext.model_validate_json(data) return None except Exception: + # Silently handle errors to avoid exposing Redis details return None @@ -111,47 +219,99 @@ def add_turn( files: Optional[list[str]] = None, tool_name: Optional[str] = None, ) -> bool: - """Add turn to existing thread""" + """ + Add turn to existing thread + + Appends a new conversation turn to an existing thread. This is the core + function for building conversation history and enabling cross-tool + continuation. Each turn preserves the tool that generated it. + + Args: + thread_id: UUID of the conversation thread + role: "user" (Claude) or "assistant" (Gemini) + content: The actual message/response content + follow_up_question: Optional follow-up question from Gemini + files: Optional list of files referenced in this turn + tool_name: Name of the tool adding this turn (for attribution) + + Returns: + bool: True if turn was successfully added, False otherwise + + Failure cases: + - Thread doesn't exist or expired + - Maximum turn limit reached (5 turns) + - Redis connection failure + + Note: + - Refreshes thread TTL to 1 hour on successful update + - Turn limits prevent runaway conversations + - File references are preserved for cross-tool access + """ context = get_thread(thread_id) if not context: return False - # Check turn limit + # Check turn limit to prevent runaway conversations if len(context.turns) >= MAX_CONVERSATION_TURNS: return False - # Add new turn + # Create new turn with complete metadata turn = ConversationTurn( role=role, content=content, timestamp=datetime.now(timezone.utc).isoformat(), follow_up_question=follow_up_question, - files=files, - tool_name=tool_name, + files=files, # Preserved for cross-tool file context + tool_name=tool_name, # Track which tool generated this turn ) context.turns.append(turn) context.last_updated_at = datetime.now(timezone.utc).isoformat() - # Save back to Redis + # Save back to Redis and refresh TTL try: client = get_redis_client() key = f"thread:{thread_id}" - client.setex(key, 3600, context.model_dump_json()) # Refresh TTL + client.setex(key, 3600, context.model_dump_json()) # Refresh TTL to 1 hour return True except Exception: return False def build_conversation_history(context: ThreadContext) -> str: - """Build formatted conversation history""" + """ + Build formatted conversation history for tool prompts + + Creates a formatted string representation of the conversation history + that can be included in tool prompts to provide context. This is the + critical function that enables cross-tool continuation by reconstructing + the full conversation context. + + Args: + context: ThreadContext containing the complete conversation + + Returns: + str: Formatted conversation history ready for inclusion in prompts + Empty string if no conversation turns exist + + Format: + - Header with thread metadata and turn count + - Each turn shows: role, tool used, files referenced, content + - Files from previous turns are explicitly listed + - Clear delimiters for AI parsing + - Continuation instruction at end + + Note: + This formatted history allows tools to "see" files and context + from previous tools, enabling true cross-tool collaboration. + """ if not context.turns: return "" history_parts = [ "=== CONVERSATION HISTORY ===", f"Thread: {context.thread_id}", - f"Tool: {context.tool_name}", + f"Tool: {context.tool_name}", # Original tool that started the conversation f"Turn {len(context.turns)}/{MAX_CONVERSATION_TURNS}", "", "Previous exchanges:", @@ -160,14 +320,14 @@ def build_conversation_history(context: ThreadContext) -> str: for i, turn in enumerate(context.turns, 1): role_label = "Claude" if turn.role == "user" else "Gemini" - # Add turn header with tool info if available + # Add turn header with tool attribution for cross-tool tracking turn_header = f"\n--- Turn {i} ({role_label}" if turn.tool_name: turn_header += f" using {turn.tool_name}" turn_header += ") ---" history_parts.append(turn_header) - # Add files context if present + # Add files context if present - critical for cross-tool file access if turn.files: history_parts.append(f"📁 Files referenced: {', '.join(turn.files)}") history_parts.append("") # Empty line for readability @@ -187,7 +347,18 @@ def build_conversation_history(context: ThreadContext) -> str: def _is_valid_uuid(val: str) -> bool: - """Validate UUID format for security""" + """ + Validate UUID format for security + + Ensures thread IDs are valid UUIDs to prevent injection attacks + and malformed requests. + + Args: + val: String to validate as UUID + + Returns: + bool: True if valid UUID format, False otherwise + """ try: uuid.UUID(val) return True