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.
- {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/METADATA +1 -1
- {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/RECORD +71 -35
- autocoder/agent/agentic_filter.py +1 -1
- autocoder/agent/base_agentic/tools/read_file_tool_resolver.py +1 -1
- autocoder/auto_coder_runner.py +121 -26
- autocoder/chat_auto_coder.py +81 -22
- autocoder/commands/auto_command.py +1 -1
- autocoder/common/__init__.py +2 -2
- autocoder/common/ac_style_command_parser/parser.py +27 -12
- autocoder/common/auto_coder_lang.py +78 -0
- autocoder/common/command_completer_v2.py +1 -1
- autocoder/common/file_monitor/test_file_monitor.py +307 -0
- autocoder/common/git_utils.py +7 -2
- autocoder/common/pruner/__init__.py +0 -0
- autocoder/common/pruner/agentic_conversation_pruner.py +197 -0
- autocoder/common/pruner/context_pruner.py +574 -0
- autocoder/common/pruner/conversation_pruner.py +132 -0
- autocoder/common/pruner/test_agentic_conversation_pruner.py +342 -0
- autocoder/common/pruner/test_context_pruner.py +546 -0
- autocoder/common/pull_requests/__init__.py +256 -0
- autocoder/common/pull_requests/base_provider.py +191 -0
- autocoder/common/pull_requests/config.py +66 -0
- autocoder/common/pull_requests/example.py +1 -0
- autocoder/common/pull_requests/exceptions.py +46 -0
- autocoder/common/pull_requests/manager.py +201 -0
- autocoder/common/pull_requests/models.py +164 -0
- autocoder/common/pull_requests/providers/__init__.py +23 -0
- autocoder/common/pull_requests/providers/gitcode_provider.py +19 -0
- autocoder/common/pull_requests/providers/gitee_provider.py +20 -0
- autocoder/common/pull_requests/providers/github_provider.py +214 -0
- autocoder/common/pull_requests/providers/gitlab_provider.py +29 -0
- autocoder/common/pull_requests/test_module.py +1 -0
- autocoder/common/pull_requests/utils.py +344 -0
- autocoder/common/tokens/__init__.py +77 -0
- autocoder/common/tokens/counter.py +231 -0
- autocoder/common/tokens/file_detector.py +105 -0
- autocoder/common/tokens/filters.py +111 -0
- autocoder/common/tokens/models.py +28 -0
- autocoder/common/v2/agent/agentic_edit.py +538 -590
- autocoder/common/v2/agent/agentic_edit_tools/__init__.py +8 -1
- autocoder/common/v2/agent/agentic_edit_tools/ac_mod_read_tool_resolver.py +40 -0
- autocoder/common/v2/agent/agentic_edit_tools/ac_mod_write_tool_resolver.py +43 -0
- autocoder/common/v2/agent/agentic_edit_tools/ask_followup_question_tool_resolver.py +8 -0
- autocoder/common/v2/agent/agentic_edit_tools/execute_command_tool_resolver.py +1 -1
- autocoder/common/v2/agent/agentic_edit_tools/read_file_tool_resolver.py +1 -1
- autocoder/common/v2/agent/agentic_edit_tools/search_files_tool_resolver.py +33 -88
- autocoder/common/v2/agent/agentic_edit_tools/test_write_to_file_tool_resolver.py +8 -8
- autocoder/common/v2/agent/agentic_edit_tools/todo_read_tool_resolver.py +118 -0
- autocoder/common/v2/agent/agentic_edit_tools/todo_write_tool_resolver.py +324 -0
- autocoder/common/v2/agent/agentic_edit_types.py +47 -4
- autocoder/common/v2/agent/runner/__init__.py +31 -0
- autocoder/common/v2/agent/runner/base_runner.py +106 -0
- autocoder/common/v2/agent/runner/event_runner.py +216 -0
- autocoder/common/v2/agent/runner/sdk_runner.py +40 -0
- autocoder/common/v2/agent/runner/terminal_runner.py +283 -0
- autocoder/common/v2/agent/runner/tool_display.py +191 -0
- autocoder/index/entry.py +1 -1
- autocoder/plugins/token_helper_plugin.py +107 -7
- autocoder/run_context.py +9 -0
- autocoder/sdk/__init__.py +114 -81
- autocoder/sdk/cli/handlers.py +2 -1
- autocoder/sdk/cli/main.py +9 -2
- autocoder/sdk/cli/options.py +4 -3
- autocoder/sdk/core/auto_coder_core.py +7 -152
- autocoder/sdk/core/bridge.py +5 -4
- autocoder/sdk/models/options.py +8 -6
- autocoder/version.py +1 -1
- {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/WHEEL +0 -0
- {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/entry_points.txt +0 -0
- {auto_coder-0.1.399.dist-info → auto_coder-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {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"])
|