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,376 +0,0 @@
1
- """Sub-agent for delegated tasks.
2
-
3
- Uses existing AgenticOrchestrator with isolated message context
4
- and read-only tools to execute generic delegated tasks.
5
- """
6
-
7
- from pathlib import Path
8
-
9
- from core.chat_manager import ChatManager
10
- from exceptions import LLMError
11
- from llm.prompts import build_sub_agent_prompt
12
- from utils.settings import sub_agent_settings
13
-
14
-
15
- class HardLimitExceeded(Exception):
16
- """Raised when the sub-agent hits its hard token limit."""
17
- pass
18
-
19
-
20
- class BilledLimitExceeded(Exception):
21
- """Raised when the sub-agent hits its cumulative billed token limit."""
22
- pass
23
-
24
-
25
- def _format_messages_dump(messages) -> str:
26
- """Format sub-agent message history as a markdown dump.
27
-
28
- Args:
29
- messages: List of message dicts from the sub-agent ChatManager.
30
-
31
- Returns:
32
- Markdown string with the full conversation context.
33
- """
34
- lines = [
35
- "## Sub-Agent Context Dump (Hard Limit Reached)",
36
- "",
37
- "The sub-agent exceeded its hard token limit. Below is the full, unabridged context of its investigation. No summary was produced.",
38
- "",
39
- "---",
40
- "",
41
- ]
42
- for i, msg in enumerate(messages):
43
- role = msg.get("role", "unknown")
44
- content = msg.get("content", "")
45
- tool_calls = msg.get("tool_calls")
46
- tool_call_id = msg.get("tool_call_id")
47
-
48
- if tool_call_id:
49
- lines.append(f"### Message {i} — tool result ({tool_call_id})")
50
- elif tool_calls:
51
- lines.append(f"### Message {i} — assistant tool calls")
52
- for tc in tool_calls:
53
- fn = tc.get("function", {})
54
- lines.append(f"- `{fn.get('name', '?')}` — `{fn.get('arguments', '')}`")
55
- else:
56
- lines.append(f"### Message {i} — {role}")
57
-
58
- if content:
59
- # Truncate large content to avoid blowing out the main agent's context
60
- max_chars = 4000
61
- if len(content) > max_chars:
62
- content = content[:max_chars] + f"\n\n... (truncated, {len(content) - max_chars:,} chars omitted)"
63
- lines.append(content)
64
- lines.append("")
65
- return "\n".join(lines)
66
-
67
-
68
- def _configure_compaction():
69
- """Create a ChatManager with compaction settings from config.
70
-
71
- Returns:
72
- ChatManager: A new ChatManager instance with compaction configured
73
- """
74
- if sub_agent_settings.enable_compaction:
75
- return ChatManager(compact_trigger_tokens=sub_agent_settings.compact_trigger_tokens)
76
- else:
77
- return ChatManager(compact_trigger_tokens=None)
78
-
79
-
80
- def _inject_system_prompt(chat_manager, sub_agent_type: str = "research"):
81
- """Build sub-agent prompt and inject it.
82
-
83
- Token usage is reported live by the wrapper in run_sub_agent(),
84
- so the system prompt is kept clean.
85
-
86
- Args:
87
- chat_manager: ChatManager instance to configure
88
- sub_agent_type: Type of sub-agent ('research' or 'review').
89
- """
90
- base_prompt = build_sub_agent_prompt(
91
- sub_agent_type=sub_agent_type,
92
- soft_limit_tokens=sub_agent_settings.soft_limit_tokens,
93
- hard_limit_tokens=sub_agent_settings.hard_limit_tokens,
94
- )
95
- chat_manager.messages = [{"role": "system", "content": base_prompt}]
96
-
97
-
98
- def _load_codebase_map(chat_manager):
99
- """Load agents.md codebase map into sub-agent context if available.
100
-
101
- Args:
102
- chat_manager: ChatManager instance to add context to
103
- """
104
- agents_path = Path.cwd() / "agents.md"
105
- if agents_path.exists():
106
- map_content = agents_path.read_text(encoding="utf-8").strip()
107
- user_msg = (
108
- "Here is the codebase map for this project. "
109
- "This provides an overview of the repository structure and file purposes. "
110
- "Use this as a reference when exploring the codebase.\n\n"
111
- f"## Codebase Map (auto-generated from agents.md)\n\n{map_content}"
112
- )
113
- chat_manager.messages.append({"role": "user", "content": user_msg})
114
-
115
-
116
- def _configure_isolation(chat_manager):
117
- """Apply isolation settings for sub-agent context.
118
-
119
- Disables conversation logging.
120
-
121
- Args:
122
- chat_manager: ChatManager instance to configure
123
- """
124
- chat_manager.markdown_logger = None
125
-
126
-
127
- def _create_chat_manager(sub_agent_type: str = "research"):
128
- """Create a fresh ChatManager instance for sub-agent use.
129
-
130
- Orchestrates compaction, prompt injection, codebase map loading,
131
- and isolation configuration.
132
-
133
- Args:
134
- sub_agent_type: Type of sub-agent ('research' or 'review').
135
-
136
- Returns:
137
- ChatManager: A new ChatManager instance with pre-configured system prompt
138
- """
139
- chat_manager = _configure_compaction()
140
- chat_manager._compaction_disabled = True
141
- _inject_system_prompt(chat_manager, sub_agent_type=sub_agent_type)
142
- _load_codebase_map(chat_manager)
143
- _configure_isolation(chat_manager)
144
- return chat_manager
145
-
146
-
147
- def run_sub_agent(
148
- task_query: str,
149
- repo_root: Path,
150
- rg_exe_path: str,
151
- console=None,
152
- panel_updater=None,
153
- sub_agent_type: str = "research",
154
- initial_context: str = None,
155
- ) -> dict:
156
- """Run sub-agent using existing AgenticOrchestrator for delegated tasks.
157
-
158
- Args:
159
- task_query: Generic task query to execute (e.g., "Read file config.json")
160
- repo_root: Repository root path
161
- rg_exe_path: Path to rg executable
162
- console: Optional Rich console for output
163
- panel_updater: Optional SubAgentPanel for live panel updates
164
- sub_agent_type: Type of sub-agent ('research' or 'review').
165
- initial_context: Optional string injected as context before the task query
166
- (e.g., a git diff for review mode).
167
-
168
- Returns:
169
- Dict with:
170
- - 'result': Formatted markdown string (goes into chat history)
171
- - 'usage': Usage data for billing
172
- - 'error': Error message if failed (None if success)
173
- """
174
- # Validate panel_updater type if provided
175
- if panel_updater is not None and not hasattr(panel_updater, 'append'):
176
- panel_updater = None
177
-
178
- # If no panel_updater provided, create a simple no-op one
179
- if panel_updater is None:
180
- from tools.sub_agent import SimplePanelUpdater
181
- panel_updater = SimplePanelUpdater(console)
182
-
183
- # Create fresh ChatManager for sub-agent
184
- temp_chat_manager = _create_chat_manager(sub_agent_type=sub_agent_type)
185
-
186
- # Inject initial context as a user/assistant exchange if provided
187
- if initial_context:
188
- temp_chat_manager.messages.append(
189
- {"role": "user", "content": initial_context}
190
- )
191
-
192
- # Import here to avoid circular import with core.agentic
193
- from core.agentic import AgenticOrchestrator
194
-
195
- # Create orchestrator (reuses existing implementation)
196
- orchestrator = AgenticOrchestrator(
197
- chat_manager=temp_chat_manager,
198
- repo_root=repo_root,
199
- rg_exe_path=rg_exe_path,
200
- console=console,
201
- debug_mode=False,
202
- suppress_result_display=True,
203
- is_sub_agent=True,
204
- panel_updater=panel_updater,
205
- force_parallel_execution=True # Enable parallel execution for read-only tools
206
- )
207
-
208
- # Wrap orchestrator._get_llm_response to check hard token limit and
209
- # wrap client.chat_completion once (outside the loop) to inject live
210
- # token feedback as a system message — avoids per-call monkey-patching
211
- # and eliminates any re-entrancy risk.
212
- original_get_llm_response = orchestrator._get_llm_response
213
- original_chat_completion = temp_chat_manager.client.chat_completion
214
-
215
- _soft_limit_warned = False
216
- _billed_warning_sent = False
217
-
218
- def _chat_completion_with_token_hint(messages, **kwargs):
219
- """Prepend a system-level token budget hint and one-time warnings to every LLM call."""
220
- nonlocal _soft_limit_warned, _billed_warning_sent
221
- tt = temp_chat_manager.token_tracker
222
- hint = f"[Token budget: {tt.current_context_tokens:,} curr / {tt.conv_total_tokens:,} total billed]"
223
- warnings = []
224
-
225
- if not _soft_limit_warned and tt.current_context_tokens >= sub_agent_settings.soft_limit_tokens:
226
- _soft_limit_warned = True
227
- warnings.append(
228
- f"WARNING: You have exceeded the current-context soft token limit "
229
- f"({tt.current_context_tokens:,} / {sub_agent_settings.soft_limit_tokens:,}). "
230
- "STOP exploring and return your findings immediately. Do NOT call any more tools."
231
- )
232
-
233
- if not _billed_warning_sent and tt.conv_total_tokens >= sub_agent_settings.billed_warning_tokens:
234
- _billed_warning_sent = True
235
- warnings.append(
236
- f"WARNING: You have exceeded the cumulative billed token warning limit "
237
- f"({tt.conv_total_tokens:,} / {sub_agent_settings.billed_warning_tokens:,}). "
238
- "This sub-agent may be running away. STOP exploring and return your findings immediately. "
239
- "Do NOT call any more tools."
240
- )
241
-
242
- if warnings:
243
- hint = "\n".join([*warnings, hint])
244
-
245
- token_msg = {"role": "system", "content": hint}
246
- return original_chat_completion([token_msg, *messages], **kwargs)
247
-
248
- def _get_llm_response_with_hard_limit(allowed_tools=None, allow_active_plugins=False):
249
- """Wrapper to check context and billed token limits and update panel state."""
250
- tt = temp_chat_manager.token_tracker
251
-
252
- # Check hard token limit before making LLM call
253
- # Use current_context_tokens (prompt size) not total_tokens (cumulative billing)
254
- # to catch prompt-length-over-limit errors before they hit the API.
255
- if tt.current_context_tokens >= sub_agent_settings.hard_limit_tokens:
256
- raise HardLimitExceeded(
257
- f"Sub-agent hard token limit exceeded: "
258
- f"{tt.current_context_tokens:,} / {sub_agent_settings.hard_limit_tokens:,} tokens."
259
- )
260
-
261
- # Check cumulative billed tokens to stop runaway sub-agents even when
262
- # current context remains below the prompt-size hard limit.
263
- #
264
- # Note: the billed warning is injected by _chat_completion_with_token_hint
265
- # on the next chat_completion call. This hard stop runs before each LLM
266
- # response, so once we hit the billed hard limit the warning may never be
267
- # delivered if no further chat_completion call is made.
268
- if tt.conv_total_tokens >= sub_agent_settings.billed_hard_limit_tokens:
269
- raise BilledLimitExceeded(
270
- f"Sub-agent billed token limit exceeded: "
271
- f"{tt.conv_total_tokens:,} / {sub_agent_settings.billed_hard_limit_tokens:,} tokens."
272
- )
273
-
274
- # Update panel with live token counts
275
- # Order: conversation length (current context) first, total tokens billed second
276
- conv_length = tt.current_context_tokens
277
- total_billed = tt.conv_total_tokens
278
- if hasattr(panel_updater, 'token_info'):
279
- panel_updater.token_info = f"{conv_length:,} curr | {total_billed:,} total"
280
- panel_updater.append("") # Refresh panel title
281
-
282
- return original_get_llm_response(
283
- allowed_tools=allowed_tools,
284
- allow_active_plugins=allow_active_plugins,
285
- )
286
-
287
- # Apply both patches once, before the orchestrator loop starts
288
- orchestrator._get_llm_response = _get_llm_response_with_hard_limit
289
- temp_chat_manager.client.chat_completion = _chat_completion_with_token_hint
290
-
291
- hard_limit_exceeded = False
292
- billed_limit_exceeded = False
293
-
294
- try:
295
- # Run sub-agent task
296
- orchestrator.run(
297
- task_query,
298
- thinking_indicator=None,
299
- allowed_tools=sub_agent_settings.allowed_tools,
300
- allow_active_plugins=sub_agent_settings.allow_active_plugins,
301
- )
302
- except HardLimitExceeded:
303
- hard_limit_exceeded = True
304
- except BilledLimitExceeded:
305
- billed_limit_exceeded = True
306
- except LLMError as e:
307
- return {
308
- "result": "",
309
- "usage": {
310
- "prompt_tokens": 0,
311
- "completion_tokens": 0,
312
- "total_tokens": 0
313
- },
314
- "model": temp_chat_manager.client.model,
315
- "error": str(e)
316
- }
317
- except Exception as e:
318
- import traceback
319
- error_details = f"{e}\n\nTraceback:\n{traceback.format_exc()}"
320
- return {
321
- "result": "",
322
- "usage": {
323
- "prompt_tokens": 0,
324
- "completion_tokens": 0,
325
- "total_tokens": 0
326
- },
327
- "model": "",
328
- "error": error_details
329
- }
330
- finally:
331
- # Restore originals
332
- temp_chat_manager.client.chat_completion = original_chat_completion
333
-
334
- # Get final token usage (no need for delta calculation on fresh instance)
335
- delta_prompt = temp_chat_manager.token_tracker.total_prompt_tokens
336
- delta_completion = temp_chat_manager.token_tracker.total_completion_tokens
337
- delta_total = temp_chat_manager.token_tracker.total_tokens
338
- tt = temp_chat_manager.token_tracker
339
- delta_cost = tt.total_actual_cost + tt.total_estimated_cost
340
-
341
- if hard_limit_exceeded and sub_agent_settings.dump_context_on_hard_limit:
342
- result = _format_messages_dump(temp_chat_manager.messages)
343
- else:
344
- # Extract final response (last assistant message with content)
345
- final_content = ""
346
- for msg in reversed(temp_chat_manager.messages):
347
- if msg.get("role") == "assistant" and msg.get("content"):
348
- final_content = msg["content"].strip()
349
- break
350
-
351
- if billed_limit_exceeded:
352
- prefix = (
353
- "WARNING: Sub-agent billed token limit reached. "
354
- "Returning current findings early to prevent runaway execution."
355
- )
356
- result = f"{prefix}\n\n{final_content}" if final_content else prefix
357
- else:
358
- result = final_content
359
-
360
- usage = {
361
- "prompt_tokens": delta_prompt,
362
- "completion_tokens": delta_completion,
363
- "total_tokens": delta_total,
364
- "context_tokens": tt.current_context_tokens,
365
- }
366
- if delta_cost > 0:
367
- usage["cost"] = delta_cost
368
-
369
- return {
370
- "result": result,
371
- "usage": usage,
372
- "model": temp_chat_manager.client.model,
373
- "error": None,
374
- "hard_limit_exceeded": hard_limit_exceeded,
375
- "billed_limit_exceeded": billed_limit_exceeded,
376
- }
@@ -1,220 +0,0 @@
1
- """Tool approval workflows for edit_file and execute_command."""
2
-
3
- from rich.text import Text
4
-
5
- from tools import confirm_tool
6
-
7
-
8
- def handle_edit_approval(preview, file_path, args_dict, console, thinking_indicator,
9
- approve_mode, cycle_approve_mode, repo_root, gitignore_spec,
10
- vault_root_str):
11
- """Handle edit_file approval workflow.
12
-
13
- Args:
14
- preview: Either a rich Text object or a plain string to display.
15
- file_path: The file path being edited (for the confirm prompt).
16
- args_dict: Tool arguments dict (path, search, replace, context_lines).
17
- console: Rich console for display.
18
- thinking_indicator: ThinkingIndicator instance (may be None).
19
- approve_mode: Current approval mode string.
20
- cycle_approve_mode: Callable to cycle approval mode.
21
- repo_root: Repository root path string.
22
- gitignore_spec: Gitignore spec object.
23
- vault_root_str: Callable returning vault root path string.
24
-
25
- Returns:
26
- (result_str, should_exit) tuple where should_exit=True means cancel the agentic loop.
27
- """
28
- # Display preview
29
- console.print(preview)
30
- console.print()
31
-
32
- # Stop thinking indicator while waiting for user input
33
- if thinking_indicator:
34
- thinking_indicator.stop()
35
-
36
- action, guidance = confirm_tool(
37
- f"edit_file: {file_path}",
38
- console,
39
- reason=args_dict.get('reason', 'Apply file edit with above changes'),
40
- requires_approval=True,
41
- approve_mode=approve_mode,
42
- is_edit_tool=True,
43
- cycle_approve_mode=cycle_approve_mode
44
- )
45
-
46
- if action == "accept":
47
- from tools.edit import _execute_edit_file
48
- final_result = _execute_edit_file(
49
- path=args_dict.get('path'),
50
- search=args_dict.get('search'),
51
- replace=args_dict.get('replace'),
52
- repo_root=repo_root,
53
- console=console,
54
- gitignore_spec=gitignore_spec,
55
- context_lines=args_dict.get('context_lines', 3),
56
- vault_root=vault_root_str()
57
- )
58
- # Strip exit_code line from final result before displaying
59
- if final_result and isinstance(final_result, str):
60
- result_lines = [line for line in final_result.split('\n') if not line.startswith('exit_code=')]
61
- final_result = '\n'.join(result_lines).strip()
62
- result_str, should_exit = final_result, False
63
- elif action == "advise":
64
- console.print(f"[dim]Edit not applied. User advice: {guidance}[/dim]")
65
- result_str = f"exit_code=1\nEdit not applied. User advice: {guidance}"
66
- should_exit = False
67
- else: # cancel
68
- console.print("[dim]Operation canceled by user.[/dim]")
69
- result_str = "exit_code=1\nOperation canceled by user. Do not retry this operation."
70
- should_exit = True
71
-
72
- # Restart thinking indicator after user input
73
- if thinking_indicator:
74
- thinking_indicator.start()
75
-
76
- return result_str, should_exit
77
-
78
-
79
- def resolve_edit_preview(result):
80
- """Extract a displayable preview from an edit_file tool result.
81
-
82
- Handles both Rich Text objects (new format) and legacy string format.
83
-
84
- Args:
85
- result: Either a rich Text object or a string.
86
-
87
- Returns:
88
- (preview, is_valid) tuple.
89
- - preview: Text object, plain string, or None if error.
90
- - is_valid: False if the result is an error (non-zero exit_code).
91
- """
92
- if isinstance(result, Text):
93
- return result, True
94
- elif isinstance(result, str) and result.startswith("exit_code=0"):
95
- lines = result.split('\n')
96
- preview_lines = [line for line in lines if not line.startswith("exit_code=")]
97
- preview = '\n'.join(preview_lines).strip()
98
- return preview, True
99
- else:
100
- # Error occurred during preview - don't show to user
101
- return None, False
102
-
103
-
104
- def handle_command_approval(command, arguments, tool, context, console,
105
- thinking_indicator, approve_mode, debug_mode,
106
- cron_job_id=None, cron_allowlist=None,
107
- cron_interactive=False):
108
- """Handle execute_command approval workflow.
109
-
110
- Checks for silent blocks, auto-approval, and prompts user if needed.
111
- When a cron_job_id and cron_allowlist are provided, commands on the
112
- job's allow list are auto-approved; unlisted commands are blocked
113
- (in scheduled mode) or prompted interactively (in test-run mode).
114
-
115
- Args:
116
- command: The shell command string.
117
- arguments: Tool arguments dict (includes 'reason').
118
- tool: The tool object to execute on approval.
119
- context: Tool execution context dict.
120
- console: Rich console for display.
121
- thinking_indicator: ThinkingIndicator instance (may be None).
122
- approve_mode: Current approval mode string.
123
- debug_mode: Whether debug mode is active (for silent block logging).
124
- cron_job_id: Optional cron job ID for allow list checking.
125
- cron_allowlist: Optional CronAllowlist instance for cron command gating.
126
- cron_interactive: If True, cron job is in interactive test-run mode.
127
-
128
- Returns:
129
- (result, should_exit, command_executed) tuple.
130
- - result: Tool result string.
131
- - should_exit: True if the user canceled (break the agentic loop).
132
- - command_executed: True if the command was actually executed (display output).
133
- """
134
- from utils.validation import is_auto_approved_command, check_for_silent_blocked_command
135
-
136
- # Check if command should be silently blocked (redirect to native tool)
137
- is_blocked, reprompt_msg = check_for_silent_blocked_command(command)
138
- if is_blocked:
139
- if debug_mode:
140
- console.print(f"[dim]Silently blocked command: {command.split()[0]}[/dim]")
141
- result = f"exit_code=1\n{reprompt_msg}"
142
- return result, False, False
143
-
144
- # Check if command should be auto-approved (global safe commands)
145
- auto_approve = is_auto_approved_command(command)
146
-
147
- # Check cron allow list
148
- cron_auto_approved = False
149
- if cron_job_id and cron_allowlist:
150
- if cron_allowlist.is_allowed(cron_job_id, command):
151
- cron_auto_approved = True
152
- elif not auto_approve:
153
- # Command not on allow list and not globally safe
154
- # Determine if we're in interactive test-run or scheduled mode
155
- if cron_interactive:
156
- # Interactive test run (/cron run) — prompt the user
157
- pass # Fall through to normal interactive approval below
158
- else:
159
- # Scheduled run — block the command, let agent adapt
160
- allowed_cmds = cron_allowlist.get_commands(cron_job_id)
161
- allowed_preview = ", ".join(f"'{c}'" for c in allowed_cmds[:5])
162
- if len(allowed_cmds) > 5:
163
- allowed_preview += f", ... ({len(allowed_cmds)} total)"
164
- if not allowed_preview:
165
- allowed_preview = "(none - run '/cron run <id>' to build the allow list)"
166
- result = (
167
- f"exit_code=1\n"
168
- f"Command not in cron allow list for job '{cron_job_id}'.\n"
169
- f"Command: {command}\n"
170
- f"Allowed: {allowed_preview}\n"
171
- f"Do not retry this command. Use only approved commands or "
172
- f"ask the user to run '/cron run {cron_job_id}' to add it."
173
- )
174
- return result, False, False
175
-
176
- if cron_auto_approved or auto_approve:
177
- # Auto-approved command - execute without prompting
178
- result = tool.execute(arguments, context)
179
- command_executed = True
180
-
181
- # In cron test-run mode, auto-save newly approved commands to allow list
182
- # Skip globally-safe commands — they're auto-approved regardless of the allow list
183
- if cron_job_id and cron_allowlist and cron_interactive and not auto_approve:
184
- cron_allowlist.add_command(cron_job_id, command)
185
-
186
- return result, False, command_executed
187
-
188
- # Interactive approval (test-run mode or normal session)
189
- # Stop thinking indicator while waiting for user input
190
- if thinking_indicator:
191
- thinking_indicator.stop()
192
-
193
- action, guidance = confirm_tool(
194
- f"execute_command: {command[:80]}{'...' if len(command) > 80 else ''}",
195
- console,
196
- reason=arguments.get('reason', 'Execute shell command'),
197
- requires_approval=True,
198
- approve_mode=approve_mode
199
- )
200
-
201
- if action == "accept":
202
- result = tool.execute(arguments, context)
203
- command_executed = True
204
- # Auto-save approved command to cron allow list during test run
205
- if cron_job_id and cron_allowlist:
206
- cron_allowlist.add_command(cron_job_id, command)
207
- elif action == "advise":
208
- result = f"Command not executed. User advice: {guidance}"
209
- command_executed = False
210
- elif action == "cancel":
211
- result = "Command canceled by user. Do not retry this operation."
212
- if thinking_indicator:
213
- thinking_indicator.start()
214
- return result, True, False
215
-
216
- # Restart thinking indicator after user input
217
- if thinking_indicator:
218
- thinking_indicator.start()
219
-
220
- return result, False, command_executed