Add Consensus Tool for Multi-Model Perspective Gathering (#67)

* WIP
Refactor resolving mode_names, should be done once at MCP call boundary
Pass around model context instead
Consensus tool allows one to get a consensus from multiple models, optionally assigning one a 'for' or 'against' stance to find nuanced responses.

* Deduplication of model resolution, model_context should be available before reaching deeper parts of the code
Improved abstraction when building conversations
Throw programmer errors early

* Guardrails
Support for `model:option` format at MCP boundary so future tools can use additional options if needed instead of handling this only for consensus
Model name now supports an optional ":option" for future use

* Simplified async flow

* Improved model for request to support natural language
Simplified async flow

* Improved model for request to support natural language
Simplified async flow

* Fix consensus tool async/sync patterns to match codebase standards

CRITICAL FIXES:
- Converted _get_consensus_responses from async to sync (matches other tools)
- Converted store_conversation_turn from async to sync (add_turn is synchronous)
- Removed unnecessary asyncio imports and sleep calls
- Fixed ClosedResourceError in MCP protocol during long consensus operations

PATTERN ALIGNMENT:
- Consensus tool now follows same sync patterns as all other tools
- Only execute() and prepare_prompt() are async (base class requirement)
- All internal operations are synchronous like analyze, chat, debug, etc.

TESTING:
- MCP simulation test now passes: consensus_stance 
- Two-model consensus works correctly in ~35 seconds
- Unknown stance handling defaults to neutral with warnings
- All 9 unit tests pass (100% success rate)

The consensus tool async patterns were anomalous in the codebase.
This fix aligns it with the established synchronous patterns used
by all other tools while maintaining full functionality.

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

Co-Authored-By: Claude <noreply@anthropic.com>

* Fixed call order and added new test

* Cleanup dead comments
Docs for the new tool
Improved tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Beehive Innovations
2025-06-17 10:53:17 +04:00
committed by GitHub
parent 9b98df650b
commit 95556ba9ea
31 changed files with 2643 additions and 324 deletions

View File

@@ -884,7 +884,7 @@ def build_conversation_history(context: ThreadContext, model_context=None, read_
history_parts.append("(No accessible files found)")
logger.debug(f"[FILES] No accessible files found from {len(files_to_include)} planned files")
else:
# Fallback to original read_files function for backward compatibility
# Fallback to original read_files function
files_content = read_files_func(all_files)
if files_content:
# Add token validation for the combined file content
@@ -940,14 +940,10 @@ def build_conversation_history(context: ThreadContext, model_context=None, read_
turn_header += ") ---"
turn_parts.append(turn_header)
# Add files context if present - but just reference which files were used
# (the actual contents are already embedded above)
if turn.files:
turn_parts.append(f"Files used in this turn: {', '.join(turn.files)}")
turn_parts.append("") # Empty line for readability
# Add the actual content
turn_parts.append(turn.content)
# Get tool-specific formatting if available
# This includes file references and the actual content
tool_formatted_content = _get_tool_formatted_content(turn)
turn_parts.extend(tool_formatted_content)
# Calculate tokens for this turn
turn_content = "\n".join(turn_parts)
@@ -1019,6 +1015,63 @@ def build_conversation_history(context: ThreadContext, model_context=None, read_
return complete_history, total_conversation_tokens
def _get_tool_formatted_content(turn: ConversationTurn) -> list[str]:
"""
Get tool-specific formatting for a conversation turn.
This function attempts to use the tool's custom formatting method if available,
falling back to default formatting if the tool cannot be found or doesn't
provide custom formatting.
Args:
turn: The conversation turn to format
Returns:
list[str]: Formatted content lines for this turn
"""
if turn.tool_name:
try:
# Dynamically import to avoid circular dependencies
from server import TOOLS
tool = TOOLS.get(turn.tool_name)
if tool and hasattr(tool, "format_conversation_turn"):
# Use tool-specific formatting
return tool.format_conversation_turn(turn)
except Exception as e:
# Log but don't fail - fall back to default formatting
logger.debug(f"[HISTORY] Could not get tool-specific formatting for {turn.tool_name}: {e}")
# Default formatting
return _default_turn_formatting(turn)
def _default_turn_formatting(turn: ConversationTurn) -> list[str]:
"""
Default formatting for conversation turns.
This provides the standard formatting when no tool-specific
formatting is available.
Args:
turn: The conversation turn to format
Returns:
list[str]: Default formatted content lines
"""
parts = []
# Add files context if present
if turn.files:
parts.append(f"Files used in this turn: {', '.join(turn.files)}")
parts.append("") # Empty line for readability
# Add the actual content
parts.append(turn.content)
return parts
def _is_valid_uuid(val: str) -> bool:
"""
Validate UUID format for security

View File

@@ -196,9 +196,7 @@ def detect_file_type(file_path: str) -> str:
"""
Detect file type for appropriate processing strategy.
NOTE: This function is currently not used for line number auto-detection
due to backward compatibility requirements. It is intended for future
features requiring specific file type handling (e.g., image processing,
This function is intended for specific file type handling (e.g., image processing,
binary file analysis, or enhanced file filtering).
Args:
@@ -247,7 +245,7 @@ def should_add_line_numbers(file_path: str, include_line_numbers: Optional[bool]
if include_line_numbers is not None:
return include_line_numbers
# Default: DO NOT add line numbers (backwards compatibility)
# Default: DO NOT add line numbers
# Tools that want line numbers must explicitly request them
return False
@@ -1026,7 +1024,7 @@ def read_file_safely(file_path: str, max_size: int = 10 * 1024 * 1024) -> Option
return None
def check_total_file_size(files: list[str], model_name: Optional[str] = None) -> Optional[dict]:
def check_total_file_size(files: list[str], model_name: str) -> Optional[dict]:
"""
Check if total file sizes would exceed token threshold before embedding.
@@ -1034,9 +1032,12 @@ def check_total_file_size(files: list[str], model_name: Optional[str] = None) ->
No partial inclusion - either all files fit or request is rejected.
This forces Claude to make better file selection decisions.
This function MUST be called with the effective model name (after resolution).
It should never receive 'auto' or None - model resolution happens earlier.
Args:
files: List of file paths to check
model_name: Model name for context-aware thresholds, or None for default
model_name: The resolved model name for context-aware thresholds (required)
Returns:
Dict with `code_too_large` response if too large, None if acceptable
@@ -1044,17 +1045,14 @@ def check_total_file_size(files: list[str], model_name: Optional[str] = None) ->
if not files:
return None
# Get model-specific token allocation (dynamic thresholds)
if not model_name:
from config import DEFAULT_MODEL
# Validate we have a proper model name (not auto or None)
if not model_name or model_name.lower() == "auto":
raise ValueError(
f"check_total_file_size called with unresolved model: '{model_name}'. "
"Model must be resolved before file size checking."
)
model_name = DEFAULT_MODEL
# Handle auto mode gracefully
if model_name.lower() == "auto":
from providers.registry import ModelProviderRegistry
model_name = ModelProviderRegistry.get_preferred_fallback_model()
logger.info(f"File size check: Using model '{model_name}' for token limit calculation")
from utils.model_context import ModelContext
@@ -1091,6 +1089,7 @@ def check_total_file_size(files: list[str], model_name: Optional[str] = None) ->
"file_count": file_count,
"threshold_percent": threshold_percent,
"model_context_window": context_window,
"model_name": model_name,
"instructions": "Reduce file selection and try again - all files must fit within budget",
},
}

View File

@@ -60,8 +60,9 @@ class ModelContext:
token calculations, ensuring consistency across the system.
"""
def __init__(self, model_name: str):
def __init__(self, model_name: str, model_option: Optional[str] = None):
self.model_name = model_name
self.model_option = model_option # Store optional model option (e.g., "for", "against", etc.)
self._provider = None
self._capabilities = None
self._token_allocation = None