patch-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. patch/__init__.py +20 -0
  2. patch/__main__.py +4 -0
  3. patch/_version.py +24 -0
  4. patch/analytics.py +260 -0
  5. patch/args.py +955 -0
  6. patch/args_formatter.py +228 -0
  7. patch/coders/__init__.py +32 -0
  8. patch/coders/architect_coder.py +47 -0
  9. patch/coders/architect_prompts.py +40 -0
  10. patch/coders/ask_coder.py +9 -0
  11. patch/coders/ask_prompts.py +41 -0
  12. patch/coders/base_coder.py +2548 -0
  13. patch/coders/base_prompts.py +60 -0
  14. patch/coders/chat_chunks.py +64 -0
  15. patch/coders/context_coder.py +53 -0
  16. patch/coders/context_prompts.py +75 -0
  17. patch/coders/edit_apply.py +6 -0
  18. patch/coders/editblock_coder.py +620 -0
  19. patch/coders/editblock_fenced_coder.py +10 -0
  20. patch/coders/editblock_fenced_prompts.py +143 -0
  21. patch/coders/editblock_func_coder.py +141 -0
  22. patch/coders/editblock_func_prompts.py +27 -0
  23. patch/coders/editblock_prompts.py +172 -0
  24. patch/coders/editor_diff_fenced_coder.py +9 -0
  25. patch/coders/editor_diff_fenced_prompts.py +11 -0
  26. patch/coders/editor_editblock_coder.py +8 -0
  27. patch/coders/editor_editblock_prompts.py +18 -0
  28. patch/coders/editor_whole_coder.py +8 -0
  29. patch/coders/editor_whole_prompts.py +10 -0
  30. patch/coders/patch_coder.py +731 -0
  31. patch/coders/patch_prompts.py +159 -0
  32. patch/coders/search_replace.py +757 -0
  33. patch/coders/shell.py +37 -0
  34. patch/coders/single_wholefile_func_coder.py +102 -0
  35. patch/coders/single_wholefile_func_prompts.py +27 -0
  36. patch/coders/udiff_coder.py +419 -0
  37. patch/coders/udiff_prompts.py +113 -0
  38. patch/coders/udiff_simple.py +14 -0
  39. patch/coders/udiff_simple_prompts.py +25 -0
  40. patch/coders/wholefile_coder.py +144 -0
  41. patch/coders/wholefile_func_coder.py +134 -0
  42. patch/coders/wholefile_func_prompts.py +27 -0
  43. patch/coders/wholefile_prompts.py +64 -0
  44. patch/commands.py +1668 -0
  45. patch/copypaste.py +70 -0
  46. patch/deprecated.py +126 -0
  47. patch/diffs.py +128 -0
  48. patch/dump.py +29 -0
  49. patch/editor.py +147 -0
  50. patch/exceptions.py +113 -0
  51. patch/format_settings.py +47 -0
  52. patch/gui.py +545 -0
  53. patch/history.py +143 -0
  54. patch/io.py +1209 -0
  55. patch/linter.py +304 -0
  56. patch/llm.py +47 -0
  57. patch/main.py +1466 -0
  58. patch/mdstream.py +243 -0
  59. patch/models.py +1342 -0
  60. patch/onboarding.py +428 -0
  61. patch/openrouter.py +128 -0
  62. patch/prompts.py +61 -0
  63. patch/queries/tree-sitter-language-pack/README.md +7 -0
  64. patch/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  65. patch/queries/tree-sitter-language-pack/bash-tags.scm +8 -0
  66. patch/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  67. patch/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  68. patch/queries/tree-sitter-language-pack/clojure-tags.scm +7 -0
  69. patch/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  70. patch/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  71. patch/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  72. patch/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  73. patch/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  74. patch/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  75. patch/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  76. patch/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  77. patch/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  78. patch/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  79. patch/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  80. patch/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  81. patch/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  82. patch/queries/tree-sitter-language-pack/matlab-tags.scm +10 -0
  83. patch/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  84. patch/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  85. patch/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  86. patch/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  87. patch/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  88. patch/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  89. patch/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  90. patch/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  91. patch/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  92. patch/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  93. patch/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  94. patch/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  95. patch/queries/tree-sitter-languages/README.md +24 -0
  96. patch/queries/tree-sitter-languages/bash-tags.scm +8 -0
  97. patch/queries/tree-sitter-languages/c-tags.scm +9 -0
  98. patch/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  99. patch/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  100. patch/queries/tree-sitter-languages/dart-tags.scm +91 -0
  101. patch/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  102. patch/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  103. patch/queries/tree-sitter-languages/elm-tags.scm +19 -0
  104. patch/queries/tree-sitter-languages/fortran-tags.scm +15 -0
  105. patch/queries/tree-sitter-languages/go-tags.scm +30 -0
  106. patch/queries/tree-sitter-languages/haskell-tags.scm +3 -0
  107. patch/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  108. patch/queries/tree-sitter-languages/java-tags.scm +20 -0
  109. patch/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  110. patch/queries/tree-sitter-languages/julia-tags.scm +60 -0
  111. patch/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  112. patch/queries/tree-sitter-languages/matlab-tags.scm +10 -0
  113. patch/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  114. patch/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  115. patch/queries/tree-sitter-languages/php-tags.scm +26 -0
  116. patch/queries/tree-sitter-languages/python-tags.scm +12 -0
  117. patch/queries/tree-sitter-languages/ql-tags.scm +26 -0
  118. patch/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  119. patch/queries/tree-sitter-languages/rust-tags.scm +60 -0
  120. patch/queries/tree-sitter-languages/scala-tags.scm +65 -0
  121. patch/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  122. patch/queries/tree-sitter-languages/zig-tags.scm +3 -0
  123. patch/reasoning_tags.py +82 -0
  124. patch/repo.py +612 -0
  125. patch/repomap.py +859 -0
  126. patch/report.py +200 -0
  127. patch/resources/__init__.py +3 -0
  128. patch/resources/model-metadata.json +715 -0
  129. patch/resources/model-settings.yml +3128 -0
  130. patch/run_cmd.py +146 -0
  131. patch/scrape.py +316 -0
  132. patch/sendchat.py +61 -0
  133. patch/special.py +203 -0
  134. patch/urls.py +17 -0
  135. patch/utils.py +365 -0
  136. patch/versioncheck.py +113 -0
  137. patch/voice.py +200 -0
  138. patch/waiting.py +222 -0
  139. patch/watch.py +318 -0
  140. patch/watch_prompts.py +12 -0
  141. patch_chat-0.1.0.dist-info/METADATA +375 -0
  142. patch_chat-0.1.0.dist-info/RECORD +146 -0
  143. patch_chat-0.1.0.dist-info/WHEEL +5 -0
  144. patch_chat-0.1.0.dist-info/entry_points.txt +2 -0
  145. patch_chat-0.1.0.dist-info/licenses/LICENSE.txt +202 -0
  146. patch_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2548 @@
