improve context sending to merge agent
This commit is contained in:
@@ -227,6 +227,256 @@ merged code here
|
||||
return prompt
|
||||
|
||||
|
||||
def build_conflict_only_prompt(
|
||||
file_path: str,
|
||||
conflicts: list[dict],
|
||||
spec_name: str,
|
||||
language: str,
|
||||
task_intent: dict | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build a focused prompt that only asks AI to resolve specific conflict regions.
|
||||
|
||||
This is MUCH more efficient than sending entire files - the AI only needs
|
||||
to resolve the actual conflicting lines, not regenerate thousands of lines.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file being merged
|
||||
conflicts: List of conflict dicts with keys:
|
||||
- id: Unique conflict identifier (e.g., "CONFLICT_1")
|
||||
- main_lines: Lines from main branch (the <<<<<<< section)
|
||||
- worktree_lines: Lines from feature branch (the >>>>>>> section)
|
||||
- context_before: Few lines before the conflict for context
|
||||
- context_after: Few lines after the conflict for context
|
||||
spec_name: Name of the feature branch/spec
|
||||
language: Programming language
|
||||
task_intent: Optional dict with title, description, spec_summary
|
||||
|
||||
Returns:
|
||||
Focused prompt asking AI to resolve only the conflict regions
|
||||
"""
|
||||
intent_section = ""
|
||||
if task_intent:
|
||||
intent_section = f"""
|
||||
FEATURE INTENT: {task_intent.get('title', spec_name)}
|
||||
{task_intent.get('description', '')}
|
||||
"""
|
||||
|
||||
conflict_sections = []
|
||||
for i, conflict in enumerate(conflicts, 1):
|
||||
context_before = conflict.get('context_before', '')
|
||||
context_after = conflict.get('context_after', '')
|
||||
main_lines = conflict.get('main_lines', '')
|
||||
worktree_lines = conflict.get('worktree_lines', '')
|
||||
conflict_id = conflict.get('id', f'CONFLICT_{i}')
|
||||
|
||||
section = f"""
|
||||
--- {conflict_id} ---
|
||||
{f"CONTEXT BEFORE:{chr(10)}{context_before}{chr(10)}" if context_before else ""}
|
||||
MAIN BRANCH VERSION:
|
||||
```{language}
|
||||
{main_lines}
|
||||
```
|
||||
|
||||
FEATURE BRANCH VERSION ({spec_name}):
|
||||
```{language}
|
||||
{worktree_lines}
|
||||
```
|
||||
{f"{chr(10)}CONTEXT AFTER:{chr(10)}{context_after}" if context_after else ""}
|
||||
"""
|
||||
conflict_sections.append(section)
|
||||
|
||||
all_conflicts = "\n".join(conflict_sections)
|
||||
|
||||
prompt = f'''You are a code merge expert. Resolve the following {len(conflicts)} conflict(s) in {file_path}.
|
||||
{intent_section}
|
||||
FILE: {file_path}
|
||||
LANGUAGE: {language}
|
||||
|
||||
{all_conflicts}
|
||||
|
||||
MERGE RULES:
|
||||
1. Keep ALL necessary code from both versions
|
||||
2. Combine changes logically - don't lose functionality from either branch
|
||||
3. If both branches add different things, include both
|
||||
4. If both branches modify the same thing differently, prefer the feature branch but include main's additions
|
||||
5. Output MUST be syntactically valid {language}
|
||||
|
||||
For EACH conflict, output the resolved code in this exact format:
|
||||
|
||||
--- {conflicts[0].get('id', 'CONFLICT_1')} RESOLVED ---
|
||||
```{language}
|
||||
resolved code here
|
||||
```
|
||||
|
||||
{f"--- CONFLICT_2 RESOLVED ---" if len(conflicts) > 1 else ""}
|
||||
{f"```{language}" if len(conflicts) > 1 else ""}
|
||||
{f"resolved code here" if len(conflicts) > 1 else ""}
|
||||
{f"```" if len(conflicts) > 1 else ""}
|
||||
|
||||
(continue for each conflict)
|
||||
'''
|
||||
return prompt
|
||||
|
||||
|
||||
def parse_conflict_markers(content: str) -> tuple[list[dict], list[str]]:
|
||||
"""
|
||||
Parse a file with git conflict markers and extract conflict regions.
|
||||
|
||||
Args:
|
||||
content: File content with git conflict markers
|
||||
|
||||
Returns:
|
||||
Tuple of (conflicts, clean_sections) where:
|
||||
- conflicts: List of conflict dicts with main_lines, worktree_lines, etc.
|
||||
- clean_sections: List of non-conflicting parts of the file (for reassembly)
|
||||
"""
|
||||
import re
|
||||
|
||||
conflicts = []
|
||||
clean_sections = []
|
||||
|
||||
# Pattern to match git conflict markers
|
||||
# <<<<<<< HEAD or <<<<<<< branch_name
|
||||
# content from current branch
|
||||
# =======
|
||||
# content from incoming branch
|
||||
# >>>>>>> branch_name or commit_hash
|
||||
conflict_pattern = re.compile(
|
||||
r'<<<<<<<[^\n]*\n' # Start marker
|
||||
r'(.*?)' # Main/HEAD content (group 1)
|
||||
r'=======\n' # Separator
|
||||
r'(.*?)' # Incoming/feature content (group 2)
|
||||
r'>>>>>>>[^\n]*\n?', # End marker
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
last_end = 0
|
||||
for i, match in enumerate(conflict_pattern.finditer(content), 1):
|
||||
# Get the clean section before this conflict
|
||||
clean_before = content[last_end:match.start()]
|
||||
clean_sections.append(clean_before)
|
||||
|
||||
# Extract context (last 3 lines before conflict)
|
||||
before_lines = clean_before.rstrip().split('\n')
|
||||
context_before = '\n'.join(before_lines[-3:]) if len(before_lines) >= 3 else clean_before.rstrip()
|
||||
|
||||
# Extract the conflict content
|
||||
main_lines = match.group(1).rstrip('\n')
|
||||
worktree_lines = match.group(2).rstrip('\n')
|
||||
|
||||
# Get context after (first 3 lines after conflict)
|
||||
after_start = match.end()
|
||||
after_content = content[after_start:after_start + 500] # Look ahead 500 chars
|
||||
after_lines = after_content.split('\n')[:3]
|
||||
context_after = '\n'.join(after_lines)
|
||||
|
||||
conflicts.append({
|
||||
'id': f'CONFLICT_{i}',
|
||||
'start': match.start(),
|
||||
'end': match.end(),
|
||||
'main_lines': main_lines,
|
||||
'worktree_lines': worktree_lines,
|
||||
'context_before': context_before,
|
||||
'context_after': context_after,
|
||||
})
|
||||
|
||||
last_end = match.end()
|
||||
|
||||
# Add the final clean section after last conflict
|
||||
if last_end < len(content):
|
||||
clean_sections.append(content[last_end:])
|
||||
|
||||
return conflicts, clean_sections
|
||||
|
||||
|
||||
def reassemble_with_resolutions(
|
||||
original_content: str,
|
||||
conflicts: list[dict],
|
||||
resolutions: dict[str, str],
|
||||
) -> str:
|
||||
"""
|
||||
Reassemble a file by replacing conflict regions with AI resolutions.
|
||||
|
||||
Args:
|
||||
original_content: File content with conflict markers
|
||||
conflicts: List of conflict dicts from parse_conflict_markers
|
||||
resolutions: Dict mapping conflict_id to resolved code
|
||||
|
||||
Returns:
|
||||
Clean file with conflicts resolved
|
||||
"""
|
||||
# Sort conflicts by start position (should already be sorted, but ensure it)
|
||||
sorted_conflicts = sorted(conflicts, key=lambda c: c['start'])
|
||||
|
||||
result_parts = []
|
||||
last_end = 0
|
||||
|
||||
for conflict in sorted_conflicts:
|
||||
# Add clean content before this conflict
|
||||
result_parts.append(original_content[last_end:conflict['start']])
|
||||
|
||||
# Add the resolution (or keep conflict if no resolution)
|
||||
conflict_id = conflict['id']
|
||||
if conflict_id in resolutions:
|
||||
result_parts.append(resolutions[conflict_id])
|
||||
else:
|
||||
# Fallback: prefer feature branch version if no resolution
|
||||
result_parts.append(conflict['worktree_lines'])
|
||||
|
||||
last_end = conflict['end']
|
||||
|
||||
# Add remaining content after last conflict
|
||||
result_parts.append(original_content[last_end:])
|
||||
|
||||
return ''.join(result_parts)
|
||||
|
||||
|
||||
def extract_conflict_resolutions(response: str, conflicts: list[dict], language: str) -> dict[str, str]:
|
||||
"""
|
||||
Extract resolved code for each conflict from AI response.
|
||||
|
||||
Args:
|
||||
response: AI response with resolved code blocks
|
||||
conflicts: List of conflict dicts (to get the IDs)
|
||||
language: Programming language for code block detection
|
||||
|
||||
Returns:
|
||||
Dict mapping conflict_id to resolved code
|
||||
"""
|
||||
import re
|
||||
|
||||
resolutions = {}
|
||||
|
||||
# Pattern to match resolution blocks
|
||||
# --- CONFLICT_1 RESOLVED --- or similar variations
|
||||
resolution_pattern = re.compile(
|
||||
r'---\s*(CONFLICT_\d+)\s*RESOLVED\s*---\s*\n'
|
||||
r'```(?:\w+)?\n'
|
||||
r'(.*?)'
|
||||
r'```',
|
||||
re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
|
||||
for match in resolution_pattern.finditer(response):
|
||||
conflict_id = match.group(1).upper()
|
||||
resolved_code = match.group(2).rstrip('\n')
|
||||
resolutions[conflict_id] = resolved_code
|
||||
|
||||
# Fallback: if only one conflict and we can find a single code block
|
||||
if len(conflicts) == 1 and not resolutions:
|
||||
code_block_pattern = re.compile(
|
||||
r'```(?:\w+)?\n(.*?)```',
|
||||
re.DOTALL
|
||||
)
|
||||
matches = list(code_block_pattern.finditer(response))
|
||||
if matches:
|
||||
# Use the first (or only) code block
|
||||
resolutions[conflicts[0]['id']] = matches[0].group(1).rstrip('\n')
|
||||
|
||||
return resolutions
|
||||
|
||||
|
||||
def optimize_prompt_for_length(
|
||||
context: "MergeContext",
|
||||
max_content_chars: int = 50000,
|
||||
|
||||
+176
-19
@@ -1694,9 +1694,15 @@ def _validate_merged_syntax(file_path: str, content: str, project_dir: Path) ->
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
# Extract first error line
|
||||
error_lines = result.stderr.strip().split('\n')[:3]
|
||||
return False, '\n'.join(error_lines)
|
||||
# Filter out npm warnings (they go to stderr but aren't errors)
|
||||
error_lines = [
|
||||
line for line in result.stderr.strip().split('\n')
|
||||
if line and not line.startswith('npm warn') and not line.startswith('npm WARN')
|
||||
]
|
||||
# Only treat as error if there are actual TypeScript errors
|
||||
if error_lines:
|
||||
return False, '\n'.join(error_lines[:3])
|
||||
# No actual errors, just npm warnings - syntax is valid
|
||||
|
||||
# Try eslint for all JS/TS
|
||||
result = subprocess.run(
|
||||
@@ -1744,6 +1750,71 @@ def _validate_merged_syntax(file_path: str, content: str, project_dir: Path) ->
|
||||
return True, ""
|
||||
|
||||
|
||||
def _create_conflict_file_with_git(
|
||||
main_content: str,
|
||||
worktree_content: str,
|
||||
base_content: Optional[str],
|
||||
project_dir: Path,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Use git merge-file to create a file with conflict markers.
|
||||
|
||||
This produces a file with standard git conflict markers that can be
|
||||
parsed to extract only the conflicting regions.
|
||||
|
||||
Returns the merged content with conflict markers, or None if no conflicts.
|
||||
"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
if not base_content:
|
||||
# Without a base, we can't do proper 3-way merge with markers
|
||||
return None
|
||||
|
||||
try:
|
||||
# Create temp files for git merge-file
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.main', delete=False) as main_f:
|
||||
main_f.write(main_content)
|
||||
main_path = main_f.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.base', delete=False) as base_f:
|
||||
base_f.write(base_content)
|
||||
base_path = base_f.name
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.worktree', delete=False) as worktree_f:
|
||||
worktree_f.write(worktree_content)
|
||||
worktree_path = worktree_f.name
|
||||
|
||||
try:
|
||||
# git merge-file modifies the first file in place
|
||||
# Returns 0 if no conflicts, >0 if conflicts
|
||||
result = subprocess.run(
|
||||
['git', 'merge-file', '-p', main_path, base_path, worktree_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Return code > 0 means conflicts exist
|
||||
if result.returncode > 0 and '<<<<<<' in result.stdout:
|
||||
return result.stdout
|
||||
elif result.returncode == 0:
|
||||
# Clean merge, no conflicts
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Cleanup temp files
|
||||
Path(main_path).unlink(missing_ok=True)
|
||||
Path(base_path).unlink(missing_ok=True)
|
||||
Path(worktree_path).unlink(missing_ok=True)
|
||||
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"git merge-file failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _merge_file_with_ai(
|
||||
file_path: str,
|
||||
main_content: str,
|
||||
@@ -1756,7 +1827,12 @@ def _merge_file_with_ai(
|
||||
"""
|
||||
Use AI to merge a conflicting file.
|
||||
|
||||
OPTIMIZED: First tries to identify specific conflict regions and only
|
||||
sends those to the AI, rather than regenerating the entire file.
|
||||
|
||||
This enhanced version includes:
|
||||
- Conflict-region-only merging (FAST - only sends conflict lines)
|
||||
- Fallback to full-file merge for complex cases
|
||||
- FileTimelineTracker context for full situational awareness
|
||||
- Task intent from implementation_plan.json
|
||||
- Binary file detection
|
||||
@@ -1766,7 +1842,14 @@ def _merge_file_with_ai(
|
||||
Returns merged content, or None if AI couldn't resolve.
|
||||
"""
|
||||
from merge import create_claude_resolver
|
||||
from merge.prompts import build_timeline_merge_prompt, build_simple_merge_prompt
|
||||
from merge.prompts import (
|
||||
build_timeline_merge_prompt,
|
||||
build_simple_merge_prompt,
|
||||
build_conflict_only_prompt,
|
||||
parse_conflict_markers,
|
||||
reassemble_with_resolutions,
|
||||
extract_conflict_resolutions,
|
||||
)
|
||||
|
||||
debug(MODULE, f"AI merge starting for: {file_path}",
|
||||
spec_name=spec_name,
|
||||
@@ -1795,8 +1878,6 @@ def _merge_file_with_ai(
|
||||
print(warning(f" File too large ({max_lines} lines > {MAX_FILE_LINES_FOR_AI}), skipping AI merge"))
|
||||
return None
|
||||
|
||||
print(muted(f" Using AI to merge changes..."))
|
||||
|
||||
# Create an AI resolver
|
||||
resolver = create_claude_resolver()
|
||||
|
||||
@@ -1805,6 +1886,90 @@ def _merge_file_with_ai(
|
||||
print(muted(f" AI not available, trying heuristic merge..."))
|
||||
return _heuristic_merge(main_content, worktree_content, base_content)
|
||||
|
||||
# Determine language
|
||||
language = resolver._infer_language(file_path)
|
||||
debug(MODULE, "Detected language", language=language)
|
||||
|
||||
# Get task intent for context
|
||||
task_intent = None
|
||||
if project_dir:
|
||||
task_intent = _get_task_intent(project_dir, spec_name)
|
||||
|
||||
# OPTIMIZATION: Try conflict-region-only merge first
|
||||
# This is MUCH faster than sending entire files to AI
|
||||
if project_dir and base_content:
|
||||
conflict_file = _create_conflict_file_with_git(
|
||||
main_content, worktree_content, base_content, project_dir
|
||||
)
|
||||
|
||||
if conflict_file:
|
||||
conflicts, _ = parse_conflict_markers(conflict_file)
|
||||
|
||||
if conflicts:
|
||||
# Calculate how much smaller this approach is
|
||||
total_conflict_lines = sum(
|
||||
len(c['main_lines'].split('\n')) + len(c['worktree_lines'].split('\n'))
|
||||
for c in conflicts
|
||||
)
|
||||
savings_pct = 100 - (total_conflict_lines * 100 // max(max_lines, 1))
|
||||
|
||||
debug(MODULE, "Using conflict-only merge (optimized)",
|
||||
num_conflicts=len(conflicts),
|
||||
conflict_lines=total_conflict_lines,
|
||||
file_lines=max_lines,
|
||||
savings_pct=savings_pct)
|
||||
print(muted(f" Found {len(conflicts)} conflict region(s) ({total_conflict_lines} lines vs {max_lines} total - {savings_pct}% smaller prompt)"))
|
||||
|
||||
# Build focused prompt with only conflict regions
|
||||
prompt = build_conflict_only_prompt(
|
||||
file_path=file_path,
|
||||
conflicts=conflicts,
|
||||
spec_name=spec_name,
|
||||
language=language,
|
||||
task_intent=task_intent,
|
||||
)
|
||||
|
||||
try:
|
||||
response = resolver.ai_call_fn(
|
||||
"You are an expert code merge assistant. Resolve ONLY the specific conflicts shown. Output the resolved code for each conflict.",
|
||||
prompt,
|
||||
)
|
||||
|
||||
if response:
|
||||
# Extract resolutions for each conflict
|
||||
resolutions = extract_conflict_resolutions(response, conflicts, language)
|
||||
|
||||
if resolutions:
|
||||
debug(MODULE, "Extracted conflict resolutions",
|
||||
num_resolutions=len(resolutions),
|
||||
expected=len(conflicts))
|
||||
|
||||
# Reassemble the file with resolved conflicts
|
||||
merged = reassemble_with_resolutions(conflict_file, conflicts, resolutions)
|
||||
|
||||
# Validate syntax
|
||||
if project_dir:
|
||||
is_valid, error_msg = _validate_merged_syntax(file_path, merged, project_dir)
|
||||
if is_valid:
|
||||
debug_success(MODULE, "Conflict-only merge succeeded",
|
||||
file_path=file_path,
|
||||
conflicts_resolved=len(resolutions))
|
||||
print(success(f" ✓ Resolved {len(resolutions)} conflict(s)"))
|
||||
return merged
|
||||
else:
|
||||
debug_warning(MODULE, "Conflict-only merge produced invalid syntax, falling back",
|
||||
error=error_msg)
|
||||
print(muted(f" Conflict-only merge had syntax issues, trying full-file merge..."))
|
||||
else:
|
||||
return merged
|
||||
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Conflict-only merge failed: {e}, falling back to full-file")
|
||||
print(muted(f" Conflict-only merge failed, trying full-file merge..."))
|
||||
|
||||
# FALLBACK: Full-file merge approach (slower but more comprehensive)
|
||||
print(muted(f" Using full-file AI merge..."))
|
||||
|
||||
# Try to get timeline context for richer merge prompt
|
||||
timeline_context = None
|
||||
if project_dir:
|
||||
@@ -1819,10 +1984,6 @@ def _merge_file_with_ai(
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not get timeline context: {e}")
|
||||
|
||||
# Determine language
|
||||
language = resolver._infer_language(file_path)
|
||||
debug(MODULE, "Detected language", language=language)
|
||||
|
||||
# Build prompt - use timeline context if available, fallback to simple prompt
|
||||
if timeline_context and timeline_context.total_commits_behind > 0:
|
||||
# Use the rich timeline-based prompt with full situational awareness
|
||||
@@ -1836,14 +1997,10 @@ def _merge_file_with_ai(
|
||||
# Fallback to simple three-way merge prompt
|
||||
debug(MODULE, "Building simple merge prompt (no timeline context)")
|
||||
|
||||
# Get task intent for basic context
|
||||
task_intent = None
|
||||
if project_dir:
|
||||
task_intent = _get_task_intent(project_dir, spec_name)
|
||||
if task_intent:
|
||||
debug(MODULE, "Loaded task intent",
|
||||
title=task_intent.get('title'),
|
||||
num_subtasks=len(task_intent.get('subtasks', [])))
|
||||
if task_intent:
|
||||
debug(MODULE, "Loaded task intent",
|
||||
title=task_intent.get('title'),
|
||||
num_subtasks=len(task_intent.get('subtasks', [])))
|
||||
|
||||
prompt = build_simple_merge_prompt(
|
||||
file_path=file_path,
|
||||
@@ -1856,7 +2013,7 @@ def _merge_file_with_ai(
|
||||
)
|
||||
|
||||
try:
|
||||
debug(MODULE, "Calling AI for merge",
|
||||
debug(MODULE, "Calling AI for full-file merge",
|
||||
file_path=file_path,
|
||||
has_timeline_context=timeline_context is not None)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user