patchpal 0.2.1__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {patchpal-0.2.1/patchpal.egg-info → patchpal-0.3.0}/PKG-INFO +11 -9
- {patchpal-0.2.1 → patchpal-0.3.0}/README.md +10 -8
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/__init__.py +1 -1
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/agent.py +88 -8
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/cli.py +4 -1
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/context.py +5 -4
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/tools.py +149 -21
- {patchpal-0.2.1 → patchpal-0.3.0/patchpal.egg-info}/PKG-INFO +11 -9
- {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_agent.py +100 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_tools.py +253 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/LICENSE +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/MANIFEST.in +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/permissions.py +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/skills.py +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal/system_prompt.md +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/SOURCES.txt +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/dependency_links.txt +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/entry_points.txt +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/requires.txt +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/patchpal.egg-info/top_level.txt +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/pyproject.toml +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/setup.cfg +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_cli.py +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_context.py +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_guardrails.py +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_operational_safety.py +0 -0
- {patchpal-0.2.1 → patchpal-0.3.0}/tests/test_skills.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchpal
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A lean Claude Code clone in pure Python
|
|
5
5
|
Author: PatchPal Contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -717,7 +717,7 @@ export PATCHPAL_MAX_ITERATIONS=150 # Max agent iterations per task (de
|
|
|
717
717
|
```bash
|
|
718
718
|
# Auto-Compaction
|
|
719
719
|
export PATCHPAL_DISABLE_AUTOCOMPACT=true # Disable auto-compaction (default: false - enabled)
|
|
720
|
-
export PATCHPAL_COMPACT_THRESHOLD=0.
|
|
720
|
+
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger compaction at % full (default: 0.75 = 75%)
|
|
721
721
|
|
|
722
722
|
# Context Limits
|
|
723
723
|
export PATCHPAL_CONTEXT_LIMIT=100000 # Override model's context limit (for testing)
|
|
@@ -892,7 +892,7 @@ PatchPal automatically manages the context window to prevent "input too long" er
|
|
|
892
892
|
**Features:**
|
|
893
893
|
- **Automatic token tracking**: Monitors context usage in real-time
|
|
894
894
|
- **Smart pruning**: Removes old tool outputs (keeps last 40k tokens) before resorting to full compaction
|
|
895
|
-
- **Auto-compaction**: Summarizes conversation history when approaching
|
|
895
|
+
- **Auto-compaction**: Summarizes conversation history when approaching 75% capacity
|
|
896
896
|
- **Manual control**: Check status with `/status`, disable with environment variable
|
|
897
897
|
|
|
898
898
|
**Commands:**
|
|
@@ -931,7 +931,7 @@ You can test the context management system with small values to trigger compacti
|
|
|
931
931
|
```bash
|
|
932
932
|
# Set up small context window for testing
|
|
933
933
|
export PATCHPAL_CONTEXT_LIMIT=10000 # Force 10k token limit (instead of 200k for Claude)
|
|
934
|
-
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (
|
|
934
|
+
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (default, but shown for clarity)
|
|
935
935
|
# Note: System prompt + output reserve = ~6.4k tokens baseline
|
|
936
936
|
# So 75% of 10k = 7.5k, leaving ~1k for conversation
|
|
937
937
|
export PATCHPAL_PRUNE_PROTECT=500 # Keep only last 500 tokens of tool outputs
|
|
@@ -951,9 +951,9 @@ You: /status
|
|
|
951
951
|
# Continue - should see pruning messages
|
|
952
952
|
You: search for "context" in all files
|
|
953
953
|
# You should see:
|
|
954
|
-
# ⚠️ Context window at
|
|
954
|
+
# ⚠️ Context window at 75% capacity. Compacting...
|
|
955
955
|
# Pruned old tool outputs (saved ~400 tokens)
|
|
956
|
-
# ✓ Compaction complete. Saved 850 tokens (
|
|
956
|
+
# ✓ Compaction complete. Saved 850 tokens (75% → 58%)
|
|
957
957
|
```
|
|
958
958
|
|
|
959
959
|
**How It Works:**
|
|
@@ -984,7 +984,7 @@ Context Window Status
|
|
|
984
984
|
Usage: 80%
|
|
985
985
|
[████████████████████████████████████████░░░░░░░░░]
|
|
986
986
|
|
|
987
|
-
Auto-compaction: Enabled (triggers at
|
|
987
|
+
Auto-compaction: Enabled (triggers at 75%)
|
|
988
988
|
======================================================================
|
|
989
989
|
```
|
|
990
990
|
|
|
@@ -998,7 +998,9 @@ The system ensures you can work for extended periods without hitting context lim
|
|
|
998
998
|
|
|
999
999
|
**Error: "Context Window Error - Input is too long"**
|
|
1000
1000
|
- PatchPal includes automatic context management (compaction) to prevent this error.
|
|
1001
|
-
-
|
|
1001
|
+
- **Quick fix:** Run `/compact` to immediately compact the conversation history and free up space.
|
|
1002
|
+
- Use `/status` to check your context window usage and see how close you are to the limit.
|
|
1002
1003
|
- If auto-compaction is disabled, re-enable it: `unset PATCHPAL_DISABLE_AUTOCOMPACT`
|
|
1003
|
-
- Context is automatically managed at
|
|
1004
|
+
- Context is automatically managed at 75% capacity through pruning and compaction.
|
|
1005
|
+
- **Note:** Token estimation may be slightly inaccurate compared to the model's actual counting. If you see this error despite auto-compaction being enabled, the 75% threshold may need to be lowered further for your workload. You can adjust it with `export PATCHPAL_COMPACT_THRESHOLD=0.70` (or lower).
|
|
1004
1006
|
- See [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration) for context management settings.
|
|
@@ -680,7 +680,7 @@ export PATCHPAL_MAX_ITERATIONS=150 # Max agent iterations per task (de
|
|
|
680
680
|
```bash
|
|
681
681
|
# Auto-Compaction
|
|
682
682
|
export PATCHPAL_DISABLE_AUTOCOMPACT=true # Disable auto-compaction (default: false - enabled)
|
|
683
|
-
export PATCHPAL_COMPACT_THRESHOLD=0.
|
|
683
|
+
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger compaction at % full (default: 0.75 = 75%)
|
|
684
684
|
|
|
685
685
|
# Context Limits
|
|
686
686
|
export PATCHPAL_CONTEXT_LIMIT=100000 # Override model's context limit (for testing)
|
|
@@ -855,7 +855,7 @@ PatchPal automatically manages the context window to prevent "input too long" er
|
|
|
855
855
|
**Features:**
|
|
856
856
|
- **Automatic token tracking**: Monitors context usage in real-time
|
|
857
857
|
- **Smart pruning**: Removes old tool outputs (keeps last 40k tokens) before resorting to full compaction
|
|
858
|
-
- **Auto-compaction**: Summarizes conversation history when approaching
|
|
858
|
+
- **Auto-compaction**: Summarizes conversation history when approaching 75% capacity
|
|
859
859
|
- **Manual control**: Check status with `/status`, disable with environment variable
|
|
860
860
|
|
|
861
861
|
**Commands:**
|
|
@@ -894,7 +894,7 @@ You can test the context management system with small values to trigger compacti
|
|
|
894
894
|
```bash
|
|
895
895
|
# Set up small context window for testing
|
|
896
896
|
export PATCHPAL_CONTEXT_LIMIT=10000 # Force 10k token limit (instead of 200k for Claude)
|
|
897
|
-
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (
|
|
897
|
+
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (default, but shown for clarity)
|
|
898
898
|
# Note: System prompt + output reserve = ~6.4k tokens baseline
|
|
899
899
|
# So 75% of 10k = 7.5k, leaving ~1k for conversation
|
|
900
900
|
export PATCHPAL_PRUNE_PROTECT=500 # Keep only last 500 tokens of tool outputs
|
|
@@ -914,9 +914,9 @@ You: /status
|
|
|
914
914
|
# Continue - should see pruning messages
|
|
915
915
|
You: search for "context" in all files
|
|
916
916
|
# You should see:
|
|
917
|
-
# ⚠️ Context window at
|
|
917
|
+
# ⚠️ Context window at 75% capacity. Compacting...
|
|
918
918
|
# Pruned old tool outputs (saved ~400 tokens)
|
|
919
|
-
# ✓ Compaction complete. Saved 850 tokens (
|
|
919
|
+
# ✓ Compaction complete. Saved 850 tokens (75% → 58%)
|
|
920
920
|
```
|
|
921
921
|
|
|
922
922
|
**How It Works:**
|
|
@@ -947,7 +947,7 @@ Context Window Status
|
|
|
947
947
|
Usage: 80%
|
|
948
948
|
[████████████████████████████████████████░░░░░░░░░]
|
|
949
949
|
|
|
950
|
-
Auto-compaction: Enabled (triggers at
|
|
950
|
+
Auto-compaction: Enabled (triggers at 75%)
|
|
951
951
|
======================================================================
|
|
952
952
|
```
|
|
953
953
|
|
|
@@ -961,7 +961,9 @@ The system ensures you can work for extended periods without hitting context lim
|
|
|
961
961
|
|
|
962
962
|
**Error: "Context Window Error - Input is too long"**
|
|
963
963
|
- PatchPal includes automatic context management (compaction) to prevent this error.
|
|
964
|
-
-
|
|
964
|
+
- **Quick fix:** Run `/compact` to immediately compact the conversation history and free up space.
|
|
965
|
+
- Use `/status` to check your context window usage and see how close you are to the limit.
|
|
965
966
|
- If auto-compaction is disabled, re-enable it: `unset PATCHPAL_DISABLE_AUTOCOMPACT`
|
|
966
|
-
- Context is automatically managed at
|
|
967
|
+
- Context is automatically managed at 75% capacity through pruning and compaction.
|
|
968
|
+
- **Note:** Token estimation may be slightly inaccurate compared to the model's actual counting. If you see this error despite auto-compaction being enabled, the 75% threshold may need to be lowered further for your workload. You can adjust it with `export PATCHPAL_COMPACT_THRESHOLD=0.70` (or lower).
|
|
967
969
|
- See [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration) for context management settings.
|
|
@@ -702,6 +702,73 @@ def _load_system_prompt() -> str:
|
|
|
702
702
|
SYSTEM_PROMPT = _load_system_prompt()
|
|
703
703
|
|
|
704
704
|
|
|
705
|
+
def _supports_prompt_caching(model_id: str) -> bool:
|
|
706
|
+
"""Check if the model supports prompt caching.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
model_id: LiteLLM model identifier
|
|
710
|
+
|
|
711
|
+
Returns:
|
|
712
|
+
True if the model supports prompt caching
|
|
713
|
+
"""
|
|
714
|
+
# Anthropic models support caching (direct API or via Bedrock)
|
|
715
|
+
if "anthropic" in model_id.lower() or "claude" in model_id.lower():
|
|
716
|
+
return True
|
|
717
|
+
# Bedrock with Anthropic models
|
|
718
|
+
if model_id.startswith("bedrock/") and "anthropic" in model_id.lower():
|
|
719
|
+
return True
|
|
720
|
+
return False
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _apply_prompt_caching(messages: List[Dict[str, Any]], model_id: str) -> List[Dict[str, Any]]:
|
|
724
|
+
"""Apply prompt caching markers to messages following OpenCode's strategy.
|
|
725
|
+
|
|
726
|
+
Caches:
|
|
727
|
+
- System messages (first 1-2 messages with role="system")
|
|
728
|
+
- Last 2 conversation messages (recent context)
|
|
729
|
+
|
|
730
|
+
This provides 90% cost reduction on cached content after the first request.
|
|
731
|
+
|
|
732
|
+
Args:
|
|
733
|
+
messages: List of message dictionaries
|
|
734
|
+
model_id: LiteLLM model identifier
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
Modified messages with cache markers
|
|
738
|
+
"""
|
|
739
|
+
if not _supports_prompt_caching(model_id):
|
|
740
|
+
return messages
|
|
741
|
+
|
|
742
|
+
# Determine cache marker format based on provider
|
|
743
|
+
if model_id.startswith("bedrock/"):
|
|
744
|
+
# Bedrock uses cachePoint
|
|
745
|
+
cache_marker = {"cachePoint": {"type": "ephemeral"}}
|
|
746
|
+
else:
|
|
747
|
+
# Direct Anthropic API uses cacheControl
|
|
748
|
+
cache_marker = {"cacheControl": {"type": "ephemeral"}}
|
|
749
|
+
|
|
750
|
+
# Find system messages (usually at the start)
|
|
751
|
+
system_messages = [i for i, msg in enumerate(messages) if msg.get("role") == "system"]
|
|
752
|
+
|
|
753
|
+
# Find last 2 non-system messages (recent context)
|
|
754
|
+
non_system_messages = [i for i, msg in enumerate(messages) if msg.get("role") != "system"]
|
|
755
|
+
last_two_indices = (
|
|
756
|
+
non_system_messages[-2:] if len(non_system_messages) >= 2 else non_system_messages
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# Apply caching to system messages (first 2)
|
|
760
|
+
for idx in system_messages[:2]:
|
|
761
|
+
if "cache_control" not in messages[idx] and "cachePoint" not in messages[idx]:
|
|
762
|
+
messages[idx] = {**messages[idx], **cache_marker}
|
|
763
|
+
|
|
764
|
+
# Apply caching to last 2 messages
|
|
765
|
+
for idx in last_two_indices:
|
|
766
|
+
if "cache_control" not in messages[idx] and "cachePoint" not in messages[idx]:
|
|
767
|
+
messages[idx] = {**messages[idx], **cache_marker}
|
|
768
|
+
|
|
769
|
+
return messages
|
|
770
|
+
|
|
771
|
+
|
|
705
772
|
class PatchPalAgent:
|
|
706
773
|
"""Simple agent that uses LiteLLM for tool calling."""
|
|
707
774
|
|
|
@@ -765,7 +832,7 @@ class PatchPalAgent:
|
|
|
765
832
|
def _perform_auto_compaction(self):
|
|
766
833
|
"""Perform automatic context window compaction.
|
|
767
834
|
|
|
768
|
-
This method is called when the context window reaches
|
|
835
|
+
This method is called when the context window reaches 75% capacity.
|
|
769
836
|
It attempts pruning first, then full compaction if needed.
|
|
770
837
|
"""
|
|
771
838
|
# Don't compact if we have very few messages - compaction summary
|
|
@@ -824,13 +891,20 @@ class PatchPalAgent:
|
|
|
824
891
|
|
|
825
892
|
try:
|
|
826
893
|
# Create compaction using the LLM
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
894
|
+
def compaction_completion(msgs):
|
|
895
|
+
# Prepare messages with system prompt
|
|
896
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + msgs
|
|
897
|
+
# Apply prompt caching for supported models
|
|
898
|
+
messages = _apply_prompt_caching(messages, self.model_id)
|
|
899
|
+
return litellm.completion(
|
|
830
900
|
model=self.model_id,
|
|
831
|
-
messages=
|
|
901
|
+
messages=messages,
|
|
832
902
|
**self.litellm_kwargs,
|
|
833
|
-
)
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
summary_msg, summary_text = self.context_manager.create_compaction(
|
|
906
|
+
self.messages,
|
|
907
|
+
compaction_completion,
|
|
834
908
|
)
|
|
835
909
|
|
|
836
910
|
# Replace message history with compacted version
|
|
@@ -906,11 +980,17 @@ class PatchPalAgent:
|
|
|
906
980
|
# Show thinking message
|
|
907
981
|
print("\033[2m🤔 Thinking...\033[0m", flush=True)
|
|
908
982
|
|
|
983
|
+
# Prepare messages with system prompt
|
|
984
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}] + self.messages
|
|
985
|
+
|
|
986
|
+
# Apply prompt caching for supported models (Anthropic/Claude)
|
|
987
|
+
messages = _apply_prompt_caching(messages, self.model_id)
|
|
988
|
+
|
|
909
989
|
# Use LiteLLM for all providers
|
|
910
990
|
try:
|
|
911
991
|
response = litellm.completion(
|
|
912
992
|
model=self.model_id,
|
|
913
|
-
messages=
|
|
993
|
+
messages=messages,
|
|
914
994
|
tools=TOOLS,
|
|
915
995
|
tool_choice="auto",
|
|
916
996
|
**self.litellm_kwargs,
|
|
@@ -1048,7 +1128,7 @@ class PatchPalAgent:
|
|
|
1048
1128
|
print("\033[2m📋 Listing TODO tasks...\033[0m", flush=True)
|
|
1049
1129
|
elif tool_name == "todo_complete":
|
|
1050
1130
|
print(
|
|
1051
|
-
f"\033[2m✓
|
|
1131
|
+
f"\033[2m✓ Completed task #{tool_args.get('task_id', '')}\033[0m",
|
|
1052
1132
|
flush=True,
|
|
1053
1133
|
)
|
|
1054
1134
|
elif tool_name == "todo_update":
|
|
@@ -351,7 +351,10 @@ Supported models: Any LiteLLM-supported model
|
|
|
351
351
|
|
|
352
352
|
# Show auto-compaction status
|
|
353
353
|
if agent.enable_auto_compact:
|
|
354
|
-
|
|
354
|
+
threshold_pct = int(agent.context_manager.COMPACT_THRESHOLD * 100)
|
|
355
|
+
print(
|
|
356
|
+
f"\n Auto-compaction: \033[32mEnabled\033[0m (triggers at {threshold_pct}%)"
|
|
357
|
+
)
|
|
355
358
|
else:
|
|
356
359
|
print(
|
|
357
360
|
"\n Auto-compaction: \033[33mDisabled\033[0m (set PATCHPAL_DISABLE_AUTOCOMPACT=false to enable)"
|
|
@@ -57,8 +57,9 @@ class TokenEstimator:
|
|
|
57
57
|
except Exception:
|
|
58
58
|
pass
|
|
59
59
|
|
|
60
|
-
# Fallback: ~
|
|
61
|
-
|
|
60
|
+
# Fallback: ~3 chars per token (conservative for code-heavy content)
|
|
61
|
+
# This is more accurate than 4 chars/token for technical content
|
|
62
|
+
return len(str(text)) // 3
|
|
62
63
|
|
|
63
64
|
def estimate_message_tokens(self, message: Dict[str, Any]) -> int:
|
|
64
65
|
"""Estimate tokens in a single message.
|
|
@@ -119,8 +120,8 @@ class ContextManager:
|
|
|
119
120
|
os.getenv("PATCHPAL_PRUNE_MINIMUM", "20000")
|
|
120
121
|
) # Minimum tokens to prune to make it worthwhile
|
|
121
122
|
COMPACT_THRESHOLD = float(
|
|
122
|
-
os.getenv("PATCHPAL_COMPACT_THRESHOLD", "0.
|
|
123
|
-
) # Compact at
|
|
123
|
+
os.getenv("PATCHPAL_COMPACT_THRESHOLD", "0.75")
|
|
124
|
+
) # Compact at 75% capacity (lower due to estimation inaccuracy)
|
|
124
125
|
|
|
125
126
|
# Model context limits (tokens)
|
|
126
127
|
# From OpenCode's models.dev data - see https://models.dev/api.json
|
|
@@ -1437,6 +1437,122 @@ def ask_user(question: str, options: Optional[list] = None) -> str:
|
|
|
1437
1437
|
return answer
|
|
1438
1438
|
|
|
1439
1439
|
|
|
1440
|
+
# ============================================================================
|
|
1441
|
+
# Edit File - Multi-Strategy String Matching
|
|
1442
|
+
# ============================================================================
|
|
1443
|
+
# Based on approaches from gemini-cli and OpenCode: try multiple matching
|
|
1444
|
+
# strategies to handle # whitespace/indentation issues without requiring
|
|
1445
|
+
# exact character-by-character matching
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _try_simple_match(content: str, old_string: str) -> Optional[str]:
|
|
1449
|
+
"""Try exact string match."""
|
|
1450
|
+
if old_string in content:
|
|
1451
|
+
return old_string
|
|
1452
|
+
return None
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def _try_line_trimmed_match(content: str, old_string: str) -> Optional[str]:
|
|
1456
|
+
"""Try matching lines where content is the same when trimmed."""
|
|
1457
|
+
content_lines = content.split("\n")
|
|
1458
|
+
search_lines = old_string.split("\n")
|
|
1459
|
+
|
|
1460
|
+
# Remove trailing empty line if present
|
|
1461
|
+
if search_lines and search_lines[-1] == "":
|
|
1462
|
+
search_lines.pop()
|
|
1463
|
+
|
|
1464
|
+
# Scan through content looking for matching block
|
|
1465
|
+
for i in range(len(content_lines) - len(search_lines) + 1):
|
|
1466
|
+
matches = True
|
|
1467
|
+
for j, search_line in enumerate(search_lines):
|
|
1468
|
+
if content_lines[i + j].strip() != search_line.strip():
|
|
1469
|
+
matches = False
|
|
1470
|
+
break
|
|
1471
|
+
|
|
1472
|
+
if matches:
|
|
1473
|
+
# Found a match - return the original lines (with indentation) joined
|
|
1474
|
+
matched_lines = content_lines[i : i + len(search_lines)]
|
|
1475
|
+
return "\n".join(matched_lines)
|
|
1476
|
+
|
|
1477
|
+
return None
|
|
1478
|
+
|
|
1479
|
+
|
|
1480
|
+
def _try_whitespace_normalized_match(content: str, old_string: str) -> Optional[str]:
|
|
1481
|
+
"""Try matching with normalized whitespace (all whitespace becomes single space)."""
|
|
1482
|
+
|
|
1483
|
+
def normalize(text: str) -> str:
|
|
1484
|
+
return " ".join(text.split())
|
|
1485
|
+
|
|
1486
|
+
normalized_search = normalize(old_string)
|
|
1487
|
+
|
|
1488
|
+
# Try single line matches
|
|
1489
|
+
for line in content.split("\n"):
|
|
1490
|
+
if normalize(line) == normalized_search:
|
|
1491
|
+
return line
|
|
1492
|
+
|
|
1493
|
+
# Try multi-line matches
|
|
1494
|
+
search_lines = old_string.split("\n")
|
|
1495
|
+
if len(search_lines) > 1:
|
|
1496
|
+
content_lines = content.split("\n")
|
|
1497
|
+
for i in range(len(content_lines) - len(search_lines) + 1):
|
|
1498
|
+
block_lines = content_lines[i : i + len(search_lines)]
|
|
1499
|
+
if normalize("\n".join(block_lines)) == normalized_search:
|
|
1500
|
+
return "\n".join(block_lines)
|
|
1501
|
+
|
|
1502
|
+
return None
|
|
1503
|
+
|
|
1504
|
+
|
|
1505
|
+
def _find_match_with_strategies(content: str, old_string: str) -> Optional[str]:
|
|
1506
|
+
"""
|
|
1507
|
+
Try multiple matching strategies in order.
|
|
1508
|
+
Returns the matched string from content (preserving original formatting).
|
|
1509
|
+
"""
|
|
1510
|
+
# Strategy 1: Exact match (but only if it's not a substring that would match better with trimming)
|
|
1511
|
+
# Skip exact match if old_string doesn't have leading/trailing whitespace
|
|
1512
|
+
# and we're searching for what looks like a complete statement
|
|
1513
|
+
use_exact = old_string in content
|
|
1514
|
+
|
|
1515
|
+
# If the old_string has no leading whitespace but contains a newline or looks like code,
|
|
1516
|
+
# skip exact match and try trimmed matching first
|
|
1517
|
+
if use_exact and not old_string.startswith((" ", "\t", "\n")):
|
|
1518
|
+
# Check if this looks like we're searching for a line of code
|
|
1519
|
+
# (contains common code patterns but no leading indentation)
|
|
1520
|
+
code_patterns = [
|
|
1521
|
+
"(",
|
|
1522
|
+
")",
|
|
1523
|
+
"=",
|
|
1524
|
+
"def ",
|
|
1525
|
+
"class ",
|
|
1526
|
+
"if ",
|
|
1527
|
+
"for ",
|
|
1528
|
+
"while ",
|
|
1529
|
+
"return ",
|
|
1530
|
+
"print(",
|
|
1531
|
+
]
|
|
1532
|
+
if any(pattern in old_string for pattern in code_patterns):
|
|
1533
|
+
# Try trimmed match first for code-like patterns
|
|
1534
|
+
match = _try_line_trimmed_match(content, old_string)
|
|
1535
|
+
if match:
|
|
1536
|
+
return match
|
|
1537
|
+
|
|
1538
|
+
# Now try exact match
|
|
1539
|
+
match = _try_simple_match(content, old_string)
|
|
1540
|
+
if match:
|
|
1541
|
+
return match
|
|
1542
|
+
|
|
1543
|
+
# Strategy 2: Line-trimmed match (handles indentation differences)
|
|
1544
|
+
match = _try_line_trimmed_match(content, old_string)
|
|
1545
|
+
if match:
|
|
1546
|
+
return match
|
|
1547
|
+
|
|
1548
|
+
# Strategy 3: Whitespace-normalized match (handles spacing differences)
|
|
1549
|
+
match = _try_whitespace_normalized_match(content, old_string)
|
|
1550
|
+
if match:
|
|
1551
|
+
return match
|
|
1552
|
+
|
|
1553
|
+
return None
|
|
1554
|
+
|
|
1555
|
+
|
|
1440
1556
|
# ============================================================================
|
|
1441
1557
|
|
|
1442
1558
|
|
|
@@ -1537,18 +1653,31 @@ def apply_patch(path: str, new_content: str) -> str:
|
|
|
1537
1653
|
|
|
1538
1654
|
def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
1539
1655
|
"""
|
|
1540
|
-
Edit a file by replacing
|
|
1656
|
+
Edit a file by replacing a string match with flexible whitespace handling.
|
|
1657
|
+
|
|
1658
|
+
Uses multiple matching strategies to find old_string:
|
|
1659
|
+
1. Exact match
|
|
1660
|
+
2. Trimmed line match (ignores indentation differences in search)
|
|
1661
|
+
3. Normalized whitespace match (ignores spacing differences in search)
|
|
1662
|
+
|
|
1663
|
+
Important: The flexible matching only applies to FINDING old_string.
|
|
1664
|
+
The new_string is used exactly as provided, so it should include proper
|
|
1665
|
+
indentation/formatting to match the surrounding code.
|
|
1541
1666
|
|
|
1542
1667
|
Args:
|
|
1543
1668
|
path: Relative path to the file from the repository root
|
|
1544
|
-
old_string: The
|
|
1545
|
-
new_string: The string
|
|
1669
|
+
old_string: The string to find (whitespace can be approximate)
|
|
1670
|
+
new_string: The replacement string (use exact whitespace/indentation you want)
|
|
1546
1671
|
|
|
1547
1672
|
Returns:
|
|
1548
1673
|
Confirmation message with the changes made
|
|
1549
1674
|
|
|
1550
1675
|
Raises:
|
|
1551
1676
|
ValueError: If file not found, old_string not found, or multiple matches
|
|
1677
|
+
|
|
1678
|
+
Example:
|
|
1679
|
+
# Find with flexible matching, but provide new_string with proper indent
|
|
1680
|
+
edit_file("test.py", "print('hello')", " print('world')") # 4 spaces
|
|
1552
1681
|
"""
|
|
1553
1682
|
_operation_limiter.check_limit(f"edit_file({path[:30]}...)")
|
|
1554
1683
|
|
|
@@ -1566,28 +1695,25 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
|
1566
1695
|
except Exception as e:
|
|
1567
1696
|
raise ValueError(f"Failed to read file: {e}")
|
|
1568
1697
|
|
|
1569
|
-
#
|
|
1570
|
-
|
|
1571
|
-
# Show first 5 lines of file to help user
|
|
1572
|
-
lines = content.split("\n")[:5]
|
|
1573
|
-
preview = "\n".join(f" {i + 1}: {line[:80]}" for i, line in enumerate(lines))
|
|
1698
|
+
# Try to find a match using multiple strategies
|
|
1699
|
+
matched_string = _find_match_with_strategies(content, old_string)
|
|
1574
1700
|
|
|
1701
|
+
if not matched_string:
|
|
1702
|
+
# No match found with any strategy
|
|
1575
1703
|
raise ValueError(
|
|
1576
1704
|
f"String not found in {path}.\n\n"
|
|
1577
1705
|
f"Searched for:\n{old_string[:200]}\n\n"
|
|
1578
|
-
f"
|
|
1579
|
-
f"💡 Tip: Use read_lines() to get exact text including whitespace, "
|
|
1580
|
-
f"or use apply_patch() for larger changes."
|
|
1706
|
+
f"💡 Tip: Use read_lines() to see exact content, or use apply_patch() for larger changes."
|
|
1581
1707
|
)
|
|
1582
1708
|
|
|
1583
|
-
# Count occurrences
|
|
1584
|
-
count = content.count(
|
|
1709
|
+
# Count occurrences of the matched string
|
|
1710
|
+
count = content.count(matched_string)
|
|
1585
1711
|
if count > 1:
|
|
1586
1712
|
# Show WHERE the matches are
|
|
1587
1713
|
positions = []
|
|
1588
1714
|
start = 0
|
|
1589
1715
|
while True:
|
|
1590
|
-
pos = content.find(
|
|
1716
|
+
pos = content.find(matched_string, start)
|
|
1591
1717
|
if pos == -1:
|
|
1592
1718
|
break
|
|
1593
1719
|
line_num = content[:pos].count("\n") + 1
|
|
@@ -1603,8 +1729,8 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
|
1603
1729
|
# Check permission before proceeding
|
|
1604
1730
|
permission_manager = _get_permission_manager()
|
|
1605
1731
|
|
|
1606
|
-
# Format colored diff for permission prompt
|
|
1607
|
-
diff_display = _format_colored_diff(
|
|
1732
|
+
# Format colored diff for permission prompt (use the matched string for accurate diff)
|
|
1733
|
+
diff_display = _format_colored_diff(matched_string, new_string, file_path=path)
|
|
1608
1734
|
|
|
1609
1735
|
# Add warning if writing outside repository
|
|
1610
1736
|
outside_repo_warning = ""
|
|
@@ -1619,19 +1745,21 @@ def edit_file(path: str, old_string: str, new_string: str) -> str:
|
|
|
1619
1745
|
# Backup if enabled
|
|
1620
1746
|
backup_path = _backup_file(p)
|
|
1621
1747
|
|
|
1622
|
-
# Perform replacement
|
|
1623
|
-
|
|
1748
|
+
# Perform replacement using the matched string
|
|
1749
|
+
# Note: newString is used as-is (OpenCode behavior)
|
|
1750
|
+
# The LLM should provide newString with proper indentation matching the original
|
|
1751
|
+
new_content = content.replace(matched_string, new_string)
|
|
1624
1752
|
|
|
1625
1753
|
# Write the new content
|
|
1626
1754
|
p.write_text(new_content)
|
|
1627
1755
|
|
|
1628
|
-
# Generate diff for the specific change
|
|
1629
|
-
old_lines =
|
|
1756
|
+
# Generate diff for the specific change (use matched_string for accurate diff)
|
|
1757
|
+
old_lines = matched_string.split("\n")
|
|
1630
1758
|
new_lines = new_string.split("\n")
|
|
1631
1759
|
diff = difflib.unified_diff(old_lines, new_lines, fromfile="old", tofile="new", lineterm="")
|
|
1632
1760
|
diff_str = "\n".join(diff)
|
|
1633
1761
|
|
|
1634
|
-
audit_logger.info(f"EDIT: {path} ({len(
|
|
1762
|
+
audit_logger.info(f"EDIT: {path} ({len(matched_string)} -> {len(new_string)} chars)")
|
|
1635
1763
|
|
|
1636
1764
|
backup_msg = f"\n[Backup saved: {backup_path}]" if backup_path else ""
|
|
1637
1765
|
return f"Successfully edited {path}{backup_msg}\n\nChange:\n{diff_str}"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: patchpal
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A lean Claude Code clone in pure Python
|
|
5
5
|
Author: PatchPal Contributors
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -717,7 +717,7 @@ export PATCHPAL_MAX_ITERATIONS=150 # Max agent iterations per task (de
|
|
|
717
717
|
```bash
|
|
718
718
|
# Auto-Compaction
|
|
719
719
|
export PATCHPAL_DISABLE_AUTOCOMPACT=true # Disable auto-compaction (default: false - enabled)
|
|
720
|
-
export PATCHPAL_COMPACT_THRESHOLD=0.
|
|
720
|
+
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger compaction at % full (default: 0.75 = 75%)
|
|
721
721
|
|
|
722
722
|
# Context Limits
|
|
723
723
|
export PATCHPAL_CONTEXT_LIMIT=100000 # Override model's context limit (for testing)
|
|
@@ -892,7 +892,7 @@ PatchPal automatically manages the context window to prevent "input too long" er
|
|
|
892
892
|
**Features:**
|
|
893
893
|
- **Automatic token tracking**: Monitors context usage in real-time
|
|
894
894
|
- **Smart pruning**: Removes old tool outputs (keeps last 40k tokens) before resorting to full compaction
|
|
895
|
-
- **Auto-compaction**: Summarizes conversation history when approaching
|
|
895
|
+
- **Auto-compaction**: Summarizes conversation history when approaching 75% capacity
|
|
896
896
|
- **Manual control**: Check status with `/status`, disable with environment variable
|
|
897
897
|
|
|
898
898
|
**Commands:**
|
|
@@ -931,7 +931,7 @@ You can test the context management system with small values to trigger compacti
|
|
|
931
931
|
```bash
|
|
932
932
|
# Set up small context window for testing
|
|
933
933
|
export PATCHPAL_CONTEXT_LIMIT=10000 # Force 10k token limit (instead of 200k for Claude)
|
|
934
|
-
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (
|
|
934
|
+
export PATCHPAL_COMPACT_THRESHOLD=0.75 # Trigger at 75% (default, but shown for clarity)
|
|
935
935
|
# Note: System prompt + output reserve = ~6.4k tokens baseline
|
|
936
936
|
# So 75% of 10k = 7.5k, leaving ~1k for conversation
|
|
937
937
|
export PATCHPAL_PRUNE_PROTECT=500 # Keep only last 500 tokens of tool outputs
|
|
@@ -951,9 +951,9 @@ You: /status
|
|
|
951
951
|
# Continue - should see pruning messages
|
|
952
952
|
You: search for "context" in all files
|
|
953
953
|
# You should see:
|
|
954
|
-
# ⚠️ Context window at
|
|
954
|
+
# ⚠️ Context window at 75% capacity. Compacting...
|
|
955
955
|
# Pruned old tool outputs (saved ~400 tokens)
|
|
956
|
-
# ✓ Compaction complete. Saved 850 tokens (
|
|
956
|
+
# ✓ Compaction complete. Saved 850 tokens (75% → 58%)
|
|
957
957
|
```
|
|
958
958
|
|
|
959
959
|
**How It Works:**
|
|
@@ -984,7 +984,7 @@ Context Window Status
|
|
|
984
984
|
Usage: 80%
|
|
985
985
|
[████████████████████████████████████████░░░░░░░░░]
|
|
986
986
|
|
|
987
|
-
Auto-compaction: Enabled (triggers at
|
|
987
|
+
Auto-compaction: Enabled (triggers at 75%)
|
|
988
988
|
======================================================================
|
|
989
989
|
```
|
|
990
990
|
|
|
@@ -998,7 +998,9 @@ The system ensures you can work for extended periods without hitting context lim
|
|
|
998
998
|
|
|
999
999
|
**Error: "Context Window Error - Input is too long"**
|
|
1000
1000
|
- PatchPal includes automatic context management (compaction) to prevent this error.
|
|
1001
|
-
-
|
|
1001
|
+
- **Quick fix:** Run `/compact` to immediately compact the conversation history and free up space.
|
|
1002
|
+
- Use `/status` to check your context window usage and see how close you are to the limit.
|
|
1002
1003
|
- If auto-compaction is disabled, re-enable it: `unset PATCHPAL_DISABLE_AUTOCOMPACT`
|
|
1003
|
-
- Context is automatically managed at
|
|
1004
|
+
- Context is automatically managed at 75% capacity through pruning and compaction.
|
|
1005
|
+
- **Note:** Token estimation may be slightly inaccurate compared to the model's actual counting. If you see this error despite auto-compaction being enabled, the 75% threshold may need to be lowered further for your workload. You can adjust it with `export PATCHPAL_COMPACT_THRESHOLD=0.70` (or lower).
|
|
1004
1006
|
- See [Configuration](https://github.com/amaiya/patchpal?tab=readme-ov-file#configuration) for context management settings.
|
|
@@ -408,3 +408,103 @@ def test_agent_doesnt_trigger_on_file_containing_cancellation_text(monkeypatch):
|
|
|
408
408
|
finally:
|
|
409
409
|
# Restore original function
|
|
410
410
|
TOOL_FUNCTIONS["read_file"] = original_read_file
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def test_prompt_caching_detection():
|
|
414
|
+
"""Test that prompt caching is correctly detected for supported models."""
|
|
415
|
+
from patchpal.agent import _supports_prompt_caching
|
|
416
|
+
|
|
417
|
+
# Anthropic models should support caching
|
|
418
|
+
assert _supports_prompt_caching("anthropic/claude-sonnet-4-5")
|
|
419
|
+
assert _supports_prompt_caching("anthropic/claude-opus-4")
|
|
420
|
+
|
|
421
|
+
# Bedrock Anthropic models should support caching
|
|
422
|
+
assert _supports_prompt_caching("bedrock/anthropic.claude-sonnet-4-5-v1:0")
|
|
423
|
+
assert _supports_prompt_caching("bedrock/anthropic.claude-v2")
|
|
424
|
+
|
|
425
|
+
# Non-Anthropic models should not support caching
|
|
426
|
+
assert not _supports_prompt_caching("openai/gpt-4o")
|
|
427
|
+
assert not _supports_prompt_caching("ollama_chat/llama3.1")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_prompt_caching_application_anthropic():
|
|
431
|
+
"""Test that prompt caching markers are correctly applied for Anthropic models."""
|
|
432
|
+
from patchpal.agent import _apply_prompt_caching
|
|
433
|
+
|
|
434
|
+
messages = [
|
|
435
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
436
|
+
{"role": "user", "content": "Hello"},
|
|
437
|
+
{"role": "assistant", "content": "Hi there!"},
|
|
438
|
+
{"role": "user", "content": "How are you?"},
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
# Test with direct Anthropic API
|
|
442
|
+
cached_messages = _apply_prompt_caching(messages.copy(), "anthropic/claude-sonnet-4-5")
|
|
443
|
+
|
|
444
|
+
# System message should have cacheControl
|
|
445
|
+
assert "cacheControl" in cached_messages[0]
|
|
446
|
+
assert cached_messages[0]["cacheControl"] == {"type": "ephemeral"}
|
|
447
|
+
|
|
448
|
+
# Last 2 messages should have cacheControl
|
|
449
|
+
assert "cacheControl" in cached_messages[-1] # Last user message
|
|
450
|
+
assert "cacheControl" in cached_messages[-2] # Last assistant message
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def test_prompt_caching_application_bedrock():
|
|
454
|
+
"""Test that prompt caching markers use correct format for Bedrock."""
|
|
455
|
+
from patchpal.agent import _apply_prompt_caching
|
|
456
|
+
|
|
457
|
+
messages = [
|
|
458
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
459
|
+
{"role": "user", "content": "Hello"},
|
|
460
|
+
{"role": "assistant", "content": "Hi there!"},
|
|
461
|
+
{"role": "user", "content": "How are you?"},
|
|
462
|
+
]
|
|
463
|
+
|
|
464
|
+
# Test with Bedrock
|
|
465
|
+
cached_messages = _apply_prompt_caching(
|
|
466
|
+
messages.copy(), "bedrock/anthropic.claude-sonnet-4-5-v1:0"
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
# System message should have cachePoint (Bedrock format)
|
|
470
|
+
assert "cachePoint" in cached_messages[0]
|
|
471
|
+
assert cached_messages[0]["cachePoint"] == {"type": "ephemeral"}
|
|
472
|
+
|
|
473
|
+
# Last 2 messages should have cachePoint
|
|
474
|
+
assert "cachePoint" in cached_messages[-1]
|
|
475
|
+
assert "cachePoint" in cached_messages[-2]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def test_prompt_caching_no_modification_for_unsupported():
|
|
479
|
+
"""Test that prompt caching doesn't modify messages for unsupported models."""
|
|
480
|
+
from patchpal.agent import _apply_prompt_caching
|
|
481
|
+
|
|
482
|
+
messages = [
|
|
483
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
484
|
+
{"role": "user", "content": "Hello"},
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
# Test with non-Anthropic model
|
|
488
|
+
cached_messages = _apply_prompt_caching(messages.copy(), "openai/gpt-4o")
|
|
489
|
+
|
|
490
|
+
# Messages should be unchanged
|
|
491
|
+
assert "cacheControl" not in cached_messages[0]
|
|
492
|
+
assert "cachePoint" not in cached_messages[0]
|
|
493
|
+
assert cached_messages == messages
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def test_prompt_caching_idempotent():
|
|
497
|
+
"""Test that applying caching multiple times doesn't add duplicate markers."""
|
|
498
|
+
from patchpal.agent import _apply_prompt_caching
|
|
499
|
+
|
|
500
|
+
messages = [
|
|
501
|
+
{"role": "system", "content": "You are a helpful assistant."},
|
|
502
|
+
{"role": "user", "content": "Hello"},
|
|
503
|
+
]
|
|
504
|
+
|
|
505
|
+
# Apply caching twice
|
|
506
|
+
cached_once = _apply_prompt_caching(messages.copy(), "anthropic/claude-sonnet-4-5")
|
|
507
|
+
cached_twice = _apply_prompt_caching(cached_once.copy(), "anthropic/claude-sonnet-4-5")
|
|
508
|
+
|
|
509
|
+
# Should be the same after second application
|
|
510
|
+
assert cached_once == cached_twice
|
|
@@ -1445,3 +1445,256 @@ def test_ask_user_empty_options_list(monkeypatch):
|
|
|
1445
1445
|
with patch("rich.prompt.Prompt.ask", return_value="Free form answer"):
|
|
1446
1446
|
result = ask_user("What do you think?", options=[])
|
|
1447
1447
|
assert result == "Free form answer"
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
# ============================================================================
|
|
1451
|
+
# Flexible edit_file Matching Strategy Tests
|
|
1452
|
+
# ============================================================================
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def test_edit_file_with_wrong_indentation(temp_repo):
|
|
1456
|
+
"""Test edit_file with flexible matching but proper indentation in new_string."""
|
|
1457
|
+
from patchpal.tools import edit_file
|
|
1458
|
+
|
|
1459
|
+
# Create a Python file with proper indentation
|
|
1460
|
+
content = """def hello():
|
|
1461
|
+
if True:
|
|
1462
|
+
print("world")
|
|
1463
|
+
return 42
|
|
1464
|
+
"""
|
|
1465
|
+
(temp_repo / "indent_test.py").write_text(content)
|
|
1466
|
+
|
|
1467
|
+
# Search without indentation (flexible matching finds it),
|
|
1468
|
+
# but provide new_string WITH proper indentation (OpenCode behavior)
|
|
1469
|
+
result = edit_file("indent_test.py", 'print("world")', ' print("universe")')
|
|
1470
|
+
|
|
1471
|
+
assert "Successfully edited" in result
|
|
1472
|
+
|
|
1473
|
+
# Verify the edit preserved indentation (because we provided it in new_string)
|
|
1474
|
+
new_content = (temp_repo / "indent_test.py").read_text()
|
|
1475
|
+
assert ' print("universe")' in new_content # 8 spaces preserved
|
|
1476
|
+
assert ' print("world")' not in new_content
|
|
1477
|
+
# Other lines should be unchanged
|
|
1478
|
+
assert "def hello():" in new_content
|
|
1479
|
+
assert " if True:" in new_content
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def test_edit_file_multiline_wrong_indentation(temp_repo):
|
|
1483
|
+
"""Test edit_file with multi-line blocks - new_string must have proper indentation."""
|
|
1484
|
+
from patchpal.tools import edit_file
|
|
1485
|
+
|
|
1486
|
+
content = """class MyClass:
|
|
1487
|
+
def process(self):
|
|
1488
|
+
if self.valid:
|
|
1489
|
+
result = self.compute()
|
|
1490
|
+
return result
|
|
1491
|
+
return None
|
|
1492
|
+
"""
|
|
1493
|
+
(temp_repo / "multiline_test.py").write_text(content)
|
|
1494
|
+
|
|
1495
|
+
# Search without proper indentation (flexible matching),
|
|
1496
|
+
# but provide replacement WITH proper indentation
|
|
1497
|
+
old_string = """if self.valid:
|
|
1498
|
+
result = self.compute()
|
|
1499
|
+
return result"""
|
|
1500
|
+
|
|
1501
|
+
new_string = """ if self.valid:
|
|
1502
|
+
result = self.compute_new()
|
|
1503
|
+
return result"""
|
|
1504
|
+
|
|
1505
|
+
result = edit_file("multiline_test.py", old_string, new_string)
|
|
1506
|
+
|
|
1507
|
+
assert "Successfully edited" in result
|
|
1508
|
+
|
|
1509
|
+
# Verify correct indentation (because we provided it in new_string)
|
|
1510
|
+
new_content = (temp_repo / "multiline_test.py").read_text()
|
|
1511
|
+
assert " if self.valid:" in new_content
|
|
1512
|
+
assert " result = self.compute_new()" in new_content
|
|
1513
|
+
assert " return result" in new_content
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def test_edit_file_whitespace_normalization(temp_repo):
|
|
1517
|
+
"""Test edit_file handles extra whitespace in search string."""
|
|
1518
|
+
from patchpal.tools import edit_file
|
|
1519
|
+
|
|
1520
|
+
content = """x = 42
|
|
1521
|
+
y = 100
|
|
1522
|
+
z = x + y
|
|
1523
|
+
"""
|
|
1524
|
+
(temp_repo / "whitespace_test.py").write_text(content)
|
|
1525
|
+
|
|
1526
|
+
# User provides with extra spaces (e.g., copied from terminal with weird formatting)
|
|
1527
|
+
old_string = "x = 42"
|
|
1528
|
+
|
|
1529
|
+
result = edit_file("whitespace_test.py", old_string, "x = 99")
|
|
1530
|
+
|
|
1531
|
+
assert "Successfully edited" in result
|
|
1532
|
+
|
|
1533
|
+
# Verify the edit worked
|
|
1534
|
+
new_content = (temp_repo / "whitespace_test.py").read_text()
|
|
1535
|
+
assert "x = 99" in new_content
|
|
1536
|
+
assert "x = 42" not in new_content
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
def test_edit_file_real_world_agent_scenario(temp_repo):
|
|
1540
|
+
"""Test the actual scenario that failed before: editing agent.py with indentation issues."""
|
|
1541
|
+
from patchpal.tools import edit_file
|
|
1542
|
+
|
|
1543
|
+
# Simulate content from agent.py around tool execution
|
|
1544
|
+
content = """ )
|
|
1545
|
+
|
|
1546
|
+
# Silently filter out invalid args (models sometimes hallucinate parameters)
|
|
1547
|
+
|
|
1548
|
+
tool_result = tool_func(**filtered_args)
|
|
1549
|
+
except Exception as e:
|
|
1550
|
+
tool_result = f"Error executing {tool_name}: {e}"
|
|
1551
|
+
print(f"\\033[1;31mX {tool_display}: {e}\\033[0m")
|
|
1552
|
+
|
|
1553
|
+
# Add tool result to messages"""
|
|
1554
|
+
|
|
1555
|
+
(temp_repo / "agent_snippet.py").write_text(content)
|
|
1556
|
+
|
|
1557
|
+
# What the LLM might provide (without correct indentation)
|
|
1558
|
+
old_string = """# Silently filter out invalid args (models sometimes hallucinate parameters)
|
|
1559
|
+
|
|
1560
|
+
tool_result = tool_func(**filtered_args)"""
|
|
1561
|
+
|
|
1562
|
+
new_string = """# Silently filter out invalid args (models sometimes hallucinate parameters)
|
|
1563
|
+
|
|
1564
|
+
tool_result = tool_func(**filtered_args)
|
|
1565
|
+
|
|
1566
|
+
# Display result for certain tools where the result contains important info
|
|
1567
|
+
if tool_name == "todo_add" and not isinstance(tool_result, Exception):
|
|
1568
|
+
# Extract and display the task number from the result
|
|
1569
|
+
print(f"\\033[2m{tool_result.split(':')[0]}\\033[0m", flush=True)"""
|
|
1570
|
+
|
|
1571
|
+
result = edit_file("agent_snippet.py", old_string, new_string)
|
|
1572
|
+
|
|
1573
|
+
assert "Successfully edited" in result
|
|
1574
|
+
|
|
1575
|
+
# Verify the edit preserved original indentation
|
|
1576
|
+
new_content = (temp_repo / "agent_snippet.py").read_text()
|
|
1577
|
+
assert " # Display result for certain tools" in new_content
|
|
1578
|
+
assert "tool_result.split" in new_content
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def test_edit_file_code_without_indentation_prefers_line_match(temp_repo):
|
|
1582
|
+
"""Test that code patterns without indentation prefer line-level matching over substring."""
|
|
1583
|
+
from patchpal.tools import edit_file
|
|
1584
|
+
|
|
1585
|
+
content = """def calculate():
|
|
1586
|
+
result = compute()
|
|
1587
|
+
return result
|
|
1588
|
+
"""
|
|
1589
|
+
(temp_repo / "code_test.py").write_text(content)
|
|
1590
|
+
|
|
1591
|
+
# Search without indentation (flexible matching finds full line),
|
|
1592
|
+
# provide replacement WITH proper indentation
|
|
1593
|
+
old_string = "return result"
|
|
1594
|
+
new_string = " return final_result" # With proper indentation
|
|
1595
|
+
|
|
1596
|
+
edit_file("code_test.py", old_string, new_string)
|
|
1597
|
+
|
|
1598
|
+
new_content = (temp_repo / "code_test.py").read_text()
|
|
1599
|
+
assert " return final_result" in new_content
|
|
1600
|
+
assert "result = compute()" in new_content # Should not have changed this line
|
|
1601
|
+
|
|
1602
|
+
|
|
1603
|
+
def test_edit_file_preserves_exact_match_when_possible(temp_repo):
|
|
1604
|
+
"""Test that exact matches are still preferred when indentation is correct."""
|
|
1605
|
+
from patchpal.tools import edit_file
|
|
1606
|
+
|
|
1607
|
+
content = """def hello():
|
|
1608
|
+
print("world")
|
|
1609
|
+
"""
|
|
1610
|
+
(temp_repo / "exact_test.py").write_text(content)
|
|
1611
|
+
|
|
1612
|
+
# With correct indentation, should use exact match
|
|
1613
|
+
old_string = ' print("world")'
|
|
1614
|
+
|
|
1615
|
+
result = edit_file("exact_test.py", old_string, ' print("universe")')
|
|
1616
|
+
|
|
1617
|
+
assert "Successfully edited" in result
|
|
1618
|
+
|
|
1619
|
+
new_content = (temp_repo / "exact_test.py").read_text()
|
|
1620
|
+
assert ' print("universe")' in new_content
|
|
1621
|
+
|
|
1622
|
+
|
|
1623
|
+
def test_edit_file_flexible_matching_error_message(temp_repo):
|
|
1624
|
+
"""Test error message when string not found."""
|
|
1625
|
+
from patchpal.tools import edit_file
|
|
1626
|
+
|
|
1627
|
+
(temp_repo / "test_error.py").write_text("def hello():\n pass\n")
|
|
1628
|
+
|
|
1629
|
+
# Try to find something that doesn't exist
|
|
1630
|
+
with pytest.raises(ValueError) as exc_info:
|
|
1631
|
+
edit_file("test_error.py", "goodbye()", "farewell()")
|
|
1632
|
+
|
|
1633
|
+
error_msg = str(exc_info.value)
|
|
1634
|
+
assert "String not found" in error_msg
|
|
1635
|
+
assert "read_lines()" in error_msg # Should suggest using read_lines()
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def test_edit_file_matching_strategies_helper_functions(temp_repo):
|
|
1639
|
+
"""Test the underlying matching strategy helper functions directly."""
|
|
1640
|
+
from patchpal.tools import (
|
|
1641
|
+
_try_line_trimmed_match,
|
|
1642
|
+
_try_simple_match,
|
|
1643
|
+
_try_whitespace_normalized_match,
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
content = """def hello():
|
|
1647
|
+
print("world")
|
|
1648
|
+
return 42
|
|
1649
|
+
"""
|
|
1650
|
+
|
|
1651
|
+
# Test simple match
|
|
1652
|
+
assert _try_simple_match(content, 'print("world")') == 'print("world")'
|
|
1653
|
+
assert _try_simple_match(content, "nonexistent") is None
|
|
1654
|
+
|
|
1655
|
+
# Test line trimmed match (should find with correct indentation)
|
|
1656
|
+
match = _try_line_trimmed_match(content, 'print("world")')
|
|
1657
|
+
assert match == ' print("world")'
|
|
1658
|
+
|
|
1659
|
+
# Test whitespace normalized match
|
|
1660
|
+
content2 = "x = 42"
|
|
1661
|
+
match = _try_whitespace_normalized_match(content2, "x = 42")
|
|
1662
|
+
assert match == "x = 42"
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def test_edit_file_multiline_trimmed_match_helper(temp_repo):
|
|
1666
|
+
"""Test line-trimmed matching with multi-line blocks."""
|
|
1667
|
+
from patchpal.tools import _try_line_trimmed_match
|
|
1668
|
+
|
|
1669
|
+
content = """class Test:
|
|
1670
|
+
def method(self):
|
|
1671
|
+
if True:
|
|
1672
|
+
do_something()
|
|
1673
|
+
return value
|
|
1674
|
+
"""
|
|
1675
|
+
|
|
1676
|
+
# Search without proper indentation
|
|
1677
|
+
search = """if True:
|
|
1678
|
+
do_something()
|
|
1679
|
+
return value"""
|
|
1680
|
+
|
|
1681
|
+
match = _try_line_trimmed_match(content, search)
|
|
1682
|
+
# Should return with proper indentation (8 spaces)
|
|
1683
|
+
assert match == " if True:\n do_something()\n return value"
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
def test_edit_file_finds_match_with_strategy_order(temp_repo):
|
|
1687
|
+
"""Test that strategies are tried in correct order."""
|
|
1688
|
+
from patchpal.tools import _find_match_with_strategies
|
|
1689
|
+
|
|
1690
|
+
# Scenario: content has both a substring and a full line
|
|
1691
|
+
# Should prefer full line match for code patterns
|
|
1692
|
+
content = """def calculate():
|
|
1693
|
+
result = process() # result is important
|
|
1694
|
+
return result
|
|
1695
|
+
"""
|
|
1696
|
+
|
|
1697
|
+
# Without indentation, should match the full line not substring in comment
|
|
1698
|
+
match = _find_match_with_strategies(content, "return result")
|
|
1699
|
+
assert match == " return result"
|
|
1700
|
+
# Should NOT match just "result" in the comment or variable name
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|