Fix conversation history duplication and optimize file embedding

This major refactoring addresses critical bugs in conversation history management
and significantly improves token efficiency through intelligent file embedding:

**Key Improvements:**
• Fixed conversation history duplication bug by centralizing reconstruction in server.py
• Added intelligent file filtering to prevent re-embedding files already in conversation history
• Centralized file processing logic in BaseTool._prepare_file_content_for_prompt()
• Enhanced log monitoring with better categorization and file embedding visibility
• Updated comprehensive test suite to verify new architecture and edge cases

**Architecture Changes:**
• Removed duplicate conversation history reconstruction from tools/base.py
• Conversation history now handled exclusively by server.py:reconstruct_thread_context
• All tools now use centralized file processing with automatic deduplication
• Improved token efficiency by embedding unique files only once per conversation

**Performance Benefits:**
• Reduced token usage through smart file filtering
• Eliminated redundant file embeddings in continued conversations
• Better observability with detailed debug logging for file operations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Fahad
2025-06-11 11:40:12 +04:00
parent 4466d1d1fe
commit 5a94737516
11 changed files with 501 additions and 84 deletions

View File

@@ -46,6 +46,7 @@ USAGE EXAMPLE:
This enables true AI-to-AI collaboration across the entire tool ecosystem.
"""
import logging
import os
import uuid
from datetime import datetime, timezone
@@ -53,6 +54,8 @@ from typing import Any, Optional
from pydantic import BaseModel
logger = logging.getLogger(__name__)
# Configuration constants
MAX_CONVERSATION_TURNS = 10 # Maximum turns allowed per conversation thread
@@ -278,45 +281,174 @@ def add_turn(
return False
def build_conversation_history(context: ThreadContext) -> str:
def get_conversation_file_list(context: ThreadContext) -> list[str]:
"""
Build formatted conversation history for tool prompts
Get all unique files referenced across all turns in a conversation.
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.
This function extracts and deduplicates file references from all conversation
turns to enable efficient file embedding - files are read once and shared
across all turns rather than being embedded multiple times.
Args:
context: ThreadContext containing the complete conversation
Returns:
str: Formatted conversation history ready for inclusion in prompts
list[str]: Deduplicated list of file paths referenced in the conversation
"""
if not context.turns:
return []
# Collect all unique files from all turns, preserving order of first appearance
seen_files = set()
unique_files = []
for turn in context.turns:
if turn.files:
for file_path in turn.files:
if file_path not in seen_files:
seen_files.add(file_path)
unique_files.append(file_path)
return unique_files
def build_conversation_history(context: ThreadContext, read_files_func=None) -> str:
"""
Build formatted conversation history for tool prompts with embedded file contents.
Creates a formatted string representation of the conversation history that includes
full file contents from all referenced files. Files are embedded only ONCE at the
start, even if referenced in multiple turns, to prevent duplication and optimize
token usage.
Args:
context: ThreadContext containing the complete conversation
Returns:
str: Formatted conversation history with embedded files 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
- All referenced files embedded once with full contents
- Each turn shows: role, tool used, which files were used, content
- 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.
This formatted history allows tools to "see" both conversation context AND
file contents from previous tools, enabling true cross-tool collaboration
while preventing duplicate file embeddings.
"""
if not context.turns:
return ""
# Get all unique files referenced in this conversation
all_files = get_conversation_file_list(context)
history_parts = [
"=== CONVERSATION HISTORY ===",
f"Thread: {context.thread_id}",
f"Tool: {context.tool_name}", # Original tool that started the conversation
f"Turn {len(context.turns)}/{MAX_CONVERSATION_TURNS}",
"",
"Previous exchanges:",
]
# Embed all files referenced in this conversation once at the start
if all_files:
history_parts.extend(
[
"=== FILES REFERENCED IN THIS CONVERSATION ===",
"The following files have been shared and analyzed during our conversation.",
"Refer to these when analyzing the context and requests below:",
"",
]
)
# Import required functions
from config import MAX_CONTEXT_TOKENS
if read_files_func is None:
from utils.file_utils import read_file_content
# Optimized: read files incrementally with token tracking
file_contents = []
total_tokens = 0
files_included = 0
files_truncated = 0
for file_path in all_files:
try:
# Correctly unpack the tuple returned by read_file_content
formatted_content, content_tokens = read_file_content(file_path)
if formatted_content:
# read_file_content already returns formatted content, use it directly
# Check if adding this file would exceed the limit
if total_tokens + content_tokens <= MAX_CONTEXT_TOKENS:
file_contents.append(formatted_content)
total_tokens += content_tokens
files_included += 1
logger.debug(
f"📄 File embedded in conversation history: {file_path} ({content_tokens:,} tokens)"
)
else:
files_truncated += 1
logger.debug(
f"📄 File truncated due to token limit: {file_path} ({content_tokens:,} tokens, would exceed {MAX_CONTEXT_TOKENS:,} limit)"
)
# Stop processing more files
break
else:
logger.debug(f"📄 File skipped (empty content): {file_path}")
except Exception as e:
# Skip files that can't be read but log the failure
logger.warning(
f"📄 Failed to embed file in conversation history: {file_path} - {type(e).__name__}: {e}"
)
continue
if file_contents:
files_content = "".join(file_contents)
if files_truncated > 0:
files_content += (
f"\n[NOTE: {files_truncated} additional file(s) were truncated due to token limit]\n"
)
history_parts.append(files_content)
logger.debug(
f"📄 Conversation history file embedding complete: {files_included} files embedded, {files_truncated} truncated, {total_tokens:,} total tokens"
)
else:
history_parts.append("(No accessible files found)")
logger.debug(
f"📄 Conversation history file embedding: no accessible files found from {len(all_files)} requested"
)
else:
# Fallback to original read_files function for backward compatibility
files_content = read_files_func(all_files)
if files_content:
# Add token validation for the combined file content
from utils.token_utils import check_token_limit
within_limit, estimated_tokens = check_token_limit(files_content)
if within_limit:
history_parts.append(files_content)
else:
# Handle token limit exceeded for conversation files
error_message = f"ERROR: The total size of files referenced in this conversation has exceeded the context limit and cannot be displayed.\nEstimated tokens: {estimated_tokens}, but limit is {MAX_CONTEXT_TOKENS}."
history_parts.append(error_message)
else:
history_parts.append("(No accessible files found)")
history_parts.extend(
[
"",
"=== END REFERENCED FILES ===",
"",
]
)
history_parts.append("Previous conversation turns:")
for i, turn in enumerate(context.turns, 1):
role_label = "Claude" if turn.role == "user" else "Gemini"
@@ -327,9 +459,10 @@ def build_conversation_history(context: ThreadContext) -> str:
turn_header += ") ---"
history_parts.append(turn_header)
# Add files context if present - critical for cross-tool file access
# Add files context if present - but just reference which files were used
# (the actual contents are already embedded above)
if turn.files:
history_parts.append(f"📁 Files referenced: {', '.join(turn.files)}")
history_parts.append(f"📁 Files used in this turn: {', '.join(turn.files)}")
history_parts.append("") # Empty line for readability
# Add the actual content
@@ -340,7 +473,7 @@ def build_conversation_history(context: ThreadContext) -> str:
history_parts.append(f"\n[Gemini's Follow-up: {turn.follow_up_question}]")
history_parts.extend(
["", "=== END HISTORY ===", "", "Continue this conversation by building on the previous context."]
["", "=== END CONVERSATION HISTORY ===", "", "Continue this conversation by building on the previous context."]
)
return "\n".join(history_parts)