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.
- package/bin/bone.js +39 -0
- package/package.json +25 -39
- package/LICENSE +0 -21
- package/README.md +0 -201
- package/bin/npm-wrapper.js +0 -235
- package/bin/rg +0 -0
- package/bin/rg.exe +0 -0
- package/config.yaml.example +0 -144
- package/prompts/main/ask_questions.md +0 -31
- package/prompts/main/batch_independent_calls.md +0 -5
- package/prompts/main/casual_interactions.md +0 -11
- package/prompts/main/code_references.md +0 -8
- package/prompts/main/communication_style.md +0 -12
- package/prompts/main/context_reliability.md +0 -12
- package/prompts/main/conversational_tool_calling.md +0 -15
- package/prompts/main/dream.md +0 -50
- package/prompts/main/editing_pattern.md +0 -13
- package/prompts/main/error_handling.md +0 -6
- package/prompts/main/exploration_pattern.md +0 -21
- package/prompts/main/intro.md +0 -1
- package/prompts/main/obsidian.md +0 -16
- package/prompts/main/obsidian_project.md +0 -79
- package/prompts/main/professional_objectivity.md +0 -3
- package/prompts/main/skills.md +0 -3
- package/prompts/main/targeted_searching.md +0 -10
- package/prompts/main/task_lists_pattern.md +0 -8
- package/prompts/main/temp_folder.md +0 -9
- package/prompts/main/think_before_acting.md +0 -10
- package/prompts/main/tone_and_style.md +0 -4
- package/prompts/main/tool_preferences.md +0 -24
- package/prompts/main/trust_subagent_context.md +0 -21
- package/prompts/main/when_to_use_sub_agent.md +0 -7
- package/prompts/micro/ask_questions.md +0 -1
- package/prompts/micro/batch_independent_calls.md +0 -1
- package/prompts/micro/casual_interactions.md +0 -1
- package/prompts/micro/code_references.md +0 -1
- package/prompts/micro/communication_style.md +0 -1
- package/prompts/micro/context_reliability.md +0 -1
- package/prompts/micro/conversational_tool_calling.md +0 -1
- package/prompts/micro/editing_pattern.md +0 -1
- package/prompts/micro/error_handling.md +0 -1
- package/prompts/micro/exploration_pattern.md +0 -1
- package/prompts/micro/intro.md +0 -1
- package/prompts/micro/obsidian.md +0 -4
- package/prompts/micro/obsidian_project.md +0 -5
- package/prompts/micro/professional_objectivity.md +0 -1
- package/prompts/micro/skills.md +0 -1
- package/prompts/micro/targeted_searching.md +0 -1
- package/prompts/micro/task_lists_pattern.md +0 -1
- package/prompts/micro/temp_folder.md +0 -1
- package/prompts/micro/think_before_acting.md +0 -5
- package/prompts/micro/tone_and_style.md +0 -1
- package/prompts/micro/tool_preferences.md +0 -1
- package/prompts/micro/trust_subagent_context.md +0 -1
- package/prompts/micro/when_to_use_sub_agent.md +0 -1
- package/requirements.txt +0 -9
- package/src/__init__.py +0 -11
- package/src/core/__init__.py +0 -1
- package/src/core/agentic.py +0 -1085
- package/src/core/chat_manager.py +0 -1577
- package/src/core/config_manager.py +0 -260
- package/src/core/cron.py +0 -578
- package/src/core/cron_allowlist.py +0 -118
- package/src/core/memory.py +0 -145
- package/src/core/metadata.py +0 -75
- package/src/core/retry.py +0 -71
- package/src/core/skills.py +0 -463
- package/src/core/sub_agent.py +0 -376
- package/src/core/tool_approval.py +0 -220
- package/src/core/tool_feedback.py +0 -789
- package/src/exceptions.py +0 -79
- package/src/llm/__init__.py +0 -1
- package/src/llm/client.py +0 -176
- package/src/llm/codex_provider.py +0 -350
- package/src/llm/config.py +0 -536
- package/src/llm/prompts.py +0 -494
- package/src/llm/providers.py +0 -438
- package/src/llm/streaming.py +0 -163
- package/src/llm/token_tracker.py +0 -399
- package/src/tools/__init__.py +0 -151
- package/src/tools/constants.py +0 -59
- package/src/tools/create_file.py +0 -136
- package/src/tools/directory.py +0 -389
- package/src/tools/edit.py +0 -549
- package/src/tools/file_reader.py +0 -322
- package/src/tools/helpers/__init__.py +0 -99
- package/src/tools/helpers/base.py +0 -599
- package/src/tools/helpers/converters.py +0 -44
- package/src/tools/helpers/file_helpers.py +0 -189
- package/src/tools/helpers/formatters.py +0 -411
- package/src/tools/helpers/loader.py +0 -145
- package/src/tools/helpers/parallel_executor.py +0 -231
- package/src/tools/helpers/path_resolver.py +0 -283
- package/src/tools/helpers/plugin_manifest.py +0 -185
- package/src/tools/obsidian.py +0 -96
- package/src/tools/review_sub_agent.py +0 -190
- package/src/tools/rg_search.py +0 -477
- package/src/tools/search_plugins.py +0 -177
- package/src/tools/select_option.py +0 -600
- package/src/tools/shell.py +0 -302
- package/src/tools/sub_agent.py +0 -139
- package/src/tools/task_list.py +0 -269
- package/src/tools/web_search.py +0 -61
- package/src/ui/__init__.py +0 -1
- package/src/ui/banner.py +0 -87
- package/src/ui/commands.py +0 -3131
- package/src/ui/displays.py +0 -239
- package/src/ui/loader.py +0 -284
- package/src/ui/main.py +0 -643
- package/src/ui/prompt_utils.py +0 -113
- package/src/ui/setting_selector.py +0 -590
- package/src/ui/setup_wizard.py +0 -294
- package/src/ui/sub_agent_panel.py +0 -234
- package/src/ui/tool_confirmation.py +0 -226
- package/src/utils/__init__.py +0 -1
- package/src/utils/citation_parser.py +0 -199
- package/src/utils/editor.py +0 -207
- package/src/utils/gitignore_filter.py +0 -149
- package/src/utils/logger.py +0 -254
- package/src/utils/paths.py +0 -30
- package/src/utils/result_parsers.py +0 -108
- package/src/utils/safe_commands.py +0 -243
- package/src/utils/settings.py +0 -195
- package/src/utils/user_message_logger.py +0 -120
- package/src/utils/validation.py +0 -201
- package/src/utils/web_search.py +0 -173
package/src/core/agentic.py
DELETED
|
@@ -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)
|