aider-ce 0.88.20__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.
Files changed (279) hide show
  1. aider/__init__.py +20 -0
  2. aider/__main__.py +4 -0
  3. aider/_version.py +34 -0
  4. aider/analytics.py +258 -0
  5. aider/args.py +1056 -0
  6. aider/args_formatter.py +228 -0
  7. aider/change_tracker.py +133 -0
  8. aider/coders/__init__.py +36 -0
  9. aider/coders/agent_coder.py +2166 -0
  10. aider/coders/agent_prompts.py +104 -0
  11. aider/coders/architect_coder.py +48 -0
  12. aider/coders/architect_prompts.py +40 -0
  13. aider/coders/ask_coder.py +9 -0
  14. aider/coders/ask_prompts.py +35 -0
  15. aider/coders/base_coder.py +3613 -0
  16. aider/coders/base_prompts.py +87 -0
  17. aider/coders/chat_chunks.py +64 -0
  18. aider/coders/context_coder.py +53 -0
  19. aider/coders/context_prompts.py +75 -0
  20. aider/coders/editblock_coder.py +657 -0
  21. aider/coders/editblock_fenced_coder.py +10 -0
  22. aider/coders/editblock_fenced_prompts.py +143 -0
  23. aider/coders/editblock_func_coder.py +141 -0
  24. aider/coders/editblock_func_prompts.py +27 -0
  25. aider/coders/editblock_prompts.py +175 -0
  26. aider/coders/editor_diff_fenced_coder.py +9 -0
  27. aider/coders/editor_diff_fenced_prompts.py +11 -0
  28. aider/coders/editor_editblock_coder.py +9 -0
  29. aider/coders/editor_editblock_prompts.py +21 -0
  30. aider/coders/editor_whole_coder.py +9 -0
  31. aider/coders/editor_whole_prompts.py +12 -0
  32. aider/coders/help_coder.py +16 -0
  33. aider/coders/help_prompts.py +46 -0
  34. aider/coders/patch_coder.py +706 -0
  35. aider/coders/patch_prompts.py +159 -0
  36. aider/coders/search_replace.py +757 -0
  37. aider/coders/shell.py +37 -0
  38. aider/coders/single_wholefile_func_coder.py +102 -0
  39. aider/coders/single_wholefile_func_prompts.py +27 -0
  40. aider/coders/udiff_coder.py +429 -0
  41. aider/coders/udiff_prompts.py +115 -0
  42. aider/coders/udiff_simple.py +14 -0
  43. aider/coders/udiff_simple_prompts.py +25 -0
  44. aider/coders/wholefile_coder.py +144 -0
  45. aider/coders/wholefile_func_coder.py +134 -0
  46. aider/coders/wholefile_func_prompts.py +27 -0
  47. aider/coders/wholefile_prompts.py +65 -0
  48. aider/commands.py +2173 -0
  49. aider/copypaste.py +72 -0
  50. aider/deprecated.py +126 -0
  51. aider/diffs.py +128 -0
  52. aider/dump.py +29 -0
  53. aider/editor.py +147 -0
  54. aider/exceptions.py +115 -0
  55. aider/format_settings.py +26 -0
  56. aider/gui.py +545 -0
  57. aider/help.py +163 -0
  58. aider/help_pats.py +19 -0
  59. aider/helpers/__init__.py +9 -0
  60. aider/helpers/similarity.py +98 -0
  61. aider/history.py +180 -0
  62. aider/io.py +1608 -0
  63. aider/linter.py +304 -0
  64. aider/llm.py +55 -0
  65. aider/main.py +1415 -0
  66. aider/mcp/__init__.py +174 -0
  67. aider/mcp/server.py +149 -0
  68. aider/mdstream.py +243 -0
  69. aider/models.py +1313 -0
  70. aider/onboarding.py +429 -0
  71. aider/openrouter.py +129 -0
  72. aider/prompts.py +56 -0
  73. aider/queries/tree-sitter-language-pack/README.md +7 -0
  74. aider/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  75. aider/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  76. aider/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  77. aider/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  78. aider/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  79. aider/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  80. aider/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  81. aider/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  82. aider/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  83. aider/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  84. aider/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  85. aider/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  86. aider/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  87. aider/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  88. aider/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  89. aider/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  90. aider/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  91. aider/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  92. aider/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  93. aider/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  94. aider/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  95. aider/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  96. aider/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  97. aider/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  98. aider/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  99. aider/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  100. aider/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  101. aider/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  102. aider/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  103. aider/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  104. aider/queries/tree-sitter-languages/README.md +24 -0
  105. aider/queries/tree-sitter-languages/c-tags.scm +9 -0
  106. aider/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  107. aider/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  108. aider/queries/tree-sitter-languages/dart-tags.scm +91 -0
  109. aider/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  110. aider/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  111. aider/queries/tree-sitter-languages/elm-tags.scm +19 -0
  112. aider/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  113. aider/queries/tree-sitter-languages/go-tags.scm +30 -0
  114. aider/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  115. aider/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  116. aider/queries/tree-sitter-languages/java-tags.scm +20 -0
  117. aider/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  118. aider/queries/tree-sitter-languages/julia-tags.scm +60 -0
  119. aider/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  120. aider/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  121. aider/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  122. aider/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  123. aider/queries/tree-sitter-languages/php-tags.scm +26 -0
  124. aider/queries/tree-sitter-languages/python-tags.scm +12 -0
  125. aider/queries/tree-sitter-languages/ql-tags.scm +26 -0
  126. aider/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  127. aider/queries/tree-sitter-languages/rust-tags.scm +60 -0
  128. aider/queries/tree-sitter-languages/scala-tags.scm +65 -0
  129. aider/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  130. aider/queries/tree-sitter-languages/zig-tags.scm +3 -0
  131. aider/reasoning_tags.py +82 -0
  132. aider/repo.py +621 -0
  133. aider/repomap.py +1174 -0
  134. aider/report.py +260 -0
  135. aider/resources/__init__.py +3 -0
  136. aider/resources/model-metadata.json +776 -0
  137. aider/resources/model-settings.yml +2068 -0
  138. aider/run_cmd.py +133 -0
  139. aider/scrape.py +293 -0
  140. aider/sendchat.py +242 -0
  141. aider/sessions.py +256 -0
  142. aider/special.py +203 -0
  143. aider/tools/__init__.py +72 -0
  144. aider/tools/command.py +105 -0
  145. aider/tools/command_interactive.py +122 -0
  146. aider/tools/delete_block.py +182 -0
  147. aider/tools/delete_line.py +155 -0
  148. aider/tools/delete_lines.py +184 -0
  149. aider/tools/extract_lines.py +341 -0
  150. aider/tools/finished.py +48 -0
  151. aider/tools/git_branch.py +129 -0
  152. aider/tools/git_diff.py +60 -0
  153. aider/tools/git_log.py +57 -0
  154. aider/tools/git_remote.py +53 -0
  155. aider/tools/git_show.py +51 -0
  156. aider/tools/git_status.py +46 -0
  157. aider/tools/grep.py +256 -0
  158. aider/tools/indent_lines.py +221 -0
  159. aider/tools/insert_block.py +288 -0
  160. aider/tools/list_changes.py +86 -0
  161. aider/tools/ls.py +93 -0
  162. aider/tools/make_editable.py +85 -0
  163. aider/tools/make_readonly.py +69 -0
  164. aider/tools/remove.py +91 -0
  165. aider/tools/replace_all.py +126 -0
  166. aider/tools/replace_line.py +173 -0
  167. aider/tools/replace_lines.py +217 -0
  168. aider/tools/replace_text.py +187 -0
  169. aider/tools/show_numbered_context.py +147 -0
  170. aider/tools/tool_utils.py +313 -0
  171. aider/tools/undo_change.py +95 -0
  172. aider/tools/update_todo_list.py +156 -0
  173. aider/tools/view.py +57 -0
  174. aider/tools/view_files_matching.py +141 -0
  175. aider/tools/view_files_with_symbol.py +129 -0
  176. aider/urls.py +17 -0
  177. aider/utils.py +456 -0
  178. aider/versioncheck.py +113 -0
  179. aider/voice.py +205 -0
  180. aider/waiting.py +38 -0
  181. aider/watch.py +318 -0
  182. aider/watch_prompts.py +12 -0
  183. aider/website/Gemfile +8 -0
  184. aider/website/_includes/blame.md +162 -0
  185. aider/website/_includes/get-started.md +22 -0
  186. aider/website/_includes/help-tip.md +5 -0
  187. aider/website/_includes/help.md +24 -0
  188. aider/website/_includes/install.md +5 -0
  189. aider/website/_includes/keys.md +4 -0
  190. aider/website/_includes/model-warnings.md +67 -0
  191. aider/website/_includes/multi-line.md +22 -0
  192. aider/website/_includes/python-m-aider.md +5 -0
  193. aider/website/_includes/recording.css +228 -0
  194. aider/website/_includes/recording.md +34 -0
  195. aider/website/_includes/replit-pipx.md +9 -0
  196. aider/website/_includes/works-best.md +1 -0
  197. aider/website/_sass/custom/custom.scss +103 -0
  198. aider/website/docs/config/adv-model-settings.md +2261 -0
  199. aider/website/docs/config/agent-mode.md +194 -0
  200. aider/website/docs/config/aider_conf.md +548 -0
  201. aider/website/docs/config/api-keys.md +90 -0
  202. aider/website/docs/config/dotenv.md +493 -0
  203. aider/website/docs/config/editor.md +127 -0
  204. aider/website/docs/config/mcp.md +95 -0
  205. aider/website/docs/config/model-aliases.md +104 -0
  206. aider/website/docs/config/options.md +890 -0
  207. aider/website/docs/config/reasoning.md +210 -0
  208. aider/website/docs/config.md +44 -0
  209. aider/website/docs/faq.md +384 -0
  210. aider/website/docs/git.md +76 -0
  211. aider/website/docs/index.md +47 -0
  212. aider/website/docs/install/codespaces.md +39 -0
  213. aider/website/docs/install/docker.md +57 -0
  214. aider/website/docs/install/optional.md +100 -0
  215. aider/website/docs/install/replit.md +8 -0
  216. aider/website/docs/install.md +115 -0
  217. aider/website/docs/languages.md +264 -0
  218. aider/website/docs/legal/contributor-agreement.md +111 -0
  219. aider/website/docs/legal/privacy.md +104 -0
  220. aider/website/docs/llms/anthropic.md +77 -0
  221. aider/website/docs/llms/azure.md +48 -0
  222. aider/website/docs/llms/bedrock.md +132 -0
  223. aider/website/docs/llms/cohere.md +34 -0
  224. aider/website/docs/llms/deepseek.md +32 -0
  225. aider/website/docs/llms/gemini.md +49 -0
  226. aider/website/docs/llms/github.md +111 -0
  227. aider/website/docs/llms/groq.md +36 -0
  228. aider/website/docs/llms/lm-studio.md +39 -0
  229. aider/website/docs/llms/ollama.md +75 -0
  230. aider/website/docs/llms/openai-compat.md +39 -0
  231. aider/website/docs/llms/openai.md +58 -0
  232. aider/website/docs/llms/openrouter.md +78 -0
  233. aider/website/docs/llms/other.md +117 -0
  234. aider/website/docs/llms/vertex.md +50 -0
  235. aider/website/docs/llms/warnings.md +10 -0
  236. aider/website/docs/llms/xai.md +53 -0
  237. aider/website/docs/llms.md +54 -0
  238. aider/website/docs/more/analytics.md +127 -0
  239. aider/website/docs/more/edit-formats.md +116 -0
  240. aider/website/docs/more/infinite-output.md +165 -0
  241. aider/website/docs/more-info.md +8 -0
  242. aider/website/docs/recordings/auto-accept-architect.md +31 -0
  243. aider/website/docs/recordings/dont-drop-original-read-files.md +35 -0
  244. aider/website/docs/recordings/index.md +21 -0
  245. aider/website/docs/recordings/model-accepts-settings.md +69 -0
  246. aider/website/docs/recordings/tree-sitter-language-pack.md +80 -0
  247. aider/website/docs/repomap.md +112 -0
  248. aider/website/docs/scripting.md +100 -0
  249. aider/website/docs/sessions.md +203 -0
  250. aider/website/docs/troubleshooting/aider-not-found.md +24 -0
  251. aider/website/docs/troubleshooting/edit-errors.md +76 -0
  252. aider/website/docs/troubleshooting/imports.md +62 -0
  253. aider/website/docs/troubleshooting/models-and-keys.md +54 -0
  254. aider/website/docs/troubleshooting/support.md +79 -0
  255. aider/website/docs/troubleshooting/token-limits.md +96 -0
  256. aider/website/docs/troubleshooting/warnings.md +12 -0
  257. aider/website/docs/troubleshooting.md +11 -0
  258. aider/website/docs/usage/browser.md +57 -0
  259. aider/website/docs/usage/caching.md +49 -0
  260. aider/website/docs/usage/commands.md +133 -0
  261. aider/website/docs/usage/conventions.md +119 -0
  262. aider/website/docs/usage/copypaste.md +121 -0
  263. aider/website/docs/usage/images-urls.md +48 -0
  264. aider/website/docs/usage/lint-test.md +118 -0
  265. aider/website/docs/usage/modes.md +211 -0
  266. aider/website/docs/usage/not-code.md +179 -0
  267. aider/website/docs/usage/notifications.md +87 -0
  268. aider/website/docs/usage/tips.md +79 -0
  269. aider/website/docs/usage/tutorials.md +30 -0
  270. aider/website/docs/usage/voice.md +121 -0
  271. aider/website/docs/usage/watch.md +294 -0
  272. aider/website/docs/usage.md +102 -0
  273. aider/website/share/index.md +101 -0
  274. aider_ce-0.88.20.dist-info/METADATA +187 -0
  275. aider_ce-0.88.20.dist-info/RECORD +279 -0
  276. aider_ce-0.88.20.dist-info/WHEEL +5 -0
  277. aider_ce-0.88.20.dist-info/entry_points.txt +2 -0
  278. aider_ce-0.88.20.dist-info/licenses/LICENSE.txt +202 -0
  279. aider_ce-0.88.20.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2166 @@
