fix: critical conversation history bug and improve Docker integration
This commit addresses several critical issues and improvements: 🔧 Critical Fixes: - Fixed conversation history not being included when using continuation_id in AI-to-AI conversations - Fixed test mock targeting issues preventing proper conversation memory validation - Fixed Docker debug logging functionality with Gemini tools 🐛 Bug Fixes: - Docker compose configuration for proper container command execution - Test mock import targeting from utils.conversation_memory.* to tools.base.* - Version bump to 3.1.0 reflecting significant improvements 🚀 Improvements: - Enhanced Docker environment configuration with comprehensive logging setup - Added cross-tool continuation documentation and examples in README - Improved error handling and validation across all tools - Better logging configuration with LOG_LEVEL environment variable support - Enhanced conversation memory system documentation 🧪 Testing: - Added comprehensive conversation history bug fix tests - Added cross-tool continuation functionality tests - All 132 tests now pass with proper conversation history validation - Improved test coverage for AI-to-AI conversation threading ✨ Code Quality: - Applied black, isort, and ruff formatting across entire codebase - Enhanced inline documentation for conversation memory system - Cleaned up temporary files and improved repository hygiene - Better test descriptions and coverage for critical functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
41
.env.example
41
.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
|
||||
# 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
|
||||
38
README.md
38
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
18
server.py
18
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
2
setup.py
2
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
251
tests/test_conversation_history_bug.py
Normal file
251
tests/test_conversation_history_bug.py
Normal file
@@ -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__])
|
||||
381
tests/test_cross_tool_continuation.py
Normal file
381
tests/test_cross_tool_continuation.py
Normal file
@@ -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__])
|
||||
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user