vmcode-cli 1.0.0
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.
- package/INSTALLATION_METHODS.md +181 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/npm-wrapper.js +171 -0
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +159 -0
- package/package.json +42 -0
- package/requirements.txt +7 -0
- package/scripts/install.js +132 -0
- package/setup.bat +114 -0
- package/setup.sh +135 -0
- package/src/__init__.py +4 -0
- package/src/core/__init__.py +1 -0
- package/src/core/agentic.py +2342 -0
- package/src/core/chat_manager.py +1201 -0
- package/src/core/config_manager.py +269 -0
- package/src/core/init.py +161 -0
- package/src/core/sub_agent.py +174 -0
- package/src/exceptions.py +75 -0
- package/src/llm/__init__.py +1 -0
- package/src/llm/client.py +149 -0
- package/src/llm/config.py +445 -0
- package/src/llm/prompts.py +569 -0
- package/src/llm/providers.py +402 -0
- package/src/llm/token_tracker.py +220 -0
- package/src/ui/__init__.py +1 -0
- package/src/ui/banner.py +103 -0
- package/src/ui/commands.py +489 -0
- package/src/ui/displays.py +167 -0
- package/src/ui/main.py +351 -0
- package/src/ui/prompt_utils.py +162 -0
- package/src/utils/__init__.py +1 -0
- package/src/utils/editor.py +158 -0
- package/src/utils/gitignore_filter.py +149 -0
- package/src/utils/logger.py +254 -0
- package/src/utils/markdown.py +32 -0
- package/src/utils/settings.py +94 -0
- package/src/utils/tools/__init__.py +55 -0
- package/src/utils/tools/command_executor.py +217 -0
- package/src/utils/tools/create_file.py +143 -0
- package/src/utils/tools/definitions.py +193 -0
- package/src/utils/tools/directory.py +374 -0
- package/src/utils/tools/file_editor.py +345 -0
- package/src/utils/tools/file_helpers.py +109 -0
- package/src/utils/tools/file_reader.py +331 -0
- package/src/utils/tools/formatters.py +458 -0
- package/src/utils/tools/parallel_executor.py +195 -0
- package/src/utils/validation.py +117 -0
- package/src/utils/web_search.py +71 -0
- package/vmcode-proxy/.env.example +5 -0
- package/vmcode-proxy/README.md +235 -0
- package/vmcode-proxy/package-lock.json +947 -0
- package/vmcode-proxy/package.json +20 -0
- package/vmcode-proxy/server.js +248 -0
- package/vmcode-proxy/server.js.bak +157 -0
|
@@ -0,0 +1,2342 @@
|
|
|
1
|
+
"""Agent tool-calling loop."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
from rich.markdown import Markdown
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich.live import Live
|
|
15
|
+
from utils.markdown import left_align_headings
|
|
16
|
+
from llm.config import TOOLS_REQUIRE_CONFIRMATION, WEB_SEARCH_REQUIRE_CONFIRMATION
|
|
17
|
+
from utils.settings import MAX_TOOL_CALLS, MAX_COMMAND_OUTPUT_LINES, MonokaiDarkBGStyle
|
|
18
|
+
from utils.validation import check_for_duplicate, check_command
|
|
19
|
+
from utils.tools import (
|
|
20
|
+
run_shell_command,
|
|
21
|
+
confirm_tool,
|
|
22
|
+
run_edit_file,
|
|
23
|
+
preview_edit_file,
|
|
24
|
+
read_file,
|
|
25
|
+
list_directory,
|
|
26
|
+
create_file,
|
|
27
|
+
TOOLS,
|
|
28
|
+
_tools_for_mode,
|
|
29
|
+
)
|
|
30
|
+
from utils.settings import tool_settings
|
|
31
|
+
from utils.web_search import run_web_search
|
|
32
|
+
from ui.prompt_utils import create_confirmation_prompt_session
|
|
33
|
+
from exceptions import (
|
|
34
|
+
LLMError,
|
|
35
|
+
LLMConnectionError,
|
|
36
|
+
LLMResponseError,
|
|
37
|
+
CommandExecutionError,
|
|
38
|
+
FileEditError,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _get_exit_code(tool_result):
|
|
43
|
+
if not isinstance(tool_result, str):
|
|
44
|
+
return None
|
|
45
|
+
first_line = tool_result.splitlines()[0] if tool_result else ""
|
|
46
|
+
if first_line.startswith("exit_code="):
|
|
47
|
+
try:
|
|
48
|
+
value = first_line.split("=", 1)[1].strip()
|
|
49
|
+
value = value.split()[0] if value else value
|
|
50
|
+
return int(value)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_truncated_result(tool_result):
|
|
57
|
+
if not isinstance(tool_result, str):
|
|
58
|
+
return False
|
|
59
|
+
first_line = tool_result.splitlines()[0] if tool_result else ""
|
|
60
|
+
return "truncated=true" in first_line
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _coerce_bool(value, default=None):
|
|
64
|
+
"""Best-effort coercion of tool arguments to boolean.
|
|
65
|
+
|
|
66
|
+
Returns None if value is None and default is None.
|
|
67
|
+
"""
|
|
68
|
+
if value is None:
|
|
69
|
+
return default
|
|
70
|
+
if isinstance(value, bool):
|
|
71
|
+
return value
|
|
72
|
+
if isinstance(value, int):
|
|
73
|
+
return bool(value)
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
normalized = value.strip().lower()
|
|
76
|
+
if normalized in {"true", "1", "yes", "y", "on"}:
|
|
77
|
+
return True
|
|
78
|
+
if normalized in {"false", "0", "no", "n", "off"}:
|
|
79
|
+
return False
|
|
80
|
+
return default
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _print_or_append(text, console, panel_updater, markup=True):
|
|
84
|
+
"""Print text to console or append to panel_updater.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
text: Text to display
|
|
88
|
+
console: Rich console
|
|
89
|
+
panel_updater: Optional SubAgentPanel for live updates
|
|
90
|
+
markup: If True, parse Rich markup (only used for console)
|
|
91
|
+
"""
|
|
92
|
+
if panel_updater:
|
|
93
|
+
panel_updater.append(text)
|
|
94
|
+
else:
|
|
95
|
+
console.print(text, markup=markup)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
MAX_TASKS = 50
|
|
99
|
+
MAX_TASK_LEN = 200
|
|
100
|
+
|
|
101
|
+
# File extension to Pygments lexer name mapping for syntax highlighting
|
|
102
|
+
_LEXER_MAP = {
|
|
103
|
+
'py': 'python',
|
|
104
|
+
'js': 'javascript',
|
|
105
|
+
'ts': 'typescript',
|
|
106
|
+
'tsx': 'typescript',
|
|
107
|
+
'jsx': 'javascript',
|
|
108
|
+
'go': 'go',
|
|
109
|
+
'rs': 'rust',
|
|
110
|
+
'java': 'java',
|
|
111
|
+
'c': 'c',
|
|
112
|
+
'cpp': 'cpp',
|
|
113
|
+
'h': 'c',
|
|
114
|
+
'hpp': 'cpp',
|
|
115
|
+
'sh': 'bash',
|
|
116
|
+
'bash': 'bash',
|
|
117
|
+
'zsh': 'bash',
|
|
118
|
+
'yaml': 'yaml',
|
|
119
|
+
'yml': 'yaml',
|
|
120
|
+
'json': 'json',
|
|
121
|
+
'toml': 'toml',
|
|
122
|
+
'md': 'markdown',
|
|
123
|
+
'html': 'html',
|
|
124
|
+
'css': 'css',
|
|
125
|
+
'sql': 'sql',
|
|
126
|
+
'php': 'php',
|
|
127
|
+
'rb': 'ruby',
|
|
128
|
+
'swift': 'swift',
|
|
129
|
+
'kt': 'kotlin',
|
|
130
|
+
'scala': 'scala',
|
|
131
|
+
'lua': 'lua',
|
|
132
|
+
'r': 'r',
|
|
133
|
+
}
|
|
134
|
+
MAX_TASK_TITLE_LEN = 80
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _coerce_int(value):
|
|
138
|
+
"""Best-effort coercion of tool arguments to int.
|
|
139
|
+
|
|
140
|
+
Returns (int_value, error_message). error_message is None on success.
|
|
141
|
+
"""
|
|
142
|
+
if value is None:
|
|
143
|
+
return None, "Missing required integer value."
|
|
144
|
+
if isinstance(value, bool):
|
|
145
|
+
return None, "Value must be an integer, not a boolean."
|
|
146
|
+
if isinstance(value, int):
|
|
147
|
+
return value, None
|
|
148
|
+
if isinstance(value, str):
|
|
149
|
+
text = value.strip()
|
|
150
|
+
if text == "":
|
|
151
|
+
return None, "Value must be a non-empty integer."
|
|
152
|
+
try:
|
|
153
|
+
return int(text), None
|
|
154
|
+
except ValueError:
|
|
155
|
+
return None, "Value must be an integer."
|
|
156
|
+
return None, "Value must be an integer."
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _format_task_list(task_list, title=None):
|
|
160
|
+
if not task_list:
|
|
161
|
+
return "exit_code=1\nerror: No task list exists. Use create_task_list first.\n\n"
|
|
162
|
+
|
|
163
|
+
safe_title = (title or "").strip() if isinstance(title, str) else ""
|
|
164
|
+
safe_title = safe_title[:MAX_TASK_TITLE_LEN] if safe_title else "untitled"
|
|
165
|
+
|
|
166
|
+
done_count = 0
|
|
167
|
+
lines = [f"Task list: {safe_title} (done={done_count} total={len(task_list)})"]
|
|
168
|
+
|
|
169
|
+
for i, task in enumerate(task_list):
|
|
170
|
+
is_done = bool(task.get("completed"))
|
|
171
|
+
if is_done:
|
|
172
|
+
done_count += 1
|
|
173
|
+
checkbox = "[x]" if is_done else "[ ]"
|
|
174
|
+
desc = str(task.get("description", ""))
|
|
175
|
+
if len(desc) > MAX_TASK_LEN:
|
|
176
|
+
desc = desc[:MAX_TASK_LEN - 3] + "..."
|
|
177
|
+
lines.append(f"{i}: {checkbox} {desc}")
|
|
178
|
+
|
|
179
|
+
# Update header with final done_count
|
|
180
|
+
lines[0] = f"Task list: {safe_title} (done={done_count} total={len(task_list)})"
|
|
181
|
+
return "\n".join(lines) + "\n\n"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _strip_leading_task_list_echo(content, task_list, title=None):
|
|
185
|
+
"""Remove a leading echoed task list from assistant content.
|
|
186
|
+
|
|
187
|
+
Some models copy the task list tool output into the final response, which
|
|
188
|
+
causes duplicate task list rendering in the CLI.
|
|
189
|
+
"""
|
|
190
|
+
if not content or not isinstance(content, str) or not task_list:
|
|
191
|
+
return content
|
|
192
|
+
|
|
193
|
+
expected = _format_task_list(task_list, title).strip()
|
|
194
|
+
if not expected:
|
|
195
|
+
return content
|
|
196
|
+
|
|
197
|
+
trimmed = content.lstrip()
|
|
198
|
+
if trimmed.startswith(expected):
|
|
199
|
+
remainder = trimmed[len(expected):]
|
|
200
|
+
return remainder.lstrip("\n").lstrip()
|
|
201
|
+
|
|
202
|
+
return content
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _build_read_file_label(path, start_line=None, max_lines=None, with_colon=False):
|
|
206
|
+
"""Build uniform read_file label.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
path: File path
|
|
210
|
+
start_line: Optional starting line number (unused in display)
|
|
211
|
+
max_lines: Optional max lines to read (unused in display)
|
|
212
|
+
with_colon: If True, use 'read_file: path' format (for batch mode labels)
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
str: Formatted label
|
|
216
|
+
"""
|
|
217
|
+
separator = ': ' if with_colon else ' '
|
|
218
|
+
label = f"read_file{separator}{path}"
|
|
219
|
+
return label
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _handle_create_file_feedback(tool_result, console, panel_updater):
|
|
223
|
+
"""Handle feedback for create_file tool.
|
|
224
|
+
|
|
225
|
+
Display syntax-highlighted file preview.
|
|
226
|
+
"""
|
|
227
|
+
lines = tool_result.split('\n')
|
|
228
|
+
# Extract path from metadata
|
|
229
|
+
path_match = re.search(r'path=([^\s]+)', tool_result)
|
|
230
|
+
path_str = path_match.group(1) if path_match else "file"
|
|
231
|
+
|
|
232
|
+
# Find file content section
|
|
233
|
+
content_start = None
|
|
234
|
+
content_end = None
|
|
235
|
+
for i, line in enumerate(lines):
|
|
236
|
+
if line.startswith("=== FILE_CONTENT ==="):
|
|
237
|
+
content_start = i + 1
|
|
238
|
+
elif line.startswith("=== END_FILE_CONTENT ===") and content_start is not None:
|
|
239
|
+
content_end = i
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
# Display summary and syntax-highlighted content
|
|
243
|
+
if content_start is not None and content_end is not None:
|
|
244
|
+
content_lines = lines[content_start:content_end]
|
|
245
|
+
content = "\n".join(content_lines)
|
|
246
|
+
|
|
247
|
+
# Get file extension for syntax highlighting
|
|
248
|
+
file_ext = Path(path_str).suffix[1:] if Path(path_str).suffix else "text"
|
|
249
|
+
lexer_name = _LEXER_MAP.get(file_ext.lower(), 'text')
|
|
250
|
+
|
|
251
|
+
# Create syntax object
|
|
252
|
+
syntax = Syntax(
|
|
253
|
+
content,
|
|
254
|
+
lexer_name,
|
|
255
|
+
theme=MonokaiDarkBGStyle,
|
|
256
|
+
line_numbers=True,
|
|
257
|
+
word_wrap=False
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Show with prefix for console
|
|
261
|
+
if panel_updater:
|
|
262
|
+
panel_updater.append(f"Created: {path_str}")
|
|
263
|
+
panel_updater.append(str(syntax))
|
|
264
|
+
else:
|
|
265
|
+
console.print(f"Created: {path_str}", markup=False)
|
|
266
|
+
console.print(syntax)
|
|
267
|
+
else:
|
|
268
|
+
# Fallback: just show path
|
|
269
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
270
|
+
message = f"{prefix}Created: {path_str}"
|
|
271
|
+
_print_or_append(message, console, panel_updater)
|
|
272
|
+
|
|
273
|
+
if not panel_updater:
|
|
274
|
+
console.print()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _handle_list_directory_feedback(tool_result, console, panel_updater):
|
|
278
|
+
"""Handle feedback for list_directory tool.
|
|
279
|
+
|
|
280
|
+
Display formatted directory tree with files and directories.
|
|
281
|
+
"""
|
|
282
|
+
lines = tool_result.split('\n')
|
|
283
|
+
# Extract items_count from metadata
|
|
284
|
+
items_count = 0
|
|
285
|
+
for line in lines:
|
|
286
|
+
match = re.search(r'items_count=(\d+)', line)
|
|
287
|
+
if match:
|
|
288
|
+
items_count = int(match.group(1))
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
# Parse content lines (skip metadata lines)
|
|
292
|
+
content_start = None
|
|
293
|
+
for i, line in enumerate(lines):
|
|
294
|
+
if line.startswith("FILE") or line.startswith("DIR"):
|
|
295
|
+
content_start = i
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
if content_start is not None and items_count > 0:
|
|
299
|
+
content_lines = lines[content_start:]
|
|
300
|
+
|
|
301
|
+
# Parse entries: kind, size, name
|
|
302
|
+
entries = []
|
|
303
|
+
for line in content_lines:
|
|
304
|
+
parts = line.split()
|
|
305
|
+
if len(parts) >= 3:
|
|
306
|
+
kind = parts[0]
|
|
307
|
+
if kind == "FILE":
|
|
308
|
+
# FILE 12345 bytes path/to/file.py
|
|
309
|
+
size = parts[1]
|
|
310
|
+
name = ' '.join(parts[3:]) # everything after "bytes"
|
|
311
|
+
entries.append(("FILE", name, size))
|
|
312
|
+
elif kind == "DIR":
|
|
313
|
+
# DIR path/to/dir/
|
|
314
|
+
name = ' '.join(parts[1:])
|
|
315
|
+
entries.append(("DIR", name))
|
|
316
|
+
|
|
317
|
+
# Sort: directories first, then alphabetically
|
|
318
|
+
entries.sort(key=lambda x: (0 if x[0] == "DIR" else 1, x[1]))
|
|
319
|
+
|
|
320
|
+
# Build tree with truncation (max 10 items)
|
|
321
|
+
max_display = 10
|
|
322
|
+
display_entries = entries[:max_display]
|
|
323
|
+
remaining = max(0, items_count - max_display)
|
|
324
|
+
|
|
325
|
+
# Format tree lines
|
|
326
|
+
tree_lines = []
|
|
327
|
+
for i, entry in enumerate(display_entries):
|
|
328
|
+
is_last = (i == len(display_entries) - 1) and (remaining == 0)
|
|
329
|
+
# Use closing pipe (└─) for last item, otherwise middle pipe (├─)
|
|
330
|
+
connector = "└─" if is_last else "├─"
|
|
331
|
+
if entry[0] == "DIR":
|
|
332
|
+
tree_lines.append(f" {connector} {entry[1]}")
|
|
333
|
+
else: # FILE
|
|
334
|
+
size_str = f"{int(entry[2]):,}" if entry[2].isdigit() else entry[2]
|
|
335
|
+
tree_lines.append(f" {connector} {entry[1]} ({size_str} bytes)")
|
|
336
|
+
|
|
337
|
+
# Add overflow indicator if needed (always use closing pipe)
|
|
338
|
+
if remaining > 0:
|
|
339
|
+
tree_lines.append(f" └─ ... and {remaining} more")
|
|
340
|
+
|
|
341
|
+
# Build output with header
|
|
342
|
+
path_match = re.search(r'path=([^\s]+)', tool_result)
|
|
343
|
+
path_str = path_match.group(1) if path_match else "directory"
|
|
344
|
+
header = f"{path_str}/ ({items_count} item{'s' if items_count != 1 else ''})"
|
|
345
|
+
|
|
346
|
+
# Display with prefix
|
|
347
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
348
|
+
output = f"{prefix}{header}\n"
|
|
349
|
+
output += "\n".join(tree_lines)
|
|
350
|
+
|
|
351
|
+
_print_or_append(output, console, panel_updater)
|
|
352
|
+
|
|
353
|
+
if not panel_updater:
|
|
354
|
+
console.print()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _handle_execute_command_feedback(tool_result, console, panel_updater):
|
|
358
|
+
"""Handle feedback for execute_command tool.
|
|
359
|
+
|
|
360
|
+
Display command output with line truncation and exit code.
|
|
361
|
+
"""
|
|
362
|
+
lines = tool_result.split('\n')
|
|
363
|
+
if lines:
|
|
364
|
+
# Extract exit code from first line
|
|
365
|
+
exit_code_match = re.search(r'exit_code=(\d+)', lines[0])
|
|
366
|
+
exit_code = int(exit_code_match.group(1)) if exit_code_match else None
|
|
367
|
+
|
|
368
|
+
# Get output (all lines after the exit_code line)
|
|
369
|
+
output_lines = lines[1:] if exit_code_match else lines
|
|
370
|
+
output_lines = [line for line in output_lines if line.strip()]
|
|
371
|
+
|
|
372
|
+
# Truncate if too many lines
|
|
373
|
+
truncation_message = None
|
|
374
|
+
if len(output_lines) > MAX_COMMAND_OUTPUT_LINES:
|
|
375
|
+
displayed_lines = output_lines[:MAX_COMMAND_OUTPUT_LINES]
|
|
376
|
+
omitted = len(output_lines) - MAX_COMMAND_OUTPUT_LINES
|
|
377
|
+
output = '\n'.join(displayed_lines)
|
|
378
|
+
truncation_message = f"[dim]... ({omitted} more lines truncated)[/dim]"
|
|
379
|
+
else:
|
|
380
|
+
output = '\n'.join(output_lines)
|
|
381
|
+
|
|
382
|
+
# Build prefix
|
|
383
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
384
|
+
|
|
385
|
+
# Show output
|
|
386
|
+
if output:
|
|
387
|
+
display_text = f"{prefix}{output}"
|
|
388
|
+
_print_or_append(display_text, console, panel_updater, markup=False)
|
|
389
|
+
|
|
390
|
+
# Show truncation message separately to preserve markup
|
|
391
|
+
if truncation_message:
|
|
392
|
+
_print_or_append(truncation_message, console, panel_updater)
|
|
393
|
+
|
|
394
|
+
# Show exit code if non-zero
|
|
395
|
+
if exit_code is not None and exit_code != 0:
|
|
396
|
+
exit_text = f"[dim](exit code: {exit_code})[/dim]"
|
|
397
|
+
_print_or_append(exit_text, console, panel_updater)
|
|
398
|
+
|
|
399
|
+
if not panel_updater:
|
|
400
|
+
console.print()
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _display_tool_feedback(command, tool_result, console, indent=False, panel_updater=None):
|
|
404
|
+
"""Display user summary for read_file, rg, and list_directory.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
command: Tool command string
|
|
408
|
+
tool_result: Tool result string
|
|
409
|
+
console: Rich console
|
|
410
|
+
indent: If True, prefix with '│ ' (for sub-agent mode)
|
|
411
|
+
panel_updater: Optional SubAgentPanel for live updates
|
|
412
|
+
"""
|
|
413
|
+
if not tool_result:
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
# For sub-agent panel: add tool call with formatted message
|
|
417
|
+
if panel_updater:
|
|
418
|
+
# Extract tool name from command
|
|
419
|
+
if command.startswith("read_file"):
|
|
420
|
+
tool_name = "read_file"
|
|
421
|
+
elif command.startswith("rg"):
|
|
422
|
+
tool_name = "rg"
|
|
423
|
+
elif command.startswith("list_directory"):
|
|
424
|
+
tool_name = "list_directory"
|
|
425
|
+
elif command.startswith(("create_task_list", "complete_task", "show_task_list")):
|
|
426
|
+
tool_name = command.split()[0]
|
|
427
|
+
elif command.startswith("web search"):
|
|
428
|
+
tool_name = "web_search"
|
|
429
|
+
elif command.startswith("execute_command"):
|
|
430
|
+
tool_name = "execute_command"
|
|
431
|
+
else:
|
|
432
|
+
tool_name = command.split()[0]
|
|
433
|
+
|
|
434
|
+
# Pass to panel updater which will handle formatting
|
|
435
|
+
panel_updater.add_tool_call(tool_name, tool_result, command)
|
|
436
|
+
|
|
437
|
+
# For task list tools: show the list (bounded by MAX_TASKS / MAX_TASK_LEN)
|
|
438
|
+
if command.startswith(("create_task_list", "complete_task", "show_task_list")):
|
|
439
|
+
exit_code = _get_exit_code(tool_result)
|
|
440
|
+
if exit_code == 0 or exit_code is None:
|
|
441
|
+
# Successful task list - display without exit_code line and without Rich markup parsing.
|
|
442
|
+
rendered = tool_result
|
|
443
|
+
if rendered.startswith("exit_code="):
|
|
444
|
+
rendered = "\n".join(rendered.splitlines()[1:])
|
|
445
|
+
_print_or_append(rendered.strip(), console, panel_updater, markup=False)
|
|
446
|
+
else:
|
|
447
|
+
# Show single-line error if present
|
|
448
|
+
first_two = "\n".join(tool_result.splitlines()[:2]).strip()
|
|
449
|
+
_print_or_append(first_two or tool_result.strip(), console, panel_updater, markup=False)
|
|
450
|
+
if not panel_updater:
|
|
451
|
+
console.print()
|
|
452
|
+
return
|
|
453
|
+
|
|
454
|
+
# For read_file: parse lines_read and start_line from first line
|
|
455
|
+
if command.startswith("read_file"):
|
|
456
|
+
first_line = tool_result.split('\n')[0]
|
|
457
|
+
match = re.search(r'lines_read=(\d+)', first_line)
|
|
458
|
+
start_match = re.search(r'start_line=(\d+)', first_line)
|
|
459
|
+
if match:
|
|
460
|
+
count = int(match.group(1))
|
|
461
|
+
# Only add prefix for console, not for panel_updater
|
|
462
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
463
|
+
|
|
464
|
+
# Build message with line range if start_line is present
|
|
465
|
+
if start_match:
|
|
466
|
+
start_line = int(start_match.group(1))
|
|
467
|
+
if start_line > 1:
|
|
468
|
+
end_line = start_line + count - 1
|
|
469
|
+
message = f"{prefix}[dim]Read lines {start_line}-{end_line} ({count} line{'s' if count != 1 else ''})[/dim]"
|
|
470
|
+
else:
|
|
471
|
+
message = f"{prefix}[dim]Read {count} line{'s' if count != 1 else ''}[/dim]"
|
|
472
|
+
else:
|
|
473
|
+
message = f"{prefix}[dim]Read {count} line{'s' if count != 1 else ''}[/dim]"
|
|
474
|
+
|
|
475
|
+
_print_or_append(message, console, panel_updater)
|
|
476
|
+
if not panel_updater:
|
|
477
|
+
console.print()
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
# For rg: parse matches/files from second line
|
|
481
|
+
if command.startswith("rg"):
|
|
482
|
+
lines = tool_result.split('\n')
|
|
483
|
+
if len(lines) > 1:
|
|
484
|
+
match = re.search(r'(matches|files)=(\d+)', lines[1])
|
|
485
|
+
if match:
|
|
486
|
+
count = int(match.group(2))
|
|
487
|
+
label = match.group(1)
|
|
488
|
+
# Only add prefix for console, not for panel_updater
|
|
489
|
+
prefix = "╰─ " if not panel_updater else ""
|
|
490
|
+
if count == 0:
|
|
491
|
+
message = f"{prefix}[dim]No {label} found[/dim]"
|
|
492
|
+
else:
|
|
493
|
+
message = f"{prefix}[dim]Found {count} {label}[/dim]"
|
|
494
|
+
_print_or_append(message, console, panel_updater)
|
|
495
|
+
if not panel_updater:
|
|
496
|
+
console.print()
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
# For list_directory: parse and display directory tree
|
|
500
|
+
if command.startswith("list_directory"):
|
|
501
|
+
_handle_list_directory_feedback(tool_result, console, panel_updater)
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
# For create_file: display preview of created file
|
|
505
|
+
if command.startswith("create_file"):
|
|
506
|
+
_handle_create_file_feedback(tool_result, console, panel_updater)
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
# For execute_command: display command output with line truncation
|
|
510
|
+
if command.startswith("execute_command"):
|
|
511
|
+
_handle_execute_command_feedback(tool_result, console, panel_updater)
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _handle_empty_response(empty_response_count, console):
|
|
517
|
+
"""Handle empty response from model.
|
|
518
|
+
|
|
519
|
+
Returns:
|
|
520
|
+
tuple: (should_continue, updated_count)
|
|
521
|
+
"""
|
|
522
|
+
empty_response_count += 1
|
|
523
|
+
if empty_response_count >= 2:
|
|
524
|
+
console.print("[red]Error: model returned empty response with no tool calls.[/red]")
|
|
525
|
+
return False, empty_response_count
|
|
526
|
+
return True, empty_response_count
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _handle_tool_limit_reached(chat_manager, console):
|
|
530
|
+
"""Handle case when tool call limit is exceeded.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
bool: True if handled successfully, False if error
|
|
534
|
+
"""
|
|
535
|
+
chat_manager.messages.append({
|
|
536
|
+
"role": "user",
|
|
537
|
+
"content": "Tool limit reached. Provide your answer without calling tools."
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
response = chat_manager.client.chat_completion(
|
|
542
|
+
chat_manager.messages, stream=False, tools=None
|
|
543
|
+
)
|
|
544
|
+
except LLMError as e:
|
|
545
|
+
console.print(f"[red]LLM Error: {e}[/red]")
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
if isinstance(response, dict) and 'usage' in response:
|
|
549
|
+
chat_manager.token_tracker.add_usage(response['usage'])
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
final_message = response["choices"][0]["message"]
|
|
553
|
+
except (KeyError, IndexError):
|
|
554
|
+
console.print("[red]Error: invalid response from model[/red]")
|
|
555
|
+
return False
|
|
556
|
+
|
|
557
|
+
content = final_message.get("content", "").strip()
|
|
558
|
+
if content:
|
|
559
|
+
md = Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left")
|
|
560
|
+
console.print(md)
|
|
561
|
+
chat_manager.messages.append(final_message)
|
|
562
|
+
console.print()
|
|
563
|
+
return True
|
|
564
|
+
|
|
565
|
+
console.print("[red]Error: model returned empty response after tool limit reached.[/red]")
|
|
566
|
+
return False
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
class SubAgentPanel:
|
|
570
|
+
"""Live panel for streaming sub-agent tool output."""
|
|
571
|
+
|
|
572
|
+
# Spinner frames for animation
|
|
573
|
+
_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
574
|
+
|
|
575
|
+
def __init__(self, query, console):
|
|
576
|
+
"""Initialize the sub-agent panel.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
query: The task query for the sub-agent
|
|
580
|
+
console: Rich console for display
|
|
581
|
+
"""
|
|
582
|
+
self.console = console
|
|
583
|
+
self.query = query
|
|
584
|
+
self.tool_calls = [] # List of tool_name strings
|
|
585
|
+
self.total_tool_calls = 0
|
|
586
|
+
self._live = None
|
|
587
|
+
self._spinner_index = 0
|
|
588
|
+
self._show_spinner = True
|
|
589
|
+
self._spinner_thread = None
|
|
590
|
+
self._stop_spinner = threading.Event()
|
|
591
|
+
|
|
592
|
+
def _get_title(self):
|
|
593
|
+
"""Get panel title with optional spinner and tool call counter.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
Rich markup string for the panel title
|
|
597
|
+
"""
|
|
598
|
+
if self._show_spinner:
|
|
599
|
+
spinner = self._SPINNER_FRAMES[self._spinner_index % len(self._SPINNER_FRAMES)]
|
|
600
|
+
return f"[cyan]{spinner} Sub-Agent ({self.total_tool_calls})[/cyan]"
|
|
601
|
+
return f"[cyan]Sub-Agent ({self.total_tool_calls})[/cyan]"
|
|
602
|
+
|
|
603
|
+
def _render_panel(self, title=None, border_style="cyan"):
|
|
604
|
+
"""Render the current panel state.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
title: Optional title override. If None, uses _get_title().
|
|
608
|
+
border_style: Border style (default: "cyan")
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
Rich Panel object with current content and title
|
|
612
|
+
"""
|
|
613
|
+
lines = [f"[bold cyan]Query:[/bold cyan] {self.query}", ""]
|
|
614
|
+
|
|
615
|
+
if self.tool_calls:
|
|
616
|
+
content = "\n".join(self.tool_calls)
|
|
617
|
+
lines.append(content)
|
|
618
|
+
else:
|
|
619
|
+
lines.append("[dim]No tools called yet[/dim]")
|
|
620
|
+
|
|
621
|
+
content = "\n".join(lines)
|
|
622
|
+
return Panel(
|
|
623
|
+
Text.from_markup(content, justify="left"),
|
|
624
|
+
title=title if title is not None else self._get_title(),
|
|
625
|
+
title_align="left",
|
|
626
|
+
border_style=border_style,
|
|
627
|
+
padding=(0, 1),
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
def _spin(self):
|
|
631
|
+
"""Background thread: continuously increment spinner and update display."""
|
|
632
|
+
while not self._stop_spinner.is_set():
|
|
633
|
+
self._spinner_index += 1
|
|
634
|
+
if self._live:
|
|
635
|
+
self._live.update(self._render_panel())
|
|
636
|
+
time.sleep(0.1) # 10 updates per second = smooth animation
|
|
637
|
+
|
|
638
|
+
def __enter__(self):
|
|
639
|
+
"""Start Live display context.
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
self for use in with statement
|
|
643
|
+
"""
|
|
644
|
+
panel = self._render_panel()
|
|
645
|
+
self._live = Live(panel, console=self.console, refresh_per_second=10)
|
|
646
|
+
self._live.__enter__()
|
|
647
|
+
|
|
648
|
+
# Start background spinner thread
|
|
649
|
+
self._spinner_thread = threading.Thread(target=self._spin, daemon=True)
|
|
650
|
+
self._spinner_thread.start()
|
|
651
|
+
|
|
652
|
+
return self
|
|
653
|
+
|
|
654
|
+
def __exit__(self, *args):
|
|
655
|
+
"""End Live display context."""
|
|
656
|
+
self._stop_spinner.set()
|
|
657
|
+
if self._spinner_thread:
|
|
658
|
+
self._spinner_thread.join(timeout=0.5)
|
|
659
|
+
if self._live:
|
|
660
|
+
self._live.__exit__(*args)
|
|
661
|
+
|
|
662
|
+
def add_tool_call(self, tool_name, tool_result=None, command=None):
|
|
663
|
+
"""Add a tool call message to the panel and refresh display.
|
|
664
|
+
|
|
665
|
+
Args:
|
|
666
|
+
tool_name: Name of the tool (e.g., "read_file", "rg")
|
|
667
|
+
tool_result: Optional tool result string (for detailed formatting)
|
|
668
|
+
command: Optional command string for context
|
|
669
|
+
"""
|
|
670
|
+
# If no tool_result provided, just show tool name (backward compatibility)
|
|
671
|
+
if tool_result is None:
|
|
672
|
+
message = f"[grey]{tool_name}[/grey]"
|
|
673
|
+
self.total_tool_calls += 1
|
|
674
|
+
self.tool_calls.append(message)
|
|
675
|
+
if len(self.tool_calls) > 5:
|
|
676
|
+
self.tool_calls.pop(0)
|
|
677
|
+
self._live.update(self._render_panel())
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
# Full implementation with tool_result
|
|
681
|
+
self.total_tool_calls += 1
|
|
682
|
+
|
|
683
|
+
# Format message based on tool type, matching main agent styling
|
|
684
|
+
if tool_name == "read_file":
|
|
685
|
+
# Extract path from command if available
|
|
686
|
+
path = ""
|
|
687
|
+
if command:
|
|
688
|
+
# Command format is "read_file: path" (parallel) or "read_file path" (sequential)
|
|
689
|
+
match = re.search(r'read_file:?\s+(.+)', command)
|
|
690
|
+
if match:
|
|
691
|
+
path = match.group(1).strip()
|
|
692
|
+
|
|
693
|
+
# Parse lines_read from result
|
|
694
|
+
first_line = tool_result.split('\n')[0]
|
|
695
|
+
match = re.search(r'lines_read=(\d+)', first_line)
|
|
696
|
+
start_match = re.search(r'start_line=(\d+)', first_line)
|
|
697
|
+
|
|
698
|
+
if match:
|
|
699
|
+
count = int(match.group(1))
|
|
700
|
+
if start_match:
|
|
701
|
+
start_line = int(start_match.group(1))
|
|
702
|
+
if start_line > 1:
|
|
703
|
+
end_line = start_line + count - 1
|
|
704
|
+
message = f"[grey]read_file {path}[/grey]\n[dim]╰─ Read lines {start_line}-{end_line} ({count} line{'s' if count != 1 else ''})[/dim]"
|
|
705
|
+
else:
|
|
706
|
+
message = f"[grey]read_file {path}[/grey]\n[dim]╰─ Read {count} line{'s' if count != 1 else ''}[/dim]"
|
|
707
|
+
else:
|
|
708
|
+
message = f"[grey]read_file {path}[/grey]\n[dim]╰─ Read {count} line{'s' if count != 1 else ''}[/dim]"
|
|
709
|
+
else:
|
|
710
|
+
message = f"[grey]read_file {path}[/grey]"
|
|
711
|
+
|
|
712
|
+
elif tool_name == "rg":
|
|
713
|
+
# Extract pattern from command if available
|
|
714
|
+
pattern = ""
|
|
715
|
+
if command:
|
|
716
|
+
# Command format is "rg: pattern" (parallel) or "rg pattern" (sequential)
|
|
717
|
+
match = re.search(r'rg:?\s+(.+)', command)
|
|
718
|
+
if match:
|
|
719
|
+
pattern = match.group(1).strip()
|
|
720
|
+
|
|
721
|
+
# Parse matches/files from result
|
|
722
|
+
lines = tool_result.split('\n')
|
|
723
|
+
if len(lines) > 1:
|
|
724
|
+
match = re.search(r'(matches|files)=(\d+)', lines[1])
|
|
725
|
+
if match:
|
|
726
|
+
count = int(match.group(2))
|
|
727
|
+
label = match.group(1)
|
|
728
|
+
if count == 0:
|
|
729
|
+
message = f"[grey]rg {pattern}[/grey]\n[dim]╰─ No {label} found[/dim]"
|
|
730
|
+
else:
|
|
731
|
+
message = f"[grey]rg {pattern}[/grey]\n[dim]╰─ Found {count} {label}[/dim]"
|
|
732
|
+
else:
|
|
733
|
+
message = f"[grey]rg {pattern}[/grey]"
|
|
734
|
+
else:
|
|
735
|
+
message = f"[grey]rg {pattern}[/grey]"
|
|
736
|
+
|
|
737
|
+
elif tool_name == "list_directory":
|
|
738
|
+
# Extract path from command if available
|
|
739
|
+
path = "."
|
|
740
|
+
if command:
|
|
741
|
+
# Command format is "list_directory: path" (parallel) or "list_directory path" (sequential)
|
|
742
|
+
match = re.search(r'list_directory:?\s+(.+)', command)
|
|
743
|
+
if match:
|
|
744
|
+
path = match.group(1).strip()
|
|
745
|
+
|
|
746
|
+
# Parse items_count from result
|
|
747
|
+
lines = tool_result.split('\n')
|
|
748
|
+
items_count = 0
|
|
749
|
+
for line in lines:
|
|
750
|
+
match = re.search(r'items_count=(\d+)', line)
|
|
751
|
+
if match:
|
|
752
|
+
items_count = int(match.group(1))
|
|
753
|
+
break
|
|
754
|
+
|
|
755
|
+
if items_count > 0:
|
|
756
|
+
message = f"[grey]list_directory {path}[/grey]\n[dim]╰─ {items_count} item{'s' if items_count != 1 else ''}[/dim]"
|
|
757
|
+
else:
|
|
758
|
+
message = f"[grey]list_directory {path}[/grey]\n[dim]╰─ No items[/dim]"
|
|
759
|
+
|
|
760
|
+
elif tool_name == "web_search":
|
|
761
|
+
# Extract query from command if available
|
|
762
|
+
query = ""
|
|
763
|
+
if command:
|
|
764
|
+
# Command format is "web search | query"
|
|
765
|
+
if "|" in command:
|
|
766
|
+
parts = command.split(" | ", 1)
|
|
767
|
+
if len(parts) > 1:
|
|
768
|
+
query = parts[1]
|
|
769
|
+
if query:
|
|
770
|
+
message = f"[bold cyan]web search | {query}[/bold cyan]\n[dim]╰─ Search completed[/dim]"
|
|
771
|
+
else:
|
|
772
|
+
message = f"[bold cyan]web_search[/bold cyan]\n[dim]╰─ Search completed[/dim]"
|
|
773
|
+
|
|
774
|
+
elif tool_name == "execute_command":
|
|
775
|
+
# Extract command from the command parameter if available
|
|
776
|
+
cmd_display = ""
|
|
777
|
+
if command:
|
|
778
|
+
# Command format is "execute_command: cmd" or just the cmd itself
|
|
779
|
+
if command.startswith("execute_command"):
|
|
780
|
+
parts = command.split(' ', 1)
|
|
781
|
+
if len(parts) > 1:
|
|
782
|
+
cmd_display = parts[1]
|
|
783
|
+
else:
|
|
784
|
+
# Just the command itself (from label builder)
|
|
785
|
+
cmd_display = command
|
|
786
|
+
if cmd_display:
|
|
787
|
+
message = f"[grey]{cmd_display}[/grey]\n[dim]╰─ Command executed[/dim]"
|
|
788
|
+
else:
|
|
789
|
+
message = f"[grey]execute_command[/grey]\n[dim]╰─ Command executed[/dim]"
|
|
790
|
+
|
|
791
|
+
elif tool_name in ("create_task_list", "complete_task", "show_task_list"):
|
|
792
|
+
# Handle task list tools - show the task list content
|
|
793
|
+
exit_code = _get_exit_code(tool_result)
|
|
794
|
+
if exit_code == 0 or exit_code is None:
|
|
795
|
+
rendered = tool_result
|
|
796
|
+
if rendered.startswith("exit_code="):
|
|
797
|
+
rendered = "\n".join(rendered.splitlines()[1:])
|
|
798
|
+
message = f"[grey]{tool_name}[/grey]\n[dim]╰─ {rendered.strip()}[/dim]"
|
|
799
|
+
else:
|
|
800
|
+
first_two = "\n".join(tool_result.splitlines()[:2]).strip()
|
|
801
|
+
message = f"[grey]{tool_name}[/grey]\n[dim]╰─ {first_two or tool_result.strip()}[/dim]"
|
|
802
|
+
|
|
803
|
+
else:
|
|
804
|
+
# Generic fallback
|
|
805
|
+
message = f"[grey]{tool_name}[/grey]"
|
|
806
|
+
|
|
807
|
+
self.tool_calls.append(message)
|
|
808
|
+
# Keep only last 5 tool calls
|
|
809
|
+
if len(self.tool_calls) > 5:
|
|
810
|
+
self.tool_calls.pop(0)
|
|
811
|
+
self._live.update(self._render_panel())
|
|
812
|
+
|
|
813
|
+
def append(self, text):
|
|
814
|
+
"""Append text to panel and refresh display (kept for compatibility).
|
|
815
|
+
|
|
816
|
+
Args:
|
|
817
|
+
text: Text to append (may contain Rich markup)
|
|
818
|
+
"""
|
|
819
|
+
# For now, just update panel to refresh title counter
|
|
820
|
+
self._live.update(self._render_panel())
|
|
821
|
+
|
|
822
|
+
def set_complete(self, usage=None):
|
|
823
|
+
"""Mark panel as complete with optional token info.
|
|
824
|
+
|
|
825
|
+
Args:
|
|
826
|
+
usage: Optional dict with 'prompt', 'completion', 'total' token counts
|
|
827
|
+
"""
|
|
828
|
+
self._show_spinner = False # Stop spinner
|
|
829
|
+
|
|
830
|
+
# Update panel with green complete title showing total tool calls
|
|
831
|
+
self._live.update(self._render_panel(
|
|
832
|
+
title=f"[green]✓ Sub-Agent Complete ({self.total_tool_calls})[/green]",
|
|
833
|
+
border_style="green"
|
|
834
|
+
))
|
|
835
|
+
|
|
836
|
+
def set_error(self, message):
|
|
837
|
+
"""Show error in panel with red styling.
|
|
838
|
+
|
|
839
|
+
Args:
|
|
840
|
+
message: Error message to display
|
|
841
|
+
"""
|
|
842
|
+
self._show_spinner = False # Stop spinner
|
|
843
|
+
self._live.update(Panel(
|
|
844
|
+
message,
|
|
845
|
+
title="[red]✗ Sub-Agent Error[/red]",
|
|
846
|
+
title_align="left",
|
|
847
|
+
border_style="red",
|
|
848
|
+
padding=(0, 1),
|
|
849
|
+
))
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
class AgenticOrchestrator:
|
|
853
|
+
"""Orchestrates the agentic tool-calling loop.
|
|
854
|
+
|
|
855
|
+
This class encapsulates the complex logic of coordinating LLM interactions
|
|
856
|
+
with tool calling, providing a cleaner, more maintainable structure.
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
def __init__(self, chat_manager, repo_root, rg_exe_path, console, debug_mode, suppress_result_display=False, is_sub_agent=False, panel_updater=None, pre_tool_planning_enabled=False, force_parallel_execution=False):
|
|
860
|
+
"""Initialize the orchestrator.
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
chat_manager: ChatManager instance for state management
|
|
864
|
+
repo_root: Path to repository root
|
|
865
|
+
rg_exe_path: Path to rg.exe
|
|
866
|
+
console: Rich console for output
|
|
867
|
+
debug_mode: Whether to show debug output
|
|
868
|
+
suppress_result_display: If True, suppress final LLM response display (for research agent)
|
|
869
|
+
is_sub_agent: If True, running as sub-agent (for visual framing)
|
|
870
|
+
panel_updater: Optional SubAgentPanel callback for live panel updates
|
|
871
|
+
pre_tool_planning_enabled: If True, enable pre-tool planning step
|
|
872
|
+
force_parallel_execution: If True, force parallel execution (for sub-agent)
|
|
873
|
+
"""
|
|
874
|
+
self.chat_manager = chat_manager
|
|
875
|
+
self.repo_root = repo_root
|
|
876
|
+
self.rg_exe_path = rg_exe_path
|
|
877
|
+
self.console = console
|
|
878
|
+
self.debug_mode = debug_mode
|
|
879
|
+
self.suppress_result_display = suppress_result_display
|
|
880
|
+
self.is_sub_agent = is_sub_agent
|
|
881
|
+
self.panel_updater = panel_updater
|
|
882
|
+
self.pre_tool_planning_enabled = pre_tool_planning_enabled
|
|
883
|
+
self.force_parallel_execution = force_parallel_execution
|
|
884
|
+
self.tool_calls_count = 0
|
|
885
|
+
self.empty_response_count = 0
|
|
886
|
+
self.gitignore_spec = chat_manager.get_gitignore_spec(repo_root)
|
|
887
|
+
# For parallel execution: temporary console override
|
|
888
|
+
self._parallel_context = {}
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def _get_console(self):
|
|
892
|
+
"""Get the console for output, respecting parallel execution context.
|
|
893
|
+
|
|
894
|
+
Returns:
|
|
895
|
+
Console object or None if suppressed during parallel execution
|
|
896
|
+
"""
|
|
897
|
+
# Check if we're in a parallel context with suppressed console
|
|
898
|
+
return self._parallel_context.get('console', self.console)
|
|
899
|
+
|
|
900
|
+
def _safe_print(self, message, indent=False):
|
|
901
|
+
"""Print to console if not suppressed.
|
|
902
|
+
|
|
903
|
+
Args:
|
|
904
|
+
message: Message to print
|
|
905
|
+
indent: If True, prefix with '│ ' when in sub-agent mode
|
|
906
|
+
"""
|
|
907
|
+
# During parallel execution, suppress ALL output (we manage display ourselves)
|
|
908
|
+
console = self._get_console()
|
|
909
|
+
if console is None:
|
|
910
|
+
return
|
|
911
|
+
|
|
912
|
+
if self.panel_updater:
|
|
913
|
+
# For sub-agent panel, non-tool messages (like warnings) are appended directly
|
|
914
|
+
self.panel_updater.append(message)
|
|
915
|
+
else:
|
|
916
|
+
if indent and self.is_sub_agent:
|
|
917
|
+
console.print(f"│ {message}", highlight=False)
|
|
918
|
+
else:
|
|
919
|
+
console.print(message, highlight=False)
|
|
920
|
+
|
|
921
|
+
def run(self, user_input, thinking_indicator=None, allowed_tools=None):
|
|
922
|
+
"""Main orchestration loop.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
user_input: User's input message
|
|
926
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
927
|
+
allowed_tools: Optional list of allowed tool names (for research)
|
|
928
|
+
"""
|
|
929
|
+
# Append user message
|
|
930
|
+
self.chat_manager.messages.append({"role": "user", "content": user_input})
|
|
931
|
+
user_msg_idx = len(self.chat_manager.messages) - 1
|
|
932
|
+
|
|
933
|
+
# Log user message
|
|
934
|
+
self.chat_manager.log_message({"role": "user", "content": user_input})
|
|
935
|
+
|
|
936
|
+
while True:
|
|
937
|
+
# Get response from LLM
|
|
938
|
+
response = self._get_llm_response(allowed_tools=allowed_tools)
|
|
939
|
+
if response is None:
|
|
940
|
+
return
|
|
941
|
+
|
|
942
|
+
# Auto-compact if over token threshold (applies to both main agent and subagent)
|
|
943
|
+
self.chat_manager.maybe_auto_compact()
|
|
944
|
+
|
|
945
|
+
# Check for tool calls
|
|
946
|
+
tool_calls = response.get("tool_calls")
|
|
947
|
+
|
|
948
|
+
if not tool_calls:
|
|
949
|
+
if self._handle_final_response(response):
|
|
950
|
+
return
|
|
951
|
+
else:
|
|
952
|
+
should_exit = self._handle_tool_calls(response, thinking_indicator, allowed_tools)
|
|
953
|
+
if should_exit:
|
|
954
|
+
return
|
|
955
|
+
|
|
956
|
+
def _get_llm_response(self, allowed_tools=None):
|
|
957
|
+
"""Get next LLM response with tool definitions.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
allowed_tools: Optional list of allowed tool names (overrides mode-based filtering)
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
Response dict from LLM, or None if error occurred
|
|
964
|
+
"""
|
|
965
|
+
# Use allowed_tools if provided, otherwise use mode-based filtering
|
|
966
|
+
if allowed_tools is not None:
|
|
967
|
+
# Validate that allowed_tools is not empty
|
|
968
|
+
if not allowed_tools:
|
|
969
|
+
self.console.print("[red]Error: allowed_tools is empty[/red]")
|
|
970
|
+
return None
|
|
971
|
+
tools = [tool for tool in TOOLS if tool["function"]["name"] in allowed_tools]
|
|
972
|
+
# Log filtered tools for debugging
|
|
973
|
+
if self.debug_mode:
|
|
974
|
+
tool_names = [t["function"]["name"] for t in tools]
|
|
975
|
+
self.console.print(f"[dim]Available tools: {tool_names}[/dim]")
|
|
976
|
+
else:
|
|
977
|
+
tools = _tools_for_mode(self.chat_manager.interaction_mode)
|
|
978
|
+
|
|
979
|
+
try:
|
|
980
|
+
response = self.chat_manager.client.chat_completion(
|
|
981
|
+
self.chat_manager.messages, stream=False, tools=tools
|
|
982
|
+
)
|
|
983
|
+
except LLMError as e:
|
|
984
|
+
self.console.print(f"[red]LLM Error: {e}[/red]")
|
|
985
|
+
return None
|
|
986
|
+
|
|
987
|
+
# Extract and track usage data
|
|
988
|
+
if isinstance(response, dict) and 'usage' in response:
|
|
989
|
+
self.chat_manager.token_tracker.add_usage(response['usage'])
|
|
990
|
+
|
|
991
|
+
try:
|
|
992
|
+
message = response["choices"][0]["message"]
|
|
993
|
+
except (KeyError, IndexError):
|
|
994
|
+
self.console.print("[red]Error: invalid response from model[/red]")
|
|
995
|
+
return None
|
|
996
|
+
|
|
997
|
+
return message
|
|
998
|
+
|
|
999
|
+
def _handle_final_response(self, response):
|
|
1000
|
+
"""Handle non-tool-call response (final answer).
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
response: Message dict from LLM
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
True if handled successfully, False if should continue looping
|
|
1007
|
+
"""
|
|
1008
|
+
content = response.get("content", "")
|
|
1009
|
+
content = _strip_leading_task_list_echo(
|
|
1010
|
+
content,
|
|
1011
|
+
getattr(self.chat_manager, "task_list", None) or [],
|
|
1012
|
+
getattr(self.chat_manager, "task_list_title", None),
|
|
1013
|
+
)
|
|
1014
|
+
# Strip leading "Assistant: " prefix that some models may output
|
|
1015
|
+
content = content.lstrip("Assistant: ").lstrip()
|
|
1016
|
+
if content and content.strip():
|
|
1017
|
+
# Only display to user if result display is not suppressed
|
|
1018
|
+
if not self.suppress_result_display:
|
|
1019
|
+
md = Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left")
|
|
1020
|
+
self.console.print(md)
|
|
1021
|
+
# Always append to message history (AI needs the result regardless)
|
|
1022
|
+
response = dict(response)
|
|
1023
|
+
response["content"] = content
|
|
1024
|
+
self.chat_manager.messages.append(response)
|
|
1025
|
+
# Log assistant response
|
|
1026
|
+
self.chat_manager.log_message(response)
|
|
1027
|
+
|
|
1028
|
+
# NEW: Compact tool results after final answer (per-message compaction)
|
|
1029
|
+
self.chat_manager.compact_tool_results()
|
|
1030
|
+
|
|
1031
|
+
# Update context tokens with current mode's tools
|
|
1032
|
+
tools_for_mode = _tools_for_mode(self.chat_manager.interaction_mode)
|
|
1033
|
+
self.chat_manager._update_context_tokens(tools_for_mode)
|
|
1034
|
+
|
|
1035
|
+
self.console.print()
|
|
1036
|
+
return True
|
|
1037
|
+
|
|
1038
|
+
# Empty response with no tools
|
|
1039
|
+
should_continue, self.empty_response_count = _handle_empty_response(
|
|
1040
|
+
self.empty_response_count, self.console
|
|
1041
|
+
)
|
|
1042
|
+
return not should_continue
|
|
1043
|
+
|
|
1044
|
+
def _handle_tool_calls(self, response, thinking_indicator, allowed_tools=None):
|
|
1045
|
+
"""Process tool calls and display accompanying content.
|
|
1046
|
+
|
|
1047
|
+
Args:
|
|
1048
|
+
response: Full message dict from LLM (includes content and tool_calls)
|
|
1049
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1050
|
+
allowed_tools: Optional list of allowed tool names
|
|
1051
|
+
|
|
1052
|
+
Returns:
|
|
1053
|
+
True if should exit the orchestration loop
|
|
1054
|
+
"""
|
|
1055
|
+
# Extract tool_calls from response
|
|
1056
|
+
tool_calls = response.get("tool_calls")
|
|
1057
|
+
if not tool_calls:
|
|
1058
|
+
return False # Should not happen if called correctly
|
|
1059
|
+
|
|
1060
|
+
self.empty_response_count = 0
|
|
1061
|
+
self.tool_calls_count += 1
|
|
1062
|
+
|
|
1063
|
+
if self.tool_calls_count > MAX_TOOL_CALLS:
|
|
1064
|
+
return not _handle_tool_limit_reached(self.chat_manager, self.console)
|
|
1065
|
+
|
|
1066
|
+
# Display conversational content if present
|
|
1067
|
+
# Skip if calling sub_agent OR if we ARE a sub-agent (sub-agent panel provides context)
|
|
1068
|
+
is_calling_sub_agent = any(
|
|
1069
|
+
tool.get("function", {}).get("name") == "sub_agent"
|
|
1070
|
+
for tool in tool_calls
|
|
1071
|
+
)
|
|
1072
|
+
content = (response.get("content") or "").strip()
|
|
1073
|
+
# Route to panel if we're a sub-agent with a panel_updater, otherwise print to console
|
|
1074
|
+
if content:
|
|
1075
|
+
if self.is_sub_agent and self.panel_updater:
|
|
1076
|
+
# Sub-agent: send thinking to panel instead of console
|
|
1077
|
+
self.panel_updater.append(content)
|
|
1078
|
+
elif not is_calling_sub_agent:
|
|
1079
|
+
# Main agent: print to console (unless calling sub_agent)
|
|
1080
|
+
md = Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left")
|
|
1081
|
+
self.console.print(md)
|
|
1082
|
+
self.console.print()
|
|
1083
|
+
|
|
1084
|
+
# Append assistant message with tool calls (include content if present)
|
|
1085
|
+
assistant_msg = {"role": "assistant", "tool_calls": tool_calls}
|
|
1086
|
+
if content:
|
|
1087
|
+
assistant_msg["content"] = content
|
|
1088
|
+
self.chat_manager.messages.append(assistant_msg)
|
|
1089
|
+
# Log assistant tool call message
|
|
1090
|
+
self.chat_manager.log_message(assistant_msg)
|
|
1091
|
+
|
|
1092
|
+
# Check if we should use parallel execution
|
|
1093
|
+
from utils.settings import tool_settings
|
|
1094
|
+
use_parallel = (
|
|
1095
|
+
tool_settings.enable_parallel_execution and
|
|
1096
|
+
len(tool_calls) > 1 and
|
|
1097
|
+
(self.chat_manager.interaction_mode != "plan" or self.force_parallel_execution) # Sequential in plan mode unless forced
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
# Force sequential if any edit_file or execute_command in the batch (safety)
|
|
1101
|
+
if use_parallel:
|
|
1102
|
+
for tool_call in tool_calls:
|
|
1103
|
+
tool_name = tool_call.get("function", {}).get("name")
|
|
1104
|
+
if tool_name == "edit_file":
|
|
1105
|
+
use_parallel = False
|
|
1106
|
+
if self.debug_mode:
|
|
1107
|
+
self.console.print("[dim]Forcing sequential execution (edit_file detected)[/dim]")
|
|
1108
|
+
break
|
|
1109
|
+
elif tool_name == "execute_command":
|
|
1110
|
+
use_parallel = False
|
|
1111
|
+
if self.debug_mode:
|
|
1112
|
+
self.console.print("[dim]Forcing sequential execution (execute_command detected)[/dim]")
|
|
1113
|
+
break
|
|
1114
|
+
elif tool_name == "sub_agent":
|
|
1115
|
+
use_parallel = False
|
|
1116
|
+
if self.debug_mode:
|
|
1117
|
+
self.console.print("[dim]Forcing sequential execution (sub_agent detected)[/dim]")
|
|
1118
|
+
break
|
|
1119
|
+
|
|
1120
|
+
if use_parallel and self.debug_mode:
|
|
1121
|
+
self.console.print(f"[cyan]Executing {len(tool_calls)} tools in parallel[/cyan]")
|
|
1122
|
+
|
|
1123
|
+
if use_parallel:
|
|
1124
|
+
return self._execute_tools_parallel(response, thinking_indicator)
|
|
1125
|
+
else:
|
|
1126
|
+
return self._execute_tools_sequential(tool_calls, thinking_indicator)
|
|
1127
|
+
|
|
1128
|
+
def _execute_tools_sequential(self, tool_calls, thinking_indicator):
|
|
1129
|
+
"""Execute tools one at a time (original behavior).
|
|
1130
|
+
|
|
1131
|
+
Args:
|
|
1132
|
+
tool_calls: List of tool call dicts from LLM
|
|
1133
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1134
|
+
|
|
1135
|
+
Returns:
|
|
1136
|
+
True if should exit the orchestration loop
|
|
1137
|
+
"""
|
|
1138
|
+
end_loop = False
|
|
1139
|
+
|
|
1140
|
+
for tool_call in tool_calls:
|
|
1141
|
+
tool_id = tool_call["id"]
|
|
1142
|
+
function_name = tool_call["function"]["name"]
|
|
1143
|
+
|
|
1144
|
+
should_exit, tool_result = self._process_single_tool_call(
|
|
1145
|
+
tool_call, thinking_indicator
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
if should_exit:
|
|
1149
|
+
end_loop = True
|
|
1150
|
+
|
|
1151
|
+
# Append tool result if not skipped (guidance mode)
|
|
1152
|
+
if tool_result is not None and tool_result is not False:
|
|
1153
|
+
tool_msg = {
|
|
1154
|
+
"role": "tool",
|
|
1155
|
+
"tool_call_id": tool_id,
|
|
1156
|
+
"content": tool_result
|
|
1157
|
+
}
|
|
1158
|
+
self.chat_manager.messages.append(tool_msg)
|
|
1159
|
+
# Log tool result
|
|
1160
|
+
self.chat_manager.log_message(tool_msg)
|
|
1161
|
+
|
|
1162
|
+
# Update context tokens with current mode's tools
|
|
1163
|
+
tools_for_mode = _tools_for_mode(self.chat_manager.interaction_mode)
|
|
1164
|
+
self.chat_manager._update_context_tokens(tools_for_mode)
|
|
1165
|
+
|
|
1166
|
+
return end_loop
|
|
1167
|
+
|
|
1168
|
+
def _execute_tools_parallel(self, response, thinking_indicator):
|
|
1169
|
+
"""Execute multiple tools concurrently.
|
|
1170
|
+
|
|
1171
|
+
Args:
|
|
1172
|
+
response: Full message dict from LLM (includes content and tool_calls)
|
|
1173
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
True if should exit the orchestration loop
|
|
1177
|
+
"""
|
|
1178
|
+
# Extract tool_calls from response
|
|
1179
|
+
tool_calls = response.get("tool_calls")
|
|
1180
|
+
if not tool_calls:
|
|
1181
|
+
return False
|
|
1182
|
+
from utils.tools.parallel_executor import ParallelToolExecutor, ToolCall
|
|
1183
|
+
|
|
1184
|
+
# Suppress console output in handlers during parallel execution
|
|
1185
|
+
# We'll display results ourselves in order below
|
|
1186
|
+
self._parallel_context['console'] = None
|
|
1187
|
+
|
|
1188
|
+
try:
|
|
1189
|
+
# Build handler map
|
|
1190
|
+
handler_map = {
|
|
1191
|
+
"rg": self._handle_rg,
|
|
1192
|
+
"read_file": self._handle_read_file,
|
|
1193
|
+
"list_directory": self._handle_list_directory,
|
|
1194
|
+
"create_file": self._handle_create_file,
|
|
1195
|
+
"edit_file": self._handle_edit_file,
|
|
1196
|
+
"web_search": self._handle_web_search,
|
|
1197
|
+
"sub_agent": self._handle_sub_agent,
|
|
1198
|
+
"create_task_list": self._handle_create_task_list,
|
|
1199
|
+
"complete_task": self._handle_complete_task,
|
|
1200
|
+
"show_task_list": self._handle_show_task_list,
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
# Prepare context
|
|
1204
|
+
context = {
|
|
1205
|
+
'thinking_indicator': thinking_indicator,
|
|
1206
|
+
'repo_root': self.repo_root,
|
|
1207
|
+
'chat_manager': self.chat_manager,
|
|
1208
|
+
'rg_exe_path': self.rg_exe_path,
|
|
1209
|
+
'debug_mode': self.debug_mode,
|
|
1210
|
+
'gitignore_spec': self.gitignore_spec,
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
# Convert to ToolCall objects
|
|
1214
|
+
tool_call_objs = []
|
|
1215
|
+
for i, tc in enumerate(tool_calls):
|
|
1216
|
+
try:
|
|
1217
|
+
arguments = json.loads(tc["function"]["arguments"])
|
|
1218
|
+
except json.JSONDecodeError:
|
|
1219
|
+
# Invalid JSON - handle inline for this tool
|
|
1220
|
+
self.chat_manager.log_message({
|
|
1221
|
+
"role": "tool",
|
|
1222
|
+
"tool_call_id": tc["id"],
|
|
1223
|
+
"content": "exit_code=1\nInvalid JSON arguments"
|
|
1224
|
+
})
|
|
1225
|
+
continue
|
|
1226
|
+
|
|
1227
|
+
tool_call_objs.append(
|
|
1228
|
+
ToolCall(
|
|
1229
|
+
tool_id=tc["id"],
|
|
1230
|
+
function_name=tc["function"]["name"],
|
|
1231
|
+
arguments=arguments,
|
|
1232
|
+
call_index=i
|
|
1233
|
+
)
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
if not tool_call_objs:
|
|
1237
|
+
# All tools had invalid arguments
|
|
1238
|
+
return False
|
|
1239
|
+
|
|
1240
|
+
# Create executor
|
|
1241
|
+
from utils.settings import tool_settings
|
|
1242
|
+
executor = ParallelToolExecutor(
|
|
1243
|
+
max_workers=tool_settings.max_parallel_workers
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
# Execute in parallel
|
|
1247
|
+
results, had_errors = executor.execute_tools(
|
|
1248
|
+
tool_call_objs,
|
|
1249
|
+
handler_map,
|
|
1250
|
+
context
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
# Display results with labels (staggered: label → feedback, like sequential mode)
|
|
1254
|
+
for result in results:
|
|
1255
|
+
if result.success:
|
|
1256
|
+
# Get tool call info
|
|
1257
|
+
tool_call = tool_calls[result.call_index]
|
|
1258
|
+
function_name = tool_call.get("function", {}).get("name", "")
|
|
1259
|
+
arguments = tool_call.get("function", {}).get("arguments", "{}")
|
|
1260
|
+
args_dict = json.loads(arguments) if isinstance(arguments, str) else arguments
|
|
1261
|
+
|
|
1262
|
+
# Label builders
|
|
1263
|
+
label_builders = {
|
|
1264
|
+
"rg": lambda a: f"rg: {a.get('pattern', '')[:40]}",
|
|
1265
|
+
"read_file": lambda a: _build_read_file_label(
|
|
1266
|
+
a.get('path', ''),
|
|
1267
|
+
a.get('start_line'),
|
|
1268
|
+
a.get('max_lines'),
|
|
1269
|
+
with_colon=True
|
|
1270
|
+
),
|
|
1271
|
+
"list_directory": lambda a: f"list_directory: {a.get('path', '')}",
|
|
1272
|
+
"create_file": lambda a: f"create_file: {a.get('path', '')}",
|
|
1273
|
+
"web_search": lambda a: f"web search | {a.get('query', '')}",
|
|
1274
|
+
"create_task_list": lambda a: "create_task_list",
|
|
1275
|
+
"complete_task": lambda a: "complete_task",
|
|
1276
|
+
"show_task_list": lambda a: "show_task_list",
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
# Print the label first
|
|
1280
|
+
label_builder = label_builders.get(function_name, lambda a: function_name)
|
|
1281
|
+
try:
|
|
1282
|
+
label = label_builder(args_dict)
|
|
1283
|
+
if function_name == "web_search":
|
|
1284
|
+
label_text = f"[bold cyan]{label}[/bold cyan]"
|
|
1285
|
+
else:
|
|
1286
|
+
label_text = f"[grey]{label}[/grey]"
|
|
1287
|
+
|
|
1288
|
+
# Route to panel_updater for sub-agent, otherwise console
|
|
1289
|
+
# For panel_updater, _display_tool_feedback will handle the complete display
|
|
1290
|
+
if not self.panel_updater:
|
|
1291
|
+
self.console.print(label_text, highlight=False)
|
|
1292
|
+
# Force flush to ensure label appears immediately
|
|
1293
|
+
self.console.file.flush()
|
|
1294
|
+
except:
|
|
1295
|
+
label_text = f"[grey]{function_name}[/grey]"
|
|
1296
|
+
if not self.panel_updater:
|
|
1297
|
+
self.console.print(label_text, highlight=False)
|
|
1298
|
+
self.console.file.flush()
|
|
1299
|
+
|
|
1300
|
+
# Display feedback immediately after label (no buffering)
|
|
1301
|
+
try:
|
|
1302
|
+
if function_name == "edit_file":
|
|
1303
|
+
pass # Edit results displayed by preview
|
|
1304
|
+
elif label:
|
|
1305
|
+
_display_tool_feedback(label, result.result, self.console, panel_updater=self.panel_updater)
|
|
1306
|
+
# Force flush to ensure immediate output
|
|
1307
|
+
if not self.panel_updater:
|
|
1308
|
+
self.console.file.flush()
|
|
1309
|
+
else:
|
|
1310
|
+
completion_text = f"[dim]{function_name} completed[/dim]"
|
|
1311
|
+
if self.panel_updater:
|
|
1312
|
+
self.panel_updater.append(completion_text)
|
|
1313
|
+
else:
|
|
1314
|
+
self.console.print(completion_text, highlight=False)
|
|
1315
|
+
self.console.file.flush()
|
|
1316
|
+
except:
|
|
1317
|
+
completion_text = f"[dim]{function_name} completed[/dim]"
|
|
1318
|
+
if self.panel_updater:
|
|
1319
|
+
self.panel_updater.append(completion_text)
|
|
1320
|
+
else:
|
|
1321
|
+
self.console.print(completion_text, highlight=False)
|
|
1322
|
+
self.console.file.flush()
|
|
1323
|
+
else:
|
|
1324
|
+
error_msg = result.error or result.result
|
|
1325
|
+
error_text = f"[red]{error_msg}[/red]"
|
|
1326
|
+
if self.panel_updater:
|
|
1327
|
+
self.panel_updater.append(error_text)
|
|
1328
|
+
else:
|
|
1329
|
+
self.console.print(error_text, markup=False)
|
|
1330
|
+
self.console.file.flush()
|
|
1331
|
+
|
|
1332
|
+
# Display summary
|
|
1333
|
+
success_count = sum(1 for r in results if r.success)
|
|
1334
|
+
if self.debug_mode:
|
|
1335
|
+
self.console.print(
|
|
1336
|
+
f"[dim]Parallel execution: {success_count}/{len(results)} succeeded[/dim]"
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
# Append all results to chat history
|
|
1340
|
+
end_loop = False
|
|
1341
|
+
for result in results:
|
|
1342
|
+
if result.success:
|
|
1343
|
+
# Check if tool requested exit
|
|
1344
|
+
if result.should_exit:
|
|
1345
|
+
end_loop = True
|
|
1346
|
+
|
|
1347
|
+
tool_msg = {
|
|
1348
|
+
"role": "tool",
|
|
1349
|
+
"tool_call_id": result.tool_id,
|
|
1350
|
+
"content": result.result
|
|
1351
|
+
}
|
|
1352
|
+
self.chat_manager.messages.append(tool_msg)
|
|
1353
|
+
# Log tool result
|
|
1354
|
+
self.chat_manager.log_message(tool_msg)
|
|
1355
|
+
else:
|
|
1356
|
+
# Tool failed
|
|
1357
|
+
error_msg = result.error or result.result
|
|
1358
|
+
tool_msg = {
|
|
1359
|
+
"role": "tool",
|
|
1360
|
+
"tool_call_id": result.tool_id,
|
|
1361
|
+
"content": f"exit_code=1\n{error_msg}"
|
|
1362
|
+
}
|
|
1363
|
+
self.chat_manager.messages.append(tool_msg)
|
|
1364
|
+
# Log tool result
|
|
1365
|
+
self.chat_manager.log_message(tool_msg)
|
|
1366
|
+
|
|
1367
|
+
# Update context tokens with current mode's tools
|
|
1368
|
+
tools_for_mode = _tools_for_mode(self.chat_manager.interaction_mode)
|
|
1369
|
+
self.chat_manager._update_context_tokens(tools_for_mode)
|
|
1370
|
+
|
|
1371
|
+
return end_loop
|
|
1372
|
+
finally:
|
|
1373
|
+
# Restore console output
|
|
1374
|
+
self._parallel_context['console'] = self.console
|
|
1375
|
+
|
|
1376
|
+
def _process_single_tool_call(self, tool_call, thinking_indicator):
|
|
1377
|
+
"""Process a single tool call.
|
|
1378
|
+
|
|
1379
|
+
Args:
|
|
1380
|
+
tool_call: Tool call dict from LLM
|
|
1381
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1382
|
+
|
|
1383
|
+
Returns:
|
|
1384
|
+
Tuple of (should_exit, tool_result)
|
|
1385
|
+
- should_exit: True if should exit orchestration loop
|
|
1386
|
+
- tool_result: Result string, or None if already appended, False if skipped
|
|
1387
|
+
"""
|
|
1388
|
+
tool_id = tool_call["id"]
|
|
1389
|
+
function_name = tool_call["function"]["name"]
|
|
1390
|
+
|
|
1391
|
+
# Check for edit_file in plan mode
|
|
1392
|
+
if function_name == "edit_file" and self.chat_manager.interaction_mode == "plan":
|
|
1393
|
+
return False, "exit_code=1\nedit_file is disabled in plan mode. Focus on theoretical outlines and provide a summary of changes at the end."
|
|
1394
|
+
|
|
1395
|
+
# Parse arguments
|
|
1396
|
+
try:
|
|
1397
|
+
args_str = tool_call["function"]["arguments"]
|
|
1398
|
+
if args_str is None:
|
|
1399
|
+
return False, "Error: Tool arguments are missing."
|
|
1400
|
+
arguments = json.loads(args_str)
|
|
1401
|
+
except (json.JSONDecodeError, TypeError):
|
|
1402
|
+
return False, "Error: Invalid JSON arguments."
|
|
1403
|
+
|
|
1404
|
+
# Route to appropriate handler
|
|
1405
|
+
handler = self._get_tool_handler(function_name)
|
|
1406
|
+
if handler:
|
|
1407
|
+
return handler(tool_id, arguments, thinking_indicator)
|
|
1408
|
+
else:
|
|
1409
|
+
return False, f"Error: Unknown tool '{function_name}'."
|
|
1410
|
+
|
|
1411
|
+
def _get_tool_handler(self, function_name):
|
|
1412
|
+
"""Return handler function for given tool name.
|
|
1413
|
+
|
|
1414
|
+
Args:
|
|
1415
|
+
function_name: Name of the tool function
|
|
1416
|
+
|
|
1417
|
+
Returns:
|
|
1418
|
+
Handler method or None
|
|
1419
|
+
"""
|
|
1420
|
+
handlers = {
|
|
1421
|
+
"rg": self._handle_rg,
|
|
1422
|
+
"execute_command": self._handle_execute_command,
|
|
1423
|
+
"read_file": self._handle_read_file,
|
|
1424
|
+
"list_directory": self._handle_list_directory,
|
|
1425
|
+
"create_file": self._handle_create_file,
|
|
1426
|
+
"edit_file": self._handle_edit_file,
|
|
1427
|
+
"web_search": self._handle_web_search,
|
|
1428
|
+
"sub_agent": self._handle_sub_agent,
|
|
1429
|
+
"create_task_list": self._handle_create_task_list,
|
|
1430
|
+
"complete_task": self._handle_complete_task,
|
|
1431
|
+
"show_task_list": self._handle_show_task_list,
|
|
1432
|
+
}
|
|
1433
|
+
return handlers.get(function_name)
|
|
1434
|
+
|
|
1435
|
+
def _handle_rg(self, tool_id, arguments, thinking_indicator):
|
|
1436
|
+
"""Handle rg (ripgrep) tool call.
|
|
1437
|
+
|
|
1438
|
+
Args:
|
|
1439
|
+
tool_id: Tool call ID
|
|
1440
|
+
arguments: Tool arguments dict
|
|
1441
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1442
|
+
|
|
1443
|
+
Returns:
|
|
1444
|
+
Tuple of (should_exit, tool_result)
|
|
1445
|
+
"""
|
|
1446
|
+
pattern = arguments.get("pattern", "")
|
|
1447
|
+
if not isinstance(pattern, str) or not pattern.strip():
|
|
1448
|
+
return False, "exit_code=1\nrg requires a non-empty 'pattern' argument."
|
|
1449
|
+
|
|
1450
|
+
# Build rg command from arguments
|
|
1451
|
+
cmd_parts = ["rg"]
|
|
1452
|
+
|
|
1453
|
+
# Add --line-number for all searches
|
|
1454
|
+
cmd_parts.append("--line-number")
|
|
1455
|
+
|
|
1456
|
+
# Add max-filesize if specified
|
|
1457
|
+
if arguments.get("max_filesize"):
|
|
1458
|
+
cmd_parts.append(f"--max-filesize={arguments['max_filesize']}")
|
|
1459
|
+
|
|
1460
|
+
# Add context if specified
|
|
1461
|
+
context = arguments.get("context")
|
|
1462
|
+
if context:
|
|
1463
|
+
if context.startswith("before:"):
|
|
1464
|
+
try:
|
|
1465
|
+
n = int(context.split(":", 1)[1])
|
|
1466
|
+
cmd_parts.append(f"--before-context={n}")
|
|
1467
|
+
except (ValueError, IndexError):
|
|
1468
|
+
pass
|
|
1469
|
+
elif context.startswith("after:"):
|
|
1470
|
+
try:
|
|
1471
|
+
n = int(context.split(":", 1)[1])
|
|
1472
|
+
cmd_parts.append(f"--after-context={n}")
|
|
1473
|
+
except (ValueError, IndexError):
|
|
1474
|
+
pass
|
|
1475
|
+
elif context.startswith("both:"):
|
|
1476
|
+
try:
|
|
1477
|
+
n = int(context.split(":", 1)[1])
|
|
1478
|
+
cmd_parts.append(f"--context={n}")
|
|
1479
|
+
except (ValueError, IndexError):
|
|
1480
|
+
pass
|
|
1481
|
+
|
|
1482
|
+
# Add file type filter if specified
|
|
1483
|
+
file_type = arguments.get("type")
|
|
1484
|
+
if file_type:
|
|
1485
|
+
cmd_parts.append(f"--type={file_type}")
|
|
1486
|
+
|
|
1487
|
+
# Add files-with-matches flag if specified
|
|
1488
|
+
files_with_matches = _coerce_bool(arguments.get("files_with_matches"), default=False)
|
|
1489
|
+
if files_with_matches:
|
|
1490
|
+
cmd_parts.append("--files-with-matches")
|
|
1491
|
+
|
|
1492
|
+
# Add pattern - quote if it contains spaces to prevent splitting by shlex
|
|
1493
|
+
import shlex
|
|
1494
|
+
if " " in pattern:
|
|
1495
|
+
cmd_parts.append(shlex.quote(pattern))
|
|
1496
|
+
else:
|
|
1497
|
+
cmd_parts.append(pattern)
|
|
1498
|
+
|
|
1499
|
+
# Add path (default to current directory)
|
|
1500
|
+
path = arguments.get("path") or "."
|
|
1501
|
+
cmd_parts.append(path)
|
|
1502
|
+
|
|
1503
|
+
# Build command string
|
|
1504
|
+
command = " ".join(cmd_parts)
|
|
1505
|
+
|
|
1506
|
+
# Check for duplicates
|
|
1507
|
+
is_duplicate, redirect_msg = check_for_duplicate(self.chat_manager, command)
|
|
1508
|
+
if is_duplicate:
|
|
1509
|
+
if self.debug_mode:
|
|
1510
|
+
self._safe_print("[yellow]Duplicate command[/yellow]")
|
|
1511
|
+
return False, redirect_msg
|
|
1512
|
+
|
|
1513
|
+
# Execute the rg command (skip full output, show summary only)
|
|
1514
|
+
should_exit, tool_result = self._execute_approved_command(command, indent=self.is_sub_agent, skip_full_output=True)
|
|
1515
|
+
|
|
1516
|
+
return should_exit, tool_result
|
|
1517
|
+
|
|
1518
|
+
def _handle_execute_command(self, tool_id, arguments, thinking_indicator):
|
|
1519
|
+
"""Handle execute_command tool call.
|
|
1520
|
+
|
|
1521
|
+
Args:
|
|
1522
|
+
tool_id: Tool call ID
|
|
1523
|
+
arguments: Tool arguments dict
|
|
1524
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1525
|
+
|
|
1526
|
+
Returns:
|
|
1527
|
+
Tuple of (should_exit, tool_result)
|
|
1528
|
+
"""
|
|
1529
|
+
if "command" not in arguments:
|
|
1530
|
+
return False, "Error: Missing 'command' argument."
|
|
1531
|
+
|
|
1532
|
+
command = arguments["command"]
|
|
1533
|
+
if not isinstance(command, str) or not command.strip():
|
|
1534
|
+
return False, "Error: 'command' argument must be a non-empty string."
|
|
1535
|
+
|
|
1536
|
+
# Check for duplicates
|
|
1537
|
+
is_duplicate, redirect_msg = check_for_duplicate(self.chat_manager, command)
|
|
1538
|
+
if is_duplicate:
|
|
1539
|
+
if self.debug_mode:
|
|
1540
|
+
self.console.print("[yellow]Duplicate command[/yellow]")
|
|
1541
|
+
return False, redirect_msg
|
|
1542
|
+
|
|
1543
|
+
# Validate command (check_command allows git, rg, file operations)
|
|
1544
|
+
is_safe, reason = check_command(command)
|
|
1545
|
+
|
|
1546
|
+
if not is_safe:
|
|
1547
|
+
return False, reason
|
|
1548
|
+
|
|
1549
|
+
# All shell and file operation commands require approval
|
|
1550
|
+
# Strip "powershell " prefix for command name detection
|
|
1551
|
+
cmd_for_check = command.strip()
|
|
1552
|
+
if cmd_for_check.lower().startswith("powershell "):
|
|
1553
|
+
cmd_for_check = cmd_for_check[11:].strip()
|
|
1554
|
+
|
|
1555
|
+
# Tokenize to get command name
|
|
1556
|
+
import shlex
|
|
1557
|
+
use_posix = os.name != "nt"
|
|
1558
|
+
tokens = shlex.split(cmd_for_check, posix=use_posix) if cmd_for_check else []
|
|
1559
|
+
cmd_name = tokens[0].lower() if tokens else ""
|
|
1560
|
+
|
|
1561
|
+
# Path traversal warnings (show but don't block)
|
|
1562
|
+
if self.debug_mode:
|
|
1563
|
+
# Detect absolute paths outside common safe directories
|
|
1564
|
+
abs_paths = []
|
|
1565
|
+
for token in tokens:
|
|
1566
|
+
# Unix absolute paths
|
|
1567
|
+
if token.startswith('/') and not token.startswith(('/home', '/tmp', '/var/log')):
|
|
1568
|
+
abs_paths.append(token)
|
|
1569
|
+
# Windows absolute paths
|
|
1570
|
+
elif len(token) >= 3 and token[1:3] == ':\\' and not token[:3].lower().startswith(('c:\\users', 'd:\\users', 'e:\\users')):
|
|
1571
|
+
abs_paths.append(token)
|
|
1572
|
+
|
|
1573
|
+
if abs_paths:
|
|
1574
|
+
self.console.print(f"[yellow]⚠ Path traversal detected: {abs_paths[0]}[/yellow]")
|
|
1575
|
+
|
|
1576
|
+
# Warn about && chaining
|
|
1577
|
+
if "&&" in command:
|
|
1578
|
+
self.console.print("[dim]→ Using && for conditional chaining[/dim]")
|
|
1579
|
+
|
|
1580
|
+
# Check if command requires approval (whitelist: only safe commands don't require approval)
|
|
1581
|
+
from llm.config import ALLOWED_COMMANDS
|
|
1582
|
+
|
|
1583
|
+
requires_approval = (
|
|
1584
|
+
cmd_name not in ALLOWED_COMMANDS
|
|
1585
|
+
)
|
|
1586
|
+
|
|
1587
|
+
# Special case: git status, diff, log, show, blame are auto-approved
|
|
1588
|
+
# branch is allowed for listing, but not deleting
|
|
1589
|
+
if cmd_name == "git" and requires_approval:
|
|
1590
|
+
git_subcommand = tokens[1].lower() if len(tokens) > 1 else ""
|
|
1591
|
+
if git_subcommand in ("status", "diff", "log", "show", "blame"):
|
|
1592
|
+
requires_approval = False
|
|
1593
|
+
elif git_subcommand == "branch" and not any(arg in tokens for arg in ("-d", "-D", "--delete")):
|
|
1594
|
+
requires_approval = False
|
|
1595
|
+
|
|
1596
|
+
if requires_approval:
|
|
1597
|
+
# Require approval for all commands not in the allowed whitelist
|
|
1598
|
+
return self._handle_command_confirmation(command, None, thinking_indicator, requires_approval)
|
|
1599
|
+
else:
|
|
1600
|
+
# Allowed commands execute without approval
|
|
1601
|
+
return self._execute_approved_command(command, indent=self.is_sub_agent, skip_full_output=False)
|
|
1602
|
+
|
|
1603
|
+
def _execute_approved_command(self, command, indent=False, skip_full_output=False):
|
|
1604
|
+
"""Execute an approved command.
|
|
1605
|
+
|
|
1606
|
+
Args:
|
|
1607
|
+
command: Command string to execute
|
|
1608
|
+
indent: If True, prefix output with '│ ' (for sub-agent mode)
|
|
1609
|
+
skip_full_output: If True, skip displaying full command output (for rg in single-tool mode)
|
|
1610
|
+
|
|
1611
|
+
Returns:
|
|
1612
|
+
Tuple of (should_exit, tool_result)
|
|
1613
|
+
"""
|
|
1614
|
+
# Get console respecting parallel context
|
|
1615
|
+
console = self._get_console()
|
|
1616
|
+
|
|
1617
|
+
if self.panel_updater:
|
|
1618
|
+
# Extract tool name from command
|
|
1619
|
+
tool_name = "execute_command"
|
|
1620
|
+
if command.strip().startswith("rg"):
|
|
1621
|
+
tool_name = "rg"
|
|
1622
|
+
# Note: _display_tool_feedback will add formatted message
|
|
1623
|
+
elif console:
|
|
1624
|
+
# Print to console in normal mode (only if not suppressed)
|
|
1625
|
+
console.print(f"[grey]{command}[/grey]", highlight=False)
|
|
1626
|
+
try:
|
|
1627
|
+
tool_result = run_shell_command(
|
|
1628
|
+
command, self.repo_root, self.rg_exe_path,
|
|
1629
|
+
self.console, self.debug_mode
|
|
1630
|
+
)
|
|
1631
|
+
# Only display feedback if console is available (not in parallel mode)
|
|
1632
|
+
if console:
|
|
1633
|
+
if skip_full_output:
|
|
1634
|
+
# For rg in single-tool mode: display summary only, not full output
|
|
1635
|
+
_display_tool_feedback(command, tool_result, console, indent=indent, panel_updater=self.panel_updater)
|
|
1636
|
+
else:
|
|
1637
|
+
# For execute_command tool: display full output with truncation
|
|
1638
|
+
label = f"execute_command {command}"
|
|
1639
|
+
_display_tool_feedback(label, tool_result, console, indent=indent, panel_updater=self.panel_updater)
|
|
1640
|
+
except CommandExecutionError as e:
|
|
1641
|
+
tool_result = f"exit_code=1\nError: {e}"
|
|
1642
|
+
if self.panel_updater:
|
|
1643
|
+
self.panel_updater.append(f"[red]Command failed: {e}[/red]")
|
|
1644
|
+
elif console:
|
|
1645
|
+
console.print(f"Command failed: {e}", style="red")
|
|
1646
|
+
|
|
1647
|
+
# Only add blank line for non-rg commands (rg commands are compact)
|
|
1648
|
+
is_rg_command = command.strip().startswith("rg")
|
|
1649
|
+
if not is_rg_command:
|
|
1650
|
+
if self.panel_updater:
|
|
1651
|
+
self.panel_updater.append("") # Blank line in panel
|
|
1652
|
+
elif console:
|
|
1653
|
+
console.print()
|
|
1654
|
+
return False, tool_result
|
|
1655
|
+
|
|
1656
|
+
def _handle_command_confirmation(self, command, reason, thinking_indicator, requires_approval=True):
|
|
1657
|
+
"""Handle command confirmation workflow.
|
|
1658
|
+
|
|
1659
|
+
Args:
|
|
1660
|
+
command: Command string
|
|
1661
|
+
reason: Reason for confirmation required
|
|
1662
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1663
|
+
requires_approval: Whether this command specifically requires approval
|
|
1664
|
+
|
|
1665
|
+
Returns:
|
|
1666
|
+
Tuple of (should_exit, tool_result)
|
|
1667
|
+
"""
|
|
1668
|
+
if thinking_indicator:
|
|
1669
|
+
thinking_indicator.pause()
|
|
1670
|
+
confirmation_result, user_guidance = confirm_tool(command, self.console, reason, requires_approval=requires_approval, prompt_session=None)
|
|
1671
|
+
if thinking_indicator:
|
|
1672
|
+
thinking_indicator.resume()
|
|
1673
|
+
|
|
1674
|
+
if confirmation_result == "execute":
|
|
1675
|
+
return self._execute_approved_command(command, skip_full_output=False)
|
|
1676
|
+
elif confirmation_result == "reject":
|
|
1677
|
+
return True, f"Tool request denied by user.\nCommand: {command}"
|
|
1678
|
+
elif confirmation_result == "guide":
|
|
1679
|
+
# Guidance mode - return tool result with user guidance
|
|
1680
|
+
# OpenAI API requires a tool response for every tool_call_id
|
|
1681
|
+
return False, f"User provided guidance: {user_guidance}"
|
|
1682
|
+
|
|
1683
|
+
def _handle_read_file(self, tool_id, arguments, thinking_indicator):
|
|
1684
|
+
"""Handle read_file tool call."""
|
|
1685
|
+
path = arguments.get("path", "")
|
|
1686
|
+
if not isinstance(path, str) or not path.strip():
|
|
1687
|
+
return False, "exit_code=1\nread_file requires a non-empty 'path' argument."
|
|
1688
|
+
|
|
1689
|
+
# Validate path doesn't contain JSON-like syntax or invalid characters
|
|
1690
|
+
# This catches cases where malformed arguments are passed as file paths
|
|
1691
|
+
invalid_chars = '[]{}"\n\r\t'
|
|
1692
|
+
if any(char in path for char in invalid_chars):
|
|
1693
|
+
return False, f"exit_code=1\nread_file 'path' contains invalid characters. Got: {path}"
|
|
1694
|
+
|
|
1695
|
+
max_lines = arguments.get("max_lines")
|
|
1696
|
+
if max_lines is not None:
|
|
1697
|
+
try:
|
|
1698
|
+
max_lines = int(max_lines)
|
|
1699
|
+
except (ValueError, TypeError):
|
|
1700
|
+
return False, "exit_code=1\nread_file 'max_lines' must be an integer."
|
|
1701
|
+
|
|
1702
|
+
start_line = arguments.get("start_line")
|
|
1703
|
+
if start_line is not None:
|
|
1704
|
+
try:
|
|
1705
|
+
start_line = int(start_line)
|
|
1706
|
+
except (ValueError, TypeError):
|
|
1707
|
+
return False, "exit_code=1\nread_file 'start_line' must be an integer (1-based)."
|
|
1708
|
+
else:
|
|
1709
|
+
start_line = 1
|
|
1710
|
+
|
|
1711
|
+
requested_mode = "partial" if max_lines is not None else "full"
|
|
1712
|
+
|
|
1713
|
+
label = _build_read_file_label(path, start_line, max_lines)
|
|
1714
|
+
self._safe_print(f"[grey]{label}[/grey]", indent=self.is_sub_agent)
|
|
1715
|
+
|
|
1716
|
+
tool_result = read_file(
|
|
1717
|
+
path,
|
|
1718
|
+
self.repo_root,
|
|
1719
|
+
max_lines=max_lines,
|
|
1720
|
+
start_line=start_line,
|
|
1721
|
+
gitignore_spec=self.gitignore_spec,
|
|
1722
|
+
)
|
|
1723
|
+
console = self._get_console()
|
|
1724
|
+
if console:
|
|
1725
|
+
_display_tool_feedback(label, tool_result, console, indent=self.is_sub_agent, panel_updater=self.panel_updater)
|
|
1726
|
+
|
|
1727
|
+
if self.debug_mode:
|
|
1728
|
+
console = self._get_console()
|
|
1729
|
+
if console:
|
|
1730
|
+
console.print()
|
|
1731
|
+
console.print(f"[dim]→ AI receives:\n\n{tool_result}\n\n[/dim]")
|
|
1732
|
+
|
|
1733
|
+
exit_code = _get_exit_code(tool_result)
|
|
1734
|
+
return False, tool_result
|
|
1735
|
+
|
|
1736
|
+
def _handle_list_directory(self, tool_id, arguments, thinking_indicator):
|
|
1737
|
+
"""Handle list_directory tool call."""
|
|
1738
|
+
path = arguments.get("path") or "."
|
|
1739
|
+
if not isinstance(path, str):
|
|
1740
|
+
return False, "exit_code=1\nlist_directory 'path' must be a string."
|
|
1741
|
+
|
|
1742
|
+
# Validate path doesn't contain JSON-like syntax or invalid characters
|
|
1743
|
+
invalid_chars = '[]{}"\n\r\t'
|
|
1744
|
+
if any(char in path for char in invalid_chars):
|
|
1745
|
+
return False, f"exit_code=1\nlist_directory 'path' contains invalid characters. Got: {path}"
|
|
1746
|
+
|
|
1747
|
+
recursive = _coerce_bool(arguments.get("recursive"), default=False)
|
|
1748
|
+
show_files = _coerce_bool(arguments.get("show_files"), default=True)
|
|
1749
|
+
show_dirs = _coerce_bool(arguments.get("show_dirs"), default=True)
|
|
1750
|
+
pattern = arguments.get("pattern")
|
|
1751
|
+
|
|
1752
|
+
label = f"list_directory {path}"
|
|
1753
|
+
if recursive:
|
|
1754
|
+
label = f"{label} -recursive"
|
|
1755
|
+
self._safe_print(f"[grey]{label}[/grey]", indent=self.is_sub_agent)
|
|
1756
|
+
|
|
1757
|
+
tool_result = list_directory(
|
|
1758
|
+
path,
|
|
1759
|
+
self.repo_root,
|
|
1760
|
+
recursive=bool(recursive),
|
|
1761
|
+
show_files=bool(show_files),
|
|
1762
|
+
show_dirs=bool(show_dirs),
|
|
1763
|
+
pattern=pattern,
|
|
1764
|
+
gitignore_spec=self.gitignore_spec,
|
|
1765
|
+
)
|
|
1766
|
+
console = self._get_console()
|
|
1767
|
+
if console:
|
|
1768
|
+
_display_tool_feedback(label, tool_result, console, indent=self.is_sub_agent, panel_updater=self.panel_updater)
|
|
1769
|
+
|
|
1770
|
+
if self.debug_mode:
|
|
1771
|
+
console = self._get_console()
|
|
1772
|
+
if console:
|
|
1773
|
+
console.print()
|
|
1774
|
+
console.print(f"[dim]→ AI receives:\n\n{tool_result}\n\n[/dim]")
|
|
1775
|
+
|
|
1776
|
+
return False, tool_result
|
|
1777
|
+
|
|
1778
|
+
def _handle_create_file(self, tool_id, arguments, thinking_indicator):
|
|
1779
|
+
"""Handle create_file tool call.
|
|
1780
|
+
|
|
1781
|
+
Args:
|
|
1782
|
+
tool_id: Tool call ID
|
|
1783
|
+
arguments: Tool arguments dict
|
|
1784
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1785
|
+
|
|
1786
|
+
Returns:
|
|
1787
|
+
Tuple of (should_exit, tool_result)
|
|
1788
|
+
"""
|
|
1789
|
+
path = arguments.get("path", "")
|
|
1790
|
+
if not isinstance(path, str) or not path.strip():
|
|
1791
|
+
return False, "exit_code=1\ncreate_file requires a non-empty 'path' argument."
|
|
1792
|
+
|
|
1793
|
+
# Validate path doesn't contain JSON-like syntax or invalid characters
|
|
1794
|
+
invalid_chars = '[]{}"\n\r\t'
|
|
1795
|
+
if any(char in path for char in invalid_chars):
|
|
1796
|
+
return False, f"exit_code=1\ncreate_file 'path' contains invalid characters. Got: {path}"
|
|
1797
|
+
|
|
1798
|
+
content = arguments.get("content")
|
|
1799
|
+
|
|
1800
|
+
label = f"create_file {path}"
|
|
1801
|
+
self._safe_print(f"[grey]{label}[/grey]")
|
|
1802
|
+
|
|
1803
|
+
tool_result = create_file(
|
|
1804
|
+
path,
|
|
1805
|
+
self.repo_root,
|
|
1806
|
+
content=content,
|
|
1807
|
+
gitignore_spec=self.gitignore_spec
|
|
1808
|
+
)
|
|
1809
|
+
console = self._get_console()
|
|
1810
|
+
if console:
|
|
1811
|
+
_display_tool_feedback(label, tool_result, console)
|
|
1812
|
+
|
|
1813
|
+
if self.debug_mode:
|
|
1814
|
+
console = self._get_console()
|
|
1815
|
+
if console:
|
|
1816
|
+
console.print()
|
|
1817
|
+
console.print(f"[dim]→ AI receives:\n\n{tool_result}\n\n[/dim]")
|
|
1818
|
+
|
|
1819
|
+
return False, tool_result
|
|
1820
|
+
|
|
1821
|
+
def _handle_create_task_list(self, tool_id, arguments, thinking_indicator):
|
|
1822
|
+
"""Handle create_task_list tool call."""
|
|
1823
|
+
if self.chat_manager.interaction_mode == "plan":
|
|
1824
|
+
return False, "exit_code=1\nerror: Task lists are disabled in PLAN mode. Switch to EDIT mode.\n\n"
|
|
1825
|
+
|
|
1826
|
+
tasks = arguments.get("tasks")
|
|
1827
|
+
if not isinstance(tasks, list):
|
|
1828
|
+
return False, "exit_code=1\nerror: 'tasks' must be an array of strings.\n\n"
|
|
1829
|
+
|
|
1830
|
+
title = arguments.get("title")
|
|
1831
|
+
if title is not None and not isinstance(title, str):
|
|
1832
|
+
return False, "exit_code=1\nerror: 'title' must be a string.\n\n"
|
|
1833
|
+
title = title.strip() if isinstance(title, str) else None
|
|
1834
|
+
if title:
|
|
1835
|
+
title = title[:MAX_TASK_TITLE_LEN]
|
|
1836
|
+
|
|
1837
|
+
normalized = []
|
|
1838
|
+
for i, task in enumerate(tasks):
|
|
1839
|
+
if not isinstance(task, str):
|
|
1840
|
+
return False, f"exit_code=1\nerror: Task at index {i} must be a string.\n\n"
|
|
1841
|
+
trimmed = task.strip()
|
|
1842
|
+
if not trimmed:
|
|
1843
|
+
return False, f"exit_code=1\nerror: Task at index {i} must be non-empty.\n\n"
|
|
1844
|
+
if len(trimmed) > MAX_TASK_LEN:
|
|
1845
|
+
return False, (
|
|
1846
|
+
f"exit_code=1\nerror: Task at index {i} exceeds MAX_TASK_LEN={MAX_TASK_LEN}.\n\n"
|
|
1847
|
+
)
|
|
1848
|
+
normalized.append(trimmed)
|
|
1849
|
+
|
|
1850
|
+
if len(normalized) == 0:
|
|
1851
|
+
return False, "exit_code=1\nerror: Provide at least one non-empty task.\n\n"
|
|
1852
|
+
if len(normalized) > MAX_TASKS:
|
|
1853
|
+
return False, f"exit_code=1\nerror: Too many tasks (max {MAX_TASKS}).\n\n"
|
|
1854
|
+
|
|
1855
|
+
self.chat_manager.task_list = [
|
|
1856
|
+
{"description": t, "completed": False}
|
|
1857
|
+
for t in normalized
|
|
1858
|
+
]
|
|
1859
|
+
self.chat_manager.task_list_title = title or None
|
|
1860
|
+
|
|
1861
|
+
label = "create_task_list"
|
|
1862
|
+
tool_result = _format_task_list(self.chat_manager.task_list, self.chat_manager.task_list_title)
|
|
1863
|
+
console = self._get_console()
|
|
1864
|
+
if console:
|
|
1865
|
+
_display_tool_feedback(label, tool_result, console, panel_updater=self.panel_updater)
|
|
1866
|
+
return False, tool_result
|
|
1867
|
+
|
|
1868
|
+
def _handle_complete_task(self, tool_id, arguments, thinking_indicator):
|
|
1869
|
+
"""Handle complete_task tool call."""
|
|
1870
|
+
if self.chat_manager.interaction_mode == "plan":
|
|
1871
|
+
return False, "exit_code=1\nerror: Task lists are disabled in PLAN mode. Switch to EDIT mode.\n\n"
|
|
1872
|
+
|
|
1873
|
+
task_id_raw = arguments.get("task_id")
|
|
1874
|
+
task_ids_raw = arguments.get("task_ids")
|
|
1875
|
+
|
|
1876
|
+
# Normalize to list: prefer task_ids if both provided
|
|
1877
|
+
if task_ids_raw is not None:
|
|
1878
|
+
ids_raw = task_ids_raw
|
|
1879
|
+
elif task_id_raw is not None:
|
|
1880
|
+
ids_raw = [task_id_raw]
|
|
1881
|
+
else:
|
|
1882
|
+
return False, "exit_code=1\nerror: Either 'task_id' or 'task_ids' must be provided.\n\n"
|
|
1883
|
+
|
|
1884
|
+
if not isinstance(ids_raw, list):
|
|
1885
|
+
return False, "exit_code=1\nerror: IDs must be an array of integers.\n\n"
|
|
1886
|
+
|
|
1887
|
+
task_list = getattr(self.chat_manager, "task_list", None) or []
|
|
1888
|
+
if not task_list:
|
|
1889
|
+
return False, "exit_code=1\nerror: No task list exists. Use create_task_list first.\n\n"
|
|
1890
|
+
|
|
1891
|
+
# Validate all IDs
|
|
1892
|
+
valid_ids = []
|
|
1893
|
+
for i, tid in enumerate(ids_raw):
|
|
1894
|
+
tid_int, error = _coerce_int(tid)
|
|
1895
|
+
if error:
|
|
1896
|
+
return False, f"exit_code=1\nerror: ID at index {i}: {error}\n\n"
|
|
1897
|
+
if tid_int < 0:
|
|
1898
|
+
return False, f"exit_code=1\nerror: ID at index {i} must be non-negative.\n\n"
|
|
1899
|
+
if tid_int >= len(task_list):
|
|
1900
|
+
return False, f"exit_code=1\nerror: ID {tid_int} (index {i}) is out of range (0-{len(task_list) - 1}).\n\n"
|
|
1901
|
+
valid_ids.append(tid_int)
|
|
1902
|
+
|
|
1903
|
+
# Mark tasks as complete
|
|
1904
|
+
for tid in valid_ids:
|
|
1905
|
+
task_list[tid]["completed"] = True
|
|
1906
|
+
|
|
1907
|
+
label = "complete_task"
|
|
1908
|
+
tool_result = _format_task_list(task_list, self.chat_manager.task_list_title)
|
|
1909
|
+
console = self._get_console()
|
|
1910
|
+
if console:
|
|
1911
|
+
_display_tool_feedback(label, tool_result, console, panel_updater=self.panel_updater)
|
|
1912
|
+
return False, tool_result
|
|
1913
|
+
|
|
1914
|
+
def _handle_show_task_list(self, tool_id, arguments, thinking_indicator):
|
|
1915
|
+
"""Handle show_task_list tool call."""
|
|
1916
|
+
if self.chat_manager.interaction_mode == "plan":
|
|
1917
|
+
return False, "exit_code=1\nerror: Task lists are disabled in PLAN mode. Switch to EDIT mode.\n\n"
|
|
1918
|
+
|
|
1919
|
+
task_list = getattr(self.chat_manager, "task_list", None) or []
|
|
1920
|
+
title = getattr(self.chat_manager, "task_list_title", None)
|
|
1921
|
+
|
|
1922
|
+
label = "show_task_list"
|
|
1923
|
+
tool_result = _format_task_list(task_list, title)
|
|
1924
|
+
console = self._get_console()
|
|
1925
|
+
if console:
|
|
1926
|
+
_display_tool_feedback(label, tool_result, console, panel_updater=self.panel_updater)
|
|
1927
|
+
return False, tool_result
|
|
1928
|
+
|
|
1929
|
+
def _handle_edit_file(self, tool_id, arguments, thinking_indicator):
|
|
1930
|
+
"""Handle edit_file tool call.
|
|
1931
|
+
|
|
1932
|
+
Args:
|
|
1933
|
+
tool_id: Tool call ID
|
|
1934
|
+
arguments: Tool arguments dict
|
|
1935
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
1936
|
+
|
|
1937
|
+
Returns:
|
|
1938
|
+
Tuple of (should_exit, tool_result)
|
|
1939
|
+
"""
|
|
1940
|
+
path = arguments.get("path", "")
|
|
1941
|
+
|
|
1942
|
+
# Validate path doesn't contain JSON-like syntax or invalid characters
|
|
1943
|
+
invalid_chars = '[]{}"\n\r\t'
|
|
1944
|
+
if any(char in path for char in invalid_chars):
|
|
1945
|
+
return False, f"exit_code=1\nedit_file 'path' contains invalid characters. Got: {path}"
|
|
1946
|
+
|
|
1947
|
+
|
|
1948
|
+
|
|
1949
|
+
# Preview edit
|
|
1950
|
+
try:
|
|
1951
|
+
preview_status, preview_diff = preview_edit_file(arguments, self.repo_root, self.gitignore_spec)
|
|
1952
|
+
except FileEditError as e:
|
|
1953
|
+
tool_result = f"exit_code=1\n{e}"
|
|
1954
|
+
self.console.print(f"Edit preview failed: {e}", style="red")
|
|
1955
|
+
return False, tool_result
|
|
1956
|
+
|
|
1957
|
+
if preview_status != "exit_code=0":
|
|
1958
|
+
return False, preview_status
|
|
1959
|
+
|
|
1960
|
+
# Display preview
|
|
1961
|
+
self.console.print(Text.from_ansi(preview_diff))
|
|
1962
|
+
self.console.print()
|
|
1963
|
+
|
|
1964
|
+
search_text = arguments.get("search", "")
|
|
1965
|
+
search_preview = search_text[:50] + "..." if len(search_text) > 50 else search_text
|
|
1966
|
+
command_label = f"edit {path} (search: {search_preview})"
|
|
1967
|
+
|
|
1968
|
+
# Check auto-edit mode
|
|
1969
|
+
if self.chat_manager.approve_mode == "accept_edits":
|
|
1970
|
+
return self._execute_edit(arguments, command_label)
|
|
1971
|
+
|
|
1972
|
+
return self._handle_edit_confirmation(arguments, command_label, thinking_indicator)
|
|
1973
|
+
|
|
1974
|
+
def _execute_edit(self, arguments, command_label):
|
|
1975
|
+
"""Execute the edit operation.
|
|
1976
|
+
|
|
1977
|
+
Args:
|
|
1978
|
+
arguments: Edit arguments dict
|
|
1979
|
+
command_label: Label for the edit operation
|
|
1980
|
+
|
|
1981
|
+
Returns:
|
|
1982
|
+
Tuple of (should_exit, tool_result)
|
|
1983
|
+
"""
|
|
1984
|
+
try:
|
|
1985
|
+
tool_result = run_edit_file(
|
|
1986
|
+
arguments, self.repo_root, self.console,
|
|
1987
|
+
self.debug_mode, self.gitignore_spec
|
|
1988
|
+
)
|
|
1989
|
+
except FileEditError as e:
|
|
1990
|
+
tool_result = f"exit_code=1\n{e}"
|
|
1991
|
+
self.console.print(f"Edit failed: {e}", style="red")
|
|
1992
|
+
|
|
1993
|
+
return False, tool_result
|
|
1994
|
+
|
|
1995
|
+
def _handle_edit_confirmation(self, arguments, command_label, thinking_indicator):
|
|
1996
|
+
"""Handle edit confirmation workflow.
|
|
1997
|
+
|
|
1998
|
+
Args:
|
|
1999
|
+
arguments: Edit arguments dict
|
|
2000
|
+
command_label: Label for the edit operation
|
|
2001
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
2002
|
+
|
|
2003
|
+
Returns:
|
|
2004
|
+
Tuple of (should_exit, tool_result)
|
|
2005
|
+
"""
|
|
2006
|
+
if thinking_indicator:
|
|
2007
|
+
thinking_indicator.pause()
|
|
2008
|
+
|
|
2009
|
+
# Simple title line
|
|
2010
|
+
self.console.print("[cyan]───[/][bold white] Edit Confirmation [/][cyan]───[/]")
|
|
2011
|
+
|
|
2012
|
+
# Create dynamic message function that updates when mode changes
|
|
2013
|
+
def get_prompt_message():
|
|
2014
|
+
from prompt_toolkit.formatted_text import HTML
|
|
2015
|
+
return HTML('Approve edit? (y/n/guidance):')
|
|
2016
|
+
|
|
2017
|
+
# Create prompt session with key bindings and toolbar
|
|
2018
|
+
session = create_confirmation_prompt_session(self.chat_manager, get_prompt_message)
|
|
2019
|
+
|
|
2020
|
+
user_input = session.prompt().strip().lower()
|
|
2021
|
+
self.console.print()
|
|
2022
|
+
|
|
2023
|
+
if thinking_indicator:
|
|
2024
|
+
thinking_indicator.resume()
|
|
2025
|
+
|
|
2026
|
+
if user_input in ("y", "yes", "approve"):
|
|
2027
|
+
return self._execute_edit(arguments, command_label)
|
|
2028
|
+
elif user_input in ("n", "no"):
|
|
2029
|
+
return True, f"exit_code=1\nEdit cancelled by user."
|
|
2030
|
+
else:
|
|
2031
|
+
# Guidance mode - return tool result with user guidance
|
|
2032
|
+
# OpenAI API requires a tool response for every tool_call_id
|
|
2033
|
+
return False, f"User provided guidance: {user_input}"
|
|
2034
|
+
|
|
2035
|
+
def _handle_web_search(self, tool_id, arguments, thinking_indicator):
|
|
2036
|
+
"""Handle web_search tool call.
|
|
2037
|
+
|
|
2038
|
+
Args:
|
|
2039
|
+
tool_id: Tool call ID
|
|
2040
|
+
arguments: Tool arguments dict
|
|
2041
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
2042
|
+
|
|
2043
|
+
Returns:
|
|
2044
|
+
Tuple of (should_exit, tool_result)
|
|
2045
|
+
"""
|
|
2046
|
+
query = arguments.get("query", "")
|
|
2047
|
+
|
|
2048
|
+
if WEB_SEARCH_REQUIRE_CONFIRMATION:
|
|
2049
|
+
return self._handle_web_search_confirmation(arguments, query, thinking_indicator)
|
|
2050
|
+
else:
|
|
2051
|
+
return self._execute_web_search(arguments, query)
|
|
2052
|
+
|
|
2053
|
+
def _execute_web_search(self, arguments, query):
|
|
2054
|
+
"""Execute the web search.
|
|
2055
|
+
|
|
2056
|
+
Args:
|
|
2057
|
+
arguments: Search arguments dict
|
|
2058
|
+
query: Search query string
|
|
2059
|
+
|
|
2060
|
+
Returns:
|
|
2061
|
+
Tuple of (should_exit, tool_result)
|
|
2062
|
+
"""
|
|
2063
|
+
# Get console respecting parallel context
|
|
2064
|
+
console = self._get_console()
|
|
2065
|
+
|
|
2066
|
+
if console:
|
|
2067
|
+
console.print(f"[bold cyan]web search | {query}[/bold cyan]", highlight=False)
|
|
2068
|
+
try:
|
|
2069
|
+
tool_result = run_web_search(arguments, self.console)
|
|
2070
|
+
if console:
|
|
2071
|
+
_display_tool_feedback(f"web search | {query}", tool_result, console, indent=self.is_sub_agent, panel_updater=self.panel_updater)
|
|
2072
|
+
except LLMConnectionError as e:
|
|
2073
|
+
tool_result = f"exit_code=1\nWeb search failed: {e}"
|
|
2074
|
+
if self.panel_updater:
|
|
2075
|
+
self.panel_updater.append(f"[red]Web search failed: {e}[/red]")
|
|
2076
|
+
elif console:
|
|
2077
|
+
console.print(f"Web search failed: {e}", style="red")
|
|
2078
|
+
# Don't cache errors
|
|
2079
|
+
return False, tool_result
|
|
2080
|
+
|
|
2081
|
+
return False, tool_result
|
|
2082
|
+
|
|
2083
|
+
def _handle_web_search_confirmation(self, arguments, query, thinking_indicator):
|
|
2084
|
+
"""Handle web search confirmation workflow.
|
|
2085
|
+
|
|
2086
|
+
Args:
|
|
2087
|
+
arguments: Search arguments dict
|
|
2088
|
+
query: Search query string
|
|
2089
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
2090
|
+
|
|
2091
|
+
Returns:
|
|
2092
|
+
Tuple of (should_exit, tool_result)
|
|
2093
|
+
"""
|
|
2094
|
+
# Note: label will be displayed by _execute_web_search
|
|
2095
|
+
|
|
2096
|
+
# Simple title line
|
|
2097
|
+
self.console.print("[cyan]───[/][bold white] Search Confirmation [/][cyan]───[/]")
|
|
2098
|
+
|
|
2099
|
+
# Create dynamic message function that updates when mode changes
|
|
2100
|
+
def get_prompt_message():
|
|
2101
|
+
from prompt_toolkit.formatted_text import HTML
|
|
2102
|
+
return HTML('Approve search? (y/n/guide):')
|
|
2103
|
+
|
|
2104
|
+
# Create prompt session with key bindings and toolbar
|
|
2105
|
+
session = create_confirmation_prompt_session(self.chat_manager, get_prompt_message)
|
|
2106
|
+
|
|
2107
|
+
user_input = session.prompt().strip().lower()
|
|
2108
|
+
self.console.print()
|
|
2109
|
+
|
|
2110
|
+
if user_input in ("y", "yes", "approve"):
|
|
2111
|
+
return self._execute_web_search(arguments, query)
|
|
2112
|
+
elif user_input in ("n", "no"):
|
|
2113
|
+
return True, f"exit_code=1\nWeb search cancelled by user."
|
|
2114
|
+
else:
|
|
2115
|
+
# Guidance mode - return tool result with user guidance
|
|
2116
|
+
# OpenAI API requires a tool response for every tool_call_id
|
|
2117
|
+
return False, f"User provided guidance: {user_input}"
|
|
2118
|
+
|
|
2119
|
+
def _calculate_line_range(self, start_line: int, end_line: int) -> int:
|
|
2120
|
+
"""Calculate max_lines from start and end line numbers.
|
|
2121
|
+
|
|
2122
|
+
Args:
|
|
2123
|
+
start_line: Starting line number (1-based, inclusive)
|
|
2124
|
+
end_line: Ending line number (1-based, inclusive)
|
|
2125
|
+
|
|
2126
|
+
Returns:
|
|
2127
|
+
Number of lines in the range
|
|
2128
|
+
"""
|
|
2129
|
+
return end_line - start_line + 1
|
|
2130
|
+
|
|
2131
|
+
def _handle_sub_agent(self, tool_id, arguments, thinking_indicator):
|
|
2132
|
+
"""Handle sub_agent tool call.
|
|
2133
|
+
|
|
2134
|
+
Args:
|
|
2135
|
+
tool_id: Tool call ID
|
|
2136
|
+
arguments: Tool arguments dict
|
|
2137
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
2138
|
+
|
|
2139
|
+
Returns:
|
|
2140
|
+
Tuple of (should_exit, tool_result)
|
|
2141
|
+
"""
|
|
2142
|
+
query = arguments.get("query", "")
|
|
2143
|
+
if not query or not isinstance(query, str) or not query.strip():
|
|
2144
|
+
return False, "exit_code=1\nsub_agent requires a non-empty 'query' argument."
|
|
2145
|
+
|
|
2146
|
+
if thinking_indicator:
|
|
2147
|
+
thinking_indicator.pause()
|
|
2148
|
+
|
|
2149
|
+
try:
|
|
2150
|
+
from core.sub_agent import run_sub_agent
|
|
2151
|
+
|
|
2152
|
+
# Use live panel for streaming tool output
|
|
2153
|
+
with SubAgentPanel(query, self.console) as panel:
|
|
2154
|
+
sub_agent_data = run_sub_agent(
|
|
2155
|
+
task_query=query,
|
|
2156
|
+
repo_root=self.repo_root,
|
|
2157
|
+
rg_exe_path=self.rg_exe_path,
|
|
2158
|
+
console=self.console,
|
|
2159
|
+
panel_updater=panel,
|
|
2160
|
+
)
|
|
2161
|
+
|
|
2162
|
+
# Check for errors
|
|
2163
|
+
if sub_agent_data.get('error'):
|
|
2164
|
+
panel.set_error(sub_agent_data['error'])
|
|
2165
|
+
return False, f"exit_code=1\n{sub_agent_data['error']}"
|
|
2166
|
+
|
|
2167
|
+
# Track usage for billing
|
|
2168
|
+
usage = sub_agent_data.get('usage', {})
|
|
2169
|
+
if usage:
|
|
2170
|
+
self.chat_manager.token_tracker.add_usage(usage)
|
|
2171
|
+
# Mark panel as complete with token info
|
|
2172
|
+
panel.set_complete({
|
|
2173
|
+
'prompt': usage.get('prompt_tokens', 0),
|
|
2174
|
+
'completion': usage.get('completion_tokens', 0),
|
|
2175
|
+
'total': usage.get('prompt_tokens', 0) + usage.get('completion_tokens', 0)
|
|
2176
|
+
})
|
|
2177
|
+
|
|
2178
|
+
# Display sub-agent result summary (NOT shown to user, used for context)
|
|
2179
|
+
raw_result = sub_agent_data.get('result', '')
|
|
2180
|
+
|
|
2181
|
+
# --- PARSE AND INJECT FILES ---
|
|
2182
|
+
|
|
2183
|
+
injected_files_content = []
|
|
2184
|
+
|
|
2185
|
+
# Regex to find explicit citation patterns (bracketed notation only for safety)
|
|
2186
|
+
# Matches: - [src/main.py] (lines 10-20)
|
|
2187
|
+
# Matches: - [src/utils.py] (full)
|
|
2188
|
+
# Matches: lines 10-20 in [src/main.py]
|
|
2189
|
+
# Matches: [src/main.py]:10-20
|
|
2190
|
+
# Matches: [src/main.py]:10 (single line)
|
|
2191
|
+
# Matches: [src/main.py] (full)
|
|
2192
|
+
# NOTE: Only bracketed formats are accepted to prevent false positives in code/comments
|
|
2193
|
+
import re
|
|
2194
|
+
file_pattern = re.compile(
|
|
2195
|
+
r"(?:-\s+\[(.*?)\]\s+\((?:lines\s+)?(\d+)-(\d+)(?:\s*lines)?|full)\)|" # - [file] (N-M) or (full)
|
|
2196
|
+
r"(?:lines\s+(\d+)-(\d+)\s+in\s+\[(.*?)\])|" # lines N-M in [file]
|
|
2197
|
+
r"(?:\[(.*?)\]:(\d+)-(\d+))|" # [file]:N-M
|
|
2198
|
+
r"(?:\[(.*?)\]:(\d+))|" # [file]:N (single line)
|
|
2199
|
+
r"(?:\[(.*?)\](?![:(]))" # [file] (no line numbers, read full)
|
|
2200
|
+
)
|
|
2201
|
+
|
|
2202
|
+
for line in raw_result.split('\n'):
|
|
2203
|
+
match = file_pattern.search(line)
|
|
2204
|
+
if match:
|
|
2205
|
+
# Handle multiple possible match groups from regex patterns
|
|
2206
|
+
# Pattern 1: - [file] (N-M) or (full) -> groups: (1=file, 2=N, 3=M)
|
|
2207
|
+
# Pattern 2: lines N-M in [file] -> groups: (4=start, 5=end, 6=file)
|
|
2208
|
+
# Pattern 3: [file]:N-M -> groups: (7=file, 8=N, 9=M)
|
|
2209
|
+
# Pattern 4: [file]:N (single line) -> groups: (10=file, 11=N)
|
|
2210
|
+
# Pattern 5: [file] (full) -> groups: (12=file)
|
|
2211
|
+
|
|
2212
|
+
# Extract path and range from whichever pattern matched
|
|
2213
|
+
if match.group(1):
|
|
2214
|
+
# Pattern 1: - [file] (N-M) or (full)
|
|
2215
|
+
rel_path = match.group(1).strip()
|
|
2216
|
+
if match.group(2) and match.group(3):
|
|
2217
|
+
# It's a range N-M
|
|
2218
|
+
start_line = int(match.group(2))
|
|
2219
|
+
end_line = int(match.group(3))
|
|
2220
|
+
max_lines = self._calculate_line_range(start_line, end_line)
|
|
2221
|
+
else:
|
|
2222
|
+
# It's "full"
|
|
2223
|
+
start_line = 1
|
|
2224
|
+
max_lines = None
|
|
2225
|
+
elif match.group(4) and match.group(5) and match.group(6):
|
|
2226
|
+
# Pattern 2: lines N-M in [file]
|
|
2227
|
+
start_line = int(match.group(4))
|
|
2228
|
+
end_line = int(match.group(5))
|
|
2229
|
+
rel_path = match.group(6).strip()
|
|
2230
|
+
max_lines = self._calculate_line_range(start_line, end_line)
|
|
2231
|
+
elif match.group(7) and match.group(8) and match.group(9):
|
|
2232
|
+
# Pattern 3: [file]:N-M
|
|
2233
|
+
rel_path = match.group(7).strip()
|
|
2234
|
+
start_line = int(match.group(8))
|
|
2235
|
+
end_line = int(match.group(9))
|
|
2236
|
+
max_lines = self._calculate_line_range(start_line, end_line)
|
|
2237
|
+
elif match.group(10) and match.group(11):
|
|
2238
|
+
# Pattern 4: [file]:N (single line)
|
|
2239
|
+
rel_path = match.group(10).strip()
|
|
2240
|
+
start_line = int(match.group(11))
|
|
2241
|
+
max_lines = 1
|
|
2242
|
+
elif match.group(12):
|
|
2243
|
+
# Pattern 5: [file] (full)
|
|
2244
|
+
rel_path = match.group(12).strip()
|
|
2245
|
+
start_line = 1
|
|
2246
|
+
max_lines = None
|
|
2247
|
+
else:
|
|
2248
|
+
continue # No valid match
|
|
2249
|
+
|
|
2250
|
+
try:
|
|
2251
|
+
# Import read_file locally to bypass duplicate check for sub-agent
|
|
2252
|
+
from utils.tools.file_reader import read_file as read_file_with_bypass
|
|
2253
|
+
|
|
2254
|
+
tool_result = read_file_with_bypass(
|
|
2255
|
+
rel_path,
|
|
2256
|
+
self.repo_root,
|
|
2257
|
+
max_lines=max_lines,
|
|
2258
|
+
start_line=start_line,
|
|
2259
|
+
gitignore_spec=self.gitignore_spec,
|
|
2260
|
+
)
|
|
2261
|
+
|
|
2262
|
+
exit_code = _get_exit_code(tool_result)
|
|
2263
|
+
if exit_code is not None and exit_code != 0:
|
|
2264
|
+
injected_files_content.append(f"### {rel_path} (Blocked or unavailable)")
|
|
2265
|
+
injected_files_content.append(tool_result.strip())
|
|
2266
|
+
injected_files_content.append("")
|
|
2267
|
+
continue
|
|
2268
|
+
|
|
2269
|
+
# Add to injected content (strip metadata line from formatted tool result)
|
|
2270
|
+
content_lines = tool_result.splitlines()[1:] if isinstance(tool_result, str) else []
|
|
2271
|
+
content = "\n".join(content_lines).rstrip()
|
|
2272
|
+
|
|
2273
|
+
# Parse actual lines_read and start_line from metadata to match main agent display format
|
|
2274
|
+
first_line = tool_result.split('\n')[0]
|
|
2275
|
+
lines_read_match = re.search(r'lines_read=(\d+)', first_line)
|
|
2276
|
+
start_line_match = re.search(r'start_line=(\d+)', first_line)
|
|
2277
|
+
|
|
2278
|
+
if lines_read_match:
|
|
2279
|
+
actual_lines_read = int(lines_read_match.group(1))
|
|
2280
|
+
actual_start_line = int(start_line_match.group(1)) if start_line_match else start_line
|
|
2281
|
+
|
|
2282
|
+
if actual_start_line > 1:
|
|
2283
|
+
end_line = actual_start_line + actual_lines_read - 1
|
|
2284
|
+
header_info = f"lines {actual_start_line}-{end_line} ({actual_lines_read} line{'s' if actual_lines_read != 1 else ''})"
|
|
2285
|
+
else:
|
|
2286
|
+
header_info = f"lines {actual_start_line}-{actual_lines_read} ({actual_lines_read} line{'s' if actual_lines_read != 1 else ''})"
|
|
2287
|
+
else:
|
|
2288
|
+
header_info = "full"
|
|
2289
|
+
injected_files_content.append(f"### {rel_path} ({header_info})")
|
|
2290
|
+
injected_files_content.append("```")
|
|
2291
|
+
injected_files_content.append(content)
|
|
2292
|
+
injected_files_content.append("```\n")
|
|
2293
|
+
|
|
2294
|
+
except Exception as e:
|
|
2295
|
+
injected_files_content.append(f"### {rel_path} (Error reading file: {e})")
|
|
2296
|
+
|
|
2297
|
+
# Combine raw result with injected content
|
|
2298
|
+
if injected_files_content:
|
|
2299
|
+
final_result = raw_result + "\n\n## Injected File Contents\n\n" + "\n".join(injected_files_content)
|
|
2300
|
+
else:
|
|
2301
|
+
final_result = raw_result
|
|
2302
|
+
|
|
2303
|
+
# Return result WITHOUT displaying to user (panel already handled display)
|
|
2304
|
+
return False, final_result
|
|
2305
|
+
|
|
2306
|
+
except Exception as e:
|
|
2307
|
+
# Show error in panel
|
|
2308
|
+
with SubAgentPanel(query, self.console) as panel:
|
|
2309
|
+
panel.set_error(f"Sub-agent failed: {e}")
|
|
2310
|
+
return False, f"exit_code=1\nSub-agent failed: {e}"
|
|
2311
|
+
finally:
|
|
2312
|
+
if thinking_indicator:
|
|
2313
|
+
thinking_indicator.resume()
|
|
2314
|
+
|
|
2315
|
+
|
|
2316
|
+
def agentic_answer(chat_manager, user_input, console, repo_root, rg_exe_path, debug_mode, thinking_indicator=None, pre_tool_planning_enabled=False):
|
|
2317
|
+
"""Main agent loop using OpenAI-style function calling.
|
|
2318
|
+
|
|
2319
|
+
This is a convenience wrapper that creates an AgenticOrchestrator
|
|
2320
|
+
and runs it with the provided parameters.
|
|
2321
|
+
|
|
2322
|
+
Args:
|
|
2323
|
+
chat_manager: ChatManager instance
|
|
2324
|
+
user_input: User's input message
|
|
2325
|
+
console: Rich console for output
|
|
2326
|
+
repo_root: Path to repository root
|
|
2327
|
+
rg_exe_path: Path to rg.exe
|
|
2328
|
+
debug_mode: Whether to show debug output
|
|
2329
|
+
thinking_indicator: Optional ThinkingIndicator instance
|
|
2330
|
+
pre_tool_planning_enabled: If True, enable pre-tool planning step
|
|
2331
|
+
"""
|
|
2332
|
+
orchestrator = AgenticOrchestrator(
|
|
2333
|
+
chat_manager=chat_manager,
|
|
2334
|
+
repo_root=repo_root,
|
|
2335
|
+
rg_exe_path=rg_exe_path,
|
|
2336
|
+
console=console,
|
|
2337
|
+
debug_mode=debug_mode,
|
|
2338
|
+
pre_tool_planning_enabled=pre_tool_planning_enabled,
|
|
2339
|
+
)
|
|
2340
|
+
orchestrator.run(user_input, thinking_indicator)
|
|
2341
|
+
|
|
2342
|
+
|