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