1
+ #!/usr/bin/env python
2
+
3
+ import base64
4
+ import hashlib
5
+ import json
6
+ import locale
7
+ import math
8
+ import mimetypes
9
+ import os
10
+ import platform
11
+ import re
12
+ import sys
13
+ import threading
14
+ import time
15
+ import traceback
16
+ from collections import defaultdict
17
+ from datetime import datetime
18
+
19
+ # Optional dependency: used to convert locale codes (eg ``en_US``)
20
+ # into human-readable language names (eg ``English``).
21
+ try:
22
+ from babel import Locale # type: ignore
23
+ except ImportError: # Babel not installed – we will fall back to a small mapping
24
+ Locale = None
25
+ from json.decoder import JSONDecodeError
26
+ from pathlib import Path
27
+
28
+ from rich.console import Console
29
+
30
+ from patch import __version__, models, prompts, urls, utils
31
+ from patch.analytics import Analytics
32
+ from patch.commands import Commands
33
+ from patch.exceptions import LiteLLMExceptions
34
+ from patch.history import ChatSummary
35
+ from patch.io import ConfirmGroup, InputOutput
36
+ from patch.linter import Linter
37
+ from patch.llm import litellm
38
+ from patch.models import RETRY_TIMEOUT
39
+ from patch.reasoning_tags import (
40
+ REASONING_TAG,
41
+ format_reasoning_content,
42
+ remove_reasoning_content,
43
+ replace_reasoning_tags,
44
+ )
45
+ from patch.repo import ANY_GIT_ERROR, GitRepo
46
+ from patch.repomap import RepoMap
47
+ from patch.run_cmd import run_cmd
48
+ from patch.utils import format_content, format_messages, format_tokens, is_image_file
49
+ from patch.waiting import WaitingSpinner
50
+
51
+ from ..dump import dump # noqa: F401
52
+ from .chat_chunks import ChatChunks
53
+
54
+
55
+ class UnknownEditFormat(ValueError):
56
+ def __init__(self, edit_format, valid_formats):
57
+ self.edit_format = edit_format
58
+ self.valid_formats = valid_formats
59
+ super().__init__(
60
+ f"Unknown edit format {edit_format}. Valid formats are: {', '.join(valid_formats)}"
61
+ )
62
+
63
+
64
+ class MissingAPIKeyError(ValueError):
65
+ pass
66
+
67
+
68
+ class FinishReasonLength(Exception):
69
+ pass
70
+
71
+
72
+ class EditApplyError(ValueError):
73
+ def __init__(self, message, applied_paths=None):
74
+ super().__init__(message)
75
+ self.applied_paths = set(applied_paths or [])
76
+
77
+
78
+ def wrap_fence(name):
79
+ return f"<{name}>", f"</{name}>"
80
+
81
+
82
+ all_fences = [
83
+ ("`" * 3, "`" * 3),
84
+ ("`" * 4, "`" * 4), # LLMs ignore and revert to triple-backtick, causing #2879
85
+ wrap_fence("source"),
86
+ wrap_fence("code"),
87
+ wrap_fence("pre"),
88
+ wrap_fence("codeblock"),
89
+ wrap_fence("sourcecode"),
90
+ ]
91
+
92
+
93
+ class Coder:
94
+ abs_fnames = None
95
+ abs_read_only_fnames = None
96
+ repo = None
97
+ last_patch_commit_hash = None
98
+ patch_edited_files = None
99
+ last_asked_for_commit_time = 0
100
+ repo_map = None
101
+ functions = None
102
+ num_exhausted_context_windows = 0
103
+ num_malformed_responses = 0
104
+ last_keyboard_interrupt = None
105
+ num_reflections = 0
106
+ max_reflections = 3
107
+ max_output_continuations = 8
108
+ edit_format = None
109
+ yield_stream = False
110
+ temperature = None
111
+ auto_lint = True
112
+ auto_test = False
113
+ test_cmd = None
114
+ lint_outcome = None
115
+ test_outcome = None
116
+ multi_response_content = ""
117
+ partial_response_content = ""
118
+ commit_before_message: list[str] = []
119
+ message_cost = 0.0
120
+ add_cache_headers = False
121
+ cache_warming_thread = None
122
+ num_cache_warming_pings = 0
123
+ suggest_shell_commands = True
124
+ detect_urls = True
125
+ ignore_mentions = None
126
+ chat_language = None
127
+ commit_language = None
128
+ file_watcher = None
129
+
130
+ @classmethod
131
+ def create(
132
+ self,
133
+ main_model=None,
134
+ edit_format=None,
135
+ io=None,
136
+ from_coder=None,
137
+ summarize_from_coder=True,
138
+ stop_from_coder_cache=True,
139
+ **kwargs,
140
+ ):
141
+ import patch.coders as coders
142
+
143
+ if not main_model:
144
+ if from_coder:
145
+ main_model = from_coder.main_model
146
+ else:
147
+ main_model = models.Model(models.DEFAULT_MODEL_NAME)
148
+
149
+ if edit_format == "code":
150
+ edit_format = None
151
+ if edit_format is None:
152
+ if from_coder:
153
+ edit_format = from_coder.edit_format
154
+ else:
155
+ edit_format = main_model.edit_format
156
+
157
+ if not io and from_coder:
158
+ io = from_coder.io
159
+
160
+ if from_coder:
161
+ use_kwargs = dict(from_coder.original_kwargs) # copy orig kwargs
162
+
163
+ # If the edit format changes, we can't leave old ASSISTANT
164
+ # messages in the chat history. The old edit format will
165
+ # confused the new LLM. It may try and imitate it, disobeying
166
+ # the system prompt.
167
+ done_messages = from_coder.done_messages
168
+ if edit_format != from_coder.edit_format and done_messages and summarize_from_coder:
169
+ try:
170
+ done_messages = from_coder.summarizer.summarize_all(done_messages)
171
+ except ValueError:
172
+ # If summarization fails, keep the original messages and warn the user
173
+ io.tool_warning(
174
+ "Chat history summarization failed, continuing with full history"
175
+ )
176
+
177
+ # Bring along context from the old Coder
178
+ update = dict(
179
+ fnames=list(from_coder.abs_fnames),
180
+ read_only_fnames=list(from_coder.abs_read_only_fnames), # Copy read-only files
181
+ done_messages=done_messages,
182
+ cur_messages=from_coder.cur_messages,
183
+ patch_commit_hashes=from_coder.patch_commit_hashes,
184
+ commands=from_coder.commands.clone(),
185
+ total_cost=from_coder.total_cost,
186
+ ignore_mentions=from_coder.ignore_mentions,
187
+ total_tokens_sent=from_coder.total_tokens_sent,
188
+ total_tokens_received=from_coder.total_tokens_received,
189
+ file_watcher=from_coder.file_watcher,
190
+ )
191
+ use_kwargs.update(update) # override to complete the switch
192
+ use_kwargs.update(kwargs) # override passed kwargs
193
+
194
+ kwargs = use_kwargs
195
+ if stop_from_coder_cache:
196
+ from_coder.ok_to_warm_cache = False
197
+
198
+ for coder in coders.__all__:
199
+ if hasattr(coder, "edit_format") and coder.edit_format == edit_format:
200
+ res = coder(main_model, io, **kwargs)
201
+ res.original_kwargs = dict(kwargs)
202
+ return res
203
+
204
+ valid_formats = [
205
+ str(c.edit_format)
206
+ for c in coders.__all__
207
+ if hasattr(c, "edit_format") and c.edit_format is not None
208
+ ]
209
+ raise UnknownEditFormat(edit_format, valid_formats)
210
+
211
+ def clone(self, **kwargs):
212
+ new_coder = Coder.create(from_coder=self, stop_from_coder_cache=False, **kwargs)
213
+ return new_coder
214
+
215
+ def get_announcements(self):
216
+ lines = []
217
+ lines.append(f"Patch v{__version__}")
218
+
219
+ # Model
220
+ main_model = self.main_model
221
+ weak_model = main_model.weak_model
222
+
223
+ if weak_model is not main_model:
224
+ prefix = "Main model"
225
+ else:
226
+ prefix = "Model"
227
+
228
+ output = f"{prefix}: {main_model.name} with {self.edit_format} edit format"
229
+
230
+ # Check for thinking token budget
231
+ thinking_tokens = main_model.get_thinking_tokens()
232
+ if thinking_tokens:
233
+ output += f", {thinking_tokens} think tokens"
234
+
235
+ # Check for reasoning effort
236
+ reasoning_effort = main_model.get_reasoning_effort()
237
+ if reasoning_effort:
238
+ output += f", reasoning {reasoning_effort}"
239
+
240
+ if self.add_cache_headers or main_model.caches_by_default:
241
+ output += ", prompt cache"
242
+ if main_model.info.get("supports_assistant_prefill"):
243
+ output += ", infinite output"
244
+
245
+ lines.append(output)
246
+
247
+ if self.edit_format == "architect":
248
+ output = (
249
+ f"Editor model: {main_model.editor_model.name} with"
250
+ f" {main_model.editor_edit_format} edit format"
251
+ )
252
+ lines.append(output)
253
+
254
+ if weak_model is not main_model:
255
+ output = f"Weak model: {weak_model.name}"
256
+ lines.append(output)
257
+
258
+ # Repo
259
+ if self.repo:
260
+ rel_repo_dir = self.repo.get_rel_repo_dir()
261
+ num_files = len(self.repo.get_tracked_files())
262
+
263
+ lines.append(f"Git repo: {rel_repo_dir} with {num_files:,} files")
264
+ if num_files > 1000:
265
+ lines.append(
266
+ "Warning: For large repos, consider using --subtree-only and .patchignore"
267
+ )
268
+ lines.append(f"See: {urls.large_repos}")
269
+ else:
270
+ lines.append("Git repo: none")
271
+
272
+ # Repo-map
273
+ if self.repo_map:
274
+ map_tokens = self.repo_map.max_map_tokens
275
+ if map_tokens > 0:
276
+ refresh = self.repo_map.refresh
277
+ lines.append(f"Repo-map: using {map_tokens} tokens, {refresh} refresh")
278
+ max_map_tokens = self.main_model.get_repo_map_tokens() * 2
279
+ if map_tokens > max_map_tokens:
280
+ lines.append(
281
+ f"Warning: map-tokens > {max_map_tokens} is not recommended. Too much"
282
+ " irrelevant code can confuse LLMs."
283
+ )
284
+ else:
285
+ lines.append("Repo-map: disabled because map_tokens == 0")
286
+ else:
287
+ lines.append("Repo-map: disabled")
288
+
289
+ # Files
290
+ for fname in self.get_inchat_relative_files():
291
+ lines.append(f"Added {fname} to the chat.")
292
+
293
+ for fname in self.abs_read_only_fnames:
294
+ rel_fname = self.get_rel_fname(fname)
295
+ lines.append(f"Added {rel_fname} to the chat (read-only).")
296
+
297
+ if self.done_messages:
298
+ lines.append("Restored previous conversation history.")
299
+
300
+ if self.io.multiline_mode:
301
+ lines.append("Multiline mode: Enabled. Enter inserts newline, Alt-Enter submits text")
302
+
303
+ return lines
304
+
305
+ ok_to_warm_cache = False
306
+
307
+ def __init__(
308
+ self,
309
+ main_model,
310
+ io,
311
+ repo=None,
312
+ fnames=None,
313
+ add_gitignore_files=False,
314
+ read_only_fnames=None,
315
+ show_diffs=False,
316
+ auto_commits=True,
317
+ dirty_commits=True,
318
+ dry_run=False,
319
+ map_tokens=1024,
320
+ verbose=False,
321
+ stream=True,
322
+ use_git=True,
323
+ cur_messages=None,
324
+ done_messages=None,
325
+ restore_chat_history=False,
326
+ auto_lint=True,
327
+ auto_test=False,
328
+ lint_cmds=None,
329
+ test_cmd=None,
330
+ patch_commit_hashes=None,
331
+ map_mul_no_files=8,
332
+ commands=None,
333
+ summarizer=None,
334
+ total_cost=0.0,
335
+ analytics=None,
336
+ map_refresh="auto",
337
+ cache_prompts=False,
338
+ num_cache_warming_pings=0,
339
+ suggest_shell_commands=True,
340
+ chat_language=None,
341
+ commit_language=None,
342
+ detect_urls=True,
343
+ ignore_mentions=None,
344
+ total_tokens_sent=0,
345
+ total_tokens_received=0,
346
+ file_watcher=None,
347
+ auto_copy_context=False,
348
+ auto_accept_architect=True,
349
+ ):
350
+ # Fill in a dummy Analytics if needed, but it is never .enable()'d
351
+ self.analytics = analytics if analytics is not None else Analytics()
352
+
353
+ self.event = self.analytics.event
354
+ self.chat_language = chat_language
355
+ self.commit_language = commit_language
356
+ self.commit_before_message = []
357
+ self.patch_commit_hashes = set()
358
+ self.rejected_urls = set()
359
+ self.abs_root_path_cache = {}
360
+
361
+ self.auto_copy_context = auto_copy_context
362
+ self.auto_accept_architect = auto_accept_architect
363
+
364
+ self.ignore_mentions = ignore_mentions
365
+ if not self.ignore_mentions:
366
+ self.ignore_mentions = set()
367
+
368
+ self.file_watcher = file_watcher
369
+ if self.file_watcher:
370
+ self.file_watcher.coder = self
371
+
372
+ self.suggest_shell_commands = suggest_shell_commands
373
+ self.detect_urls = detect_urls
374
+
375
+ self.num_cache_warming_pings = num_cache_warming_pings
376
+
377
+ if not fnames:
378
+ fnames = []
379
+
380
+ if io is None:
381
+ io = InputOutput()
382
+
383
+ if patch_commit_hashes:
384
+ self.patch_commit_hashes = patch_commit_hashes
385
+ else:
386
+ self.patch_commit_hashes = set()
387
+
388
+ self.chat_completion_call_hashes = []
389
+ self.chat_completion_response_hashes = []
390
+ self.need_commit_before_edits = set()
391
+
392
+ self.total_cost = total_cost
393
+ self.total_tokens_sent = total_tokens_sent
394
+ self.total_tokens_received = total_tokens_received
395
+ self.message_tokens_sent = 0
396
+ self.message_tokens_received = 0
397
+
398
+ self.verbose = verbose
399
+ self.abs_fnames = set()
400
+ self.abs_read_only_fnames = set()
401
+ self.add_gitignore_files = add_gitignore_files
402
+
403
+ if cur_messages:
404
+ self.cur_messages = cur_messages
405
+ else:
406
+ self.cur_messages = []
407
+
408
+ if done_messages:
409
+ self.done_messages = done_messages
410
+ else:
411
+ self.done_messages = []
412
+
413
+ self.io = io
414
+
415
+ self.shell_commands = []
416
+
417
+ if not auto_commits:
418
+ dirty_commits = False
419
+
420
+ self.auto_commits = auto_commits
421
+ self.dirty_commits = dirty_commits
422
+
423
+ self.dry_run = dry_run
424
+ self.pretty = self.io.pretty
425
+
426
+ self.main_model = main_model
427
+ # Set the reasoning tag name based on model settings or default
428
+ self.reasoning_tag_name = (
429
+ self.main_model.reasoning_tag if self.main_model.reasoning_tag else REASONING_TAG
430
+ )
431
+
432
+ self.stream = stream and main_model.streaming
433
+
434
+ if cache_prompts and self.main_model.cache_control:
435
+ self.add_cache_headers = True
436
+
437
+ self.show_diffs = show_diffs
438
+
439
+ self.commands = commands or Commands(self.io, self)
440
+ self.commands.coder = self
441
+
442
+ self.repo = repo
443
+ if use_git and self.repo is None:
444
+ try:
445
+ self.repo = GitRepo(
446
+ self.io,
447
+ fnames,
448
+ None,
449
+ models=main_model.commit_message_models(),
450
+ )
451
+ except FileNotFoundError:
452
+ pass
453
+
454
+ if self.repo:
455
+ self.root = self.repo.root
456
+
457
+ for fname in fnames:
458
+ fname = Path(fname)
459
+ if self.repo and self.repo.git_ignored_file(fname) and not self.add_gitignore_files:
460
+ self.io.tool_warning(f"Skipping {fname} that matches gitignore spec.")
461
+ continue
462
+
463
+ if self.repo and self.repo.ignored_file(fname):
464
+ self.io.tool_warning(f"Skipping {fname} that matches patchignore spec.")
465
+ continue
466
+
467
+ if not fname.exists():
468
+ if utils.touch_file(fname):
469
+ self.io.tool_output(f"Creating empty file {fname}")
470
+ else:
471
+ self.io.tool_warning(f"Can not create {fname}, skipping.")
472
+ continue
473
+
474
+ if not fname.is_file():
475
+ self.io.tool_warning(f"Skipping {fname} that is not a normal file.")
476
+ continue
477
+
478
+ fname = str(fname.resolve())
479
+
480
+ self.abs_fnames.add(fname)
481
+ self.check_added_files()
482
+
483
+ if not self.repo:
484
+ self.root = utils.find_common_root(self.abs_fnames)
485
+
486
+ if read_only_fnames:
487
+ self.abs_read_only_fnames = set()
488
+ for fname in read_only_fnames:
489
+ abs_fname = self.abs_root_path(fname)
490
+ if os.path.exists(abs_fname):
491
+ self.abs_read_only_fnames.add(abs_fname)
492
+ else:
493
+ self.io.tool_warning(f"Error: Read-only file {fname} does not exist. Skipping.")
494
+
495
+ if map_tokens is None:
496
+ use_repo_map = main_model.use_repo_map
497
+ map_tokens = 1024
498
+ else:
499
+ use_repo_map = map_tokens > 0
500
+
501
+ max_inp_tokens = self.main_model.info.get("max_input_tokens") or 0
502
+
503
+ has_map_prompt = hasattr(self, "gpt_prompts") and self.gpt_prompts.repo_content_prefix
504
+
505
+ if use_repo_map and self.repo and has_map_prompt:
506
+ self.repo_map = RepoMap(
507
+ map_tokens,
508
+ self.root,
509
+ self.main_model,
510
+ io,
511
+ self.gpt_prompts.repo_content_prefix,
512
+ self.verbose,
513
+ max_inp_tokens,
514
+ map_mul_no_files=map_mul_no_files,
515
+ refresh=map_refresh,
516
+ )
517
+
518
+ self.summarizer = summarizer or ChatSummary(
519
+ [self.main_model.weak_model, self.main_model],
520
+ self.main_model.max_chat_history_tokens,
521
+ )
522
+
523
+ self.summarizer_thread = None
524
+ self.summarized_done_messages = []
525
+ self.summarizing_messages = None
526
+
527
+ if not self.done_messages and restore_chat_history:
528
+ history_md = self.io.read_text(self.io.chat_history_file)
529
+ if history_md:
530
+ self.done_messages = utils.split_chat_history_markdown(history_md)
531
+ self.summarize_start()
532
+
533
+ # Linting and testing
534
+ self.linter = Linter(root=self.root, encoding=io.encoding)
535
+ self.auto_lint = auto_lint
536
+ self.setup_lint_cmds(lint_cmds)
537
+ self.lint_cmds = lint_cmds
538
+ self.auto_test = auto_test
539
+ self.test_cmd = test_cmd
540
+
541
+ # validate the functions jsonschema
542
+ if self.functions:
543
+ from jsonschema import Draft7Validator
544
+
545
+ for function in self.functions:
546
+ Draft7Validator.check_schema(function)
547
+
548
+ if self.verbose:
549
+ self.io.tool_output("JSON Schema:")
550
+ self.io.tool_output(json.dumps(self.functions, indent=4))
551
+
552
+ def setup_lint_cmds(self, lint_cmds):
553
+ if not lint_cmds:
554
+ return
555
+ for lang, cmd in lint_cmds.items():
556
+ self.linter.set_linter(lang, cmd)
557
+
558
+ def show_announcements(self):
559
+ bold = True
560
+ for line in self.get_announcements():
561
+ self.io.tool_output(line, bold=bold)
562
+ bold = False
563
+
564
+ def add_rel_fname(self, rel_fname):
565
+ self.abs_fnames.add(self.abs_root_path(rel_fname))
566
+ self.check_added_files()
567
+
568
+ def drop_rel_fname(self, fname):
569
+ abs_fname = self.abs_root_path(fname)
570
+ if abs_fname in self.abs_fnames:
571
+ self.abs_fnames.remove(abs_fname)
572
+ return True
573
+
574
+ def abs_root_path(self, path):
575
+ key = path
576
+ if key in self.abs_root_path_cache:
577
+ return self.abs_root_path_cache[key]
578
+
579
+ res = Path(self.root) / path
580
+ res = utils.safe_abs_path(res)
581
+ self.abs_root_path_cache[key] = res
582
+ return res
583
+
584
+ def path_within_root(self, path):
585
+ """Does this path stay inside the repo root once fully resolved?
586
+
587
+ Read-only files are deliberately exempt: they are supplied by the user,
588
+ not the model, and pointing at a reference file outside the repo is a
589
+ supported workflow. Only write paths are constrained.
590
+ """
591
+ if utils.safe_abs_path(path) in self.abs_read_only_fnames:
592
+ return True
593
+
594
+ return utils.path_is_within(path, self.root)
595
+
596
+ fences = all_fences
597
+ fence = fences[0]
598
+
599
+ def show_pretty(self):
600
+ if not self.pretty:
601
+ return False
602
+
603
+ # only show pretty output if fences are the normal triple-backtick
604
+ if self.fence[0][0] != "`":
605
+ return False
606
+
607
+ return True
608
+
609
+ def _stop_waiting_spinner(self):
610
+ """Stop and clear the waiting spinner if it is running."""
611
+ spinner = getattr(self, "waiting_spinner", None)
612
+ if spinner:
613
+ try:
614
+ spinner.stop()
615
+ finally:
616
+ self.waiting_spinner = None
617
+
618
+ def get_abs_fnames_content(self):
619
+ for fname in list(self.abs_fnames):
620
+ content = self.io.read_text(fname)
621
+
622
+ if content is None:
623
+ relative_fname = self.get_rel_fname(fname)
624
+ self.io.tool_warning(f"Dropping {relative_fname} from the chat.")
625
+ self.abs_fnames.remove(fname)
626
+ else:
627
+ yield fname, content
628
+
629
+ def choose_fence(self):
630
+ all_content = ""
631
+ for _fname, content in self.get_abs_fnames_content():
632
+ all_content += content + "\n"
633
+ for _fname in self.abs_read_only_fnames:
634
+ content = self.io.read_text(_fname)
635
+ if content is not None:
636
+ all_content += content + "\n"
637
+
638
+ lines = all_content.splitlines()
639
+ good = False
640
+ for fence_open, fence_close in self.fences:
641
+ if any(line.startswith(fence_open) or line.startswith(fence_close) for line in lines):
642
+ continue
643
+ good = True
644
+ break
645
+
646
+ if good:
647
+ self.fence = (fence_open, fence_close)
648
+ else:
649
+ self.fence = self.fences[0]
650
+ self.io.tool_warning(
651
+ "Unable to find a fencing strategy! Falling back to:"
652
+ f" {self.fence[0]}...{self.fence[1]}"
653
+ )
654
+
655
+ return
656
+
657
+ def get_files_content(self):
658
+ prompt = ""
659
+ for fname, content in self.get_abs_fnames_content():
660
+ if not is_image_file(fname):
661
+ relative_fname = self.get_rel_fname(fname)
662
+ prompt += "\n"
663
+ prompt += relative_fname
664
+ prompt += f"\n{self.fence[0]}\n"
665
+
666
+ prompt += content
667
+
668
+ # lines = content.splitlines(keepends=True)
669
+ # lines = [f"{i+1:03}:{line}" for i, line in enumerate(lines)]
670
+ # prompt += "".join(lines)
671
+
672
+ prompt += f"{self.fence[1]}\n"
673
+
674
+ return prompt
675
+
676
+ def get_read_only_files_content(self):
677
+ prompt = ""
678
+ for fname in self.abs_read_only_fnames:
679
+ content = self.io.read_text(fname)
680
+ if content is not None and not is_image_file(fname):
681
+ relative_fname = self.get_rel_fname(fname)
682
+ prompt += "\n"
683
+ prompt += relative_fname
684
+ prompt += f"\n{self.fence[0]}\n"
685
+ prompt += content
686
+ prompt += f"{self.fence[1]}\n"
687
+ return prompt
688
+
689
+ def get_cur_message_text(self):
690
+ text = ""
691
+ for msg in self.cur_messages:
692
+ text += msg["content"] + "\n"
693
+ return text
694
+
695
+ def get_ident_mentions(self, text):
696
+ # Split the string on any character that is not alphanumeric
697
+ # \W+ matches one or more non-word characters (equivalent to [^a-zA-Z0-9_]+)
698
+ words = set(re.split(r"\W+", text))
699
+ return words
700
+
701
+ def get_ident_filename_matches(self, idents):
702
+ all_fnames = defaultdict(set)
703
+ for fname in self.get_all_relative_files():
704
+ # Skip empty paths or just '.'
705
+ if not fname or fname == ".":
706
+ continue
707
+
708
+ try:
709
+ # Handle dotfiles properly
710
+ path = Path(fname)
711
+ base = path.stem.lower() # Use stem instead of with_suffix("").name
712
+ if len(base) >= 5:
713
+ all_fnames[base].add(fname)
714
+ except ValueError:
715
+ # Skip paths that can't be processed
716
+ continue
717
+
718
+ matches = set()
719
+ for ident in idents:
720
+ if len(ident) < 5:
721
+ continue
722
+ matches.update(all_fnames[ident.lower()])
723
+
724
+ return matches
725
+
726
+ def get_repo_map(self, force_refresh=False):
727
+ if not self.repo_map:
728
+ return
729
+
730
+ cur_msg_text = self.get_cur_message_text()
731
+ mentioned_fnames = self.get_file_mentions(cur_msg_text)
732
+ mentioned_idents = self.get_ident_mentions(cur_msg_text)
733
+
734
+ mentioned_fnames.update(self.get_ident_filename_matches(mentioned_idents))
735
+
736
+ all_abs_files = set(self.get_all_abs_files())
737
+ repo_abs_read_only_fnames = set(self.abs_read_only_fnames) & all_abs_files
738
+ chat_files = set(self.abs_fnames) | repo_abs_read_only_fnames
739
+ other_files = all_abs_files - chat_files
740
+
741
+ repo_content = self.repo_map.get_repo_map(
742
+ chat_files,
743
+ other_files,
744
+ mentioned_fnames=mentioned_fnames,
745
+ mentioned_idents=mentioned_idents,
746
+ force_refresh=force_refresh,
747
+ )
748
+
749
+ # fall back to global repo map if files in chat are disjoint from rest of repo
750
+ if not repo_content:
751
+ repo_content = self.repo_map.get_repo_map(
752
+ set(),
753
+ all_abs_files,
754
+ mentioned_fnames=mentioned_fnames,
755
+ mentioned_idents=mentioned_idents,
756
+ )
757
+
758
+ # fall back to completely unhinted repo
759
+ if not repo_content:
760
+ repo_content = self.repo_map.get_repo_map(
761
+ set(),
762
+ all_abs_files,
763
+ )
764
+
765
+ return repo_content
766
+
767
+ def get_repo_messages(self):
768
+ repo_messages = []
769
+ repo_content = self.get_repo_map()
770
+ if repo_content:
771
+ repo_messages += [
772
+ dict(role="user", content=repo_content),
773
+ dict(
774
+ role="assistant",
775
+ content="Ok, I won't try and edit those files without asking first.",
776
+ ),
777
+ ]
778
+ return repo_messages
779
+
780
+ def get_readonly_files_messages(self):
781
+ readonly_messages = []
782
+
783
+ # Handle non-image files
784
+ read_only_content = self.get_read_only_files_content()
785
+ if read_only_content:
786
+ readonly_messages += [
787
+ dict(
788
+ role="user", content=self.gpt_prompts.read_only_files_prefix + read_only_content
789
+ ),
790
+ dict(
791
+ role="assistant",
792
+ content="Ok, I will use these files as references.",
793
+ ),
794
+ ]
795
+
796
+ # Handle image files
797
+ images_message = self.get_images_message(self.abs_read_only_fnames)
798
+ if images_message is not None:
799
+ readonly_messages += [
800
+ images_message,
801
+ dict(role="assistant", content="Ok, I will use these images as references."),
802
+ ]
803
+
804
+ return readonly_messages
805
+
806
+ def get_chat_files_messages(self):
807
+ chat_files_messages = []
808
+ if self.abs_fnames:
809
+ files_content = self.gpt_prompts.files_content_prefix
810
+ files_content += self.get_files_content()
811
+ files_reply = self.gpt_prompts.files_content_assistant_reply
812
+ elif self.get_repo_map() and self.gpt_prompts.files_no_full_files_with_repo_map:
813
+ files_content = self.gpt_prompts.files_no_full_files_with_repo_map
814
+ files_reply = self.gpt_prompts.files_no_full_files_with_repo_map_reply
815
+ else:
816
+ files_content = self.gpt_prompts.files_no_full_files
817
+ files_reply = "Ok."
818
+
819
+ if files_content:
820
+ chat_files_messages += [
821
+ dict(role="user", content=files_content),
822
+ dict(role="assistant", content=files_reply),
823
+ ]
824
+
825
+ images_message = self.get_images_message(self.abs_fnames)
826
+ if images_message is not None:
827
+ chat_files_messages += [
828
+ images_message,
829
+ dict(role="assistant", content="Ok."),
830
+ ]
831
+
832
+ return chat_files_messages
833
+
834
+ def get_images_message(self, fnames):
835
+ supports_images = self.main_model.info.get("supports_vision")
836
+ supports_pdfs = self.main_model.info.get("supports_pdf_input") or self.main_model.info.get(
837
+ "max_pdf_size_mb"
838
+ )
839
+
840
+ # https://github.com/BerriAI/litellm/pull/6928
841
+ supports_pdfs = supports_pdfs or "claude-3-5-sonnet-20241022" in self.main_model.name
842
+
843
+ if not (supports_images or supports_pdfs):
844
+ return None
845
+
846
+ image_messages = []
847
+ for fname in fnames:
848
+ if not is_image_file(fname):
849
+ continue
850
+
851
+ mime_type, _ = mimetypes.guess_type(fname)
852
+ if not mime_type:
853
+ continue
854
+
855
+ with open(fname, "rb") as image_file:
856
+ encoded_string = base64.b64encode(image_file.read()).decode("utf-8")
857
+ image_url = f"data:{mime_type};base64,{encoded_string}"
858
+ rel_fname = self.get_rel_fname(fname)
859
+
860
+ if mime_type.startswith("image/") and supports_images:
861
+ image_messages += [
862
+ {"type": "text", "text": f"Image file: {rel_fname}"},
863
+ {"type": "image_url", "image_url": {"url": image_url, "detail": "high"}},
864
+ ]
865
+ elif mime_type == "application/pdf" and supports_pdfs:
866
+ image_messages += [
867
+ {"type": "text", "text": f"PDF file: {rel_fname}"},
868
+ {"type": "image_url", "image_url": image_url},
869
+ ]
870
+
871
+ if not image_messages:
872
+ return None
873
+
874
+ return {"role": "user", "content": image_messages}
875
+
876
+ def run_stream(self, user_message):
877
+ self.io.user_input(user_message)
878
+ self.init_before_message()
879
+ yield from self.send_message(user_message)
880
+
881
+ def init_before_message(self):
882
+ self.patch_edited_files = set()
883
+ self.reflected_message = None
884
+ self.num_reflections = 0
885
+ self.lint_outcome = None
886
+ self.test_outcome = None
887
+ self.shell_commands = []
888
+ self.message_cost = 0
889
+
890
+ if self.repo:
891
+ self.commit_before_message.append(self.repo.get_head_commit_sha())
892
+
893
+ def run(self, with_message=None, preproc=True):
894
+ try:
895
+ if with_message:
896
+ self.io.user_input(with_message)
897
+ self.run_one(with_message, preproc)
898
+ return self.partial_response_content
899
+ while True:
900
+ try:
901
+ if not self.io.placeholder:
902
+ self.copy_context()
903
+ user_message = self.get_input()
904
+ self.run_one(user_message, preproc)
905
+ self.show_undo_hint()
906
+ except KeyboardInterrupt:
907
+ self.keyboard_interrupt()
908
+ except EOFError:
909
+ return
910
+
911
+ def copy_context(self):
912
+ if self.auto_copy_context:
913
+ self.commands.cmd_copy_context()
914
+
915
+ def get_input(self):
916
+ inchat_files = self.get_inchat_relative_files()
917
+ read_only_files = [self.get_rel_fname(fname) for fname in self.abs_read_only_fnames]
918
+ all_files = sorted(set(inchat_files + read_only_files))
919
+ edit_format = "" if self.edit_format == self.main_model.edit_format else self.edit_format
920
+ return self.io.get_input(
921
+ self.root,
922
+ all_files,
923
+ self.get_addable_relative_files(),
924
+ self.commands,
925
+ self.abs_read_only_fnames,
926
+ edit_format=edit_format,
927
+ )
928
+
929
+ def preproc_user_input(self, inp):
930
+ if not inp:
931
+ return
932
+
933
+ if self.commands.is_command(inp):
934
+ return self.commands.run(inp)
935
+
936
+ self.check_for_file_mentions(inp)
937
+ inp = self.check_for_urls(inp)
938
+
939
+ return inp
940
+
941
+ def run_one(self, user_message, preproc):
942
+ self.init_before_message()
943
+
944
+ if preproc:
945
+ message = self.preproc_user_input(user_message)
946
+ else:
947
+ message = user_message
948
+
949
+ while message:
950
+ self.reflected_message = None
951
+ list(self.send_message(message))
952
+
953
+ if not self.reflected_message:
954
+ break
955
+
956
+ if self.num_reflections >= self.max_reflections:
957
+ self.io.tool_warning(f"Only {self.max_reflections} reflections allowed, stopping.")
958
+ return
959
+
960
+ self.num_reflections += 1
961
+ message = self.reflected_message
962
+
963
+ def check_and_open_urls(self, exc, friendly_msg=None):
964
+ """Check exception for URLs, offer to open in a browser, with user-friendly error msgs."""
965
+ text = str(exc)
966
+
967
+ if friendly_msg:
968
+ self.io.tool_warning(text)
969
+ self.io.tool_error(f"{friendly_msg}")
970
+ else:
971
+ self.io.tool_error(text)
972
+
973
+ # Exclude double quotes from the matched URL characters
974
+ url_pattern = re.compile(r'(https?://[^\s/$.?#].[^\s"]*)')
975
+ urls = list(set(url_pattern.findall(text))) # Use set to remove duplicates
976
+ for url in urls:
977
+ url = url.rstrip(".',\"}") # Added } to the characters to strip
978
+ self.io.offer_url(url)
979
+ return urls
980
+
981
+ def check_for_urls(self, inp: str) -> str:
982
+ """Check input for URLs and offer to add them to the chat."""
983
+ if not self.detect_urls:
984
+ return inp
985
+
986
+ # Exclude double quotes from the matched URL characters
987
+ url_pattern = re.compile(r'(https?://[^\s/$.?#].[^\s"]*[^\s,.])')
988
+ urls = list(set(url_pattern.findall(inp))) # Use set to remove duplicates
989
+ group = ConfirmGroup(urls)
990
+ for url in urls:
991
+ if url not in self.rejected_urls:
992
+ url = url.rstrip(".',\"")
993
+ if self.io.confirm_ask(
994
+ "Add URL to the chat?", subject=url, group=group, allow_never=True
995
+ ):
996
+ inp += "\n\n"
997
+ inp += self.commands.cmd_web(url, return_content=True)
998
+ else:
999
+ self.rejected_urls.add(url)
1000
+
1001
+ return inp
1002
+
1003
+ def keyboard_interrupt(self):
1004
+ # Ensure cursor is visible on exit
1005
+ Console().show_cursor(True)
1006
+
1007
+ now = time.time()
1008
+
1009
+ thresh = 2 # seconds
1010
+ if self.last_keyboard_interrupt and now - self.last_keyboard_interrupt < thresh:
1011
+ self.io.tool_warning("\n\n^C KeyboardInterrupt")
1012
+ self.event("exit", reason="Control-C")
1013
+ sys.exit()
1014
+
1015
+ self.io.tool_warning("\n\n^C again to exit")
1016
+
1017
+ self.last_keyboard_interrupt = now
1018
+
1019
+ def summarize_start(self):
1020
+ if not self.summarizer.too_big(self.done_messages):
1021
+ return
1022
+
1023
+ self.summarize_end()
1024
+
1025
+ if self.verbose:
1026
+ self.io.tool_output("Starting to summarize chat history.")
1027
+
1028
+ self.summarizer_thread = threading.Thread(target=self.summarize_worker, daemon=True)
1029
+ self.summarizer_thread.start()
1030
+
1031
+ def summarize_worker(self):
1032
+ self.summarizing_messages = list(self.done_messages)
1033
+ try:
1034
+ self.summarized_done_messages = self.summarizer.summarize(self.summarizing_messages)
1035
+ except ValueError as err:
1036
+ self.io.tool_warning(err.args[0])
1037
+
1038
+ if self.verbose:
1039
+ self.io.tool_output("Finished summarizing chat history.")
1040
+
1041
+ def summarize_end(self):
1042
+ if self.summarizer_thread is None:
1043
+ return
1044
+
1045
+ self.summarizer_thread.join()
1046
+ self.summarizer_thread = None
1047
+
1048
+ if self.summarizing_messages == self.done_messages:
1049
+ self.done_messages = self.summarized_done_messages
1050
+ self.summarizing_messages = None
1051
+ self.summarized_done_messages = []
1052
+
1053
+ def move_back_cur_messages(self, message):
1054
+ self.done_messages += self.cur_messages
1055
+ self.summarize_start()
1056
+
1057
+ # TODO check for impact on image messages
1058
+ if message:
1059
+ self.done_messages += [
1060
+ dict(role="user", content=message),
1061
+ dict(role="assistant", content="Ok."),
1062
+ ]
1063
+ self.cur_messages = []
1064
+
1065
+ def normalize_language(self, lang_code):
1066
+ """
1067
+ Convert a locale code such as ``en_US`` or ``fr`` into a readable
1068
+ language name (e.g. ``English`` or ``French``). If Babel is
1069
+ available it is used for reliable conversion; otherwise a small
1070
+ built-in fallback map handles common languages.
1071
+ """
1072
+ if not lang_code:
1073
+ return None
1074
+
1075
+ if lang_code.upper() in ("C", "POSIX"):
1076
+ return None
1077
+
1078
+ # Probably already a language name
1079
+ if (
1080
+ len(lang_code) > 3
1081
+ and "_" not in lang_code
1082
+ and "-" not in lang_code
1083
+ and lang_code[0].isupper()
1084
+ ):
1085
+ return lang_code
1086
+
1087
+ # Preferred: Babel
1088
+ if Locale is not None:
1089
+ try:
1090
+ loc = Locale.parse(lang_code.replace("-", "_"))
1091
+ return loc.get_display_name("en").capitalize()
1092
+ except Exception:
1093
+ pass # Fall back to manual mapping
1094
+
1095
+ # Simple fallback for common languages
1096
+ fallback = {
1097
+ "en": "English",
1098
+ "fr": "French",
1099
+ "es": "Spanish",
1100
+ "de": "German",
1101
+ "it": "Italian",
1102
+ "pt": "Portuguese",
1103
+ "zh": "Chinese",
1104
+ "ja": "Japanese",
1105
+ "ko": "Korean",
1106
+ "ru": "Russian",
1107
+ }
1108
+ primary_lang_code = lang_code.replace("-", "_").split("_")[0].lower()
1109
+ return fallback.get(primary_lang_code, lang_code)
1110
+
1111
+ def get_user_language(self):
1112
+ """
1113
+ Detect the user's language preference and return a human-readable
1114
+ language name such as ``English``. Detection order:
1115
+
1116
+ 1. ``self.chat_language`` if explicitly set
1117
+ 2. ``locale.getlocale()``
1118
+ 3. ``LANG`` / ``LANGUAGE`` / ``LC_ALL`` / ``LC_MESSAGES`` environment variables
1119
+ """
1120
+
1121
+ # Explicit override
1122
+ if self.chat_language:
1123
+ return self.normalize_language(self.chat_language)
1124
+
1125
+ # System locale
1126
+ try:
1127
+ lang = locale.getlocale()[0]
1128
+ if lang:
1129
+ lang = self.normalize_language(lang)
1130
+ if lang:
1131
+ return lang
1132
+ except Exception:
1133
+ pass
1134
+
1135
+ # Environment variables
1136
+ for env_var in ("LANG", "LANGUAGE", "LC_ALL", "LC_MESSAGES"):
1137
+ lang = os.environ.get(env_var)
1138
+ if lang:
1139
+ lang = lang.split(".")[0] # Strip encoding if present
1140
+ return self.normalize_language(lang)
1141
+
1142
+ return None
1143
+
1144
+ def get_platform_info(self):
1145
+ platform_text = ""
1146
+ try:
1147
+ platform_text = f"- Platform: {platform.platform()}\n"
1148
+ except KeyError:
1149
+ # Skip platform info if it can't be retrieved
1150
+ platform_text = "- Platform information unavailable\n"
1151
+
1152
+ shell_var = "COMSPEC" if os.name == "nt" else "SHELL"
1153
+ shell_val = os.getenv(shell_var)
1154
+ platform_text += f"- Shell: {shell_var}={shell_val}\n"
1155
+
1156
+ user_lang = self.get_user_language()
1157
+ if user_lang:
1158
+ platform_text += f"- Language: {user_lang}\n"
1159
+
1160
+ dt = datetime.now().astimezone().strftime("%Y-%m-%d")
1161
+ platform_text += f"- Current date: {dt}\n"
1162
+
1163
+ if self.repo:
1164
+ platform_text += "- The user is operating inside a git repository\n"
1165
+
1166
+ if self.lint_cmds:
1167
+ if self.auto_lint:
1168
+ platform_text += (
1169
+ "- The user's pre-commit runs these lint commands, don't suggest running"
1170
+ " them:\n"
1171
+ )
1172
+ else:
1173
+ platform_text += "- The user prefers these lint commands:\n"
1174
+ for lang, cmd in self.lint_cmds.items():
1175
+ if lang is None:
1176
+ platform_text += f" - {cmd}\n"
1177
+ else:
1178
+ platform_text += f" - {lang}: {cmd}\n"
1179
+
1180
+ if self.test_cmd:
1181
+ if self.auto_test:
1182
+ platform_text += (
1183
+ "- The user's pre-commit runs this test command, don't suggest running them: "
1184
+ )
1185
+ else:
1186
+ platform_text += "- The user prefers this test command: "
1187
+ platform_text += self.test_cmd + "\n"
1188
+
1189
+ return platform_text
1190
+
1191
+ def fmt_system_prompt(self, prompt):
1192
+ final_reminders = []
1193
+ if self.main_model.lazy:
1194
+ final_reminders.append(self.gpt_prompts.lazy_prompt)
1195
+ if self.main_model.overeager:
1196
+ final_reminders.append(self.gpt_prompts.overeager_prompt)
1197
+
1198
+ user_lang = self.get_user_language()
1199
+ if user_lang:
1200
+ final_reminders.append(f"Reply in {user_lang}.\n")
1201
+
1202
+ platform_text = self.get_platform_info()
1203
+
1204
+ if self.suggest_shell_commands:
1205
+ shell_cmd_prompt = self.gpt_prompts.shell_cmd_prompt.format(platform=platform_text)
1206
+ shell_cmd_reminder = self.gpt_prompts.shell_cmd_reminder.format(platform=platform_text)
1207
+ rename_with_shell = self.gpt_prompts.rename_with_shell
1208
+ else:
1209
+ shell_cmd_prompt = self.gpt_prompts.no_shell_cmd_prompt.format(platform=platform_text)
1210
+ shell_cmd_reminder = self.gpt_prompts.no_shell_cmd_reminder.format(
1211
+ platform=platform_text
1212
+ )
1213
+ rename_with_shell = ""
1214
+
1215
+ if user_lang: # user_lang is the result of self.get_user_language()
1216
+ language = user_lang
1217
+ else:
1218
+ language = "the same language they are using" # Default if no specific lang detected
1219
+
1220
+ if self.fence[0] == "`" * 4:
1221
+ quad_backtick_reminder = (
1222
+ "\nIMPORTANT: Use *quadruple* backticks ```` as fences, not triple backticks!\n"
1223
+ )
1224
+ else:
1225
+ quad_backtick_reminder = ""
1226
+
1227
+ final_reminders = "\n\n".join(final_reminders)
1228
+
1229
+ prompt = prompt.format(
1230
+ fence=self.fence,
1231
+ quad_backtick_reminder=quad_backtick_reminder,
1232
+ final_reminders=final_reminders,
1233
+ platform=platform_text,
1234
+ shell_cmd_prompt=shell_cmd_prompt,
1235
+ rename_with_shell=rename_with_shell,
1236
+ shell_cmd_reminder=shell_cmd_reminder,
1237
+ go_ahead_tip=self.gpt_prompts.go_ahead_tip,
1238
+ language=language,
1239
+ )
1240
+
1241
+ return prompt
1242
+
1243
+ def format_chat_chunks(self):
1244
+ self.choose_fence()
1245
+ main_sys = self.fmt_system_prompt(self.gpt_prompts.main_system)
1246
+ if self.main_model.system_prompt_prefix:
1247
+ main_sys = self.main_model.system_prompt_prefix + "\n" + main_sys
1248
+
1249
+ example_messages = []
1250
+ if self.main_model.examples_as_sys_msg:
1251
+ if self.gpt_prompts.example_messages:
1252
+ main_sys += "\n# Example conversations:\n\n"
1253
+ for msg in self.gpt_prompts.example_messages:
1254
+ role = msg["role"]
1255
+ content = self.fmt_system_prompt(msg["content"])
1256
+ main_sys += f"## {role.upper()}: {content}\n\n"
1257
+ main_sys = main_sys.strip()
1258
+ else:
1259
+ for msg in self.gpt_prompts.example_messages:
1260
+ example_messages.append(
1261
+ dict(
1262
+ role=msg["role"],
1263
+ content=self.fmt_system_prompt(msg["content"]),
1264
+ )
1265
+ )
1266
+ if self.gpt_prompts.example_messages:
1267
+ example_messages += [
1268
+ dict(
1269
+ role="user",
1270
+ content=(
1271
+ "I switched to a new code base. Please don't consider the above files"
1272
+ " or try to edit them any longer."
1273
+ ),
1274
+ ),
1275
+ dict(role="assistant", content="Ok."),
1276
+ ]
1277
+
1278
+ if self.gpt_prompts.system_reminder:
1279
+ main_sys += "\n" + self.fmt_system_prompt(self.gpt_prompts.system_reminder)
1280
+
1281
+ chunks = ChatChunks()
1282
+
1283
+ if self.main_model.use_system_prompt:
1284
+ chunks.system = [
1285
+ dict(role="system", content=main_sys),
1286
+ ]
1287
+ else:
1288
+ chunks.system = [
1289
+ dict(role="user", content=main_sys),
1290
+ dict(role="assistant", content="Ok."),
1291
+ ]
1292
+
1293
+ chunks.examples = example_messages
1294
+
1295
+ self.summarize_end()
1296
+ chunks.done = self.done_messages
1297
+
1298
+ chunks.repo = self.get_repo_messages()
1299
+ chunks.readonly_files = self.get_readonly_files_messages()
1300
+ chunks.chat_files = self.get_chat_files_messages()
1301
+
1302
+ if self.gpt_prompts.system_reminder:
1303
+ reminder_message = [
1304
+ dict(
1305
+ role="system", content=self.fmt_system_prompt(self.gpt_prompts.system_reminder)
1306
+ ),
1307
+ ]
1308
+ else:
1309
+ reminder_message = []
1310
+
1311
+ chunks.cur = list(self.cur_messages)
1312
+ chunks.reminder = []
1313
+
1314
+ # TODO review impact of token count on image messages
1315
+ messages_tokens = self.main_model.token_count(chunks.all_messages())
1316
+ reminder_tokens = self.main_model.token_count(reminder_message)
1317
+ cur_tokens = self.main_model.token_count(chunks.cur)
1318
+
1319
+ if None not in (messages_tokens, reminder_tokens, cur_tokens):
1320
+ total_tokens = messages_tokens + reminder_tokens + cur_tokens
1321
+ else:
1322
+ # add the reminder anyway
1323
+ total_tokens = 0
1324
+
1325
+ if chunks.cur:
1326
+ final = chunks.cur[-1]
1327
+ else:
1328
+ final = None
1329
+
1330
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
1331
+ # Add the reminder prompt if we still have room to include it.
1332
+ if (
1333
+ not max_input_tokens
1334
+ or total_tokens < max_input_tokens
1335
+ and self.gpt_prompts.system_reminder
1336
+ ):
1337
+ if self.main_model.reminder == "sys":
1338
+ chunks.reminder = reminder_message
1339
+ elif self.main_model.reminder == "user" and final and final["role"] == "user":
1340
+ # stuff it into the user message
1341
+ new_content = (
1342
+ final["content"]
1343
+ + "\n\n"
1344
+ + self.fmt_system_prompt(self.gpt_prompts.system_reminder)
1345
+ )
1346
+ chunks.cur[-1] = dict(role=final["role"], content=new_content)
1347
+
1348
+ return chunks
1349
+
1350
+ def format_messages(self):
1351
+ chunks = self.format_chat_chunks()
1352
+ if self.add_cache_headers:
1353
+ chunks.add_cache_control_headers()
1354
+
1355
+ return chunks
1356
+
1357
+ def warm_cache(self, chunks):
1358
+ if not self.add_cache_headers:
1359
+ return
1360
+ if not self.num_cache_warming_pings:
1361
+ return
1362
+ if not self.ok_to_warm_cache:
1363
+ return
1364
+
1365
+ delay = 5 * 60 - 5
1366
+ delay = float(os.environ.get("PATCH_CACHE_KEEPALIVE_DELAY", delay))
1367
+ self.next_cache_warm = time.time() + delay
1368
+ self.warming_pings_left = self.num_cache_warming_pings
1369
+ self.cache_warming_chunks = chunks
1370
+
1371
+ if self.cache_warming_thread:
1372
+ return
1373
+
1374
+ def warm_cache_worker():
1375
+ while self.ok_to_warm_cache:
1376
+ time.sleep(1)
1377
+ if self.warming_pings_left <= 0:
1378
+ continue
1379
+ now = time.time()
1380
+ if now < self.next_cache_warm:
1381
+ continue
1382
+
1383
+ self.warming_pings_left -= 1
1384
+ self.next_cache_warm = time.time() + delay
1385
+
1386
+ kwargs = dict(self.main_model.extra_params) or dict()
1387
+ kwargs["max_tokens"] = 1
1388
+
1389
+ try:
1390
+ completion = litellm.completion(
1391
+ model=self.main_model.name,
1392
+ messages=self.cache_warming_chunks.cacheable_messages(),
1393
+ stream=False,
1394
+ **kwargs,
1395
+ )
1396
+ except Exception as err:
1397
+ self.io.tool_warning(f"Cache warming error: {str(err)}")
1398
+ continue
1399
+
1400
+ cache_hit_tokens = getattr(
1401
+ completion.usage, "prompt_cache_hit_tokens", 0
1402
+ ) or getattr(completion.usage, "cache_read_input_tokens", 0)
1403
+
1404
+ if self.verbose:
1405
+ self.io.tool_output(f"Warmed {format_tokens(cache_hit_tokens)} cached tokens.")
1406
+
1407
+ self.cache_warming_thread = threading.Timer(0, warm_cache_worker)
1408
+ self.cache_warming_thread.daemon = True
1409
+ self.cache_warming_thread.start()
1410
+
1411
+ return chunks
1412
+
1413
+ def check_tokens(self, messages):
1414
+ """Check if the messages will fit within the model's token limits."""
1415
+ input_tokens = self.main_model.token_count(messages)
1416
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
1417
+
1418
+ if max_input_tokens and input_tokens >= max_input_tokens:
1419
+ self.io.tool_error(
1420
+ f"Your estimated chat context of {input_tokens:,} tokens exceeds the"
1421
+ f" {max_input_tokens:,} token limit for {self.main_model.name}!"
1422
+ )
1423
+ self.io.tool_output("To reduce the chat context:")
1424
+ self.io.tool_output("- Use /drop to remove unneeded files from the chat")
1425
+ self.io.tool_output("- Use /clear to clear the chat history")
1426
+ self.io.tool_output("- Break your code into smaller files")
1427
+ self.io.tool_output(
1428
+ "It's probably safe to try and send the request, most providers won't charge if"
1429
+ " the context limit is exceeded."
1430
+ )
1431
+
1432
+ if not self.io.confirm_ask("Try to proceed anyway?"):
1433
+ return False
1434
+ return True
1435
+
1436
+ def send_message(self, inp):
1437
+ self.event("message_send_starting")
1438
+
1439
+ # Notify IO that LLM processing is starting
1440
+ self.io.llm_started()
1441
+
1442
+ self.cur_messages += [
1443
+ dict(role="user", content=inp),
1444
+ ]
1445
+
1446
+ chunks = self.format_messages()
1447
+ messages = chunks.all_messages()
1448
+ if not self.check_tokens(messages):
1449
+ return
1450
+ self.warm_cache(chunks)
1451
+
1452
+ if self.verbose:
1453
+ utils.show_messages(messages, functions=self.functions)
1454
+
1455
+ self.multi_response_content = ""
1456
+ if self.show_pretty():
1457
+ self.waiting_spinner = WaitingSpinner("Waiting for " + self.main_model.name)
1458
+ self.waiting_spinner.start()
1459
+ if self.stream:
1460
+ self.mdstream = self.io.get_assistant_mdstream()
1461
+ else:
1462
+ self.mdstream = None
1463
+ else:
1464
+ self.mdstream = None
1465
+
1466
+ retry_delay = 0.125
1467
+
1468
+ litellm_ex = LiteLLMExceptions()
1469
+
1470
+ self.usage_report = None
1471
+ exhausted = False
1472
+ interrupted = False
1473
+ output_continuations = 0
1474
+ try:
1475
+ while True:
1476
+ try:
1477
+ yield from self.send(messages, functions=self.functions)
1478
+ break
1479
+ except litellm_ex.exceptions_tuple() as err:
1480
+ ex_info = litellm_ex.get_ex_info(err)
1481
+
1482
+ if ex_info.name == "ContextWindowExceededError":
1483
+ exhausted = True
1484
+ break
1485
+
1486
+ should_retry = ex_info.retry
1487
+ if should_retry:
1488
+ retry_delay *= 2
1489
+ if retry_delay > RETRY_TIMEOUT:
1490
+ should_retry = False
1491
+
1492
+ if not should_retry:
1493
+ self.mdstream = None
1494
+ self.check_and_open_urls(err, ex_info.description)
1495
+ break
1496
+
1497
+ err_msg = str(err)
1498
+ if ex_info.description:
1499
+ self.io.tool_warning(err_msg)
1500
+ self.io.tool_error(ex_info.description)
1501
+ else:
1502
+ self.io.tool_error(err_msg)
1503
+
1504
+ self.io.tool_output(f"Retrying in {retry_delay:.1f} seconds...")
1505
+ time.sleep(retry_delay)
1506
+ continue
1507
+ except KeyboardInterrupt:
1508
+ interrupted = True
1509
+ break
1510
+ except FinishReasonLength:
1511
+ # We hit the output limit!
1512
+ if not self.main_model.info.get("supports_assistant_prefill"):
1513
+ exhausted = True
1514
+ break
1515
+
1516
+ content = self.get_multi_response_content_in_progress()
1517
+ if content == self.multi_response_content:
1518
+ self.io.tool_warning(
1519
+ "Model hit the output limit without returning more content; stopping."
1520
+ )
1521
+ exhausted = True
1522
+ break
1523
+
1524
+ self.multi_response_content = content
1525
+ if output_continuations >= self.max_output_continuations:
1526
+ self.io.tool_warning(
1527
+ f"Model hit the output limit after {self.max_output_continuations}"
1528
+ " continuation requests; stopping."
1529
+ )
1530
+ exhausted = True
1531
+ break
1532
+ output_continuations += 1
1533
+
1534
+ if messages[-1]["role"] == "assistant":
1535
+ messages[-1]["content"] = self.multi_response_content
1536
+ else:
1537
+ messages.append(
1538
+ dict(role="assistant", content=self.multi_response_content, prefix=True)
1539
+ )
1540
+ except Exception as err:
1541
+ self.mdstream = None
1542
+ lines = traceback.format_exception(type(err), err, err.__traceback__)
1543
+ self.io.tool_warning("".join(lines))
1544
+ self.io.tool_error(str(err))
1545
+ self.event("message_send_exception", exception=str(err))
1546
+ return
1547
+ finally:
1548
+ if self.mdstream:
1549
+ self.live_incremental_response(True)
1550
+ self.mdstream = None
1551
+
1552
+ # Ensure any waiting spinner is stopped
1553
+ self._stop_waiting_spinner()
1554
+
1555
+ self.partial_response_content = self.get_multi_response_content_in_progress(True)
1556
+ self.remove_reasoning_content()
1557
+ self.multi_response_content = ""
1558
+
1559
+ ###
1560
+ # print()
1561
+ # print("=" * 20)
1562
+ # dump(self.partial_response_content)
1563
+
1564
+ self.io.tool_output()
1565
+
1566
+ self.show_usage_report()
1567
+
1568
+ self.add_assistant_reply_to_cur_messages()
1569
+
1570
+ if exhausted:
1571
+ if self.cur_messages and self.cur_messages[-1]["role"] == "user":
1572
+ self.cur_messages += [
1573
+ dict(
1574
+ role="assistant",
1575
+ content="FinishReasonLength exception: you sent too many tokens",
1576
+ ),
1577
+ ]
1578
+
1579
+ self.show_exhausted_error()
1580
+ self.num_exhausted_context_windows += 1
1581
+ return
1582
+
1583
+ if self.partial_response_function_call:
1584
+ args = self.parse_partial_args()
1585
+ if args:
1586
+ content = args.get("explanation") or ""
1587
+ else:
1588
+ content = ""
1589
+ elif self.partial_response_content:
1590
+ content = self.partial_response_content
1591
+ else:
1592
+ content = ""
1593
+
1594
+ if not interrupted:
1595
+ add_rel_files_message = self.check_for_file_mentions(content)
1596
+ if add_rel_files_message:
1597
+ if self.reflected_message:
1598
+ self.reflected_message += "\n\n" + add_rel_files_message
1599
+ else:
1600
+ self.reflected_message = add_rel_files_message
1601
+ return
1602
+
1603
+ try:
1604
+ if self.reply_completed():
1605
+ return
1606
+ except KeyboardInterrupt:
1607
+ interrupted = True
1608
+
1609
+ if interrupted:
1610
+ if self.cur_messages and self.cur_messages[-1]["role"] == "user":
1611
+ self.cur_messages[-1]["content"] += "\n^C KeyboardInterrupt"
1612
+ else:
1613
+ self.cur_messages += [dict(role="user", content="^C KeyboardInterrupt")]
1614
+ self.cur_messages += [
1615
+ dict(role="assistant", content="I see that you interrupted my previous reply.")
1616
+ ]
1617
+ return
1618
+
1619
+ edited = self.apply_updates()
1620
+
1621
+ if edited:
1622
+ self.patch_edited_files.update(edited)
1623
+ saved_message = self.auto_commit(edited)
1624
+
1625
+ if not saved_message and hasattr(self.gpt_prompts, "files_content_gpt_edits_no_repo"):
1626
+ saved_message = self.gpt_prompts.files_content_gpt_edits_no_repo
1627
+
1628
+ self.move_back_cur_messages(saved_message)
1629
+
1630
+ if self.reflected_message:
1631
+ return
1632
+
1633
+ if edited and self.auto_lint:
1634
+ lint_errors = self.lint_edited(edited)
1635
+ self.auto_commit(edited, context="Ran the linter")
1636
+ self.lint_outcome = not lint_errors
1637
+ if lint_errors:
1638
+ ok = self.io.confirm_ask("Attempt to fix lint errors?")
1639
+ if ok:
1640
+ self.reflected_message = lint_errors
1641
+ return
1642
+
1643
+ shared_output = self.run_shell_commands()
1644
+ if shared_output:
1645
+ self.cur_messages += [
1646
+ dict(role="user", content=shared_output),
1647
+ dict(role="assistant", content="Ok"),
1648
+ ]
1649
+
1650
+ if edited and self.auto_test:
1651
+ test_errors = self.commands.cmd_test(self.test_cmd)
1652
+ self.test_outcome = not test_errors
1653
+ if test_errors:
1654
+ ok = self.io.confirm_ask("Attempt to fix test errors?")
1655
+ if ok:
1656
+ self.reflected_message = test_errors
1657
+ return
1658
+
1659
+ def reply_completed(self):
1660
+ pass
1661
+
1662
+ def show_exhausted_error(self):
1663
+ output_tokens = 0
1664
+ if self.partial_response_content:
1665
+ output_tokens = self.main_model.token_count(self.partial_response_content)
1666
+ max_output_tokens = self.main_model.info.get("max_output_tokens") or 0
1667
+
1668
+ input_tokens = self.main_model.token_count(self.format_messages().all_messages())
1669
+ max_input_tokens = self.main_model.info.get("max_input_tokens") or 0
1670
+
1671
+ total_tokens = input_tokens + output_tokens
1672
+
1673
+ fudge = 0.7
1674
+
1675
+ out_err = ""
1676
+ if output_tokens >= max_output_tokens * fudge:
1677
+ out_err = " -- possibly exceeded output limit!"
1678
+
1679
+ inp_err = ""
1680
+ if input_tokens >= max_input_tokens * fudge:
1681
+ inp_err = " -- possibly exhausted context window!"
1682
+
1683
+ tot_err = ""
1684
+ if total_tokens >= max_input_tokens * fudge:
1685
+ tot_err = " -- possibly exhausted context window!"
1686
+
1687
+ res = ["", ""]
1688
+ res.append(f"Model {self.main_model.name} has hit a token limit!")
1689
+ res.append("Token counts below are approximate.")
1690
+ res.append("")
1691
+ res.append(f"Input tokens: ~{input_tokens:,} of {max_input_tokens:,}{inp_err}")
1692
+ res.append(f"Output tokens: ~{output_tokens:,} of {max_output_tokens:,}{out_err}")
1693
+ res.append(f"Total tokens: ~{total_tokens:,} of {max_input_tokens:,}{tot_err}")
1694
+
1695
+ if output_tokens >= max_output_tokens:
1696
+ res.append("")
1697
+ res.append("To reduce output tokens:")
1698
+ res.append("- Ask for smaller changes in each request.")
1699
+ res.append("- Break your code into smaller source files.")
1700
+ if "diff" not in self.main_model.edit_format:
1701
+ res.append("- Use a stronger model that can return diffs.")
1702
+
1703
+ if input_tokens >= max_input_tokens or total_tokens >= max_input_tokens:
1704
+ res.append("")
1705
+ res.append("To reduce input tokens:")
1706
+ res.append("- Use /tokens to see token usage.")
1707
+ res.append("- Use /drop to remove unneeded files from the chat session.")
1708
+ res.append("- Use /clear to clear the chat history.")
1709
+ res.append("- Break your code into smaller source files.")
1710
+
1711
+ res = "".join([line + "\n" for line in res])
1712
+ self.io.tool_error(res)
1713
+ self.io.offer_url(urls.token_limits)
1714
+
1715
+ def lint_edited(self, fnames):
1716
+ res = ""
1717
+ for fname in fnames:
1718
+ if not fname:
1719
+ continue
1720
+ errors = self.linter.lint(self.abs_root_path(fname))
1721
+
1722
+ if errors:
1723
+ res += "\n"
1724
+ res += errors
1725
+ res += "\n"
1726
+
1727
+ if res:
1728
+ self.io.tool_warning(res)
1729
+
1730
+ return res
1731
+
1732
+ def __del__(self):
1733
+ """Cleanup when the Coder object is destroyed."""
1734
+ self.ok_to_warm_cache = False
1735
+
1736
+ def add_assistant_reply_to_cur_messages(self):
1737
+ if self.partial_response_content:
1738
+ self.cur_messages += [dict(role="assistant", content=self.partial_response_content)]
1739
+ if self.partial_response_function_call:
1740
+ self.cur_messages += [
1741
+ dict(
1742
+ role="assistant",
1743
+ content=None,
1744
+ function_call=self.partial_response_function_call,
1745
+ )
1746
+ ]
1747
+
1748
+ def get_file_mentions(self, content, ignore_current=False):
1749
+ words = set(word for word in content.split())
1750
+
1751
+ # drop sentence punctuation from the end
1752
+ words = set(word.rstrip(",.!;:?") for word in words)
1753
+
1754
+ # strip away all kinds of quotes
1755
+ quotes = "\"'`*_"
1756
+ words = set(word.strip(quotes) for word in words)
1757
+
1758
+ if ignore_current:
1759
+ addable_rel_fnames = self.get_all_relative_files()
1760
+ existing_basenames = {}
1761
+ else:
1762
+ addable_rel_fnames = self.get_addable_relative_files()
1763
+
1764
+ # Get basenames of files already in chat or read-only
1765
+ existing_basenames = {os.path.basename(f) for f in self.get_inchat_relative_files()} | {
1766
+ os.path.basename(self.get_rel_fname(f)) for f in self.abs_read_only_fnames
1767
+ }
1768
+
1769
+ mentioned_rel_fnames = set()
1770
+ fname_to_rel_fnames = {}
1771
+ for rel_fname in addable_rel_fnames:
1772
+ normalized_rel_fname = rel_fname.replace("\\", "/")
1773
+ normalized_words = set(word.replace("\\", "/") for word in words)
1774
+ if normalized_rel_fname in normalized_words:
1775
+ mentioned_rel_fnames.add(rel_fname)
1776
+
1777
+ fname = os.path.basename(rel_fname)
1778
+
1779
+ # Don't add basenames that could be plain words like "run" or "make"
1780
+ if "/" in fname or "\\" in fname or "." in fname or "_" in fname or "-" in fname:
1781
+ if fname not in fname_to_rel_fnames:
1782
+ fname_to_rel_fnames[fname] = []
1783
+ fname_to_rel_fnames[fname].append(rel_fname)
1784
+
1785
+ for fname, rel_fnames in fname_to_rel_fnames.items():
1786
+ # If the basename is already in chat, don't add based on a basename mention
1787
+ if fname in existing_basenames:
1788
+ continue
1789
+ # If the basename mention is unique among addable files and present in the text
1790
+ if len(rel_fnames) == 1 and fname in words:
1791
+ mentioned_rel_fnames.add(rel_fnames[0])
1792
+
1793
+ return mentioned_rel_fnames
1794
+
1795
+ def check_for_file_mentions(self, content):
1796
+ mentioned_rel_fnames = self.get_file_mentions(content)
1797
+
1798
+ new_mentions = mentioned_rel_fnames - self.ignore_mentions
1799
+
1800
+ if not new_mentions:
1801
+ return
1802
+
1803
+ added_fnames = []
1804
+ group = ConfirmGroup(new_mentions)
1805
+ for rel_fname in sorted(new_mentions):
1806
+ if self.io.confirm_ask(
1807
+ "Add file to the chat?", subject=rel_fname, group=group, allow_never=True
1808
+ ):
1809
+ self.add_rel_fname(rel_fname)
1810
+ added_fnames.append(rel_fname)
1811
+ else:
1812
+ self.ignore_mentions.add(rel_fname)
1813
+
1814
+ if added_fnames:
1815
+ return prompts.added_files.format(fnames=", ".join(added_fnames))
1816
+
1817
+ def send(self, messages, model=None, functions=None):
1818
+ self.got_reasoning_content = False
1819
+ self.ended_reasoning_content = False
1820
+
1821
+ if not model:
1822
+ model = self.main_model
1823
+
1824
+ self.partial_response_content = ""
1825
+ self.partial_response_function_call = dict()
1826
+
1827
+ self.io.log_llm_history("TO LLM", format_messages(messages))
1828
+
1829
+ completion = None
1830
+ try:
1831
+ hash_object, completion = model.send_completion(
1832
+ messages,
1833
+ functions,
1834
+ self.stream,
1835
+ self.temperature,
1836
+ )
1837
+ self.chat_completion_call_hashes.append(hash_object.hexdigest())
1838
+
1839
+ if self.stream:
1840
+ yield from self.show_send_output_stream(completion)
1841
+ else:
1842
+ self.show_send_output(completion)
1843
+
1844
+ # Calculate costs for successful responses
1845
+ self.calculate_and_show_tokens_and_cost(messages, completion)
1846
+
1847
+ except LiteLLMExceptions().exceptions_tuple() as err:
1848
+ ex_info = LiteLLMExceptions().get_ex_info(err)
1849
+ if ex_info.name == "ContextWindowExceededError":
1850
+ # Still calculate costs for context window errors
1851
+ self.calculate_and_show_tokens_and_cost(messages, completion)
1852
+ raise
1853
+ except KeyboardInterrupt as kbi:
1854
+ self.keyboard_interrupt()
1855
+ raise kbi
1856
+ finally:
1857
+ self.io.log_llm_history(
1858
+ "LLM RESPONSE",
1859
+ format_content("ASSISTANT", self.partial_response_content),
1860
+ )
1861
+
1862
+ if self.partial_response_content:
1863
+ self.io.ai_output(self.partial_response_content)
1864
+ elif self.partial_response_function_call:
1865
+ # TODO: push this into subclasses
1866
+ args = self.parse_partial_args()
1867
+ if args:
1868
+ self.io.ai_output(json.dumps(args, indent=4))
1869
+
1870
+ def show_send_output(self, completion):
1871
+ # Stop spinner once we have a response
1872
+ self._stop_waiting_spinner()
1873
+
1874
+ if self.verbose:
1875
+ print(completion)
1876
+
1877
+ if not completion.choices:
1878
+ self.io.tool_error(str(completion))
1879
+ return
1880
+
1881
+ show_func_err = None
1882
+ show_content_err = None
1883
+ try:
1884
+ if completion.choices[0].message.tool_calls:
1885
+ self.partial_response_function_call = (
1886
+ completion.choices[0].message.tool_calls[0].function
1887
+ )
1888
+ except AttributeError as func_err:
1889
+ show_func_err = func_err
1890
+
1891
+ try:
1892
+ reasoning_content = completion.choices[0].message.reasoning_content
1893
+ except AttributeError:
1894
+ try:
1895
+ reasoning_content = completion.choices[0].message.reasoning
1896
+ except AttributeError:
1897
+ reasoning_content = None
1898
+
1899
+ try:
1900
+ self.partial_response_content = completion.choices[0].message.content or ""
1901
+ except AttributeError as content_err:
1902
+ show_content_err = content_err
1903
+
1904
+ resp_hash = dict(
1905
+ function_call=str(self.partial_response_function_call),
1906
+ content=self.partial_response_content,
1907
+ )
1908
+ resp_hash = hashlib.sha1(json.dumps(resp_hash, sort_keys=True).encode())
1909
+ self.chat_completion_response_hashes.append(resp_hash.hexdigest())
1910
+
1911
+ if show_func_err and show_content_err:
1912
+ self.io.tool_error(show_func_err)
1913
+ self.io.tool_error(show_content_err)
1914
+ raise Exception("No data found in LLM response!")
1915
+
1916
+ show_resp = self.render_incremental_response(True)
1917
+
1918
+ if reasoning_content:
1919
+ formatted_reasoning = format_reasoning_content(
1920
+ reasoning_content, self.reasoning_tag_name
1921
+ )
1922
+ show_resp = formatted_reasoning + show_resp
1923
+
1924
+ show_resp = replace_reasoning_tags(show_resp, self.reasoning_tag_name)
1925
+
1926
+ self.io.assistant_output(show_resp, pretty=self.show_pretty())
1927
+
1928
+ if (
1929
+ hasattr(completion.choices[0], "finish_reason")
1930
+ and completion.choices[0].finish_reason == "length"
1931
+ ):
1932
+ raise FinishReasonLength()
1933
+
1934
+ def show_send_output_stream(self, completion):
1935
+ received_content = False
1936
+
1937
+ for chunk in completion:
1938
+ if len(chunk.choices) == 0:
1939
+ continue
1940
+
1941
+ if (
1942
+ hasattr(chunk.choices[0], "finish_reason")
1943
+ and chunk.choices[0].finish_reason == "length"
1944
+ ):
1945
+ raise FinishReasonLength()
1946
+
1947
+ try:
1948
+ func = chunk.choices[0].delta.function_call
1949
+ # dump(func)
1950
+ for k, v in func.items():
1951
+ if k in self.partial_response_function_call:
1952
+ self.partial_response_function_call[k] += v
1953
+ else:
1954
+ self.partial_response_function_call[k] = v
1955
+ received_content = True
1956
+ except AttributeError:
1957
+ pass
1958
+
1959
+ text = ""
1960
+
1961
+ try:
1962
+ reasoning_content = chunk.choices[0].delta.reasoning_content
1963
+ except AttributeError:
1964
+ try:
1965
+ reasoning_content = chunk.choices[0].delta.reasoning
1966
+ except AttributeError:
1967
+ reasoning_content = None
1968
+
1969
+ if reasoning_content:
1970
+ if not self.got_reasoning_content:
1971
+ text += f"<{REASONING_TAG}>\n\n"
1972
+ text += reasoning_content
1973
+ self.got_reasoning_content = True
1974
+ received_content = True
1975
+
1976
+ try:
1977
+ content = chunk.choices[0].delta.content
1978
+ if content:
1979
+ if self.got_reasoning_content and not self.ended_reasoning_content:
1980
+ text += f"\n\n</{self.reasoning_tag_name}>\n\n"
1981
+ self.ended_reasoning_content = True
1982
+
1983
+ text += content
1984
+ received_content = True
1985
+ except AttributeError:
1986
+ pass
1987
+
1988
+ if received_content:
1989
+ self._stop_waiting_spinner()
1990
+ self.partial_response_content += text
1991
+
1992
+ if self.show_pretty():
1993
+ self.live_incremental_response(False)
1994
+ elif text:
1995
+ # Apply reasoning tag formatting
1996
+ text = replace_reasoning_tags(text, self.reasoning_tag_name)
1997
+ try:
1998
+ sys.stdout.write(text)
1999
+ except UnicodeEncodeError:
2000
+ # Safely encode and decode the text
2001
+ safe_text = text.encode(sys.stdout.encoding, errors="backslashreplace").decode(
2002
+ sys.stdout.encoding
2003
+ )
2004
+ sys.stdout.write(safe_text)
2005
+ sys.stdout.flush()
2006
+ yield text
2007
+
2008
+ if not received_content:
2009
+ self.io.tool_warning("Empty response received from LLM. Check your provider account?")
2010
+
2011
+ def live_incremental_response(self, final):
2012
+ show_resp = self.render_incremental_response(final)
2013
+ # Apply any reasoning tag formatting
2014
+ show_resp = replace_reasoning_tags(show_resp, self.reasoning_tag_name)
2015
+ self.mdstream.update(show_resp, final=final)
2016
+
2017
+ def render_incremental_response(self, final):
2018
+ return self.get_multi_response_content_in_progress()
2019
+
2020
+ def remove_reasoning_content(self):
2021
+ """Remove reasoning content from the model's response."""
2022
+
2023
+ self.partial_response_content = remove_reasoning_content(
2024
+ self.partial_response_content,
2025
+ self.reasoning_tag_name,
2026
+ )
2027
+
2028
+ def calculate_and_show_tokens_and_cost(self, messages, completion=None) -> None:
2029
+ prompt_tokens = 0
2030
+ completion_tokens = 0
2031
+ cache_hit_tokens = 0
2032
+ cache_write_tokens = 0
2033
+
2034
+ if completion and hasattr(completion, "usage") and completion.usage is not None:
2035
+ prompt_tokens = completion.usage.prompt_tokens
2036
+ completion_tokens = completion.usage.completion_tokens
2037
+ cache_hit_tokens = getattr(completion.usage, "prompt_cache_hit_tokens", 0) or getattr(
2038
+ completion.usage, "cache_read_input_tokens", 0
2039
+ )
2040
+ cache_write_tokens = getattr(completion.usage, "cache_creation_input_tokens", 0)
2041
+
2042
+ if hasattr(completion.usage, "cache_read_input_tokens") or hasattr(
2043
+ completion.usage, "cache_creation_input_tokens"
2044
+ ):
2045
+ self.message_tokens_sent += prompt_tokens
2046
+ self.message_tokens_sent += cache_write_tokens
2047
+ else:
2048
+ self.message_tokens_sent += prompt_tokens
2049
+
2050
+ else:
2051
+ prompt_tokens = self.main_model.token_count(messages)
2052
+ completion_tokens = self.main_model.token_count(self.partial_response_content)
2053
+ self.message_tokens_sent += prompt_tokens
2054
+
2055
+ self.message_tokens_received += completion_tokens
2056
+
2057
+ tokens_report = f"Tokens: {format_tokens(self.message_tokens_sent)} sent"
2058
+
2059
+ if cache_write_tokens:
2060
+ tokens_report += f", {format_tokens(cache_write_tokens)} cache write"
2061
+ if cache_hit_tokens:
2062
+ tokens_report += f", {format_tokens(cache_hit_tokens)} cache hit"
2063
+ tokens_report += f", {format_tokens(self.message_tokens_received)} received."
2064
+
2065
+ if not self.main_model.info.get("input_cost_per_token"):
2066
+ self.usage_report = tokens_report
2067
+ return
2068
+
2069
+ try:
2070
+ # Try and use litellm's built in cost calculator. Seems to work for non-streaming only?
2071
+ cost = litellm.completion_cost(completion_response=completion)
2072
+ except Exception:
2073
+ cost = 0
2074
+
2075
+ if not cost:
2076
+ cost = self.compute_costs_from_tokens(
2077
+ prompt_tokens, completion_tokens, cache_write_tokens, cache_hit_tokens
2078
+ )
2079
+
2080
+ self.total_cost += cost
2081
+ self.message_cost += cost
2082
+
2083
+ def format_cost(value):
2084
+ if value == 0:
2085
+ return "0.00"
2086
+ magnitude = abs(value)
2087
+ if magnitude >= 0.01:
2088
+ return f"{value:.2f}"
2089
+ else:
2090
+ return f"{value:.{max(2, 2 - int(math.log10(magnitude)))}f}"
2091
+
2092
+ cost_report = (
2093
+ f"Cost: ${format_cost(self.message_cost)} message,"
2094
+ f" ${format_cost(self.total_cost)} session."
2095
+ )
2096
+
2097
+ if cache_hit_tokens and cache_write_tokens:
2098
+ sep = "\n"
2099
+ else:
2100
+ sep = " "
2101
+
2102
+ self.usage_report = tokens_report + sep + cost_report
2103
+
2104
+ def compute_costs_from_tokens(
2105
+ self,
2106
+ prompt_tokens: int,
2107
+ completion_tokens: int,
2108
+ cache_write_tokens: int,
2109
+ cache_hit_tokens: int,
2110
+ ) -> float:
2111
+ cost = 0.0
2112
+
2113
+ input_cost_per_token = self.main_model.info.get("input_cost_per_token") or 0
2114
+ output_cost_per_token = self.main_model.info.get("output_cost_per_token") or 0
2115
+ input_cost_per_token_cache_hit = (
2116
+ self.main_model.info.get("input_cost_per_token_cache_hit") or 0
2117
+ )
2118
+
2119
+ # deepseek
2120
+ # prompt_cache_hit_tokens + prompt_cache_miss_tokens
2121
+ # == prompt_tokens == total tokens that were sent
2122
+ #
2123
+ # Anthropic
2124
+ # cache_creation_input_tokens + cache_read_input_tokens + prompt
2125
+ # == total tokens that were
2126
+
2127
+ if input_cost_per_token_cache_hit:
2128
+ # must be deepseek
2129
+ cost += input_cost_per_token_cache_hit * cache_hit_tokens
2130
+ uncached_prompt_tokens = max(prompt_tokens - cache_hit_tokens, 0)
2131
+ cost += uncached_prompt_tokens * input_cost_per_token
2132
+ else:
2133
+ # hard code the anthropic adjustments, no-ops for other models since cache_x_tokens==0
2134
+ cost += cache_write_tokens * input_cost_per_token * 1.25
2135
+ cost += cache_hit_tokens * input_cost_per_token * 0.10
2136
+ cost += prompt_tokens * input_cost_per_token
2137
+
2138
+ cost += completion_tokens * output_cost_per_token
2139
+ return cost
2140
+
2141
+ def show_usage_report(self):
2142
+ if not self.usage_report:
2143
+ return
2144
+
2145
+ self.total_tokens_sent += self.message_tokens_sent
2146
+ self.total_tokens_received += self.message_tokens_received
2147
+
2148
+ self.io.tool_output(self.usage_report)
2149
+
2150
+ prompt_tokens = self.message_tokens_sent
2151
+ completion_tokens = self.message_tokens_received
2152
+ self.event(
2153
+ "message_send",
2154
+ main_model=self.main_model,
2155
+ edit_format=self.edit_format,
2156
+ prompt_tokens=prompt_tokens,
2157
+ completion_tokens=completion_tokens,
2158
+ total_tokens=prompt_tokens + completion_tokens,
2159
+ cost=self.message_cost,
2160
+ total_cost=self.total_cost,
2161
+ )
2162
+
2163
+ self.message_cost = 0.0
2164
+ self.message_tokens_sent = 0
2165
+ self.message_tokens_received = 0
2166
+
2167
+ def get_multi_response_content_in_progress(self, final=False):
2168
+ cur = self.multi_response_content or ""
2169
+ new = self.partial_response_content or ""
2170
+
2171
+ if new.rstrip() != new and not final:
2172
+ new = new.rstrip()
2173
+
2174
+ return cur + new
2175
+
2176
+ def get_rel_fname(self, fname):
2177
+ try:
2178
+ return os.path.relpath(fname, self.root)
2179
+ except ValueError:
2180
+ return fname
2181
+
2182
+ def get_inchat_relative_files(self):
2183
+ files = [self.get_rel_fname(fname) for fname in self.abs_fnames]
2184
+ return sorted(set(files))
2185
+
2186
+ def is_file_safe(self, fname):
2187
+ try:
2188
+ return Path(self.abs_root_path(fname)).is_file()
2189
+ except OSError:
2190
+ return
2191
+
2192
+ def get_all_relative_files(self):
2193
+ if self.repo:
2194
+ files = self.repo.get_tracked_files()
2195
+ else:
2196
+ files = self.get_inchat_relative_files()
2197
+
2198
+ # This is quite slow in large repos
2199
+ # files = [fname for fname in files if self.is_file_safe(fname)]
2200
+
2201
+ return sorted(set(files))
2202
+
2203
+ def get_all_abs_files(self):
2204
+ files = self.get_all_relative_files()
2205
+ files = [self.abs_root_path(path) for path in files]
2206
+ return files
2207
+
2208
+ def get_addable_relative_files(self):
2209
+ all_files = set(self.get_all_relative_files())
2210
+ inchat_files = set(self.get_inchat_relative_files())
2211
+ read_only_files = set(self.get_rel_fname(fname) for fname in self.abs_read_only_fnames)
2212
+ return all_files - inchat_files - read_only_files
2213
+
2214
+ def check_for_dirty_commit(self, path):
2215
+ if not self.repo:
2216
+ return
2217
+ if not self.dirty_commits:
2218
+ return
2219
+ if not self.repo.is_dirty(path):
2220
+ return
2221
+
2222
+ # We need a committed copy of the file in order to /undo, so skip this
2223
+ # fullp = Path(self.abs_root_path(path))
2224
+ # if not fullp.stat().st_size:
2225
+ # return
2226
+
2227
+ self.io.tool_output(f"Committing {path} before applying edits.")
2228
+ self.need_commit_before_edits.add(path)
2229
+
2230
+ def allowed_to_edit(self, path):
2231
+ full_path = self.abs_root_path(path)
2232
+
2233
+ # The single policy point for "may this path be written?". Model output
2234
+ # reaches here directly, so absolute paths, `..` traversal and symlinks
2235
+ # that escape the repo root must be refused before any mkdir/touch.
2236
+ if not self.path_within_root(full_path):
2237
+ self.io.tool_error(
2238
+ f"Skipping edits to {path} which is outside the repository: {self.root}"
2239
+ )
2240
+ return
2241
+
2242
+ if self.repo:
2243
+ need_to_add = not self.repo.path_in_repo(path)
2244
+ else:
2245
+ need_to_add = False
2246
+
2247
+ if full_path in self.abs_fnames:
2248
+ self.check_for_dirty_commit(path)
2249
+ return True
2250
+
2251
+ if self.repo and self.repo.git_ignored_file(path):
2252
+ self.io.tool_warning(f"Skipping edits to {path} that matches gitignore spec.")
2253
+ return
2254
+
2255
+ if not Path(full_path).exists():
2256
+ if not self.io.confirm_ask("Create new file?", subject=path):
2257
+ self.io.tool_output(f"Skipping edits to {path}")
2258
+ return
2259
+
2260
+ if not self.dry_run:
2261
+ if not utils.touch_file(full_path):
2262
+ self.io.tool_error(f"Unable to create {path}, skipping edits.")
2263
+ return
2264
+
2265
+ # Seems unlikely that we needed to create the file, but it was
2266
+ # actually already part of the repo.
2267
+ # But let's only add if we need to, just to be safe.
2268
+ if need_to_add and self.auto_commits:
2269
+ self.repo.repo.git.add(full_path)
2270
+
2271
+ self.abs_fnames.add(full_path)
2272
+ self.check_added_files()
2273
+ return True
2274
+
2275
+ if not self.io.confirm_ask(
2276
+ "Allow edits to file that has not been added to the chat?",
2277
+ subject=path,
2278
+ ):
2279
+ self.io.tool_output(f"Skipping edits to {path}")
2280
+ return
2281
+
2282
+ if need_to_add and self.auto_commits:
2283
+ self.repo.repo.git.add(full_path)
2284
+
2285
+ self.abs_fnames.add(full_path)
2286
+ self.check_added_files()
2287
+ self.check_for_dirty_commit(path)
2288
+
2289
+ return True
2290
+
2291
+ warning_given = False
2292
+
2293
+ def check_added_files(self):
2294
+ if self.warning_given:
2295
+ return
2296
+
2297
+ warn_number_of_files = 4
2298
+ warn_number_of_tokens = 20 * 1024
2299
+
2300
+ num_files = len(self.abs_fnames)
2301
+ if num_files < warn_number_of_files:
2302
+ return
2303
+
2304
+ tokens = 0
2305
+ for fname in self.abs_fnames:
2306
+ if is_image_file(fname):
2307
+ continue
2308
+ content = self.io.read_text(fname)
2309
+ tokens += self.main_model.token_count(content)
2310
+
2311
+ if tokens < warn_number_of_tokens:
2312
+ return
2313
+
2314
+ self.io.tool_warning("Warning: it's best to only add files that need changes to the chat.")
2315
+ self.io.tool_warning(urls.edit_errors)
2316
+ self.warning_given = True
2317
+
2318
+ def prepare_to_edit(self, edits):
2319
+ res = []
2320
+ seen = dict()
2321
+
2322
+ self.need_commit_before_edits = set()
2323
+
2324
+ for edit in edits:
2325
+ allowed = True
2326
+ for path in self.get_edit_paths(edit):
2327
+ if path is None:
2328
+ continue
2329
+ if path == "python":
2330
+ dump(edits)
2331
+ if path in seen:
2332
+ path_allowed = seen[path]
2333
+ else:
2334
+ path_allowed = self.allowed_to_edit(path)
2335
+ seen[path] = path_allowed
2336
+
2337
+ if not path_allowed:
2338
+ allowed = False
2339
+
2340
+ if allowed:
2341
+ res.append(edit)
2342
+
2343
+ self.dirty_commit()
2344
+ self.need_commit_before_edits = set()
2345
+
2346
+ return res
2347
+
2348
+ def apply_updates(self):
2349
+ edited = set()
2350
+ try:
2351
+ edits = self.get_edits()
2352
+ edits = self.apply_edits_dry_run(edits)
2353
+ edits = self.prepare_to_edit(edits)
2354
+ edited = {
2355
+ path for edit in edits for path in self.get_edit_paths(edit) if path is not None
2356
+ }
2357
+
2358
+ self.apply_edits(edits)
2359
+ except ValueError as err:
2360
+ edited = set(getattr(err, "applied_paths", []))
2361
+ self.num_malformed_responses += 1
2362
+
2363
+ err = err.args[0]
2364
+
2365
+ self.io.tool_error("The LLM did not conform to the edit format.")
2366
+ self.io.tool_output(urls.edit_errors)
2367
+ self.io.tool_output()
2368
+ self.io.tool_output(str(err))
2369
+
2370
+ self.reflected_message = str(err)
2371
+ return edited
2372
+
2373
+ except OSError as err:
2374
+ self.io.tool_error(f"Unable to apply edits: {err}")
2375
+ self.reflected_message = str(err)
2376
+ return set()
2377
+ except ANY_GIT_ERROR as err:
2378
+ self.io.tool_error(str(err))
2379
+ self.reflected_message = str(err)
2380
+ return set()
2381
+ except Exception as err:
2382
+ self.io.tool_error("Exception while updating files:")
2383
+ self.io.tool_error(str(err), strip=False)
2384
+
2385
+ traceback.print_exc()
2386
+
2387
+ self.reflected_message = str(err)
2388
+ return set()
2389
+
2390
+ for path in edited:
2391
+ if self.dry_run:
2392
+ self.io.tool_output(f"Did not apply edit to {path} (--dry-run)")
2393
+ else:
2394
+ self.io.tool_output(f"Applied edit to {path}")
2395
+
2396
+ return edited
2397
+
2398
+ def parse_partial_args(self):
2399
+ # dump(self.partial_response_function_call)
2400
+
2401
+ data = self.partial_response_function_call.get("arguments")
2402
+ if not data:
2403
+ return
2404
+
2405
+ try:
2406
+ return json.loads(data)
2407
+ except JSONDecodeError:
2408
+ pass
2409
+
2410
+ try:
2411
+ return json.loads(data + "]}")
2412
+ except JSONDecodeError:
2413
+ pass
2414
+
2415
+ try:
2416
+ return json.loads(data + "}]}")
2417
+ except JSONDecodeError:
2418
+ pass
2419
+
2420
+ try:
2421
+ return json.loads(data + '"}]}')
2422
+ except JSONDecodeError:
2423
+ pass
2424
+
2425
+ # commits...
2426
+
2427
+ def get_context_from_history(self, history):
2428
+ context = ""
2429
+ if history:
2430
+ for msg in history:
2431
+ context += "\n" + msg["role"].upper() + ": " + msg["content"] + "\n"
2432
+
2433
+ return context
2434
+
2435
+ def auto_commit(self, edited, context=None):
2436
+ if not self.repo or not self.auto_commits or self.dry_run:
2437
+ return
2438
+
2439
+ if not context:
2440
+ context = self.get_context_from_history(self.cur_messages)
2441
+
2442
+ try:
2443
+ res = self.repo.commit(fnames=edited, context=context, patch_edits=True, coder=self)
2444
+ if res:
2445
+ self.show_auto_commit_outcome(res)
2446
+ commit_hash, commit_message = res
2447
+ return self.gpt_prompts.files_content_gpt_edits.format(
2448
+ hash=commit_hash,
2449
+ message=commit_message,
2450
+ )
2451
+
2452
+ return self.gpt_prompts.files_content_gpt_no_edits
2453
+ except ANY_GIT_ERROR as err:
2454
+ self.io.tool_error(f"Unable to commit: {str(err)}")
2455
+ return
2456
+
2457
+ def show_auto_commit_outcome(self, res):
2458
+ commit_hash, commit_message = res
2459
+ self.last_patch_commit_hash = commit_hash
2460
+ self.patch_commit_hashes.add(commit_hash)
2461
+ self.last_patch_commit_message = commit_message
2462
+ if self.show_diffs:
2463
+ self.commands.cmd_diff()
2464
+
2465
+ def show_undo_hint(self):
2466
+ if not self.commit_before_message:
2467
+ return
2468
+ if self.commit_before_message[-1] != self.repo.get_head_commit_sha():
2469
+ self.io.tool_output("You can use /undo to undo and discard each Patch commit.")
2470
+
2471
+ def dirty_commit(self):
2472
+ if not self.need_commit_before_edits:
2473
+ return
2474
+ if not self.dirty_commits:
2475
+ return
2476
+ if not self.repo:
2477
+ return
2478
+
2479
+ self.repo.commit(fnames=self.need_commit_before_edits, coder=self)
2480
+
2481
+ # files changed, move cur messages back behind the files messages
2482
+ # self.move_back_cur_messages(self.gpt_prompts.files_content_local_edits)
2483
+ return True
2484
+
2485
+ def get_edits(self, mode="update"):
2486
+ return []
2487
+
2488
+ def apply_edits(self, edits):
2489
+ return
2490
+
2491
+ def get_edit_paths(self, edit):
2492
+ return [edit[0]]
2493
+
2494
+ def apply_edits_dry_run(self, edits):
2495
+ return edits
2496
+
2497
+ def run_shell_commands(self):
2498
+ if not self.suggest_shell_commands:
2499
+ return ""
2500
+
2501
+ done = set()
2502
+ group = ConfirmGroup(set(self.shell_commands))
2503
+ accumulated_output = ""
2504
+ for command in self.shell_commands:
2505
+ if command in done:
2506
+ continue
2507
+ done.add(command)
2508
+ output = self.handle_shell_commands(command, group)
2509
+ if output:
2510
+ accumulated_output += output + "\n\n"
2511
+ return accumulated_output
2512
+
2513
+ def handle_shell_commands(self, commands_str, group):
2514
+ commands = commands_str.strip().splitlines()
2515
+ command_count = sum(
2516
+ 1 for cmd in commands if cmd.strip() and not cmd.strip().startswith("#")
2517
+ )
2518
+ prompt = "Run shell command?" if command_count == 1 else "Run shell commands?"
2519
+ if not self.io.confirm_ask(
2520
+ prompt,
2521
+ subject="\n".join(commands),
2522
+ explicit_yes_required=True,
2523
+ group=group,
2524
+ allow_never=True,
2525
+ ):
2526
+ return
2527
+
2528
+ accumulated_output = ""
2529
+ for command in commands:
2530
+ command = command.strip()
2531
+ if not command or command.startswith("#"):
2532
+ continue
2533
+
2534
+ self.io.tool_output()
2535
+ self.io.tool_output(f"Running {command}")
2536
+ # Add the command to input history
2537
+ self.io.add_to_input_history(f"/run {command.strip()}")
2538
+ exit_status, output = run_cmd(command, error_print=self.io.tool_error, cwd=self.root)
2539
+ if output:
2540
+ accumulated_output += f"Output from {command}\n{output}\n"
2541
+
2542
+ if accumulated_output.strip() and self.io.confirm_ask(
2543
+ "Add command output to the chat?", allow_never=True
2544
+ ):
2545
+ num_lines = len(accumulated_output.strip().splitlines())
2546
+ line_plural = "line" if num_lines == 1 else "lines"
2547
+ self.io.tool_output(f"Added {num_lines} {line_plural} of output to the chat.")
2548
+ return accumulated_output