bone-agent 1.4.0 → 2.0.1

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