auto-coder 0.1.399__py3-none-any.whl → 1.0.0__py3-none-any.whl

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.

Potentially problematic release.


This version of auto-coder might be problematic. Click here for more details.

Files changed (71) hide show
  1. {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/METADATA +1 -1
  2. {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/RECORD +71 -35
  3. autocoder/agent/agentic_filter.py +1 -1
  4. autocoder/agent/base_agentic/tools/read_file_tool_resolver.py +1 -1
  5. autocoder/auto_coder_runner.py +121 -26
  6. autocoder/chat_auto_coder.py +81 -22
  7. autocoder/commands/auto_command.py +1 -1
  8. autocoder/common/__init__.py +2 -2
  9. autocoder/common/ac_style_command_parser/parser.py +27 -12
  10. autocoder/common/auto_coder_lang.py +78 -0
  11. autocoder/common/command_completer_v2.py +1 -1
  12. autocoder/common/file_monitor/test_file_monitor.py +307 -0
  13. autocoder/common/git_utils.py +7 -2
  14. autocoder/common/pruner/__init__.py +0 -0
  15. autocoder/common/pruner/agentic_conversation_pruner.py +197 -0
  16. autocoder/common/pruner/context_pruner.py +574 -0
  17. autocoder/common/pruner/conversation_pruner.py +132 -0
  18. autocoder/common/pruner/test_agentic_conversation_pruner.py +342 -0
  19. autocoder/common/pruner/test_context_pruner.py +546 -0
  20. autocoder/common/pull_requests/__init__.py +256 -0
  21. autocoder/common/pull_requests/base_provider.py +191 -0
  22. autocoder/common/pull_requests/config.py +66 -0
  23. autocoder/common/pull_requests/example.py +1 -0
  24. autocoder/common/pull_requests/exceptions.py +46 -0
  25. autocoder/common/pull_requests/manager.py +201 -0
  26. autocoder/common/pull_requests/models.py +164 -0
  27. autocoder/common/pull_requests/providers/__init__.py +23 -0
  28. autocoder/common/pull_requests/providers/gitcode_provider.py +19 -0
  29. autocoder/common/pull_requests/providers/gitee_provider.py +20 -0
  30. autocoder/common/pull_requests/providers/github_provider.py +214 -0
  31. autocoder/common/pull_requests/providers/gitlab_provider.py +29 -0
  32. autocoder/common/pull_requests/test_module.py +1 -0
  33. autocoder/common/pull_requests/utils.py +344 -0
  34. autocoder/common/tokens/__init__.py +77 -0
  35. autocoder/common/tokens/counter.py +231 -0
  36. autocoder/common/tokens/file_detector.py +105 -0
  37. autocoder/common/tokens/filters.py +111 -0
  38. autocoder/common/tokens/models.py +28 -0
  39. autocoder/common/v2/agent/agentic_edit.py +538 -590
  40. autocoder/common/v2/agent/agentic_edit_tools/__init__.py +8 -1
  41. autocoder/common/v2/agent/agentic_edit_tools/ac_mod_read_tool_resolver.py +40 -0
  42. autocoder/common/v2/agent/agentic_edit_tools/ac_mod_write_tool_resolver.py +43 -0
  43. autocoder/common/v2/agent/agentic_edit_tools/ask_followup_question_tool_resolver.py +8 -0
  44. autocoder/common/v2/agent/agentic_edit_tools/execute_command_tool_resolver.py +1 -1
  45. autocoder/common/v2/agent/agentic_edit_tools/read_file_tool_resolver.py +1 -1
  46. autocoder/common/v2/agent/agentic_edit_tools/search_files_tool_resolver.py +33 -88
  47. autocoder/common/v2/agent/agentic_edit_tools/test_write_to_file_tool_resolver.py +8 -8
  48. autocoder/common/v2/agent/agentic_edit_tools/todo_read_tool_resolver.py +118 -0
  49. autocoder/common/v2/agent/agentic_edit_tools/todo_write_tool_resolver.py +324 -0
  50. autocoder/common/v2/agent/agentic_edit_types.py +47 -4
  51. autocoder/common/v2/agent/runner/__init__.py +31 -0
  52. autocoder/common/v2/agent/runner/base_runner.py +106 -0
  53. autocoder/common/v2/agent/runner/event_runner.py +216 -0
  54. autocoder/common/v2/agent/runner/sdk_runner.py +40 -0
  55. autocoder/common/v2/agent/runner/terminal_runner.py +283 -0
  56. autocoder/common/v2/agent/runner/tool_display.py +191 -0
  57. autocoder/index/entry.py +1 -1
  58. autocoder/plugins/token_helper_plugin.py +107 -7
  59. autocoder/run_context.py +9 -0
  60. autocoder/sdk/__init__.py +114 -81
  61. autocoder/sdk/cli/handlers.py +2 -1
  62. autocoder/sdk/cli/main.py +9 -2
  63. autocoder/sdk/cli/options.py +4 -3
  64. autocoder/sdk/core/auto_coder_core.py +7 -152
  65. autocoder/sdk/core/bridge.py +5 -4
  66. autocoder/sdk/models/options.py +8 -6
  67. autocoder/version.py +1 -1
  68. {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/WHEEL +0 -0
  69. {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/entry_points.txt +0 -0
  70. {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/licenses/LICENSE +0 -0
  71. {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,132 @@
1
+ from typing import List, Dict, Any, Union
2
+ import json
3
+ from pydantic import BaseModel
4
+ import byzerllm
5
+ from autocoder.common.printer import Printer
6
+ from autocoder.rag.token_counter import count_tokens
7
+ from loguru import logger
8
+ from autocoder.common import AutoCoderArgs
9
+
10
+ class PruneStrategy(BaseModel):
11
+ name: str
12
+ description: str
13
+ config: Dict[str, Any] = {"safe_zone_tokens": 0, "group_size": 4}
14
+
15
+ class ConversationPruner:
16
+ def __init__(self, args: AutoCoderArgs, llm: Union[byzerllm.ByzerLLM, byzerllm.SimpleByzerLLM]):
17
+ self.args = args
18
+ self.llm = llm
19
+ self.printer = Printer()
20
+ self.strategies = {
21
+ "summarize": PruneStrategy(
22
+ name="summarize",
23
+ description="对早期对话进行分组摘要,保留关键信息",
24
+ config={"safe_zone_tokens": self.args.conversation_prune_safe_zone_tokens, "group_size": self.args.conversation_prune_group_size}
25
+ ),
26
+ "truncate": PruneStrategy(
27
+ name="truncate",
28
+ description="分组截断最早的部分对话",
29
+ config={"safe_zone_tokens": self.args.conversation_prune_safe_zone_tokens, "group_size": self.args.conversation_prune_group_size}
30
+ ),
31
+ "hybrid": PruneStrategy(
32
+ name="hybrid",
33
+ description="先尝试分组摘要,如果仍超限则分组截断",
34
+ config={"safe_zone_tokens": self.args.conversation_prune_safe_zone_tokens, "group_size": self.args.conversation_prune_group_size}
35
+ )
36
+ }
37
+
38
+ def get_available_strategies(self) -> List[Dict[str, Any]]:
39
+ """获取所有可用策略"""
40
+ return [strategy.dict() for strategy in self.strategies.values()]
41
+
42
+ def prune_conversations(self, conversations: List[Dict[str, Any]],
43
+ strategy_name: str = "summarize") -> List[Dict[str, Any]]:
44
+ """
45
+ 根据策略修剪对话
46
+ Args:
47
+ conversations: 原始对话列表
48
+ strategy_name: 策略名称
49
+ Returns:
50
+ 修剪后的对话列表
51
+ """
52
+ current_tokens = count_tokens(json.dumps(conversations, ensure_ascii=False))
53
+ if current_tokens <= self.args.conversation_prune_safe_zone_tokens:
54
+ return conversations
55
+
56
+ strategy = self.strategies.get(strategy_name, self.strategies["summarize"])
57
+
58
+ if strategy.name == "summarize":
59
+ return self._summarize_prune(conversations, strategy.config)
60
+ elif strategy.name == "truncate":
61
+ return self._truncate_prune(conversations)
62
+ elif strategy.name == "hybrid":
63
+ pruned = self._summarize_prune(conversations, strategy.config)
64
+ if count_tokens(json.dumps(pruned, ensure_ascii=False)) > self.args.conversation_prune_safe_zone_tokens:
65
+ return self._truncate_prune(pruned)
66
+ return pruned
67
+ else:
68
+ logger.warning(f"Unknown strategy: {strategy_name}, using summarize instead")
69
+ return self._summarize_prune(conversations, strategy.config)
70
+
71
+ def _summarize_prune(self, conversations: List[Dict[str, Any]],
72
+ config: Dict[str, Any]) -> List[Dict[str, Any]]:
73
+ """摘要式剪枝"""
74
+ safe_zone_tokens = config.get("safe_zone_tokens", 50*1024)
75
+ group_size = config.get("group_size", 4)
76
+ processed_conversations = conversations.copy()
77
+
78
+ while True:
79
+ current_tokens = count_tokens(json.dumps(processed_conversations, ensure_ascii=False))
80
+ if current_tokens <= safe_zone_tokens:
81
+ break
82
+
83
+ # 找到要处理的对话组
84
+ early_conversations = processed_conversations[:group_size]
85
+ recent_conversations = processed_conversations[group_size:]
86
+
87
+ if not early_conversations:
88
+ break
89
+
90
+ # 生成当前组的摘要
91
+ group_summary = self._generate_summary.with_llm(self.llm).run(early_conversations[-group_size:])
92
+
93
+ # 更新对话历史
94
+ processed_conversations = [
95
+ {"role": "user", "content": f"历史对话摘要:\n{group_summary}"},
96
+ {"role": "assistant", "content": f"收到"}
97
+ ] + recent_conversations
98
+
99
+ return processed_conversations
100
+
101
+ @byzerllm.prompt()
102
+ def _generate_summary(self, conversations: List[Dict[str, Any]]) -> str:
103
+ '''
104
+ 请用中文将以下对话浓缩为要点,保留关键决策和技术细节:
105
+
106
+ <history_conversations>
107
+ {{conversations}}
108
+ </history_conversations>
109
+ '''
110
+ return {
111
+ "conversations": json.dumps(conversations, ensure_ascii=False)
112
+ }
113
+
114
+ def _truncate_prune(self, conversations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
115
+ """截断式剪枝"""
116
+ safe_zone_tokens = self.strategies["truncate"].config.get("safe_zone_tokens", 0)
117
+ group_size = self.strategies["truncate"].config.get("group_size", 4)
118
+ processed_conversations = conversations.copy()
119
+
120
+ while True:
121
+ current_tokens = count_tokens(json.dumps(processed_conversations, ensure_ascii=False))
122
+ if current_tokens <= safe_zone_tokens:
123
+ break
124
+
125
+ # 如果剩余对话不足一组,直接返回
126
+ if len(processed_conversations) <= group_size:
127
+ return []
128
+
129
+ # 移除最早的一组对话
130
+ processed_conversations = processed_conversations[group_size:]
131
+
132
+ return processed_conversations
@@ -0,0 +1,342 @@
1
+ """
2
+ Pytest tests for AgenticConversationPruner
3
+
4
+ This module contains comprehensive tests for the AgenticConversationPruner class,
5
+ including functionality tests, edge cases, and integration tests.
6
+ """
7
+
8
+ import json
9
+ import pytest
10
+ from unittest.mock import MagicMock, patch
11
+ from autocoder.common.pruner.agentic_conversation_pruner import AgenticConversationPruner
12
+ from autocoder.common import AutoCoderArgs
13
+
14
+
15
+ class TestAgenticConversationPruner:
16
+ """Test suite for AgenticConversationPruner class"""
17
+
18
+ @pytest.fixture
19
+ def mock_args(self):
20
+ """Create mock AutoCoderArgs for testing"""
21
+ args = MagicMock(spec=AutoCoderArgs)
22
+ args.conversation_prune_safe_zone_tokens = 1000 # Small threshold for testing
23
+ return args
24
+
25
+ @pytest.fixture
26
+ def mock_llm(self):
27
+ """Create mock LLM for testing"""
28
+ return MagicMock()
29
+
30
+ @pytest.fixture
31
+ def pruner(self, mock_args, mock_llm):
32
+ """Create AgenticConversationPruner instance for testing"""
33
+ return AgenticConversationPruner(args=mock_args, llm=mock_llm)
34
+
35
+ @pytest.fixture
36
+ def sample_conversations(self):
37
+ """Sample conversations with tool results for testing"""
38
+ return [
39
+ {"role": "system", "content": "You are a helpful assistant."},
40
+ {"role": "user", "content": "Please read a file for me."},
41
+ {"role": "assistant", "content": "I'll read the file for you.\n\n<read_file>\n<path>test.py</path>\n</read_file>"},
42
+ {
43
+ "role": "user",
44
+ "content": "<tool_result tool_name='ReadFileTool' success='true'><message>File read successfully</message><content>def hello():\n print('Hello, world!')\n # This is a very long file content that would take up many tokens\n # We want to clean this up to save space in the conversation\n for i in range(100):\n print(f'Line {i}: This is line number {i} with some content')\n return 'done'</content></tool_result>"
45
+ },
46
+ {"role": "assistant", "content": "I can see the file content. Let me analyze it for you."},
47
+ {"role": "user", "content": "Now please list files in the directory."},
48
+ {"role": "assistant", "content": "I'll list the files for you.\n\n<list_files>\n<path>.</path>\n</list_files>"},
49
+ {
50
+ "role": "user",
51
+ "content": "<tool_result tool_name='ListFilesTool' success='true'><message>Files listed successfully</message><content>['file1.py', 'file2.js', 'file3.md', 'very_long_file_with_many_tokens_that_should_be_cleaned.txt', 'another_file.py', 'config.json', 'readme.md', 'test_data.csv']</content></tool_result>"
52
+ },
53
+ {"role": "assistant", "content": "Here are the files in the directory. Is there anything specific you'd like to do with them?"}
54
+ ]
55
+
56
+ def test_initialization(self, mock_args, mock_llm):
57
+ """Test AgenticConversationPruner initialization"""
58
+ pruner = AgenticConversationPruner(args=mock_args, llm=mock_llm)
59
+
60
+ assert pruner.args == mock_args
61
+ assert pruner.llm == mock_llm
62
+ assert hasattr(pruner, 'strategies')
63
+ assert 'tool_output_cleanup' in pruner.strategies
64
+ assert pruner.replacement_message == "This message has been cleared. If you still want to get this information, you can call the tool again to retrieve it."
65
+
66
+ def test_get_available_strategies(self, pruner):
67
+ """Test getting available strategies"""
68
+ strategies = pruner.get_available_strategies()
69
+
70
+ assert isinstance(strategies, list)
71
+ assert len(strategies) > 0
72
+
73
+ strategy = strategies[0]
74
+ assert 'name' in strategy
75
+ assert 'description' in strategy
76
+ assert 'config' in strategy
77
+ assert strategy['name'] == 'tool_output_cleanup'
78
+
79
+ @patch('autocoder.rag.token_counter.count_tokens')
80
+ def test_prune_conversations_within_limit(self, mock_count_tokens, pruner, sample_conversations):
81
+ """Test pruning when conversations are within token limit"""
82
+ # Mock token count to be within safe zone
83
+ mock_count_tokens.return_value = 500 # Below the 1000 token threshold
84
+
85
+ result = pruner.prune_conversations(sample_conversations)
86
+
87
+ # Should return original conversations unchanged
88
+ assert result == sample_conversations
89
+ mock_count_tokens.assert_called_once()
90
+
91
+ @patch('autocoder.rag.token_counter.count_tokens')
92
+ def test_prune_conversations_exceeds_limit(self, mock_count_tokens, pruner, sample_conversations):
93
+ """Test pruning when conversations exceed token limit"""
94
+ # Mock token count to exceed safe zone initially, then be within limit after pruning
95
+ mock_count_tokens.side_effect = [2000, 1500, 800] # First call exceeds, subsequent calls within limit
96
+
97
+ result = pruner.prune_conversations(sample_conversations)
98
+
99
+ # Should have processed the conversations
100
+ assert isinstance(result, list)
101
+ assert len(result) == len(sample_conversations)
102
+
103
+ # Check that tool results were cleaned
104
+ cleaned_found = False
105
+ for conv in result:
106
+ if conv.get("role") == "user" and "<tool_result" in conv.get("content", ""):
107
+ if "This message has been cleared" in conv.get("content", ""):
108
+ cleaned_found = True
109
+ break
110
+
111
+ assert cleaned_found, "Expected to find cleaned tool results"
112
+
113
+ def test_is_tool_result_message(self, pruner):
114
+ """Test tool result message detection"""
115
+ # Test cases for tool result detection
116
+ test_cases = [
117
+ ("<tool_result tool_name='ReadFileTool' success='true'>content</tool_result>", True),
118
+ ("<tool_result tool_name=\"ListTool\" success=\"false\">error</tool_result>", True),
119
+ ("Regular message without tool result", False),
120
+ ("<tool_result>missing tool_name</tool_result>", False),
121
+ ("", False),
122
+ ("<some_other_tag tool_name='test'>content</some_other_tag>", False)
123
+ ]
124
+
125
+ for content, expected in test_cases:
126
+ result = pruner._is_tool_result_message(content)
127
+ assert result == expected, f"Failed for content: {content}"
128
+
129
+ def test_extract_tool_name(self, pruner):
130
+ """Test tool name extraction from tool results"""
131
+ test_cases = [
132
+ ("<tool_result tool_name='ReadFileTool' success='true'>", "ReadFileTool"),
133
+ ('<tool_result tool_name="ListFilesTool" success="true">', "ListFilesTool"),
134
+ ("<tool_result success='true' tool_name='WriteTool'>", "WriteTool"),
135
+ ("<tool_result success='true'>", "unknown"),
136
+ ("Not a tool result", "unknown"),
137
+ ("", "unknown"),
138
+ ("<tool_result tool_name=''>", ""),
139
+ ("<tool_result tool_name='Tool With Spaces'>", "Tool With Spaces")
140
+ ]
141
+
142
+ for content, expected in test_cases:
143
+ result = pruner._extract_tool_name(content)
144
+ assert result == expected, f"Failed for content: {content}"
145
+
146
+ def test_generate_replacement_message(self, pruner):
147
+ """Test replacement message generation"""
148
+ test_cases = [
149
+ "ReadFileTool",
150
+ "ListFilesTool",
151
+ "unknown",
152
+ "",
153
+ "CustomTool"
154
+ ]
155
+
156
+ for tool_name in test_cases:
157
+ replacement = pruner._generate_replacement_message(tool_name)
158
+
159
+ # Check that replacement contains expected elements
160
+ assert "<tool_result" in replacement
161
+ assert "Content cleared to save tokens" in replacement
162
+ assert pruner.replacement_message in replacement
163
+
164
+ if tool_name and tool_name != "unknown":
165
+ assert f"tool_name='{tool_name}'" in replacement
166
+
167
+ @patch('autocoder.rag.token_counter.count_tokens')
168
+ def test_get_cleanup_statistics(self, mock_count_tokens, pruner, sample_conversations):
169
+ """Test cleanup statistics calculation"""
170
+ # Mock token counts
171
+ mock_count_tokens.side_effect = [2000, 1200] # Original: 2000, Pruned: 1200
172
+
173
+ # Create pruned conversations (simulate cleaning)
174
+ pruned_conversations = sample_conversations.copy()
175
+ pruned_conversations[3]["content"] = "<tool_result tool_name='ReadFileTool' success='true'><message>Content cleared to save tokens</message><content>This message has been cleared</content></tool_result>"
176
+
177
+ stats = pruner.get_cleanup_statistics(sample_conversations, pruned_conversations)
178
+
179
+ # Verify statistics
180
+ assert stats['original_tokens'] == 2000
181
+ assert stats['pruned_tokens'] == 1200
182
+ assert stats['tokens_saved'] == 800
183
+ assert stats['compression_ratio'] == 0.6
184
+ assert stats['tool_results_cleaned'] == 1
185
+ assert stats['total_messages'] == len(sample_conversations)
186
+
187
+ def test_prune_conversations_invalid_strategy(self, pruner, sample_conversations):
188
+ """Test pruning with invalid strategy name"""
189
+ with patch('autocoder.rag.token_counter.count_tokens', return_value=2000):
190
+ # Should fall back to default strategy
191
+ result = pruner.prune_conversations(sample_conversations, strategy_name="invalid_strategy")
192
+ assert isinstance(result, list)
193
+
194
+ def test_prune_conversations_empty_list(self, pruner):
195
+ """Test pruning with empty conversation list"""
196
+ with patch('autocoder.rag.token_counter.count_tokens', return_value=0):
197
+ result = pruner.prune_conversations([])
198
+ assert result == []
199
+
200
+ def test_prune_conversations_no_tool_results(self, pruner):
201
+ """Test pruning conversations without tool results"""
202
+ conversations = [
203
+ {"role": "user", "content": "Hello"},
204
+ {"role": "assistant", "content": "Hi there!"},
205
+ {"role": "user", "content": "How are you?"},
206
+ {"role": "assistant", "content": "I'm doing well, thank you!"}
207
+ ]
208
+
209
+ with patch('autocoder.rag.token_counter.count_tokens', return_value=2000):
210
+ result = pruner.prune_conversations(conversations)
211
+ # Should return original since no tool results to clean
212
+ assert result == conversations
213
+
214
+ @patch('autocoder.rag.token_counter.count_tokens')
215
+ def test_progressive_cleanup(self, mock_count_tokens, pruner):
216
+ """Test that cleanup happens progressively from earliest tool results"""
217
+ conversations = [
218
+ {"role": "user", "content": "First request"},
219
+ {"role": "user", "content": "<tool_result tool_name='Tool1'><content>First result</content></tool_result>"},
220
+ {"role": "user", "content": "Second request"},
221
+ {"role": "user", "content": "<tool_result tool_name='Tool2'><content>Second result</content></tool_result>"},
222
+ {"role": "user", "content": "Third request"},
223
+ {"role": "user", "content": "<tool_result tool_name='Tool3'><content>Third result</content></tool_result>"}
224
+ ]
225
+
226
+ # Mock token counts: initial exceeds limit, after first cleanup still exceeds, after second cleanup within limit
227
+ mock_count_tokens.side_effect = [3000, 2500, 1800, 800]
228
+
229
+ result = pruner.prune_conversations(conversations)
230
+
231
+ # Check that first two tool results were cleaned (progressive cleanup)
232
+ cleaned_count = 0
233
+ for conv in result:
234
+ if conv.get("role") == "user" and "<tool_result" in conv.get("content", ""):
235
+ if "This message has been cleared" in conv.get("content", ""):
236
+ cleaned_count += 1
237
+
238
+ assert cleaned_count >= 1, "Expected at least one tool result to be cleaned"
239
+
240
+ def test_edge_cases(self, pruner):
241
+ """Test various edge cases"""
242
+ # Test with None content
243
+ assert not pruner._is_tool_result_message(None)
244
+
245
+ # Test with malformed tool result
246
+ malformed = "<tool_result tool_name='Test' incomplete"
247
+ assert pruner._extract_tool_name(malformed) == "Test"
248
+
249
+ # Test with special characters in tool name
250
+ special_chars = "<tool_result tool_name='Tool-With_Special.Chars123' success='true'>"
251
+ assert pruner._extract_tool_name(special_chars) == "Tool-With_Special.Chars123"
252
+
253
+
254
+ class TestAgenticConversationPrunerIntegration:
255
+ """Integration tests for AgenticConversationPruner"""
256
+
257
+ @pytest.fixture
258
+ def real_args(self):
259
+ """Create real AutoCoderArgs for integration testing"""
260
+ # Note: This would require actual AutoCoderArgs implementation
261
+ # For now, use MagicMock with realistic values
262
+ args = MagicMock()
263
+ args.conversation_prune_safe_zone_tokens = 50000
264
+ return args
265
+
266
+ def test_realistic_scenario(self, real_args):
267
+ """Test with realistic conversation scenario"""
268
+ mock_llm = MagicMock()
269
+ pruner = AgenticConversationPruner(args=real_args, llm=mock_llm)
270
+
271
+ # Create a realistic conversation with large tool outputs
272
+ conversations = [
273
+ {"role": "system", "content": "You are a helpful coding assistant."},
274
+ {"role": "user", "content": "Can you read the main.py file and analyze it?"},
275
+ {"role": "assistant", "content": "I'll read the file for you.\n\n<read_file>\n<path>main.py</path>\n</read_file>"},
276
+ {
277
+ "role": "user",
278
+ "content": f"<tool_result tool_name='ReadFileTool' success='true'><message>File read successfully</message><content>{'# ' + 'Very long file content ' * 1000}</content></tool_result>"
279
+ },
280
+ {"role": "assistant", "content": "I can see this is a large Python file. Let me analyze its structure..."},
281
+ {"role": "user", "content": "Now can you list all Python files in the directory?"},
282
+ {"role": "assistant", "content": "I'll list the Python files.\n\n<list_files>\n<path>.</path>\n<pattern>*.py</pattern>\n</list_files>"},
283
+ {
284
+ "role": "user",
285
+ "content": f"<tool_result tool_name='ListFilesTool' success='true'><message>Files listed</message><content>{json.dumps(['file' + str(i) + '.py' for i in range(100)])}</content></tool_result>"
286
+ }
287
+ ]
288
+
289
+ with patch('autocoder.rag.token_counter.count_tokens') as mock_count:
290
+ # Simulate large token count that exceeds limit
291
+ mock_count.side_effect = [100000, 80000, 45000] # Progressive reduction
292
+
293
+ result = pruner.prune_conversations(conversations)
294
+
295
+ # Verify the result is valid
296
+ assert isinstance(result, list)
297
+ assert len(result) == len(conversations)
298
+
299
+ # Verify that some cleanup occurred
300
+ stats = pruner.get_cleanup_statistics(conversations, result)
301
+ assert isinstance(stats, dict)
302
+ assert all(key in stats for key in ['original_tokens', 'pruned_tokens', 'tokens_saved', 'compression_ratio', 'tool_results_cleaned', 'total_messages'])
303
+
304
+
305
+ # Parametrized tests for comprehensive coverage
306
+ class TestParametrized:
307
+ """Parametrized tests for comprehensive coverage"""
308
+
309
+ @pytest.mark.parametrize("tool_name,expected", [
310
+ ("ReadFileTool", "ReadFileTool"),
311
+ ("ListFilesTool", "ListFilesTool"),
312
+ ("WriteTool", "WriteTool"),
313
+ ("", ""),
314
+ ("Tool_With_Underscores", "Tool_With_Underscores"),
315
+ ("Tool-With-Hyphens", "Tool-With-Hyphens"),
316
+ ("Tool123", "Tool123"),
317
+ ])
318
+ def test_tool_name_extraction_parametrized(self, tool_name, expected):
319
+ """Parametrized test for tool name extraction"""
320
+ pruner = AgenticConversationPruner(MagicMock(), MagicMock())
321
+ content = f"<tool_result tool_name='{tool_name}' success='true'>"
322
+ result = pruner._extract_tool_name(content)
323
+ assert result == expected
324
+
325
+ @pytest.mark.parametrize("content,expected", [
326
+ ("<tool_result tool_name='Test' success='true'>content</tool_result>", True),
327
+ ("<tool_result tool_name=\"Test\" success=\"true\">content</tool_result>", True),
328
+ ("Regular message", False),
329
+ ("<tool_result>no tool_name</tool_result>", False),
330
+ ("", False),
331
+ ("<other_tag tool_name='test'>content</other_tag>", False),
332
+ ])
333
+ def test_tool_result_detection_parametrized(self, content, expected):
334
+ """Parametrized test for tool result detection"""
335
+ pruner = AgenticConversationPruner(MagicMock(), MagicMock())
336
+ result = pruner._is_tool_result_message(content)
337
+ assert result == expected
338
+
339
+
340
+ if __name__ == "__main__":
341
+ # Run tests with pytest
342
+ pytest.main([__file__, "-v"])