1
+ import ast
2
+ import asyncio
3
+ import base64
4
+ import json
5
+ import locale
6
+ import os
7
+ import platform
8
+ import re
9
+ import time
10
+ import traceback
11
+
12
+ # Add necessary imports if not already present
13
+ from collections import Counter, defaultdict
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+
17
+ from litellm import experimental_mcp_client
18
+
19
+ from aider import urls, utils
20
+
21
+ # Import the change tracker
22
+ from aider.change_tracker import ChangeTracker
23
+ from aider.mcp.server import LocalServer
24
+ from aider.repo import ANY_GIT_ERROR
25
+
26
+ # Import tool modules for registry
27
+ # Import tool modules for registry
28
+ from aider.tools import (
29
+ command,
30
+ command_interactive,
31
+ delete_block,
32
+ delete_line,
33
+ delete_lines,
34
+ extract_lines,
35
+ finished,
36
+ git_branch,
37
+ git_diff,
38
+ git_log,
39
+ git_remote,
40
+ git_show,
41
+ git_status,
42
+ grep,
43
+ indent_lines,
44
+ insert_block,
45
+ list_changes,
46
+ ls,
47
+ make_editable,
48
+ make_readonly,
49
+ remove,
50
+ replace_all,
51
+ replace_line,
52
+ replace_lines,
53
+ replace_text,
54
+ show_numbered_context,
55
+ undo_change,
56
+ update_todo_list,
57
+ view,
58
+ view_files_matching,
59
+ view_files_with_symbol,
60
+ )
61
+
62
+ from .agent_prompts import AgentPrompts
63
+ from .base_coder import ChatChunks, Coder
64
+ from .editblock_coder import do_replace, find_original_update_blocks, find_similar_lines
65
+
66
+
67
+ class AgentCoder(Coder):
68
+ """Mode where the LLM autonomously manages which files are in context."""
69
+
70
+ edit_format = "agent"
71
+
72
+ def __init__(self, *args, **kwargs):
73
+ # Initialize appropriate prompt set before calling parent constructor
74
+ # This needs to happen before super().__init__ so the parent class has access to gpt_prompts
75
+ self.gpt_prompts = AgentPrompts()
76
+
77
+ # Dictionary to track recently removed files
78
+ self.recently_removed = {}
79
+
80
+ # Tool usage history
81
+ self.tool_usage_history = []
82
+ self.tool_usage_retries = 10
83
+ self.read_tools = {
84
+ "viewfilesatglob",
85
+ "viewfilesmatching",
86
+ "ls",
87
+ "viewfileswithsymbol",
88
+ "grep",
89
+ "listchanges",
90
+ "extractlines",
91
+ "shownumberedcontext",
92
+ }
93
+ self.write_tools = {
94
+ "command",
95
+ "commandinteractive",
96
+ "insertblock",
97
+ "replaceblock",
98
+ "replaceall",
99
+ "replacetext",
100
+ "undochange",
101
+ }
102
+
103
+ # Configuration parameters
104
+ self.max_tool_calls = 100 # Maximum number of tool calls per response
105
+
106
+ # Context management parameters
107
+ # Will be overridden by agent_config if provided
108
+ self.large_file_token_threshold = (
109
+ 25000 # Files larger than this in tokens are considered large
110
+ )
111
+
112
+ # Enable context management by default only in agent mode
113
+ self.context_management_enabled = True # Enabled by default for agent mode
114
+
115
+ # Initialize change tracker for granular editing
116
+ self.change_tracker = ChangeTracker()
117
+
118
+ # Initialize tool registry
119
+ self.args = kwargs.get("args")
120
+ self._tool_registry = self._build_tool_registry()
121
+
122
+ # Track files added during current exploration
123
+ self.files_added_in_exploration = set()
124
+
125
+ # Counter for tool calls
126
+ self.tool_call_count = 0
127
+
128
+ # Set high max reflections to allow many exploration rounds
129
+ # This controls how many automatic iterations the LLM can do
130
+ self.max_reflections = 15
131
+
132
+ # Enable enhanced context blocks by default
133
+ self.use_enhanced_context = True
134
+
135
+ # Initialize empty token tracking dictionary and cache structures
136
+ # but don't populate yet to avoid startup delay
137
+ self.context_block_tokens = {}
138
+ self.context_blocks_cache = {}
139
+ self.tokens_calculated = False
140
+
141
+ self.skip_cli_confirmations = False
142
+
143
+ self._get_agent_config()
144
+ super().__init__(*args, **kwargs)
145
+
146
+ def _build_tool_registry(self):
147
+ """
148
+ Build a registry of available tools with their normalized names and process_response functions.
149
+ Handles agent configuration with includelist/excludelist functionality.
150
+
151
+ Returns:
152
+ dict: Mapping of normalized tool names to tool modules
153
+ """
154
+ registry = {}
155
+
156
+ # Add tools that have been imported
157
+ tool_modules = [
158
+ command,
159
+ command_interactive,
160
+ delete_block,
161
+ delete_line,
162
+ delete_lines,
163
+ extract_lines,
164
+ finished,
165
+ git_branch,
166
+ git_diff,
167
+ git_log,
168
+ git_remote,
169
+ git_show,
170
+ git_status,
171
+ grep,
172
+ indent_lines,
173
+ insert_block,
174
+ list_changes,
175
+ ls,
176
+ make_editable,
177
+ make_readonly,
178
+ remove,
179
+ replace_all,
180
+ replace_line,
181
+ replace_lines,
182
+ replace_text,
183
+ show_numbered_context,
184
+ undo_change,
185
+ update_todo_list,
186
+ view,
187
+ view_files_matching,
188
+ view_files_with_symbol,
189
+ ]
190
+
191
+ # Process agent configuration if provided
192
+ agent_config = self._get_agent_config()
193
+ tools_includelist = agent_config.get(
194
+ "tools_includelist", agent_config.get("tools_whitelist", [])
195
+ )
196
+ tools_excludelist = agent_config.get(
197
+ "tools_excludelist", agent_config.get("tools_blacklist", [])
198
+ )
199
+
200
+ # Always include essential tools regardless of includelist/excludelist
201
+ essential_tools = {"makeeditable", "replacetext", "view", "finished"}
202
+ for module in tool_modules:
203
+ if hasattr(module, "NORM_NAME") and hasattr(module, "process_response"):
204
+ tool_name = module.NORM_NAME
205
+
206
+ # Check if tool should be included based on configuration
207
+ should_include = True
208
+
209
+ # If includelist is specified, only include tools in includelist
210
+ if tools_includelist:
211
+ should_include = tool_name in tools_includelist
212
+
213
+ # Always include essential tools
214
+ if tool_name in essential_tools:
215
+ should_include = True
216
+
217
+ # Exclude tools in excludelist (unless they're essential)
218
+ if tool_name in tools_excludelist and tool_name not in essential_tools:
219
+ should_include = False
220
+
221
+ if should_include:
222
+ registry[tool_name] = module
223
+
224
+ return registry
225
+
226
+ def _get_agent_config(self):
227
+ """
228
+ Parse and return agent configuration from args.agent_config.
229
+
230
+ Returns:
231
+ dict: Agent configuration with defaults for missing values
232
+ """
233
+ config = {}
234
+
235
+ # Check if agent_config is provided via args
236
+ if (
237
+ hasattr(self, "args")
238
+ and self.args
239
+ and hasattr(self.args, "agent_config")
240
+ and self.args.agent_config
241
+ ):
242
+ try:
243
+ config = json.loads(self.args.agent_config)
244
+ except (json.JSONDecodeError, TypeError) as e:
245
+ self.io.tool_warning(f"Failed to parse agent-config JSON: {e}")
246
+ return {}
247
+
248
+ # Set defaults for missing values
249
+ if "large_file_token_threshold" not in config:
250
+ config["large_file_token_threshold"] = 25000
251
+ if "tools_includelist" not in config:
252
+ config["tools_includelist"] = []
253
+ if "tools_excludelist" not in config:
254
+ config["tools_excludelist"] = []
255
+
256
+ # Apply configuration to instance
257
+ self.large_file_token_threshold = config["large_file_token_threshold"]
258
+ self.skip_cli_confirmations = config.get(
259
+ "skip_cli_confirmations", config.get("yolo", False)
260
+ )
261
+
262
+ return config
263
+
264
+ def get_local_tool_schemas(self):
265
+ """Returns the JSON schemas for all local tools using the tool registry."""
266
+ schemas = []
267
+
268
+ # Get schemas from the tool registry
269
+ for tool_module in self._tool_registry.values():
270
+ if hasattr(tool_module, "schema"):
271
+ schemas.append(tool_module.schema)
272
+
273
+ return schemas
274
+
275
+ async def initialize_mcp_tools(self):
276
+ await super().initialize_mcp_tools()
277
+
278
+ local_tools = self.get_local_tool_schemas()
279
+ if not local_tools:
280
+ return
281
+
282
+ local_server_config = {"name": "local_tools"}
283
+ local_server = LocalServer(local_server_config)
284
+
285
+ if not self.mcp_servers:
286
+ self.mcp_servers = []
287
+ if not any(isinstance(s, LocalServer) for s in self.mcp_servers):
288
+ self.mcp_servers.append(local_server)
289
+
290
+ if not self.mcp_tools:
291
+ self.mcp_tools = []
292
+
293
+ if "local_tools" not in [name for name, _ in self.mcp_tools]:
294
+ self.mcp_tools.append((local_server.name, local_tools))
295
+
296
+ async def _execute_local_tool_calls(self, tool_calls_list):
297
+ tool_responses = []
298
+ for tool_call in tool_calls_list:
299
+ tool_name = tool_call.function.name
300
+ result_message = ""
301
+ try:
302
+ # Arguments can be a stream of JSON objects.
303
+ # We need to parse them and run a tool call for each.
304
+ args_string = tool_call.function.arguments.strip()
305
+ parsed_args_list = []
306
+ if args_string:
307
+ json_chunks = utils.split_concatenated_json(args_string)
308
+ for chunk in json_chunks:
309
+ try:
310
+ parsed_args_list.append(json.loads(chunk))
311
+ except json.JSONDecodeError:
312
+ self.io.tool_warning(
313
+ f"Could not parse JSON chunk for tool {tool_name}: {chunk}"
314
+ )
315
+ continue
316
+
317
+ if not parsed_args_list and not args_string:
318
+ parsed_args_list.append({}) # For tool calls with no arguments
319
+
320
+ all_results_content = []
321
+ norm_tool_name = tool_name.lower()
322
+
323
+ tasks = []
324
+
325
+ # Use the tool registry for execution
326
+ if norm_tool_name in self._tool_registry:
327
+ tool_module = self._tool_registry[norm_tool_name]
328
+ for params in parsed_args_list:
329
+ # Use the process_response function from the tool module
330
+ result = tool_module.process_response(self, params)
331
+ # Handle async functions
332
+ if asyncio.iscoroutine(result):
333
+ tasks.append(result)
334
+ else:
335
+ tasks.append(asyncio.to_thread(lambda: result))
336
+ else:
337
+ # Handle MCP tools for tools not in registry
338
+ if self.mcp_tools:
339
+ for server_name, server_tools in self.mcp_tools:
340
+ if any(
341
+ t.get("function", {}).get("name") == norm_tool_name
342
+ for t in server_tools
343
+ ):
344
+ server = next(
345
+ (s for s in self.mcp_servers if s.name == server_name), None
346
+ )
347
+ if server:
348
+ for params in parsed_args_list:
349
+ tasks.append(
350
+ self._execute_mcp_tool(server, norm_tool_name, params)
351
+ )
352
+ break
353
+ else:
354
+ all_results_content.append(f"Error: Unknown tool name '{tool_name}'")
355
+ else:
356
+ all_results_content.append(f"Error: Unknown tool name '{tool_name}'")
357
+
358
+ if tasks:
359
+ task_results = await asyncio.gather(*tasks)
360
+ all_results_content.extend(str(res) for res in task_results)
361
+
362
+ result_message = "\n\n".join(all_results_content)
363
+
364
+ except Exception as e:
365
+ result_message = f"Error executing {tool_name}: {e}"
366
+ self.io.tool_error(
367
+ f"Error during {tool_name} execution: {e}\n{traceback.format_exc()}"
368
+ )
369
+
370
+ tool_responses.append(
371
+ {
372
+ "role": "tool",
373
+ "tool_call_id": tool_call.id,
374
+ "content": result_message,
375
+ }
376
+ )
377
+ return tool_responses
378
+
379
+ async def _execute_mcp_tool(self, server, tool_name, params):
380
+ """Helper to execute a single MCP tool call, created from legacy format."""
381
+
382
+ # This is a simplified, synchronous wrapper around async logic
383
+ # It's duplicating logic from BaseCoder for legacy tool support.
384
+ async def _exec_async():
385
+ # Construct a ToolCall object-like structure to be compatible with mcp_client
386
+ function_dict = {"name": tool_name, "arguments": json.dumps(params)}
387
+ tool_call_dict = {
388
+ "id": f"mcp-tool-call-{time.time()}",
389
+ "function": function_dict,
390
+ "type": "function",
391
+ }
392
+ try:
393
+ session = await server.connect()
394
+ call_result = await experimental_mcp_client.call_openai_tool(
395
+ session=session,
396
+ openai_tool=tool_call_dict,
397
+ )
398
+
399
+ content_parts = []
400
+ if call_result.content:
401
+ for item in call_result.content:
402
+ if hasattr(item, "resource"): # EmbeddedResource
403
+ resource = item.resource
404
+ if hasattr(resource, "text"): # TextResourceContents
405
+ content_parts.append(resource.text)
406
+ elif hasattr(resource, "blob"): # BlobResourceContents
407
+ try:
408
+ decoded_blob = base64.b64decode(resource.blob).decode("utf-8")
409
+ content_parts.append(decoded_blob)
410
+ except (UnicodeDecodeError, TypeError):
411
+ name = getattr(resource, "name", "unnamed")
412
+ mime_type = getattr(resource, "mimeType", "unknown mime type")
413
+ content_parts.append(
414
+ f"[embedded binary resource: {name} ({mime_type})]"
415
+ )
416
+ elif hasattr(item, "text"): # TextContent
417
+ content_parts.append(item.text)
418
+
419
+ return "".join(content_parts)
420
+
421
+ except Exception as e:
422
+ self.io.tool_warning(
423
+ f"Executing {tool_name} on {server.name} failed: \n Error: {e}\n"
424
+ )
425
+ return f"Error executing tool call {tool_name}: {e}"
426
+
427
+ return await _exec_async()
428
+
429
+ def _calculate_context_block_tokens(self, force=False):
430
+ """
431
+ Calculate token counts for all enhanced context blocks.
432
+ This is the central method for calculating token counts,
433
+ ensuring they're consistent across all parts of the code.
434
+
435
+ This method populates the cache for context blocks and calculates tokens.
436
+
437
+ Args:
438
+ force: If True, recalculate tokens even if already calculated
439
+ """
440
+ # Skip if already calculated and not forced
441
+ if hasattr(self, "tokens_calculated") and self.tokens_calculated and not force:
442
+ return
443
+
444
+ # Clear existing token counts
445
+ self.context_block_tokens = {}
446
+
447
+ # Initialize the cache for context blocks if needed
448
+ if not hasattr(self, "context_blocks_cache"):
449
+ self.context_blocks_cache = {}
450
+
451
+ if not self.use_enhanced_context:
452
+ return
453
+
454
+ try:
455
+ # First, clear the cache to force regeneration of all blocks
456
+ self.context_blocks_cache = {}
457
+
458
+ # Generate all context blocks and calculate token counts
459
+ block_types = [
460
+ "environment_info",
461
+ "directory_structure",
462
+ "git_status",
463
+ "symbol_outline",
464
+ ]
465
+
466
+ for block_type in block_types:
467
+ block_content = self._generate_context_block(block_type)
468
+ if block_content:
469
+ self.context_block_tokens[block_type] = self.main_model.token_count(
470
+ block_content
471
+ )
472
+
473
+ # Mark as calculated
474
+ self.tokens_calculated = True
475
+ except Exception:
476
+ # Silently handle errors during calculation
477
+ # This prevents errors in token counting from breaking the main functionality
478
+ pass
479
+
480
+ def _generate_context_block(self, block_name):
481
+ """
482
+ Generate a specific context block and cache it.
483
+ This is a helper method for get_cached_context_block.
484
+ """
485
+ content = None
486
+
487
+ if block_name == "environment_info":
488
+ content = self.get_environment_info()
489
+ elif block_name == "directory_structure":
490
+ content = self.get_directory_structure()
491
+ elif block_name == "git_status":
492
+ content = self.get_git_status()
493
+ elif block_name == "symbol_outline":
494
+ content = self.get_context_symbol_outline()
495
+ elif block_name == "context_summary":
496
+ content = self.get_context_summary()
497
+ elif block_name == "todo_list":
498
+ content = self.get_todo_list()
499
+
500
+ # Cache the result if it's not None
501
+ if content is not None:
502
+ self.context_blocks_cache[block_name] = content
503
+
504
+ return content
505
+
506
+ def get_cached_context_block(self, block_name):
507
+ """
508
+ Get a context block from the cache, or generate it if not available.
509
+ This should be used by format_chat_chunks to avoid regenerating blocks.
510
+
511
+ This will ensure tokens are calculated if they haven't been yet.
512
+ """
513
+ # Make sure tokens have been calculated at least once
514
+ if not hasattr(self, "tokens_calculated") or not self.tokens_calculated:
515
+ self._calculate_context_block_tokens()
516
+
517
+ # Return from cache if available
518
+ if hasattr(self, "context_blocks_cache") and block_name in self.context_blocks_cache:
519
+ return self.context_blocks_cache[block_name]
520
+
521
+ # Otherwise generate and cache the block
522
+ return self._generate_context_block(block_name)
523
+
524
+ def get_context_symbol_outline(self):
525
+ """
526
+ Generate a symbol outline for files currently in context using Tree-sitter,
527
+ bypassing the cache for freshness.
528
+ """
529
+ if not self.use_enhanced_context or not self.repo_map:
530
+ return None
531
+
532
+ try:
533
+ result = '<context name="symbol_outline">\n'
534
+ result += "## Symbol Outline (Current Context)\n\n"
535
+ result += (
536
+ "Code definitions (classes, functions, methods, etc.) found in files currently in"
537
+ " chat context.\n\n"
538
+ )
539
+
540
+ files_to_outline = list(self.abs_fnames) + list(self.abs_read_only_fnames)
541
+ if not files_to_outline:
542
+ result += "No files currently in context.\n"
543
+ result += "</context>"
544
+ return result
545
+
546
+ all_tags_by_file = defaultdict(list)
547
+ has_symbols = False
548
+
549
+ # Use repo_map which should be initialized in BaseCoder
550
+ if not self.repo_map:
551
+ self.io.tool_warning("RepoMap not initialized, cannot generate symbol outline.")
552
+ return None # Or return a message indicating repo map is unavailable
553
+
554
+ for abs_fname in sorted(files_to_outline):
555
+ rel_fname = self.get_rel_fname(abs_fname)
556
+ try:
557
+ # Call get_tags_raw directly to bypass cache and ensure freshness
558
+ tags = list(self.repo_map.get_tags_raw(abs_fname, rel_fname))
559
+ if tags:
560
+ all_tags_by_file[rel_fname].extend(tags)
561
+ has_symbols = True
562
+ except Exception as e:
563
+ self.io.tool_warning(f"Could not get symbols for {rel_fname}: {e}")
564
+
565
+ if not has_symbols:
566
+ result += "No symbols found in the current context files.\n"
567
+ else:
568
+ for rel_fname in sorted(all_tags_by_file.keys()):
569
+ tags = sorted(all_tags_by_file[rel_fname], key=lambda t: (t.line, t.name))
570
+
571
+ definition_tags = []
572
+ for tag in tags:
573
+ # Use specific_kind first if available, otherwise fall back to kind
574
+ kind_to_check = tag.specific_kind or tag.kind
575
+ # Check if the kind represents a definition using the set from RepoMap
576
+ if (
577
+ kind_to_check
578
+ and kind_to_check.lower() in self.repo_map.definition_kinds
579
+ ):
580
+ definition_tags.append(tag)
581
+
582
+ if definition_tags:
583
+ result += f"### {rel_fname}\n"
584
+ # Simple list format for now, could be enhanced later (e.g., indentation for scope)
585
+ for tag in definition_tags:
586
+ # Display line number if available
587
+ line_info = f", line {tag.line + 1}" if tag.line >= 0 else ""
588
+ # Display the specific kind (which we checked)
589
+ kind_to_check = tag.specific_kind or tag.kind # Recalculate for safety
590
+ result += f"- {tag.name} ({kind_to_check}{line_info})\n"
591
+ result += "\n" # Add space between files
592
+
593
+ result += "</context>"
594
+ return result.strip() # Remove trailing newline if any
595
+
596
+ except Exception as e:
597
+ self.io.tool_error(f"Error generating symbol outline: {str(e)}")
598
+ # Optionally include traceback for debugging if verbose
599
+ # if self.verbose:
600
+ # self.io.tool_error(traceback.format_exc())
601
+ return None
602
+
603
+ def format_chat_chunks(self):
604
+ """
605
+ Override parent's format_chat_chunks to include enhanced context blocks with a
606
+ cleaner, more hierarchical structure for better organization.
607
+
608
+ Optimized for prompt caching by placing context blocks strategically:
609
+ 1. Relatively static blocks (directory structure, environment info) before done_messages
610
+ 2. Dynamic blocks (context summary, symbol outline, git status) after chat_files
611
+
612
+ This approach preserves prefix caching while providing fresh context information.
613
+ """
614
+ # If enhanced context blocks are not enabled, use the base implementation
615
+ if not self.use_enhanced_context:
616
+ return super().format_chat_chunks()
617
+
618
+ # Build chunks from scratch to avoid duplication with enhanced context blocks
619
+ self.choose_fence()
620
+ main_sys = self.fmt_system_prompt(self.gpt_prompts.main_system)
621
+
622
+ example_messages = []
623
+ if self.main_model.examples_as_sys_msg:
624
+ if self.gpt_prompts.example_messages:
625
+ main_sys += "\n# Example conversations:\n\n"
626
+ for msg in self.gpt_prompts.example_messages:
627
+ role = msg["role"]
628
+ content = self.fmt_system_prompt(msg["content"])
629
+ main_sys += f"## {role.upper()}: {content}\n\n"
630
+ main_sys = main_sys.strip()
631
+ else:
632
+ for msg in self.gpt_prompts.example_messages:
633
+ example_messages.append(
634
+ dict(
635
+ role=msg["role"],
636
+ content=self.fmt_system_prompt(msg["content"]),
637
+ )
638
+ )
639
+ if self.gpt_prompts.example_messages:
640
+ example_messages += [
641
+ dict(
642
+ role="user",
643
+ content=(
644
+ "I switched to a new code base. Please don't consider the above files"
645
+ " or try to edit them any longer."
646
+ ),
647
+ ),
648
+ dict(role="assistant", content="Ok."),
649
+ ]
650
+
651
+ if self.gpt_prompts.system_reminder:
652
+ main_sys += "\n" + self.fmt_system_prompt(self.gpt_prompts.system_reminder)
653
+
654
+ chunks = ChatChunks()
655
+
656
+ if self.main_model.use_system_prompt:
657
+ chunks.system = [
658
+ dict(role="system", content=main_sys),
659
+ ]
660
+ else:
661
+ chunks.system = [
662
+ dict(role="user", content=main_sys),
663
+ dict(role="assistant", content="Ok."),
664
+ ]
665
+
666
+ chunks.examples = example_messages
667
+
668
+ self.summarize_end()
669
+ chunks.done = list(self.done_messages)
670
+
671
+ chunks.repo = self.get_repo_messages()
672
+ chunks.readonly_files = self.get_readonly_files_messages()
673
+ chunks.chat_files = self.get_chat_files_messages()
674
+
675
+ # Make sure token counts are updated - using centralized method
676
+ # This also populates the context block cache
677
+ self._calculate_context_block_tokens()
678
+
679
+ # Get blocks from cache to avoid regenerating them
680
+ env_context = self.get_cached_context_block("environment_info")
681
+ dir_structure = self.get_cached_context_block("directory_structure")
682
+ git_status = self.get_cached_context_block("git_status")
683
+ symbol_outline = self.get_cached_context_block("symbol_outline")
684
+ todo_list = self.get_cached_context_block("todo_list")
685
+
686
+ # Context summary needs special handling because it depends on other blocks
687
+ context_summary = self.get_context_summary()
688
+
689
+ # 1. Add relatively static blocks BEFORE done_messages
690
+ # These blocks change less frequently and can be part of the cacheable prefix
691
+ static_blocks = []
692
+ if dir_structure:
693
+ static_blocks.append(dir_structure)
694
+ if env_context:
695
+ static_blocks.append(env_context)
696
+
697
+ if static_blocks:
698
+ static_message = "\n\n".join(static_blocks)
699
+ # Insert as a system message right before done_messages
700
+ chunks.done.insert(0, dict(role="system", content=static_message))
701
+
702
+ # 2. Add dynamic blocks AFTER chat_files
703
+ # These blocks change with the current files in context
704
+ dynamic_blocks = []
705
+ if todo_list:
706
+ dynamic_blocks.append(todo_list)
707
+ if context_summary:
708
+ dynamic_blocks.append(context_summary)
709
+ if symbol_outline:
710
+ dynamic_blocks.append(symbol_outline)
711
+ if git_status:
712
+ dynamic_blocks.append(git_status)
713
+
714
+ # Add tool usage context if there are repetitive tools
715
+ if hasattr(self, "tool_usage_history") and self.tool_usage_history:
716
+ repetitive_tools = self._get_repetitive_tools()
717
+ if repetitive_tools:
718
+ tool_context = self._generate_tool_context(repetitive_tools)
719
+ if tool_context:
720
+ dynamic_blocks.append(tool_context)
721
+
722
+ if dynamic_blocks:
723
+ dynamic_message = "\n\n".join(dynamic_blocks)
724
+ # Append as a system message after chat_files
725
+ chunks.chat_files.append(dict(role="system", content=dynamic_message))
726
+
727
+ # Add reminder if needed
728
+ if self.gpt_prompts.system_reminder:
729
+ reminder_message = [
730
+ dict(
731
+ role="system", content=self.fmt_system_prompt(self.gpt_prompts.system_reminder)
732
+ ),
733
+ ]
734
+ else:
735
+ reminder_message = []
736
+
737
+ chunks.cur = list(self.cur_messages)
738
+ chunks.reminder = []
739
+
740
+ # Use accurate token counting method that considers enhanced context blocks
741
+ base_messages = chunks.all_messages()
742
+ messages_tokens = self.main_model.token_count(base_messages)
743
+ reminder_tokens = self.main_model.token_count(reminder_message)
744
+ cur_tokens = self.main_model.token_count(chunks.cur)
745
+
746
+ if None not in (messages_tokens, reminder_tokens, cur_tokens):
747
+ total_tokens = messages_tokens
748
+ # Only add tokens for reminder and cur if they're not already included
749
+ # in the messages_tokens calculation
750
+ if not chunks.reminder:
751
+ total_tokens += reminder_tokens
752
+ if not chunks.cur:
753
+ total_tokens += cur_tokens
754
+ else:
755
+ # add the reminder anyway
756
+ total_tokens = 0
757
+
758
+ if chunks.cur:
759
+ final = chunks.cur[-1]
760
+ else:
761
+ final = None
762
+
763
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
764
+ # Add the reminder prompt if we still have room to include it.
765
+ if (
766
+ not max_input_tokens
767
+ or total_tokens < max_input_tokens
768
+ and self.gpt_prompts.system_reminder
769
+ ):
770
+ if self.main_model.reminder == "sys":
771
+ chunks.reminder = reminder_message
772
+ elif self.main_model.reminder == "user" and final and final["role"] == "user":
773
+ # stuff it into the user message
774
+ new_content = (
775
+ final["content"]
776
+ + "\n\n"
777
+ + self.fmt_system_prompt(self.gpt_prompts.system_reminder)
778
+ )
779
+ chunks.cur[-1] = dict(role=final["role"], content=new_content)
780
+
781
+ return chunks
782
+
783
+ def get_context_summary(self):
784
+ """
785
+ Generate a summary of the current context, including file content tokens and additional context blocks,
786
+ with an accurate total token count.
787
+ """
788
+ if not self.use_enhanced_context:
789
+ return None
790
+
791
+ # If context_summary is already in the cache, return it
792
+ if hasattr(self, "context_blocks_cache") and "context_summary" in self.context_blocks_cache:
793
+ return self.context_blocks_cache["context_summary"]
794
+
795
+ try:
796
+ # Make sure token counts are updated before generating the summary
797
+ if not hasattr(self, "context_block_tokens") or not self.context_block_tokens:
798
+ self._calculate_context_block_tokens()
799
+
800
+ result = '<context name="context_summary">\n'
801
+ result += "## Current Context Overview\n\n"
802
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
803
+ if max_input_tokens:
804
+ result += f"Model context limit: {max_input_tokens:,} tokens\n\n"
805
+
806
+ total_file_tokens = 0
807
+ editable_tokens = 0
808
+ readonly_tokens = 0
809
+ editable_files = []
810
+ readonly_files = []
811
+
812
+ # Editable files
813
+ if self.abs_fnames:
814
+ result += "### Editable Files\n\n"
815
+ for fname in sorted(self.abs_fnames):
816
+ rel_fname = self.get_rel_fname(fname)
817
+ content = self.io.read_text(fname)
818
+ if content is not None:
819
+ tokens = self.main_model.token_count(content)
820
+ total_file_tokens += tokens
821
+ editable_tokens += tokens
822
+ size_indicator = (
823
+ "🔴 Large"
824
+ if tokens > 5000
825
+ else ("🟡 Medium" if tokens > 1000 else "🟢 Small")
826
+ )
827
+ editable_files.append(
828
+ f"- {rel_fname}: {tokens:,} tokens ({size_indicator})"
829
+ )
830
+ if editable_files:
831
+ result += "\n".join(editable_files) + "\n\n"
832
+ result += (
833
+ f"**Total editable: {len(editable_files)} files,"
834
+ f" {editable_tokens:,} tokens**\n\n"
835
+ )
836
+ else:
837
+ result += "No editable files in context\n\n"
838
+
839
+ # Read-only files
840
+ if self.abs_read_only_fnames:
841
+ result += "### Read-Only Files\n\n"
842
+ for fname in sorted(self.abs_read_only_fnames):
843
+ rel_fname = self.get_rel_fname(fname)
844
+ content = self.io.read_text(fname)
845
+ if content is not None:
846
+ tokens = self.main_model.token_count(content)
847
+ total_file_tokens += tokens
848
+ readonly_tokens += tokens
849
+ size_indicator = (
850
+ "🔴 Large"
851
+ if tokens > 5000
852
+ else ("🟡 Medium" if tokens > 1000 else "🟢 Small")
853
+ )
854
+ readonly_files.append(
855
+ f"- {rel_fname}: {tokens:,} tokens ({size_indicator})"
856
+ )
857
+ if readonly_files:
858
+ result += "\n".join(readonly_files) + "\n\n"
859
+ result += (
860
+ f"**Total read-only: {len(readonly_files)} files,"
861
+ f" {readonly_tokens:,} tokens**\n\n"
862
+ )
863
+ else:
864
+ result += "No read-only files in context\n\n"
865
+
866
+ # Use the pre-calculated context block tokens
867
+ extra_tokens = sum(self.context_block_tokens.values())
868
+ total_tokens = total_file_tokens + extra_tokens
869
+
870
+ result += f"**Total files usage: {total_file_tokens:,} tokens**\n\n"
871
+ result += f"**Additional context usage: {extra_tokens:,} tokens**\n\n"
872
+ result += f"**Total context usage: {total_tokens:,} tokens**"
873
+ if max_input_tokens:
874
+ percentage = (total_tokens / max_input_tokens) * 100
875
+ result += f" ({percentage:.1f}% of limit)"
876
+ if percentage > 80:
877
+ result += "\n\n⚠️ **Context is getting full!** Remove non-essential files via:\n"
878
+ result += '- `[tool_call(Remove, file_path="path/to/large_file.ext")]`\n'
879
+ result += "- Keep only essential files in context for best performance"
880
+ result += "\n</context>"
881
+
882
+ # Cache the result
883
+ if not hasattr(self, "context_blocks_cache"):
884
+ self.context_blocks_cache = {}
885
+ self.context_blocks_cache["context_summary"] = result
886
+
887
+ return result
888
+ except Exception as e:
889
+ self.io.tool_error(f"Error generating context summary: {str(e)}")
890
+ return None
891
+
892
+ def get_environment_info(self):
893
+ """
894
+ Generate an environment information context block with key system details.
895
+ Returns formatted string with working directory, platform, date, and other relevant environment details.
896
+ """
897
+ if not self.use_enhanced_context:
898
+ return None
899
+
900
+ try:
901
+ # Get current date in ISO format
902
+ current_date = datetime.now().strftime("%Y-%m-%d")
903
+
904
+ # Get platform information
905
+ platform_info = platform.platform()
906
+
907
+ # Get language preference
908
+ language = self.chat_language or locale.getlocale()[0] or "en-US"
909
+
910
+ result = '<context name="environment_info">\n'
911
+ result += "## Environment Information\n\n"
912
+ result += f"- Working directory: {self.root}\n"
913
+ result += f"- Current date: {current_date}\n"
914
+ result += f"- Platform: {platform_info}\n"
915
+ result += f"- Language preference: {language}\n"
916
+
917
+ # Add git repo information if available
918
+ if self.repo:
919
+ try:
920
+ rel_repo_dir = self.repo.get_rel_repo_dir()
921
+ num_files = len(self.repo.get_tracked_files())
922
+ result += f"- Git repository: {rel_repo_dir} with {num_files:,} files\n"
923
+ except Exception:
924
+ result += "- Git repository: active but details unavailable\n"
925
+ else:
926
+ result += "- Git repository: none\n"
927
+
928
+ # Add enabled features information
929
+ features = []
930
+ if self.context_management_enabled:
931
+ features.append("context management")
932
+ if self.use_enhanced_context:
933
+ features.append("enhanced context blocks")
934
+ if features:
935
+ result += f"- Enabled features: {', '.join(features)}\n"
936
+
937
+ result += "</context>"
938
+ return result
939
+ except Exception as e:
940
+ self.io.tool_error(f"Error generating environment info: {str(e)}")
941
+ return None
942
+
943
+ async def process_tool_calls(self, tool_call_response):
944
+ """
945
+ Track tool usage before calling the base implementation.
946
+ """
947
+ self.auto_save_session()
948
+
949
+ if self.partial_response_tool_calls:
950
+ for tool_call in self.partial_response_tool_calls:
951
+ self.tool_usage_history.append(tool_call.get("function", {}).get("name"))
952
+
953
+ if len(self.tool_usage_history) > self.tool_usage_retries:
954
+ self.tool_usage_history.pop(0)
955
+
956
+ return await super().process_tool_calls(tool_call_response)
957
+
958
+ async def reply_completed(self):
959
+ """Process the completed response from the LLM.
960
+
961
+ This is a key method that:
962
+ 1. Processes any tool commands in the response (only after a '---' line)
963
+ 2. Processes any SEARCH/REPLACE blocks in the response (only before the '---' line if one exists)
964
+ 3. If tool commands were found, sets up for another automatic round
965
+
966
+ This enables the "auto-exploration" workflow where the LLM can
967
+ iteratively discover and analyze relevant files before providing
968
+ a final answer to the user's question.
969
+ """
970
+ # Legacy tool call processing for use_granular_editing=False
971
+ self.agent_finished = False
972
+ content = self.partial_response_content
973
+ if not content or not content.strip():
974
+ if len(self.tool_usage_history) > self.tool_usage_retries:
975
+ self.tool_usage_history = []
976
+ return True
977
+ original_content = content # Keep the original response
978
+
979
+ # Process tool commands: returns content with tool calls removed, results, flag if any tool calls were found
980
+ (
981
+ processed_content,
982
+ result_messages,
983
+ tool_calls_found,
984
+ content_before_last_separator,
985
+ tool_names_this_turn,
986
+ ) = await self._process_tool_commands(content)
987
+
988
+ if self.agent_finished:
989
+ self.tool_usage_history = []
990
+ return True
991
+
992
+ # Since we are no longer suppressing, the partial_response_content IS the final content.
993
+ # We might want to update it to the processed_content (without tool calls) if we don't
994
+ # want the raw tool calls to remain in the final assistant message history.
995
+ # Let's update it for cleaner history.
996
+ self.partial_response_content = processed_content.strip()
997
+
998
+ # Process implicit file mentions using the content *after* tool calls were removed
999
+ self._process_file_mentions(processed_content)
1000
+
1001
+ # Check if the content contains the SEARCH/REPLACE markers
1002
+ has_search = "<<<<<<< SEARCH" in self.partial_response_content
1003
+ has_divider = "=======" in self.partial_response_content
1004
+ has_replace = ">>>>>>> REPLACE" in self.partial_response_content
1005
+ edit_match = has_search and has_divider and has_replace
1006
+
1007
+ # Check if there's a '---' line - if yes, SEARCH/REPLACE blocks can only appear before it
1008
+ separator_marker = "\n---\n"
1009
+ if separator_marker in original_content and edit_match:
1010
+ # Check if the edit blocks are only in the part before the last '---' line
1011
+ has_search_before = "<<<<<<< SEARCH" in content_before_last_separator
1012
+ has_divider_before = "=======" in content_before_last_separator
1013
+ has_replace_before = ">>>>>>> REPLACE" in content_before_last_separator
1014
+ edit_match = has_search_before and has_divider_before and has_replace_before
1015
+
1016
+ if edit_match:
1017
+ self.io.tool_output("Detected edit blocks, applying changes within Agent...")
1018
+ edited_files = await self._apply_edits_from_response()
1019
+ # If _apply_edits_from_response set a reflected_message (due to errors),
1020
+ # return False to trigger a reflection loop.
1021
+ if self.reflected_message:
1022
+ return False
1023
+
1024
+ # If edits were successfully applied and we haven't exceeded reflection limits,
1025
+ # set up for another iteration (similar to tool calls)
1026
+ if edited_files and self.num_reflections < self.max_reflections:
1027
+ # Get the original user question from the most recent user message
1028
+ if self.cur_messages and len(self.cur_messages) >= 1:
1029
+ for msg in reversed(self.cur_messages):
1030
+ if msg["role"] == "user":
1031
+ original_question = msg["content"]
1032
+ break
1033
+ else:
1034
+ # Default if no user message found
1035
+ original_question = (
1036
+ "Please continue your exploration and provide a final answer."
1037
+ )
1038
+
1039
+ # Construct the message for the next turn
1040
+ next_prompt = (
1041
+ "I have applied the edits you suggested. "
1042
+ f"The following files were modified: {', '.join(edited_files)}. "
1043
+ "Let me continue working on your request.\n\n"
1044
+ f"Your original question was: {original_question}"
1045
+ )
1046
+
1047
+ self.reflected_message = next_prompt
1048
+ self.io.tool_output("Continuing after applying edits...")
1049
+ return False # Indicate that we need another iteration
1050
+
1051
+ # If any tool calls were found and we haven't exceeded reflection limits, set up for another iteration
1052
+ # This is implicit continuation when any tool calls are present, rather than requiring Continue explicitly
1053
+ if tool_calls_found and self.num_reflections < self.max_reflections:
1054
+ # Reset tool counter for next iteration
1055
+ self.tool_call_count = 0
1056
+
1057
+ # Clear exploration files for the next round
1058
+ self.files_added_in_exploration = set()
1059
+
1060
+ # Get the original user question from the most recent user message
1061
+ if self.cur_messages and len(self.cur_messages) >= 1:
1062
+ for msg in reversed(self.cur_messages):
1063
+ if msg["role"] == "user":
1064
+ original_question = msg["content"]
1065
+ break
1066
+ else:
1067
+ # Default if no user message found
1068
+ original_question = (
1069
+ "Please continue your exploration and provide a final answer."
1070
+ )
1071
+
1072
+ # Construct the message for the next turn, including tool results
1073
+ next_prompt_parts = []
1074
+ next_prompt_parts.append(
1075
+ "I have processed the results of the previous tool calls. "
1076
+ "Let me analyze them and continue working towards your request."
1077
+ )
1078
+
1079
+ if result_messages:
1080
+ next_prompt_parts.append("\nResults from previous tool calls:")
1081
+ # result_messages already have [Result (...): ...] format
1082
+ next_prompt_parts.extend(result_messages)
1083
+ next_prompt_parts.append(
1084
+ "\nBased on these results and the updated file context, I will proceed."
1085
+ )
1086
+ else:
1087
+ next_prompt_parts.append(
1088
+ "\nNo specific results were returned from the previous tool calls, but the"
1089
+ " file context may have been updated. I will proceed based on the current"
1090
+ " context."
1091
+ )
1092
+
1093
+ next_prompt_parts.append(f"\nYour original question was: {original_question}")
1094
+
1095
+ self.reflected_message = "\n".join(next_prompt_parts)
1096
+
1097
+ self.io.tool_output("Continuing exploration...")
1098
+ return False # Indicate that we need another iteration
1099
+ else:
1100
+ # Exploration finished for this turn.
1101
+ # Append results to the content that will be stored in history.
1102
+ if result_messages:
1103
+ results_block = "\n\n" + "\n".join(result_messages)
1104
+ # Append results to the cleaned content
1105
+ self.partial_response_content += results_block
1106
+
1107
+ # After applying edits OR determining no edits were needed (and no reflection needed),
1108
+ # the turn is complete. Reset counters and finalize history.
1109
+
1110
+ # Auto-commit any files edited by granular tools
1111
+ if self.files_edited_by_tools:
1112
+ saved_message = await self.auto_commit(self.files_edited_by_tools)
1113
+ if not saved_message and hasattr(self.gpt_prompts, "files_content_gpt_edits_no_repo"):
1114
+ saved_message = self.gpt_prompts.files_content_gpt_edits_no_repo
1115
+ self.move_back_cur_messages(saved_message)
1116
+
1117
+ self.tool_call_count = 0
1118
+ self.files_added_in_exploration = set()
1119
+ self.files_edited_by_tools = set()
1120
+ # Move cur_messages to done_messages
1121
+ self.move_back_cur_messages(
1122
+ None
1123
+ ) # Pass None as we handled commit message earlier if needed
1124
+
1125
+ return False # Always Loop Until the Finished Tool is Called
1126
+
1127
+ async def _execute_tool_with_registry(self, norm_tool_name, params):
1128
+ """
1129
+ Execute a tool using the tool registry.
1130
+
1131
+ Args:
1132
+ norm_tool_name: Normalized tool name (lowercase)
1133
+ params: Dictionary of parameters
1134
+
1135
+ Returns:
1136
+ str: Result message
1137
+ """
1138
+ # Check if tool exists in registry
1139
+ if norm_tool_name in self._tool_registry:
1140
+ tool_module = self._tool_registry[norm_tool_name]
1141
+ try:
1142
+ # Use the process_response function from the tool module
1143
+ result = tool_module.process_response(self, params)
1144
+ # Handle async functions
1145
+ if asyncio.iscoroutine(result):
1146
+ result = await result
1147
+ return result
1148
+ except Exception as e:
1149
+ self.io.tool_error(
1150
+ f"Error during {norm_tool_name} execution: {e}\n{traceback.format_exc()}"
1151
+ )
1152
+ return f"Error executing {norm_tool_name}: {str(e)}"
1153
+
1154
+ # Handle MCP tools for tools not in registry
1155
+ if self.mcp_tools:
1156
+ for server_name, server_tools in self.mcp_tools:
1157
+ if any(t.get("function", {}).get("name") == norm_tool_name for t in server_tools):
1158
+ server = next((s for s in self.mcp_servers if s.name == server_name), None)
1159
+ if server:
1160
+ return await self._execute_mcp_tool(server, norm_tool_name, params)
1161
+ else:
1162
+ return f"Error: Could not find server instance for {server_name}"
1163
+
1164
+ return f"Error: Unknown tool name '{norm_tool_name}'"
1165
+
1166
+ async def _process_tool_commands(self, content):
1167
+ """
1168
+ Process tool commands in the `[tool_call(name, param=value)]` format within the content.
1169
+
1170
+ Rules:
1171
+ 1. Tool calls must appear after the LAST '---' line separator in the content
1172
+ 2. Any tool calls before this last separator are treated as text (not executed)
1173
+ 3. SEARCH/REPLACE blocks can only appear before this last separator
1174
+
1175
+ Returns processed content, result messages, and a flag indicating if any tool calls were found.
1176
+ Also returns the content before the last separator for SEARCH/REPLACE block validation.
1177
+ """
1178
+ result_messages = []
1179
+ modified_content = content # Start with original content
1180
+ tool_calls_found = False
1181
+ call_count = 0
1182
+ max_calls = self.max_tool_calls
1183
+ tool_names = []
1184
+
1185
+ # Check if there's a '---' separator and only process tool calls after the LAST one
1186
+ separator_marker = "---"
1187
+ content_parts = content.split(separator_marker)
1188
+
1189
+ # If there's no separator, treat the entire content as before the separator
1190
+ if len(content_parts) == 1:
1191
+ # Return the original content with no tool calls processed, and the content itself as before_separator
1192
+ return content, result_messages, False, content, tool_names
1193
+
1194
+ # Take everything before the last separator (including intermediate separators)
1195
+ content_before_separator = separator_marker.join(content_parts[:-1])
1196
+ # Take only what comes after the last separator
1197
+ content_after_separator = content_parts[-1]
1198
+
1199
+ # Find tool calls using a more robust method, but only in the content after separator
1200
+ processed_content = content_before_separator + separator_marker
1201
+ last_index = 0
1202
+
1203
+ # Support any [tool_...(...)] format
1204
+ tool_call_pattern = re.compile(r"\[tool_.*?\(", re.DOTALL)
1205
+ end_marker = "]" # The parenthesis balancing finds the ')', we just need the final ']'
1206
+
1207
+ while True:
1208
+ match = tool_call_pattern.search(content_after_separator, last_index)
1209
+ if not match:
1210
+ processed_content += content_after_separator[last_index:]
1211
+ break
1212
+
1213
+ start_pos = match.start()
1214
+ start_marker = match.group(0)
1215
+
1216
+ # Check for escaped tool call: \[tool_...
1217
+ # Count preceding backslashes to handle \\
1218
+ backslashes = 0
1219
+ p = start_pos - 1
1220
+ while p >= 0 and content_after_separator[p] == "\\":
1221
+ backslashes += 1
1222
+ p -= 1
1223
+
1224
+ if backslashes % 2 == 1:
1225
+ # Odd number of backslashes means it's escaped. Treat as text.
1226
+ # We append up to the end of the marker and continue searching.
1227
+ processed_content += content_after_separator[
1228
+ last_index : start_pos + len(start_marker)
1229
+ ]
1230
+ last_index = start_pos + len(start_marker)
1231
+ continue
1232
+
1233
+ # Append content before the (non-escaped) tool call
1234
+ processed_content += content_after_separator[last_index:start_pos]
1235
+
1236
+ scan_start_pos = start_pos + len(start_marker)
1237
+ paren_level = 1
1238
+ in_single_quotes = False
1239
+ in_double_quotes = False
1240
+ escaped = False
1241
+ end_paren_pos = -1
1242
+
1243
+ # Scan to find the matching closing parenthesis, respecting quotes
1244
+ for i in range(scan_start_pos, len(content_after_separator)):
1245
+ char = content_after_separator[i]
1246
+
1247
+ if escaped:
1248
+ escaped = False
1249
+ elif char == "\\":
1250
+ escaped = True
1251
+ elif char == "'" and not in_double_quotes:
1252
+ in_single_quotes = not in_single_quotes
1253
+ elif char == '"' and not in_single_quotes:
1254
+ in_double_quotes = not in_double_quotes
1255
+ elif char == "(" and not in_single_quotes and not in_double_quotes:
1256
+ paren_level += 1
1257
+ elif char == ")" and not in_single_quotes and not in_double_quotes:
1258
+ paren_level -= 1
1259
+ if paren_level == 0:
1260
+ end_paren_pos = i
1261
+ break
1262
+
1263
+ # Check for the end marker after the closing parenthesis, skipping whitespace
1264
+ expected_end_marker_start = end_paren_pos + 1
1265
+ actual_end_marker_start = -1
1266
+ end_marker_found = False
1267
+ if end_paren_pos != -1: # Only search if we found a closing parenthesis
1268
+ for j in range(expected_end_marker_start, len(content_after_separator)):
1269
+ if not content_after_separator[j].isspace():
1270
+ actual_end_marker_start = j
1271
+ # Check if the found character is the end marker ']'
1272
+ if content_after_separator[actual_end_marker_start] == end_marker:
1273
+ end_marker_found = True
1274
+ break # Stop searching after first non-whitespace char
1275
+
1276
+ if not end_marker_found:
1277
+ # Try to extract the tool name for better error message
1278
+ tool_name = "unknown"
1279
+ try:
1280
+ # Look for the first comma after the tool call start
1281
+ partial_content = content_after_separator[
1282
+ scan_start_pos : scan_start_pos + 100
1283
+ ] # Limit to avoid huge strings
1284
+ comma_pos = partial_content.find(",")
1285
+ if comma_pos > 0:
1286
+ tool_name = partial_content[:comma_pos].strip()
1287
+ else:
1288
+ # If no comma, look for opening parenthesis or first whitespace
1289
+ space_pos = partial_content.find(" ")
1290
+ paren_pos = partial_content.find("(")
1291
+ if space_pos > 0 and (paren_pos < 0 or space_pos < paren_pos):
1292
+ tool_name = partial_content[:space_pos].strip()
1293
+ elif paren_pos > 0:
1294
+ tool_name = partial_content[:paren_pos].strip()
1295
+ except Exception:
1296
+ pass # Silently fail if we can't extract the name
1297
+
1298
+ # Malformed call: couldn't find matching ')' or the subsequent ']'
1299
+ self.io.tool_warning(
1300
+ f"Malformed tool call for '{tool_name}'. Missing closing parenthesis or"
1301
+ " bracket. Skipping."
1302
+ )
1303
+ # Append the start marker itself to processed content so it's not lost
1304
+ processed_content += start_marker
1305
+ last_index = scan_start_pos # Continue searching after the marker
1306
+ continue
1307
+
1308
+ # Found a potential tool call
1309
+ # Adjust full_match_str and last_index based on the actual end marker ']' position
1310
+ full_match_str = content_after_separator[
1311
+ start_pos : actual_end_marker_start + 1
1312
+ ] # End marker ']' is 1 char
1313
+ inner_content = content_after_separator[scan_start_pos:end_paren_pos].strip()
1314
+ last_index = actual_end_marker_start + 1 # Move past the processed call (including ']')
1315
+
1316
+ call_count += 1
1317
+ if call_count > max_calls:
1318
+ self.io.tool_warning(
1319
+ f"Exceeded maximum tool calls ({max_calls}). Skipping remaining calls."
1320
+ )
1321
+ # Don't append the skipped call to processed_content
1322
+ continue # Skip processing this call
1323
+
1324
+ tool_calls_found = True
1325
+ tool_name = None
1326
+ params = {}
1327
+ result_message = None
1328
+
1329
+ # Mark that we found at least one tool call (assuming it passes validation)
1330
+ tool_calls_found = True
1331
+
1332
+ try:
1333
+ # Pre-process inner_content to handle non-identifier tool names by quoting them.
1334
+ # This allows ast.parse to succeed on names like 'resolve-library-id'.
1335
+ if inner_content:
1336
+ parts = inner_content.split(",", 1)
1337
+ potential_tool_name = parts[0].strip()
1338
+
1339
+ is_string = (
1340
+ potential_tool_name.startswith("'") and potential_tool_name.endswith("'")
1341
+ ) or (potential_tool_name.startswith('"') and potential_tool_name.endswith('"'))
1342
+
1343
+ if not potential_tool_name.isidentifier() and not is_string:
1344
+ # It's not a valid identifier and not a string, so quote it.
1345
+ # Use json.dumps to handle escaping correctly.
1346
+ quoted_tool_name = json.dumps(potential_tool_name)
1347
+ if len(parts) > 1:
1348
+ inner_content = quoted_tool_name + ", " + parts[1]
1349
+ else:
1350
+ inner_content = quoted_tool_name
1351
+
1352
+ # Wrap the inner content to make it parseable as a function call
1353
+ # Example: ToolName, key="value" becomes f(ToolName, key="value")
1354
+ parse_str = f"f({inner_content})"
1355
+ parsed_ast = ast.parse(parse_str)
1356
+
1357
+ # Validate AST structure
1358
+ if (
1359
+ not isinstance(parsed_ast, ast.Module)
1360
+ or not parsed_ast.body
1361
+ or not isinstance(parsed_ast.body[0], ast.Expr)
1362
+ ):
1363
+ raise ValueError("Unexpected AST structure")
1364
+ call_node = parsed_ast.body[0].value
1365
+ if not isinstance(call_node, ast.Call):
1366
+ raise ValueError("Expected a Call node")
1367
+
1368
+ # Extract tool name (should be the first positional argument)
1369
+ if not call_node.args:
1370
+ raise ValueError("Tool name not found or invalid")
1371
+
1372
+ tool_name_node = call_node.args[0]
1373
+ if isinstance(tool_name_node, ast.Name):
1374
+ tool_name = tool_name_node.id
1375
+ elif isinstance(tool_name_node, ast.Constant) and isinstance(
1376
+ tool_name_node.value, str
1377
+ ):
1378
+ tool_name = tool_name_node.value
1379
+ else:
1380
+ raise ValueError("Tool name must be an identifier or a string literal")
1381
+
1382
+ tool_names.append(tool_name)
1383
+
1384
+ # Extract keyword arguments
1385
+ for keyword in call_node.keywords:
1386
+ key = keyword.arg
1387
+ value_node = keyword.value
1388
+ # Extract value based on AST node type
1389
+ if isinstance(value_node, ast.Constant):
1390
+ value = value_node.value
1391
+ # Check if this is a multiline string and trim whitespace
1392
+ if isinstance(value, str) and "\n" in value:
1393
+ # Get the source line(s) for this node to check if it's a triple-quoted string
1394
+ lineno = value_node.lineno if hasattr(value_node, "lineno") else 0
1395
+ end_lineno = (
1396
+ value_node.end_lineno
1397
+ if hasattr(value_node, "end_lineno")
1398
+ else lineno
1399
+ )
1400
+ if end_lineno > lineno: # It's a multiline string
1401
+ # Trim exactly one leading and one trailing newline if present
1402
+ if value.startswith("\n"):
1403
+ value = value[1:]
1404
+ if value.endswith("\n"):
1405
+ value = value[:-1]
1406
+ elif isinstance(
1407
+ value_node, ast.Name
1408
+ ): # Handle unquoted values like True/False/None or variables
1409
+ id_val = value_node.id.lower()
1410
+ if id_val == "true":
1411
+ value = True
1412
+ elif id_val == "false":
1413
+ value = False
1414
+ elif id_val == "none":
1415
+ value = None
1416
+ else:
1417
+ value = value_node.id # Keep as string if it's something else
1418
+ # Add more types if needed (e.g., ast.List, ast.Dict)
1419
+ else:
1420
+ # Attempt to reconstruct the source for complex types, or raise error
1421
+ try:
1422
+ # Note: ast.unparse requires Python 3.9+
1423
+ # If using older Python, might need a different approach or limit supported types
1424
+ value = ast.unparse(value_node)
1425
+ except AttributeError: # Handle case where ast.unparse is not available
1426
+ raise ValueError(
1427
+ f"Unsupported argument type for key '{key}': {type(value_node)}"
1428
+ )
1429
+ except Exception as unparse_e:
1430
+ raise ValueError(
1431
+ f"Could not unparse value for key '{key}': {unparse_e}"
1432
+ )
1433
+
1434
+ # Check for suppressed values (e.g., "...")
1435
+ suppressed_arg_values = ["..."]
1436
+ if isinstance(value, str) and value in suppressed_arg_values:
1437
+ self.io.tool_warning(
1438
+ f"Skipping suppressed argument value '{value}' for key '{key}' in tool"
1439
+ f" '{tool_name}'"
1440
+ )
1441
+ continue
1442
+
1443
+ params[key] = value
1444
+
1445
+ except (SyntaxError, ValueError) as e:
1446
+ result_message = f"Error parsing tool call '{inner_content}': {e}"
1447
+ self.io.tool_error(f"Failed to parse tool call: {full_match_str}\nError: {e}")
1448
+ # Don't append the malformed call to processed_content
1449
+ result_messages.append(f"[Result (Parse Error): {result_message}]")
1450
+ continue # Skip execution
1451
+ except Exception as e: # Catch any other unexpected parsing errors
1452
+ result_message = f"Unexpected error parsing tool call '{inner_content}': {e}"
1453
+ self.io.tool_error(
1454
+ f"Unexpected error during parsing: {full_match_str}\nError:"
1455
+ f" {e}\n{traceback.format_exc()}"
1456
+ )
1457
+ result_messages.append(f"[Result (Parse Error): {result_message}]")
1458
+ continue
1459
+
1460
+ # Execute the tool using the registry
1461
+ try:
1462
+ # Normalize tool name for case-insensitive matching
1463
+ norm_tool_name = tool_name.lower()
1464
+
1465
+ # Use the tool registry for execution
1466
+ result_message = await self._execute_tool_with_registry(norm_tool_name, params)
1467
+
1468
+ except Exception as e:
1469
+ result_message = f"Error executing {tool_name}: {str(e)}"
1470
+ self.io.tool_error(
1471
+ f"Error during {tool_name} execution: {e}\n{traceback.format_exc()}"
1472
+ )
1473
+
1474
+ if result_message:
1475
+ result_messages.append(f"[Result ({tool_name}): {result_message}]")
1476
+
1477
+ # Note: We don't add the tool call string back to processed_content
1478
+
1479
+ # Update internal counter
1480
+ self.tool_call_count += call_count
1481
+
1482
+ # Return the content with tool calls removed
1483
+ modified_content = processed_content
1484
+
1485
+ return (
1486
+ modified_content,
1487
+ result_messages,
1488
+ tool_calls_found,
1489
+ content_before_separator,
1490
+ tool_names,
1491
+ )
1492
+
1493
+ def _get_repetitive_tools(self):
1494
+ """
1495
+ Identifies repetitive tool usage patterns from a flat list of tool calls.
1496
+
1497
+ This method checks for the following patterns in order:
1498
+ 1. If the last tool used was a write tool, it assumes progress and returns no repetitive tools.
1499
+ 2. It checks for any read tool that has been used 2 or more times in the history.
1500
+ 3. If no tools are repeated, but all tools in the history are read tools,
1501
+ it flags all of them as potentially repetitive.
1502
+
1503
+ It avoids flagging repetition if a "write" tool was used recently,
1504
+ as that suggests progress is being made.
1505
+ """
1506
+ history_len = len(self.tool_usage_history)
1507
+
1508
+ # Not enough history to detect a pattern
1509
+ if history_len < 2:
1510
+ return set()
1511
+
1512
+ # If the last tool was a write tool, we're likely making progress.
1513
+ if isinstance(self.tool_usage_history[-1], str):
1514
+ last_tool_lower = self.tool_usage_history[-1].lower()
1515
+
1516
+ if last_tool_lower in self.write_tools:
1517
+ self.tool_usage_history = []
1518
+ return set()
1519
+
1520
+ # If all tools in history are read tools, return all of them
1521
+ if all(tool.lower() in self.read_tools for tool in self.tool_usage_history):
1522
+ return set(tool for tool in self.tool_usage_history)
1523
+
1524
+ # Check for any read tool used more than once
1525
+ tool_counts = Counter(tool for tool in self.tool_usage_history)
1526
+ repetitive_tools = {
1527
+ tool
1528
+ for tool, count in tool_counts.items()
1529
+ if count >= 2 and tool.lower() in self.read_tools
1530
+ }
1531
+
1532
+ if repetitive_tools:
1533
+ return repetitive_tools
1534
+
1535
+ return set()
1536
+
1537
+ def _generate_tool_context(self, repetitive_tools):
1538
+ """
1539
+ Generate a context message for the LLM about recent tool usage.
1540
+ """
1541
+ if not self.tool_usage_history:
1542
+ return ""
1543
+
1544
+ context_parts = ['<context name="tool_usage_history">']
1545
+
1546
+ # Add turn and tool call statistics
1547
+ context_parts.append("## Turn and Tool Call Statistics")
1548
+ context_parts.append(f"- Current turn: {self.num_reflections + 1}")
1549
+ context_parts.append(f"- Tool calls this turn: {self.tool_call_count}")
1550
+ context_parts.append(f"- Total tool calls in session: {self.num_tool_calls}")
1551
+ context_parts.append("\n\n")
1552
+
1553
+ # Add recent tool usage history
1554
+ context_parts.append("## Recent Tool Usage History")
1555
+ if len(self.tool_usage_history) > 10:
1556
+ recent_history = self.tool_usage_history[-10:]
1557
+ context_parts.append("(Showing last 10 tools)")
1558
+ else:
1559
+ recent_history = self.tool_usage_history
1560
+
1561
+ for i, tool in enumerate(recent_history, 1):
1562
+ context_parts.append(f"{i}. {tool}")
1563
+ context_parts.append("\n\n")
1564
+
1565
+ if repetitive_tools:
1566
+ context_parts.append(
1567
+ "**Instruction:**\nYou have used the following tool(s) repeatedly:"
1568
+ )
1569
+
1570
+ context_parts.append("### DO NOT USE THE FOLLOWING TOOLS/FUNCTIONS")
1571
+
1572
+ for tool in repetitive_tools:
1573
+ context_parts.append(f"- `{tool}`")
1574
+ context_parts.append(
1575
+ "Your exploration appears to be stuck in a loop. Please try a different approach:"
1576
+ )
1577
+ context_parts.append("\n")
1578
+ context_parts.append("**Suggestions for alternative approaches:**")
1579
+ context_parts.append(
1580
+ "- If you've been searching for files, try working with the files already in"
1581
+ " context"
1582
+ )
1583
+ context_parts.append(
1584
+ "- If you've been viewing files, try making actual edits to move forward"
1585
+ )
1586
+ context_parts.append("- Consider using different tools that you haven't used recently")
1587
+ context_parts.append(
1588
+ "- Focus on making concrete progress rather than gathering more information"
1589
+ )
1590
+ context_parts.append(
1591
+ "- Use the files you've already discovered to implement the requested changes"
1592
+ )
1593
+ context_parts.append("\n")
1594
+ context_parts.append(
1595
+ "You most likely have enough context for a subset of the necessary changes."
1596
+ )
1597
+ context_parts.append("Please prioritize file editing over further exploration.")
1598
+
1599
+ context_parts.append("</context>")
1600
+ return "\n".join(context_parts)
1601
+
1602
+ async def _apply_edits_from_response(self):
1603
+ """
1604
+ Parses and applies SEARCH/REPLACE edits found in self.partial_response_content.
1605
+ Returns a set of relative file paths that were successfully edited.
1606
+ """
1607
+ edited_files = set()
1608
+ try:
1609
+ # 1. Get edits (logic from EditBlockCoder.get_edits)
1610
+ # Use the current partial_response_content which contains the LLM response
1611
+ # including the edit blocks but excluding the tool calls.
1612
+ edits = list(
1613
+ find_original_update_blocks(
1614
+ self.partial_response_content,
1615
+ self.fence,
1616
+ self.get_inchat_relative_files(),
1617
+ )
1618
+ )
1619
+ # Separate shell commands from file edits
1620
+ self.shell_commands += [edit[1] for edit in edits if edit[0] is None]
1621
+ edits = [edit for edit in edits if edit[0] is not None]
1622
+
1623
+ # 2. Prepare edits (check permissions, commit dirty files)
1624
+ prepared_edits = []
1625
+ seen_paths = dict()
1626
+ self.need_commit_before_edits = set() # Reset before checking
1627
+
1628
+ for edit in edits:
1629
+ path = edit[0]
1630
+ if path in seen_paths:
1631
+ allowed = seen_paths[path]
1632
+ else:
1633
+ # Use the base Coder's permission check method
1634
+ allowed = await self.allowed_to_edit(path)
1635
+ seen_paths[path] = allowed
1636
+ if allowed:
1637
+ prepared_edits.append(edit)
1638
+
1639
+ # Commit any dirty files identified by allowed_to_edit
1640
+ await self.dirty_commit()
1641
+ self.need_commit_before_edits = set() # Clear after commit
1642
+
1643
+ # 3. Apply edits (logic adapted from EditBlockCoder.apply_edits)
1644
+ failed = []
1645
+ passed = []
1646
+ for edit in prepared_edits:
1647
+ path, original, updated = edit
1648
+ full_path = self.abs_root_path(path)
1649
+ new_content = None
1650
+
1651
+ if Path(full_path).exists():
1652
+ content = self.io.read_text(full_path)
1653
+ # Use the imported do_replace function
1654
+ new_content = do_replace(full_path, content, original, updated, self.fence)
1655
+
1656
+ # Simplified cross-file patching check from EditBlockCoder
1657
+ if not new_content and original.strip():
1658
+ for other_full_path in self.abs_fnames:
1659
+ if other_full_path == full_path:
1660
+ continue
1661
+ other_content = self.io.read_text(other_full_path)
1662
+ other_new_content = do_replace(
1663
+ other_full_path, other_content, original, updated, self.fence
1664
+ )
1665
+ if other_new_content:
1666
+ path = self.get_rel_fname(other_full_path)
1667
+ full_path = other_full_path
1668
+ new_content = other_new_content
1669
+ self.io.tool_warning(f"Applied edit intended for {edit[0]} to {path}")
1670
+ break
1671
+
1672
+ if new_content:
1673
+ if not self.dry_run:
1674
+ self.io.write_text(full_path, new_content)
1675
+ self.io.tool_output(f"Applied edit to {path}")
1676
+ else:
1677
+ self.io.tool_output(f"Did not apply edit to {path} (--dry-run)")
1678
+ passed.append((path, original, updated)) # Store path relative to root
1679
+ else:
1680
+ failed.append(edit)
1681
+
1682
+ if failed:
1683
+ # Handle failed edits (adapted from EditBlockCoder)
1684
+ blocks = "block" if len(failed) == 1 else "blocks"
1685
+ error_message = f"# {len(failed)} SEARCH/REPLACE {blocks} failed to match!\n"
1686
+ for edit in failed:
1687
+ path, original, updated = edit
1688
+ full_path = self.abs_root_path(path)
1689
+ content = self.io.read_text(full_path) # Read content again for context
1690
+
1691
+ error_message += f"""
1692
+ ## SearchReplaceNoExactMatch: This SEARCH block failed to exactly match lines in {path}
1693
+ <<<<<<< SEARCH
1694
+ {original}=======
1695
+ {updated}>>>>>>> REPLACE
1696
+
1697
+ """
1698
+ did_you_mean = find_similar_lines(original, content)
1699
+ if did_you_mean:
1700
+ error_message += f"""Did you mean to match some of these actual lines from {path}?
1701
+
1702
+ {self.fence[0]}
1703
+ {did_you_mean}
1704
+ {self.fence[1]}
1705
+
1706
+ """
1707
+ if updated in content and updated:
1708
+ error_message += f"""Are you sure you need this SEARCH/REPLACE block?
1709
+ The REPLACE lines are already in {path}!
1710
+
1711
+ """
1712
+ error_message += (
1713
+ "The SEARCH section must exactly match an existing block of lines including all"
1714
+ " white space, comments, indentation, docstrings, etc\n"
1715
+ )
1716
+ if passed:
1717
+ pblocks = "block" if len(passed) == 1 else "blocks"
1718
+ error_message += f"""
1719
+ # The other {len(passed)} SEARCH/REPLACE {pblocks} were applied successfully.
1720
+ Don't re-send them.
1721
+ Just reply with fixed versions of the {blocks} above that failed to match.
1722
+ """
1723
+ self.io.tool_error(error_message)
1724
+ # Set reflected_message to prompt LLM to fix the failed blocks
1725
+ self.reflected_message = error_message
1726
+
1727
+ edited_files = set(edit[0] for edit in passed) # Use relative paths stored in passed
1728
+
1729
+ # 4. Post-edit actions (commit, lint, test, shell commands)
1730
+ if edited_files:
1731
+ self.aider_edited_files.update(edited_files) # Track edited files
1732
+ self.auto_commit(edited_files)
1733
+ # We don't use saved_message here as we are not moving history back
1734
+
1735
+ if self.auto_lint:
1736
+ lint_errors = self.lint_edited(edited_files)
1737
+ self.auto_commit(edited_files, context="Ran the linter")
1738
+ if lint_errors and not self.reflected_message: # Reflect only if no edit errors
1739
+ ok = await self.io.confirm_ask("Attempt to fix lint errors?")
1740
+ if ok:
1741
+ self.reflected_message = lint_errors
1742
+
1743
+ shared_output = await self.run_shell_commands()
1744
+ if shared_output:
1745
+ # Add shell output as a new user message? Or just display?
1746
+ # Let's just display for now to avoid complex history manipulation
1747
+ self.io.tool_output("Shell command output:\n" + shared_output)
1748
+
1749
+ if self.auto_test and not self.reflected_message: # Reflect only if no prior errors
1750
+ test_errors = await self.commands.cmd_test(self.test_cmd)
1751
+ if test_errors:
1752
+ ok = await self.io.confirm_ask("Attempt to fix test errors?")
1753
+ if ok:
1754
+ self.reflected_message = test_errors
1755
+
1756
+ self.show_undo_hint()
1757
+
1758
+ except ValueError as err:
1759
+ # Handle parsing errors from find_original_update_blocks
1760
+ self.num_malformed_responses += 1
1761
+ error_message = err.args[0]
1762
+ self.io.tool_error("The LLM did not conform to the edit format.")
1763
+ self.io.tool_output(urls.edit_errors)
1764
+ self.io.tool_output()
1765
+ self.io.tool_output(str(error_message))
1766
+ self.reflected_message = str(error_message) # Reflect parsing errors
1767
+ except ANY_GIT_ERROR as err:
1768
+ self.io.tool_error(f"Git error during edit application: {str(err)}")
1769
+ self.reflected_message = f"Git error during edit application: {str(err)}"
1770
+ except Exception as err:
1771
+ self.io.tool_error("Exception while applying edits:")
1772
+ self.io.tool_error(str(err), strip=False)
1773
+ self.io.tool_error(traceback.format_exc())
1774
+ self.reflected_message = f"Exception while applying edits: {str(err)}"
1775
+
1776
+ return edited_files
1777
+
1778
+ def _add_file_to_context(self, file_path, explicit=False):
1779
+ """
1780
+ Helper method to add a file to context as read-only.
1781
+
1782
+ Parameters:
1783
+ - file_path: Path to the file to add
1784
+ - explicit: Whether this was an explicit view command (vs. implicit through ViewFilesMatching)
1785
+ """
1786
+ # Check if file exists
1787
+ abs_path = self.abs_root_path(file_path)
1788
+ rel_path = self.get_rel_fname(abs_path)
1789
+
1790
+ if not os.path.isfile(abs_path):
1791
+ self.io.tool_output(f"⚠️ File '{file_path}' not found")
1792
+ return "File not found"
1793
+
1794
+ # Check if the file is already in context (either editable or read-only)
1795
+ if abs_path in self.abs_fnames:
1796
+ if explicit:
1797
+ self.io.tool_output(f"📎 File '{file_path}' already in context as editable")
1798
+ return "File already in context as editable"
1799
+ return "File already in context as editable"
1800
+
1801
+ if abs_path in self.abs_read_only_fnames:
1802
+ if explicit:
1803
+ self.io.tool_output(f"📎 File '{file_path}' already in context as read-only")
1804
+ return "File already in context as read-only"
1805
+ return "File already in context as read-only"
1806
+
1807
+ # Add file to context as read-only
1808
+ try:
1809
+ # Check for large file and apply context management if enabled
1810
+ content = self.io.read_text(abs_path)
1811
+ if content is None:
1812
+ return f"Error reading file: {file_path}"
1813
+
1814
+ # Check if file is very large and context management is enabled
1815
+ if self.context_management_enabled:
1816
+ file_tokens = self.main_model.token_count(content)
1817
+ if file_tokens > self.large_file_token_threshold:
1818
+ self.io.tool_output(
1819
+ f"⚠️ '{file_path}' is very large ({file_tokens} tokens). "
1820
+ "Use /context-management to toggle truncation off if needed."
1821
+ )
1822
+
1823
+ # Add to read-only files
1824
+ self.abs_read_only_fnames.add(abs_path)
1825
+
1826
+ # Track in exploration set
1827
+ self.files_added_in_exploration.add(rel_path)
1828
+
1829
+ # Inform user
1830
+ if explicit:
1831
+ self.io.tool_output(f"📎 Viewed '{file_path}' (added to context as read-only)")
1832
+ return "Viewed file (added to context as read-only)"
1833
+ else:
1834
+ # For implicit adds (from ViewFilesAtGlob/ViewFilesMatching), just return success
1835
+ return "Added file to context as read-only"
1836
+
1837
+ except Exception as e:
1838
+ self.io.tool_error(f"Error adding file '{file_path}' for viewing: {str(e)}")
1839
+ return f"Error adding file for viewing: {str(e)}"
1840
+
1841
+ def _process_file_mentions(self, content):
1842
+ """
1843
+ Process implicit file mentions in the content, adding files if they're not already in context.
1844
+
1845
+ This handles the case where the LLM mentions file paths without using explicit tool commands.
1846
+ """
1847
+ # Extract file mentions using the parent class's method
1848
+ mentioned_files = set(self.get_file_mentions(content, ignore_current=False))
1849
+ current_files = set(self.get_inchat_relative_files())
1850
+
1851
+ # Get new files to add (not already in context)
1852
+ mentioned_files - current_files
1853
+
1854
+ # In agent mode, we *only* add files via explicit tool commands (`View`, `ViewFilesAtGlob`, etc.).
1855
+ # Do nothing here for implicit mentions.
1856
+ pass
1857
+
1858
+ async def check_for_file_mentions(self, content):
1859
+ """
1860
+ Override parent's method to use our own file processing logic.
1861
+
1862
+ Override parent's method to disable implicit file mention handling in agent mode.
1863
+ Files should only be added via explicit tool commands
1864
+ (`View`, `ViewFilesAtGlob`, `ViewFilesMatching`, `ViewFilesWithSymbol`).
1865
+ """
1866
+ # Do nothing - disable implicit file adds in agent mode.
1867
+ pass
1868
+
1869
+ async def preproc_user_input(self, inp):
1870
+ """
1871
+ Override parent's method to wrap user input in a context block.
1872
+ This clearly delineates user input from other sections in the context window.
1873
+ """
1874
+ # First apply the parent's preprocessing
1875
+ inp = await super().preproc_user_input(inp)
1876
+
1877
+ # If we still have input after preprocessing, wrap it in a context block
1878
+ if inp and not inp.startswith('<context name="user_input">'):
1879
+ inp = f'<context name="user_input">\n{inp}\n</context>'
1880
+
1881
+ return inp
1882
+
1883
+ def get_directory_structure(self):
1884
+ """
1885
+ Generate a structured directory listing of the project file structure.
1886
+ Returns a formatted string representation of the directory tree.
1887
+ """
1888
+ if not self.use_enhanced_context:
1889
+ return None
1890
+
1891
+ try:
1892
+ # Start with the header
1893
+ result = '<context name="directoryStructure">\n'
1894
+ result += "## Project File Structure\n\n"
1895
+ result += (
1896
+ "Below is a snapshot of this project's file structure at the current time. It skips"
1897
+ " over .gitignore patterns.\n\n"
1898
+ )
1899
+
1900
+ # Get the root directory
1901
+ Path(self.root)
1902
+
1903
+ # Get all files in the repo (both tracked and untracked)
1904
+ if self.repo:
1905
+ # Get tracked files
1906
+ tracked_files = self.repo.get_tracked_files()
1907
+
1908
+ # Get untracked files (files present in the working directory but not in git)
1909
+ untracked_files = []
1910
+ try:
1911
+ # Run git status to get untracked files
1912
+ untracked_output = self.repo.repo.git.status("--porcelain")
1913
+ for line in untracked_output.splitlines():
1914
+ if line.startswith("??"):
1915
+ # Extract the filename (remove the '?? ' prefix)
1916
+ untracked_file = line[3:]
1917
+ if not self.repo.git_ignored_file(untracked_file):
1918
+ untracked_files.append(untracked_file)
1919
+ except Exception as e:
1920
+ self.io.tool_warning(f"Error getting untracked files: {str(e)}")
1921
+
1922
+ # Combine tracked and untracked files
1923
+ all_files = tracked_files + untracked_files
1924
+ else:
1925
+ # If no repo, get all files relative to root
1926
+ all_files = []
1927
+ for path in Path(self.root).rglob("*"):
1928
+ if path.is_file():
1929
+ all_files.append(str(path.relative_to(self.root)))
1930
+
1931
+ # Sort files to ensure deterministic output
1932
+ all_files = sorted(all_files)
1933
+
1934
+ # Filter out .aider files/dirs
1935
+ all_files = [
1936
+ f for f in all_files if not any(part.startswith(".aider") for part in f.split("/"))
1937
+ ]
1938
+
1939
+ # Build tree structure
1940
+ tree = {}
1941
+ for file in all_files:
1942
+ parts = file.split("/")
1943
+ current = tree
1944
+ for i, part in enumerate(parts):
1945
+ if i == len(parts) - 1: # Last part (file)
1946
+ if "." not in current:
1947
+ current["."] = []
1948
+ current["."].append(part)
1949
+ else: # Directory
1950
+ if part not in current:
1951
+ current[part] = {}
1952
+ current = current[part]
1953
+
1954
+ # Function to recursively print the tree
1955
+ def print_tree(node, prefix="- ", indent=" ", current_path=""):
1956
+ lines = []
1957
+ # First print all directories
1958
+ dirs = sorted([k for k in node.keys() if k != "."])
1959
+ for i, dir_name in enumerate(dirs):
1960
+ # Only print the current directory name, not the full path
1961
+ lines.append(f"{prefix}{dir_name}/")
1962
+ sub_lines = print_tree(
1963
+ node[dir_name], prefix=prefix, indent=indent, current_path=dir_name
1964
+ )
1965
+ for sub_line in sub_lines:
1966
+ lines.append(f"{indent}{sub_line}")
1967
+
1968
+ # Then print all files
1969
+ if "." in node:
1970
+ for file_name in sorted(node["."]):
1971
+ # Only print the current file name, not the full path
1972
+ lines.append(f"{prefix}{file_name}")
1973
+
1974
+ return lines
1975
+
1976
+ # Generate the tree starting from root
1977
+ tree_lines = print_tree(tree, prefix="- ")
1978
+ result += "\n".join(tree_lines)
1979
+ result += "\n</context>"
1980
+
1981
+ return result
1982
+ except Exception as e:
1983
+ self.io.tool_error(f"Error generating directory structure: {str(e)}")
1984
+ return None
1985
+
1986
+ def get_todo_list(self):
1987
+ """
1988
+ Generate a todo list context block from the .aider.todo.txt file.
1989
+ Returns formatted string with the current todo list or None if empty/not present.
1990
+ """
1991
+
1992
+ try:
1993
+ # Define the todo file path
1994
+ todo_file_path = ".aider.todo.txt"
1995
+ abs_path = self.abs_root_path(todo_file_path)
1996
+
1997
+ # Check if file exists
1998
+ import os
1999
+
2000
+ if not os.path.isfile(abs_path):
2001
+ return (
2002
+ '<context name="todo_list">\n'
2003
+ "Todo list does not exist. Please update it."
2004
+ "</context>"
2005
+ )
2006
+
2007
+ # Read todo list content
2008
+ content = self.io.read_text(abs_path)
2009
+ if content is None or not content.strip():
2010
+ return None
2011
+
2012
+ # Format the todo list context block
2013
+ result = '<context name="todo_list">\n'
2014
+ result += "## Current Todo List\n\n"
2015
+ result += "Below is the current todo list managed via `UpdateTodoList` tool:\n\n"
2016
+ result += f"```\n{content}\n```\n"
2017
+ result += "</context>"
2018
+
2019
+ return result
2020
+ except Exception as e:
2021
+ self.io.tool_error(f"Error generating todo list context: {str(e)}")
2022
+ return None
2023
+
2024
+ def get_git_status(self):
2025
+ """
2026
+ Generate a git status context block for repository information.
2027
+ Returns a formatted string with git branch, status, and recent commits.
2028
+ """
2029
+ if not self.use_enhanced_context or not self.repo:
2030
+ return None
2031
+
2032
+ try:
2033
+ result = '<context name="gitStatus">\n'
2034
+ result += "## Git Repository Status\n\n"
2035
+ result += "This is a snapshot of the git status at the current time.\n"
2036
+
2037
+ # Get current branch
2038
+ try:
2039
+ current_branch = self.repo.repo.active_branch.name
2040
+ result += f"Current branch: {current_branch}\n\n"
2041
+ except Exception:
2042
+ result += "Current branch: (detached HEAD state)\n\n"
2043
+
2044
+ # Get main/master branch
2045
+ main_branch = None
2046
+ try:
2047
+ for branch in self.repo.repo.branches:
2048
+ if branch.name in ("main", "master"):
2049
+ main_branch = branch.name
2050
+ break
2051
+ if main_branch:
2052
+ result += f"Main branch (you will usually use this for PRs): {main_branch}\n\n"
2053
+ except Exception:
2054
+ pass
2055
+
2056
+ # Git status
2057
+ result += "Status:\n"
2058
+ try:
2059
+ # Get modified files
2060
+ status = self.repo.repo.git.status("--porcelain")
2061
+
2062
+ # Process and categorize the status output
2063
+ if status:
2064
+ status_lines = status.strip().split("\n")
2065
+
2066
+ # Group by status type for better organization
2067
+ staged_added = []
2068
+ staged_modified = []
2069
+ staged_deleted = []
2070
+ unstaged_modified = []
2071
+ unstaged_deleted = []
2072
+ untracked = []
2073
+
2074
+ for line in status_lines:
2075
+ if len(line) < 4: # Ensure the line has enough characters
2076
+ continue
2077
+
2078
+ status_code = line[:2]
2079
+ file_path = line[3:]
2080
+
2081
+ # Skip .aider files/dirs
2082
+ if any(part.startswith(".aider") for part in file_path.split("/")):
2083
+ continue
2084
+
2085
+ # Staged changes
2086
+ if status_code[0] == "A":
2087
+ staged_added.append(file_path)
2088
+ elif status_code[0] == "M":
2089
+ staged_modified.append(file_path)
2090
+ elif status_code[0] == "D":
2091
+ staged_deleted.append(file_path)
2092
+ # Unstaged changes
2093
+ if status_code[1] == "M":
2094
+ unstaged_modified.append(file_path)
2095
+ elif status_code[1] == "D":
2096
+ unstaged_deleted.append(file_path)
2097
+ # Untracked files
2098
+ if status_code == "??":
2099
+ untracked.append(file_path)
2100
+
2101
+ # Output in a nicely formatted manner
2102
+ if staged_added:
2103
+ for file in staged_added:
2104
+ result += f"A {file}\n"
2105
+ if staged_modified:
2106
+ for file in staged_modified:
2107
+ result += f"M {file}\n"
2108
+ if staged_deleted:
2109
+ for file in staged_deleted:
2110
+ result += f"D {file}\n"
2111
+ if unstaged_modified:
2112
+ for file in unstaged_modified:
2113
+ result += f" M {file}\n"
2114
+ if unstaged_deleted:
2115
+ for file in unstaged_deleted:
2116
+ result += f" D {file}\n"
2117
+ if untracked:
2118
+ for file in untracked:
2119
+ result += f"?? {file}\n"
2120
+ else:
2121
+ result += "Working tree clean\n"
2122
+ except Exception as e:
2123
+ result += f"Unable to get modified files: {str(e)}\n"
2124
+
2125
+ # Recent commits
2126
+ result += "\nRecent commits:\n"
2127
+ try:
2128
+ commits = list(self.repo.repo.iter_commits(max_count=5))
2129
+ for commit in commits:
2130
+ short_hash = commit.hexsha[:8]
2131
+ message = commit.message.strip().split("\n")[0] # First line only
2132
+ result += f"{short_hash} {message}\n"
2133
+ except Exception:
2134
+ result += "Unable to get recent commits\n"
2135
+
2136
+ result += "</context>"
2137
+ return result
2138
+ except Exception as e:
2139
+ self.io.tool_error(f"Error generating git status: {str(e)}")
2140
+ return None
2141
+
2142
+ def cmd_context_blocks(self, args=""):
2143
+ """
2144
+ Toggle enhanced context blocks feature.
2145
+ """
2146
+ self.use_enhanced_context = not self.use_enhanced_context
2147
+
2148
+ if self.use_enhanced_context:
2149
+ self.io.tool_output(
2150
+ "Enhanced context blocks are now ON - directory structure and git status will be"
2151
+ " included."
2152
+ )
2153
+ # Mark tokens as needing calculation, but don't calculate yet (lazy calculation)
2154
+ self.tokens_calculated = False
2155
+ self.context_blocks_cache = {}
2156
+ else:
2157
+ self.io.tool_output(
2158
+ "Enhanced context blocks are now OFF - directory structure and git status will not"
2159
+ " be included."
2160
+ )
2161
+ # Clear token counts and cache when disabled
2162
+ self.context_block_tokens = {}
2163
+ self.context_blocks_cache = {}
2164
+ self.tokens_calculated = False
2165
+
2166
+ return True