bone-agent 1.3.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,1054 @@
1
+ """Agent tool-calling loop."""
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ from rich.markdown import Markdown
12
+ from rich.text import Text
13
+
14
+ from utils.settings import MAX_TOOL_CALLS, MonokaiDarkBGStyle, left_align_headings
15
+ from tools import (
16
+ read_file,
17
+ list_directory,
18
+ create_file,
19
+ TOOLS,
20
+ )
21
+ from utils.settings import tool_settings
22
+
23
+ from llm.config import get_provider_config
24
+ from utils.result_parsers import extract_exit_code
25
+ from core.retry import (
26
+ RETRY_MAX_ATTEMPTS,
27
+ RETRY_DELAYS,
28
+ is_retryable_error,
29
+ wait_with_cancel_message,
30
+ )
31
+ from core.tool_approval import (
32
+ handle_edit_approval,
33
+ handle_command_approval,
34
+ resolve_edit_preview,
35
+ )
36
+ from exceptions import (
37
+ LLMError,
38
+ LLMResponseError,
39
+ )
40
+
41
+ from core.tool_feedback import (
42
+ vault_root_str,
43
+ _print_or_append,
44
+ strip_leading_task_list_echo,
45
+ build_read_file_label,
46
+ build_tool_label,
47
+ display_tool_feedback,
48
+ )
49
+ from ui.sub_agent_panel import SubAgentPanel
50
+
51
+
52
+ def _handle_empty_response(empty_response_count, console):
53
+ """Handle empty response from model.
54
+
55
+ Returns:
56
+ tuple: (should_continue, updated_count)
57
+ """
58
+ empty_response_count += 1
59
+ if empty_response_count >= 2:
60
+ console.print("[red]Error: model returned empty response with no tool calls.[/red]")
61
+ return False, empty_response_count
62
+ return True, empty_response_count
63
+
64
+
65
+
66
+ def _handle_tool_limit_reached(chat_manager, console):
67
+ """Handle case when tool call limit is exceeded.
68
+
69
+ Returns:
70
+ bool: True if handled successfully, False if error
71
+ """
72
+ chat_manager.messages.append({
73
+ "role": "user",
74
+ "content": "Tool limit reached. Provide your answer without calling tools."
75
+ })
76
+
77
+ try:
78
+ response = chat_manager.client.chat_completion(
79
+ chat_manager.messages, stream=False, tools=None
80
+ )
81
+ except LLMError as e:
82
+ console.print(f"[red]LLM Error: {e}[/red]")
83
+ return False
84
+
85
+ if isinstance(response, dict) and 'usage' in response:
86
+ provider_cfg = get_provider_config(chat_manager.client.provider)
87
+ chat_manager.token_tracker.add_usage(
88
+ response,
89
+ model_name=provider_cfg.get("model", ""),
90
+ )
91
+
92
+ try:
93
+ final_message = response["choices"][0]["message"]
94
+ except (KeyError, IndexError):
95
+ console.print("[red]Error: invalid response from model[/red]")
96
+ return False
97
+
98
+ content = final_message.get("content", "").strip()
99
+ if content:
100
+ md = Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left")
101
+ console.print(md)
102
+ chat_manager.messages.append(final_message)
103
+ console.print()
104
+ return True
105
+
106
+ console.print("[red]Error: model returned empty response after tool limit reached.[/red]")
107
+ return False
108
+
109
+
110
+
111
+ class AgenticOrchestrator:
112
+ """Orchestrates the agentic tool-calling loop.
113
+
114
+ This class encapsulates the complex logic of coordinating LLM interactions
115
+ with tool calling, providing a cleaner, more maintainable structure.
116
+ """
117
+
118
+ def __init__(self, chat_manager, repo_root, rg_exe_path, console, debug_mode, suppress_result_display=False, is_sub_agent=False, panel_updater=None, force_parallel_execution=False, cron_job_id=None, cron_allowlist=None, cron_interactive=False):
119
+ """Initialize the orchestrator.
120
+
121
+ Args:
122
+ chat_manager: ChatManager instance for state management
123
+ repo_root: Path to repository root
124
+ rg_exe_path: Path to rg.exe
125
+ console: Rich console for output
126
+ debug_mode: Whether to show debug output
127
+ suppress_result_display: If True, suppress final LLM response display (for research agent)
128
+ is_sub_agent: If True, running as sub-agent (for visual framing)
129
+ panel_updater: Optional SubAgentPanel callback for live panel updates
130
+ force_parallel_execution: If True, force parallel execution (for sub-agent)
131
+ cron_job_id: Optional cron job ID for command allow list gating
132
+ cron_allowlist: Optional CronAllowlist instance for cron command gating
133
+ cron_interactive: If True, cron job is running in interactive test mode
134
+ """
135
+ self.chat_manager = chat_manager
136
+ self.repo_root = repo_root
137
+ self.rg_exe_path = rg_exe_path
138
+ self.console = console
139
+ self.debug_mode = debug_mode
140
+ self.suppress_result_display = suppress_result_display
141
+ self.is_sub_agent = is_sub_agent
142
+ self.panel_updater = panel_updater
143
+ self.force_parallel_execution = force_parallel_execution
144
+ self.cron_job_id = cron_job_id
145
+ self.cron_allowlist = cron_allowlist
146
+ self.cron_interactive = cron_interactive
147
+ self.tool_calls_count = 0
148
+ self.empty_response_count = 0
149
+ self.gitignore_spec = chat_manager.get_gitignore_spec(repo_root)
150
+ # For parallel execution: temporary console override
151
+ self._parallel_context = {}
152
+ # Initialize vault session with known repo_root (for project folder derivation)
153
+ try:
154
+ from tools.obsidian import init_session
155
+ init_session(repo_root)
156
+ except Exception as e:
157
+ logger.warning("Failed to initialize vault session: %s", e)
158
+ # Bootstrap memory system (creates ~/.bone/ and .bone/ dirs + files if missing)
159
+ try:
160
+ from core.memory import MemoryManager
161
+ MemoryManager.get_instance(repo_root).ensure_exists()
162
+ except Exception as e:
163
+ logger.warning("Failed to initialize memory system: %s", e)
164
+
165
+
166
+ def _get_console(self):
167
+ """Get the console for output, respecting parallel execution context.
168
+
169
+ Returns:
170
+ Console object or None if suppressed during parallel execution
171
+ """
172
+ # Check if we're in a parallel context with suppressed console
173
+ return self._parallel_context.get('console', self.console)
174
+
175
+ def _is_memory_file(self, path: str) -> bool:
176
+ """Check if path targets a memory file (auto-approved).
177
+
178
+ Auto-approve scope (restricted to known memory paths):
179
+ - {repo_root}/.bone/agents.md — project memory
180
+ - ~/.bone/user_memory.md — global user memory
181
+ - Any file under {repo_root}/.bone/ — project memory directory
182
+
183
+ Args:
184
+ path: File path from tool arguments.
185
+
186
+ Returns:
187
+ True if the file should be auto-approved as a memory file.
188
+ """
189
+ p = Path(path).resolve()
190
+ repo_root = Path(self.repo_root).resolve()
191
+ # Known memory paths
192
+ if p == Path.home() / ".bone" / "user_memory.md":
193
+ return True
194
+ # Any file under {repo_root}/.bone/ (future memory files)
195
+ bone_dir = repo_root / ".bone"
196
+ if p.is_relative_to(bone_dir):
197
+ return True
198
+ return False
199
+
200
+ def _execute_memory_edit(self, arguments) -> bool:
201
+ """Apply a memory file edit synchronously with one retry on failure.
202
+
203
+ Args:
204
+ arguments: Tool arguments dict (path, search, replace, etc.)
205
+ """
206
+ from tools.edit import _execute_edit_file
207
+
208
+ kwargs = dict(
209
+ path=arguments.get("path"),
210
+ search=arguments.get("search"),
211
+ replace=arguments.get("replace"),
212
+ repo_root=self.repo_root,
213
+ console=None, # silent — no output in chat
214
+ gitignore_spec=self.gitignore_spec,
215
+ context_lines=arguments.get("context_lines", 3),
216
+ vault_root=vault_root_str(),
217
+ )
218
+ try:
219
+ _execute_edit_file(**kwargs)
220
+ return True
221
+ except Exception as e:
222
+ logger.warning("Memory edit failed (retrying in 0.5s): %s", e)
223
+ time.sleep(0.5)
224
+ try:
225
+ _execute_edit_file(**kwargs)
226
+ logger.info("Memory edit retry succeeded after initial failure.")
227
+ return True
228
+ except Exception as e2:
229
+ logger.error("Memory edit failed after retry: %s", e2)
230
+ return False
231
+
232
+ def run(self, user_input, thinking_indicator=None, allowed_tools=None):
233
+ """Main orchestration loop.
234
+
235
+ Args:
236
+ user_input: User's input message
237
+ thinking_indicator: Optional ThinkingIndicator instance
238
+ allowed_tools: Optional list of allowed tool names (for research)
239
+ """
240
+ # Append user message
241
+ self.chat_manager.messages.append({"role": "user", "content": user_input})
242
+
243
+ # Log user message
244
+ self.chat_manager.log_message({"role": "user", "content": user_input})
245
+
246
+ from tools.base import ToolRegistry
247
+
248
+ while True:
249
+ # Decrement plugin TTLs after previous iteration's tool execution.
250
+ # Evicted plugins are excluded from the next LLM call's context window.
251
+ evicted = ToolRegistry.decrement_plugin_ttls()
252
+ if evicted and self.debug_mode:
253
+ self.console.print(f"[dim]Plugins evicted (TTL expired): {evicted}[/dim]")
254
+
255
+ # Get response from LLM
256
+ response = self._get_llm_response(allowed_tools=allowed_tools)
257
+ if response is None:
258
+ return
259
+
260
+ # Auto-compact if over token threshold (applies to both main agent and subagent)
261
+ self.chat_manager.maybe_auto_compact()
262
+
263
+ # Check for tool calls
264
+ tool_calls = response.get("tool_calls")
265
+
266
+ if not tool_calls:
267
+ if self._handle_final_response(response, thinking_indicator):
268
+ return
269
+ else:
270
+ should_exit = self._handle_tool_calls(response, thinking_indicator, allowed_tools)
271
+ if should_exit:
272
+ return
273
+
274
+ def _get_llm_response(self, allowed_tools=None):
275
+ """Get next LLM response with tool definitions.
276
+
277
+ Includes automatic retry with live countdown for timeout/connection errors.
278
+ Retries up to 3 times with a 5-second countdown between attempts.
279
+
280
+ Args:
281
+ allowed_tools: Optional list of allowed tool names (overrides mode-based filtering)
282
+
283
+ Returns:
284
+ Response dict from LLM, or None if error occurred
285
+ """
286
+ # Pre-send guard: ensure context fits before the LLM call
287
+ self.chat_manager.ensure_context_fits(console=self.console)
288
+
289
+ # Use allowed_tools if provided, otherwise use mode-based filtering
290
+ if allowed_tools is not None:
291
+ # Validate that allowed_tools is not empty
292
+ if not allowed_tools:
293
+ self.console.print("[red]Error: allowed_tools is empty[/red]")
294
+ return None
295
+ # TOOLS is a function, call it to get the list
296
+ tools = [tool for tool in TOOLS() if tool["function"]["name"] in allowed_tools]
297
+ # Log filtered tools for debugging
298
+ if self.debug_mode:
299
+ tool_names = [t["function"]["name"] for t in tools]
300
+ self.console.print(f"[dim]Available tools: {tool_names}[/dim]")
301
+ else:
302
+ tools = TOOLS()
303
+
304
+ # Retry loop for timeout/connection errors
305
+ last_error = None
306
+ for attempt in range(1, RETRY_MAX_ATTEMPTS + 1):
307
+ try:
308
+ response = self.chat_manager.client.chat_completion(
309
+ self.chat_manager.messages, stream=False, tools=tools
310
+ )
311
+ except LLMError as e:
312
+ last_error = e
313
+
314
+ # Check if this error is retryable
315
+ if is_retryable_error(e) and attempt < RETRY_MAX_ATTEMPTS:
316
+ delay = RETRY_DELAYS[min(attempt - 1, len(RETRY_DELAYS) - 1)]
317
+ wait_ok = wait_with_cancel_message(self.console, delay)
318
+ if not wait_ok:
319
+ return None
320
+ continue
321
+ else:
322
+ # Non-retryable error or final attempt exhausted
323
+ self.console.print(f"[red]LLM Error: {e}[/red]")
324
+ return None
325
+
326
+ # Successful response — parse and return
327
+ # Extract and track usage data
328
+ if isinstance(response, dict) and 'usage' in response:
329
+ provider_cfg = get_provider_config(self.chat_manager.client.provider)
330
+ self.chat_manager.token_tracker.add_usage(
331
+ response,
332
+ model_name=provider_cfg.get("model", ""),
333
+ )
334
+
335
+ try:
336
+ message = response["choices"][0]["message"]
337
+ except (KeyError, IndexError):
338
+ self.console.print("[red]Error: invalid response from model[/red]")
339
+ return None
340
+
341
+ return message
342
+
343
+ # Should not reach here, but handle gracefully
344
+ self.console.print(f"[red]LLM Error: {last_error}[/red]")
345
+ return None
346
+
347
+ def _handle_final_response(self, response, thinking_indicator=None):
348
+ """Handle non-tool-call response (final answer).
349
+
350
+ Args:
351
+ response: Message dict from LLM
352
+ thinking_indicator: Optional ThinkingIndicator instance to clear before displaying
353
+
354
+ Returns:
355
+ True if handled successfully, False if should continue looping
356
+ """
357
+ content = response.get("content", "")
358
+ content = strip_leading_task_list_echo(
359
+ content,
360
+ getattr(self.chat_manager, "task_list", None) or [],
361
+ getattr(self.chat_manager, "task_list_title", None),
362
+ )
363
+ # Strip leading "Assistant: " prefix that some models may output
364
+ if content.startswith("Assistant: "):
365
+ content = content[len("Assistant: "):]
366
+ content = content.lstrip()
367
+ if content and content.strip():
368
+ # Clear thinking indicator before printing response to avoid flash
369
+ if thinking_indicator:
370
+ thinking_indicator.stop(reset=True)
371
+ # Only display to user if result display is not suppressed
372
+ if not self.suppress_result_display:
373
+ md = Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left")
374
+ self.console.print(md)
375
+ # Always append to message history (AI needs the result regardless)
376
+ response = dict(response)
377
+ response["content"] = content
378
+ self.chat_manager.messages.append(response)
379
+ # Log assistant response
380
+ self.chat_manager.log_message(response)
381
+
382
+ # NEW: Compact tool results after final answer (per-message compaction)
383
+ self.chat_manager.compact_tool_results()
384
+
385
+ # Update context tokens with current mode's tools
386
+ tools_for_mode = TOOLS()
387
+ self.chat_manager._update_context_tokens(tools_for_mode)
388
+
389
+ self.console.print()
390
+ return True
391
+
392
+ # Empty response with no tools
393
+ should_continue, self.empty_response_count = _handle_empty_response(
394
+ self.empty_response_count, self.console
395
+ )
396
+ return not should_continue
397
+
398
+ def _handle_tool_calls(self, response, thinking_indicator, allowed_tools=None):
399
+ """Process tool calls and display accompanying content.
400
+
401
+ Args:
402
+ response: Full message dict from LLM (includes content and tool_calls)
403
+ thinking_indicator: Optional ThinkingIndicator instance
404
+ allowed_tools: Optional list of allowed tool names
405
+
406
+ Returns:
407
+ True if should exit the orchestration loop
408
+ """
409
+ # Extract tool_calls from response
410
+ tool_calls = response.get("tool_calls")
411
+ if not tool_calls:
412
+ return False # Should not happen if called correctly
413
+
414
+ # Append assistant message with ALL tool calls (include content if present)
415
+ # This must happen BEFORE filtering so the LLM sees its original intent
416
+ content = (response.get("content") or "").strip()
417
+ assistant_msg = {"role": "assistant", "tool_calls": tool_calls}
418
+ if content:
419
+ assistant_msg["content"] = content
420
+ self.chat_manager.messages.append(assistant_msg)
421
+ # Log assistant tool call message
422
+ self.chat_manager.log_message(assistant_msg)
423
+
424
+ # NEW: Filter out non-allowed tools BEFORE execution
425
+ # This silently removes unknown tools or tools not in the allowed whitelist
426
+ # to prevent error messages from reaching the user while allowing the agent
427
+ # to continue with alternative tools.
428
+ from tools.base import ToolRegistry
429
+
430
+ filtered_calls = []
431
+ filtered_tool_ids = [] # Track filtered tool IDs to provide feedback
432
+
433
+ for tool_call in tool_calls:
434
+ function_name = tool_call.get("function", {}).get("name")
435
+
436
+ # Check if tool exists in registry
437
+ if not ToolRegistry.get(function_name):
438
+ # Silent fail - skip this tool call entirely
439
+ # Agent will receive empty result and can retry with correct tool
440
+ if self.debug_mode:
441
+ self.console.print(f"[dim]Silently filtered unknown tool: {function_name}[/dim]")
442
+ filtered_tool_ids.append(tool_call.get("id"))
443
+ continue
444
+
445
+ # Check if tool is in allowed_tools whitelist (if provided)
446
+ # Plugin-tier tools bypass the whitelist — they are already vetted
447
+ # by the manifest and activated on-demand via search_plugins.
448
+ if allowed_tools and function_name not in allowed_tools and not ToolRegistry.is_plugin_active(function_name):
449
+ # Silent fail - skip this tool
450
+ if self.debug_mode:
451
+ self.console.print(f"[dim]Silently filtered non-allowed tool: {function_name}[/dim]")
452
+ filtered_tool_ids.append(tool_call.get("id"))
453
+ continue
454
+
455
+ filtered_calls.append(tool_call)
456
+
457
+ # Replace with filtered list
458
+ tool_calls = filtered_calls
459
+
460
+ # Provide feedback to agent for filtered tools
461
+ # This allows the agent to understand which tools were not available
462
+ # without showing error messages to the user
463
+ if filtered_tool_ids:
464
+ for tool_id in filtered_tool_ids:
465
+ tool_msg = {
466
+ "role": "tool",
467
+ "tool_call_id": tool_id,
468
+ "content": "exit_code=1\nTool not available. Please use the available tools from the function list."
469
+ }
470
+ self.chat_manager.messages.append(tool_msg)
471
+ self.chat_manager.log_message(tool_msg)
472
+
473
+ # If all tools were filtered, return early
474
+ if not tool_calls:
475
+ if self.debug_mode:
476
+ self.console.print("[dim]All tool calls were filtered, continuing...[/dim]")
477
+ return False
478
+
479
+ self.empty_response_count = 0
480
+ self.tool_calls_count += 1
481
+
482
+ if self.tool_calls_count > MAX_TOOL_CALLS:
483
+ return not _handle_tool_limit_reached(self.chat_manager, self.console)
484
+
485
+ # Display conversational content if present
486
+ # Skip if calling sub_agent OR if we ARE a sub-agent (sub-agent panel provides context)
487
+ is_calling_sub_agent = any(
488
+ tool.get("function", {}).get("name") == "sub_agent"
489
+ for tool in tool_calls
490
+ )
491
+ # Route to panel if we're a sub-agent with a panel_updater, otherwise print to console
492
+ if content:
493
+ if self.is_sub_agent and self.panel_updater:
494
+ # Sub-agent: send thinking to panel instead of console
495
+ self.panel_updater.append(content)
496
+ elif not is_calling_sub_agent:
497
+ # Main agent: print to console (unless calling sub_agent)
498
+ md = Markdown(left_align_headings(content), code_theme=MonokaiDarkBGStyle, justify="left")
499
+ self.console.print(md)
500
+ self.console.print()
501
+
502
+ # Check if we should use parallel execution
503
+ use_parallel = (
504
+ tool_settings.enable_parallel_execution and
505
+ len(tool_calls) > 1
506
+ )
507
+
508
+ # Force sequential if any edit_file or execute_command in the batch (safety)
509
+ if use_parallel:
510
+ for tool_call in tool_calls:
511
+ tool_name = tool_call.get("function", {}).get("name")
512
+ if tool_name == "edit_file":
513
+ use_parallel = False
514
+ if self.debug_mode:
515
+ self.console.print("[dim]Forcing sequential execution (edit_file detected)[/dim]")
516
+ break
517
+ elif tool_name == "execute_command":
518
+ use_parallel = False
519
+ if self.debug_mode:
520
+ self.console.print("[dim]Forcing sequential execution (execute_command detected)[/dim]")
521
+ break
522
+ elif tool_name == "sub_agent":
523
+ use_parallel = False
524
+ if self.debug_mode:
525
+ self.console.print("[dim]Forcing sequential execution (sub_agent detected)[/dim]")
526
+ break
527
+ elif tool_name == "select_option":
528
+ use_parallel = False
529
+ if self.debug_mode:
530
+ self.console.print("[dim]Forcing sequential execution (select_option detected)[/dim]")
531
+ break
532
+
533
+ if use_parallel and self.debug_mode:
534
+ self.console.print(f"[#5F9EA0]Executing {len(tool_calls)} tools in parallel[/#5F9EA0]")
535
+
536
+ # Lock compaction during tool execution to prevent orphaning tool_call_ids
537
+ self.chat_manager.set_compaction_lock(True)
538
+
539
+ if use_parallel:
540
+ result = self._execute_tools_parallel(tool_calls, thinking_indicator)
541
+ else:
542
+ result = self._execute_tools_sequential(tool_calls, thinking_indicator)
543
+
544
+ # Unlock compaction after all tool results are appended
545
+ self.chat_manager.set_compaction_lock(False)
546
+
547
+ return result
548
+
549
+ def _execute_tools_sequential(self, tool_calls, thinking_indicator):
550
+ """Execute tools one at a time (original behavior).
551
+
552
+ Args:
553
+ tool_calls: List of tool call dicts from LLM
554
+ thinking_indicator: Optional ThinkingIndicator instance
555
+
556
+ Returns:
557
+ True if should exit the orchestration loop
558
+ """
559
+ end_loop = False
560
+
561
+ for tool_call in tool_calls:
562
+ tool_id = tool_call["id"]
563
+ should_exit, tool_result = self._process_single_tool_call(
564
+ tool_call, thinking_indicator
565
+ )
566
+
567
+ if should_exit:
568
+ # Cancel was selected - append this result and break immediately
569
+ if tool_result is not None and tool_result is not False:
570
+ if isinstance(tool_result, Text):
571
+ content_for_agent = f"exit_code=0\n{str(tool_result)}"
572
+ else:
573
+ content_for_agent = str(tool_result)
574
+ tool_msg = {
575
+ "role": "tool",
576
+ "tool_call_id": tool_id,
577
+ "content": content_for_agent
578
+ }
579
+ self.chat_manager.messages.append(tool_msg)
580
+ self.chat_manager.log_message(tool_msg)
581
+ return True # Exit orchestration loop immediately
582
+
583
+ # Append tool result if not skipped (guidance mode)
584
+ if tool_result is not None and tool_result is not False:
585
+ # Add exit_code prefix for agent consumption
586
+ if isinstance(tool_result, Text):
587
+ # Rich Text object = successful edit (exit_code=0)
588
+ content_for_agent = f"exit_code=0\n{str(tool_result)}"
589
+ else:
590
+ content_for_agent = str(tool_result)
591
+ tool_msg = {
592
+ "role": "tool",
593
+ "tool_call_id": tool_id,
594
+ "content": content_for_agent
595
+ }
596
+ self.chat_manager.messages.append(tool_msg)
597
+ # Log tool result
598
+ self.chat_manager.log_message(tool_msg)
599
+
600
+ # Mid-loop compaction: compact older completed tool blocks
601
+ # after each tool result is appended (safe — only compacts completed blocks)
602
+ self.chat_manager.compact_tool_results()
603
+
604
+ # Update context tokens with current mode's tools
605
+ tools_for_mode = TOOLS()
606
+ self.chat_manager._update_context_tokens(tools_for_mode)
607
+
608
+ # Pre-send guard: ensure context fits before next LLM call
609
+ self.chat_manager.ensure_context_fits(console=self.console)
610
+
611
+ return end_loop
612
+
613
+ def _execute_tools_parallel(self, tool_calls, thinking_indicator):
614
+ """Execute multiple tools concurrently.
615
+
616
+ Args:
617
+ tool_calls: List of tool call dicts from LLM (already filtered)
618
+ thinking_indicator: Optional ThinkingIndicator instance
619
+
620
+ Returns:
621
+ True if should exit the orchestration loop
622
+ """
623
+ if not tool_calls:
624
+ return False
625
+ from tools.parallel_executor import ParallelToolExecutor, ToolCall
626
+
627
+ # Suppress console output in handlers during parallel execution
628
+ # We'll display results ourselves in order below
629
+ self._parallel_context['console'] = None
630
+
631
+ try:
632
+ # Prepare context
633
+ context = {
634
+ 'thinking_indicator': thinking_indicator,
635
+ 'repo_root': self.repo_root,
636
+ 'chat_manager': self.chat_manager,
637
+ 'rg_exe_path': self.rg_exe_path,
638
+ 'debug_mode': self.debug_mode,
639
+ 'gitignore_spec': self.gitignore_spec,
640
+ 'panel_updater': self.panel_updater,
641
+ 'vault_root': vault_root_str(),
642
+ }
643
+
644
+ # Convert to ToolCall objects
645
+ tool_call_objs = []
646
+ for i, tc in enumerate(tool_calls):
647
+ try:
648
+ arguments = json.loads(tc["function"]["arguments"])
649
+ except json.JSONDecodeError:
650
+ # Invalid JSON - handle inline for this tool
651
+ tool_msg = {
652
+ "role": "tool",
653
+ "tool_call_id": tc["id"],
654
+ "content": "exit_code=1\nInvalid JSON arguments"
655
+ }
656
+ self.chat_manager.messages.append(tool_msg)
657
+ self.chat_manager.log_message(tool_msg)
658
+ continue
659
+
660
+ tool_call_objs.append(
661
+ ToolCall(
662
+ tool_id=tc["id"],
663
+ function_name=tc["function"]["name"],
664
+ arguments=arguments,
665
+ call_index=i
666
+ )
667
+ )
668
+
669
+ if not tool_call_objs:
670
+ # All tools had invalid arguments
671
+ return False
672
+
673
+ # Create executor
674
+ executor = ParallelToolExecutor(
675
+ max_workers=tool_settings.max_parallel_workers
676
+ )
677
+
678
+ # Execute in parallel
679
+ results, _ = executor.execute_tools(
680
+ tool_call_objs,
681
+ context
682
+ )
683
+
684
+ # Display results with labels (staggered: label → feedback, like sequential mode)
685
+ for result in results:
686
+ if result.success:
687
+ # Get tool call info
688
+ tool_call = tool_calls[result.call_index]
689
+ function_name = tool_call.get("function", {}).get("name", "")
690
+ arguments = tool_call.get("function", {}).get("arguments", "{}")
691
+ args_dict = json.loads(arguments) if isinstance(arguments, str) else arguments
692
+
693
+ # Label builders
694
+ label_builders = {
695
+ "rg": lambda a: f"rg: {a.get('pattern', '')[:40]}",
696
+ "read_file": lambda a: build_read_file_label(
697
+ a.get('path_str', ''),
698
+ a.get('start_line'),
699
+ a.get('max_lines'),
700
+ with_colon=True
701
+ ),
702
+ "list_directory": lambda a: f"list_directory: {a.get('path_str', '')}",
703
+ "search_plugins": lambda a: f"search_plugins: {a.get('query', '')}",
704
+ "create_file": lambda a: f"create_file: {a.get('path_str', '')}",
705
+ "web_search": lambda a: f"web search | {a.get('query', '')}",
706
+ "create_task_list": lambda a: "create_task_list",
707
+ "complete_task": lambda a: "complete_task",
708
+ "show_task_list": lambda a: "show_task_list",
709
+ }
710
+
711
+ # Print the label first (staggered output: label → feedback)
712
+ label_builder = label_builders.get(function_name, lambda a: function_name)
713
+ try:
714
+ label = label_builder(args_dict)
715
+
716
+ # Print the label before feedback (matches sequential path)
717
+ if not self.panel_updater and function_name not in ("create_task_list", "complete_task", "show_task_list"):
718
+ label_text = f"[grey]{label}[/grey]" if not function_name.startswith("web search") else f"[bold #5F9EA0]{label}[/bold #5F9EA0]"
719
+ self.console.print(label_text, highlight=False)
720
+ self.console.file.flush()
721
+
722
+ # For task list tools: only show the task list, no label duplication
723
+ # Skip the feedback display below since we already showed it
724
+ continue_flag = False
725
+ if function_name in ("create_task_list", "complete_task", "show_task_list"):
726
+ exit_code = extract_exit_code(result.result)
727
+ if exit_code == 0 or exit_code is None:
728
+ rendered = result.result
729
+ if rendered.startswith("exit_code="):
730
+ rendered = "\n".join(rendered.splitlines()[1:])
731
+ if self.panel_updater:
732
+ self.panel_updater.append(rendered.strip())
733
+ else:
734
+ self.console.print(rendered.strip(), markup=True)
735
+ self.console.print()
736
+ else:
737
+ first_two = "\n".join(result.result.splitlines()[:2]).strip()
738
+ if self.panel_updater:
739
+ self.panel_updater.append(first_two or result.result.strip())
740
+ else:
741
+ self.console.print(first_two or result.result.strip(), markup=False)
742
+ self.console.print()
743
+ continue_flag = True
744
+ label = function_name
745
+ except Exception:
746
+ label_text = f"[grey]{function_name}[/grey]"
747
+ if not self.panel_updater:
748
+ self.console.print(label_text, highlight=False)
749
+ self.console.file.flush()
750
+ label = function_name # Fallback for error path
751
+ continue_flag = False
752
+
753
+ # Display feedback immediately after label (no buffering)
754
+ # Skip for task list tools since they handled their own display
755
+ if continue_flag:
756
+ continue
757
+ try:
758
+ if function_name == "edit_file" and result.requires_approval:
759
+ # Handle approval workflow for edit_file in parallel mode
760
+ thinking_indicator = context.get('thinking_indicator')
761
+ preview, is_valid = resolve_edit_preview(result.result)
762
+ if is_valid:
763
+ approved_result, should_exit = handle_edit_approval(
764
+ preview, args_dict.get('path', ''), args_dict,
765
+ self.console, thinking_indicator,
766
+ self.chat_manager.approve_mode,
767
+ lambda: self.chat_manager.cycle_approve_mode(),
768
+ self.repo_root, self.gitignore_spec,
769
+ vault_root_str)
770
+ result.result = approved_result
771
+ if should_exit:
772
+ result.should_exit = True
773
+ elif label:
774
+ display_tool_feedback(label, result.result, self.console, panel_updater=self.panel_updater)
775
+ # Force flush to ensure immediate output
776
+ if not self.panel_updater:
777
+ self.console.file.flush()
778
+ else:
779
+ completion_text = f"[dim]{function_name} completed[/dim]"
780
+ if self.panel_updater:
781
+ self.panel_updater.append(completion_text)
782
+ else:
783
+ self.console.print(completion_text, highlight=False)
784
+ self.console.file.flush()
785
+ except Exception:
786
+ completion_text = f"[dim]{function_name} completed[/dim]"
787
+ if self.panel_updater:
788
+ self.panel_updater.append(completion_text)
789
+ else:
790
+ self.console.print(completion_text, highlight=False)
791
+ self.console.file.flush()
792
+ else:
793
+ error_msg = result.error or result.result
794
+ error_text = f"[red]{error_msg}[/red]"
795
+ if self.panel_updater:
796
+ self.panel_updater.append(error_text)
797
+ else:
798
+ self.console.print(error_text)
799
+ self.console.file.flush()
800
+
801
+ # Display summary
802
+ success_count = sum(1 for r in results if r.success)
803
+ if self.debug_mode:
804
+ self.console.print(
805
+ f"[dim]Parallel execution: {success_count}/{len(results)} succeeded[/dim]"
806
+ )
807
+
808
+ # Append all results to chat history
809
+ end_loop = False
810
+ for result in results:
811
+ if result.success:
812
+ # Check if tool requested exit
813
+ if result.should_exit:
814
+ end_loop = True
815
+
816
+ # Add exit_code prefix for agent consumption (Rich Text = success)
817
+ if isinstance(result.result, Text):
818
+ content_for_agent = f"exit_code=0\n{str(result.result)}"
819
+ else:
820
+ content_for_agent = str(result.result)
821
+ tool_msg = {
822
+ "role": "tool",
823
+ "tool_call_id": result.tool_id,
824
+ "content": content_for_agent
825
+ }
826
+ self.chat_manager.messages.append(tool_msg)
827
+ # Log tool result
828
+ self.chat_manager.log_message(tool_msg)
829
+ else:
830
+ # Tool failed
831
+ error_msg = result.error or result.result
832
+ tool_msg = {
833
+ "role": "tool",
834
+ "tool_call_id": result.tool_id,
835
+ "content": f"exit_code=1\n{error_msg}"
836
+ }
837
+ self.chat_manager.messages.append(tool_msg)
838
+ # Log tool result
839
+ self.chat_manager.log_message(tool_msg)
840
+
841
+ # Mid-loop compaction: compact older completed tool blocks
842
+ # after all parallel results are appended (safe — only compacts completed blocks)
843
+ self.chat_manager.compact_tool_results()
844
+
845
+ # Update context tokens with current mode's tools
846
+ tools_for_mode = TOOLS()
847
+ self.chat_manager._update_context_tokens(tools_for_mode)
848
+
849
+ # Pre-send guard: ensure context fits before next LLM call
850
+ self.chat_manager.ensure_context_fits(console=self.console)
851
+
852
+ return end_loop
853
+ finally:
854
+ # Restore console output
855
+ self._parallel_context['console'] = self.console
856
+
857
+ def _process_single_tool_call(self, tool_call, thinking_indicator):
858
+ """Process a single tool call.
859
+
860
+ Args:
861
+ tool_call: Tool call dict from LLM
862
+ thinking_indicator: Optional ThinkingIndicator instance
863
+
864
+ Returns:
865
+ Tuple of (should_exit, tool_result)
866
+ - should_exit: True if should exit orchestration loop
867
+ - tool_result: Result string, or None if already appended, False if skipped
868
+ """
869
+ tool_id = tool_call["id"]
870
+ function_name = tool_call["function"]["name"]
871
+
872
+ # Parse arguments
873
+ try:
874
+ args_str = tool_call["function"]["arguments"]
875
+ if args_str is None:
876
+ return False, "Error: Tool arguments are missing."
877
+ arguments = json.loads(args_str)
878
+ except (json.JSONDecodeError, TypeError):
879
+ return False, "Error: Invalid JSON arguments."
880
+
881
+ # Create SubAgentPanel for sub_agent tool calls
882
+ panel_to_use = self.panel_updater
883
+ if function_name == "sub_agent":
884
+ query = arguments.get("query", "")
885
+ panel_to_use = SubAgentPanel(query, self.console)
886
+
887
+ # Execute via tool registry
888
+ from tools.base import ToolRegistry, build_context
889
+
890
+ tool = ToolRegistry.get(function_name)
891
+ if tool:
892
+ # Reset TTL for plugin-tier tools when they are actually called
893
+ if ToolRegistry.is_plugin_active(function_name):
894
+ ToolRegistry.touch_plugin(function_name)
895
+ try:
896
+ context = build_context(
897
+ repo_root=self.repo_root,
898
+ console=self.console,
899
+ gitignore_spec=self.gitignore_spec,
900
+ debug_mode=self.debug_mode,
901
+ chat_manager=self.chat_manager,
902
+ rg_exe_path=self.rg_exe_path,
903
+ panel_updater=panel_to_use,
904
+ vault_root=vault_root_str()
905
+ )
906
+ # Determine terminal policy for thinking indicator management
907
+ from tools.helpers.base import get_terminal_policy, TERMINAL_YIELD
908
+ policy = get_terminal_policy(function_name)
909
+
910
+ # Check if tool requires approval
911
+ if tool.requires_approval:
912
+ # For edit_file: check memory file auto-approve first
913
+ if function_name == "edit_file":
914
+ edit_path = arguments.get("path", "")
915
+ if not edit_path:
916
+ return False, "Error: path is required for edit_file."
917
+
918
+ # Memory file: auto-approve, fire-and-forget
919
+ if self._is_memory_file(edit_path):
920
+ # Generate preview to validate the edit (reuses existing logic)
921
+ result = tool.execute(arguments, context)
922
+ preview, is_valid = resolve_edit_preview(result)
923
+ if is_valid:
924
+ ok = self._execute_memory_edit(arguments)
925
+ if self.debug_mode:
926
+ console = self._get_console()
927
+ if console:
928
+ console.print(f"[dim]Memory edit auto-approved: {edit_path}[/dim]")
929
+ return False, "Memory saved." if ok else f"Memory edit failed: {edit_path}"
930
+ return False, str(result)
931
+
932
+ # Normal edit: generate preview and request approval
933
+ result = tool.execute(arguments, context)
934
+
935
+ # Display preview
936
+ console = self._get_console()
937
+ if console:
938
+ preview, is_valid = resolve_edit_preview(result)
939
+ if is_valid:
940
+ approved_result, should_exit = handle_edit_approval(
941
+ preview, arguments.get('path', ''), arguments,
942
+ console, thinking_indicator,
943
+ self.chat_manager.approve_mode,
944
+ lambda: self.chat_manager.cycle_approve_mode(),
945
+ self.repo_root, self.gitignore_spec,
946
+ vault_root_str)
947
+ if should_exit:
948
+ return True, approved_result
949
+ result = approved_result
950
+ return False, str(result)
951
+ elif function_name == "execute_command":
952
+ console = self._get_console()
953
+ command = arguments.get('command', '')
954
+ result, should_exit, command_executed = handle_command_approval(
955
+ command, arguments, tool, context, console,
956
+ thinking_indicator, self.chat_manager.approve_mode,
957
+ self.debug_mode,
958
+ cron_job_id=self.cron_job_id,
959
+ cron_allowlist=self.cron_allowlist,
960
+ cron_interactive=self.cron_interactive)
961
+ if should_exit:
962
+ return True, result
963
+
964
+ # Display execute_command output when command actually ran
965
+ if command_executed:
966
+ label = build_tool_label(function_name, arguments)
967
+ label_text = f"[grey]{label}[/grey]"
968
+ if not self.panel_updater:
969
+ console.print(label_text, highlight=False)
970
+ console.file.flush()
971
+ display_tool_feedback(label, result, console, indent=self.is_sub_agent, panel_updater=self.panel_updater)
972
+ return False, result
973
+ else:
974
+ # Other tools with requires_approval can be handled here in the future
975
+ result = tool.execute(arguments, context)
976
+ else:
977
+ # No approval required - execute normally
978
+ # Handle thinking indicator based on tool's terminal policy
979
+ if policy == TERMINAL_YIELD and thinking_indicator:
980
+ thinking_indicator.pause()
981
+ # Force print to clear the status line
982
+ temp_console = self._get_console()
983
+ temp_console.print()
984
+ temp_console.file.flush()
985
+
986
+ result = tool.execute(arguments, context)
987
+
988
+ # Resume thinking indicator for yield policy
989
+ if policy == TERMINAL_YIELD and thinking_indicator:
990
+ thinking_indicator.resume()
991
+
992
+ # Display result for registry tools
993
+ # Skip display for tools that take over the terminal (they handle their own display)
994
+ if policy != TERMINAL_YIELD:
995
+ console = self._get_console()
996
+ if console:
997
+ # Build label with arguments for better display
998
+ label = build_tool_label(function_name, arguments)
999
+
1000
+ # For task list tools: only show the task list, no label duplication
1001
+ if function_name in ("create_task_list", "complete_task", "show_task_list"):
1002
+ # Extract and format task list directly
1003
+ exit_code = extract_exit_code(result)
1004
+ if exit_code == 0 or exit_code is None:
1005
+ rendered = result
1006
+ if rendered.startswith("exit_code="):
1007
+ rendered = "\n".join(rendered.splitlines()[1:])
1008
+ _print_or_append(rendered.strip(), console, self.panel_updater, markup=True)
1009
+ else:
1010
+ first_two = "\n".join(result.splitlines()[:2]).strip()
1011
+ _print_or_append(first_two or result.strip(), console, self.panel_updater, markup=False)
1012
+ if not self.panel_updater:
1013
+ console.print()
1014
+ else:
1015
+ # Print label first (like parallel mode)
1016
+ label_text = f"[grey]{label}[/grey]" if not function_name.startswith("web search") else f"[bold #5F9EA0]{label}[/bold #5F9EA0]"
1017
+ if not self.panel_updater:
1018
+ console.print(label_text, highlight=False)
1019
+ console.file.flush()
1020
+
1021
+ # Then display feedback
1022
+ display_tool_feedback(label, result, console, indent=self.is_sub_agent, panel_updater=self.panel_updater)
1023
+
1024
+ return False, str(result)
1025
+ except Exception as e:
1026
+ return False, f"Error executing tool '{function_name}': {str(e)}"
1027
+
1028
+ return False, f"Error: Unknown tool '{function_name}'."
1029
+
1030
+ def agentic_answer(chat_manager, user_input, console, repo_root, rg_exe_path, debug_mode, thinking_indicator=None):
1031
+ """Main agent loop using OpenAI-style function calling.
1032
+
1033
+ This is a convenience wrapper that creates an AgenticOrchestrator
1034
+ and runs it with the provided parameters.
1035
+
1036
+ Args:
1037
+ chat_manager: ChatManager instance
1038
+ user_input: User's input message
1039
+ console: Rich console for output
1040
+ repo_root: Path to repository root
1041
+ rg_exe_path: Path to rg.exe
1042
+ debug_mode: Whether to show debug output
1043
+ thinking_indicator: Optional ThinkingIndicator instance
1044
+ """
1045
+ orchestrator = AgenticOrchestrator(
1046
+ chat_manager=chat_manager,
1047
+ repo_root=repo_root,
1048
+ rg_exe_path=rg_exe_path,
1049
+ console=console,
1050
+ debug_mode=debug_mode,
1051
+ )
1052
+ orchestrator.run(user_input, thinking_indicator)
1053
+
1054
+