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.
Files changed (56) hide show
  1. package/INSTALLATION_METHODS.md +181 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/bin/npm-wrapper.js +171 -0
  5. package/bin/rg +0 -0
  6. package/bin/rg.exe +0 -0
  7. package/config.yaml.example +159 -0
  8. package/package.json +42 -0
  9. package/requirements.txt +7 -0
  10. package/scripts/install.js +132 -0
  11. package/setup.bat +114 -0
  12. package/setup.sh +135 -0
  13. package/src/__init__.py +4 -0
  14. package/src/core/__init__.py +1 -0
  15. package/src/core/agentic.py +2342 -0
  16. package/src/core/chat_manager.py +1201 -0
  17. package/src/core/config_manager.py +269 -0
  18. package/src/core/init.py +161 -0
  19. package/src/core/sub_agent.py +174 -0
  20. package/src/exceptions.py +75 -0
  21. package/src/llm/__init__.py +1 -0
  22. package/src/llm/client.py +149 -0
  23. package/src/llm/config.py +445 -0
  24. package/src/llm/prompts.py +569 -0
  25. package/src/llm/providers.py +402 -0
  26. package/src/llm/token_tracker.py +220 -0
  27. package/src/ui/__init__.py +1 -0
  28. package/src/ui/banner.py +103 -0
  29. package/src/ui/commands.py +489 -0
  30. package/src/ui/displays.py +167 -0
  31. package/src/ui/main.py +351 -0
  32. package/src/ui/prompt_utils.py +162 -0
  33. package/src/utils/__init__.py +1 -0
  34. package/src/utils/editor.py +158 -0
  35. package/src/utils/gitignore_filter.py +149 -0
  36. package/src/utils/logger.py +254 -0
  37. package/src/utils/markdown.py +32 -0
  38. package/src/utils/settings.py +94 -0
  39. package/src/utils/tools/__init__.py +55 -0
  40. package/src/utils/tools/command_executor.py +217 -0
  41. package/src/utils/tools/create_file.py +143 -0
  42. package/src/utils/tools/definitions.py +193 -0
  43. package/src/utils/tools/directory.py +374 -0
  44. package/src/utils/tools/file_editor.py +345 -0
  45. package/src/utils/tools/file_helpers.py +109 -0
  46. package/src/utils/tools/file_reader.py +331 -0
  47. package/src/utils/tools/formatters.py +458 -0
  48. package/src/utils/tools/parallel_executor.py +195 -0
  49. package/src/utils/validation.py +117 -0
  50. package/src/utils/web_search.py +71 -0
  51. package/vmcode-proxy/.env.example +5 -0
  52. package/vmcode-proxy/README.md +235 -0
  53. package/vmcode-proxy/package-lock.json +947 -0
  54. package/vmcode-proxy/package.json +20 -0
  55. package/vmcode-proxy/server.js +248 -0
  56. 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
+