devorch 0.1.2__py3-none-any.whl

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.
core/agent.py ADDED
@@ -0,0 +1,433 @@
1
+ import json
2
+ from collections.abc import Callable
3
+
4
+ from rich.panel import Panel
5
+ from rich.syntax import Syntax
6
+ from rich.text import Text
7
+
8
+ from core.executor import Executor
9
+ from core.modes import AgentMode, ModeManager
10
+ from core.planner import Planner
11
+ from core.sessions import SessionManager
12
+ from providers.base import LLMProvider
13
+ from schemas.message import Message, ToolCall
14
+ from utils.logger import get_console, print_info, print_success, print_warning
15
+
16
+ console = get_console()
17
+
18
+ SUMMARIZATION_PROMPT = """Please provide a concise summary of our conversation so far. Include:
19
+ 1. The main topics we discussed
20
+ 2. Key decisions or conclusions reached
21
+ 3. Any important context or information that would be needed to continue this conversation
22
+ 4. Current task status if any work is in progress
23
+
24
+ Keep the summary focused and under 500 words."""
25
+
26
+ PLAN_MODE_PROMPT = """You are in PLAN MODE. Before taking any actions:
27
+
28
+ 1. First, analyze the user's request carefully
29
+ 2. Create a clear, numbered plan of steps you will take
30
+ 3. List each tool you plan to use and why
31
+ 4. Present this plan to the user in a clear format like:
32
+
33
+ 📋 **Plan:**
34
+ 1. [Step description] - using [tool_name]
35
+ 2. [Step description] - using [tool_name]
36
+ ...
37
+
38
+ After presenting the plan, ask: "Should I proceed with this plan? (yes/no/modify)"
39
+
40
+ Do NOT execute any tools until the user approves the plan."""
41
+
42
+
43
+ class Agent:
44
+ def __init__(
45
+ self,
46
+ provider: LLMProvider,
47
+ planner: Planner,
48
+ executor: Executor,
49
+ tools: list,
50
+ session_manager: SessionManager | None = None,
51
+ on_session_continue: Callable[[str], None] | None = None,
52
+ mode_manager: ModeManager | None = None,
53
+ ):
54
+ self.provider = provider
55
+ self.planner = planner
56
+ self.executor = executor
57
+ self.tools = tools
58
+ self.session_manager = session_manager
59
+ self.on_session_continue = on_session_continue # Callback when session continues
60
+ self.mode_manager = mode_manager or ModeManager()
61
+ self.history: list[Message] = []
62
+ self._context_summary: str | None = None # Summary from previous session
63
+ self._awaiting_plan_approval: bool = False
64
+ self._pending_plan_response: str | None = None
65
+
66
+ def set_history(self, messages: list[Message]):
67
+ """Set the conversation history (used when resuming a session)."""
68
+ self.history = messages
69
+
70
+ def set_context_summary(self, summary: str):
71
+ """Set context summary from a previous session."""
72
+ self._context_summary = summary
73
+
74
+ def _display_tool_call(self, call: ToolCall):
75
+ """Display a tool call in a compact, user-friendly format."""
76
+ args = call.arguments
77
+
78
+ # Build a clean summary based on tool type
79
+ if call.name == "shell":
80
+ cmd = args.get("command", "")
81
+ syntax = Syntax(cmd, "bash", theme="monokai", line_numbers=False, word_wrap=True)
82
+ console.print(
83
+ Panel(
84
+ syntax,
85
+ title="[bold magenta]Shell[/bold magenta]",
86
+ border_style="magenta",
87
+ padding=(0, 1),
88
+ )
89
+ )
90
+
91
+ elif call.name == "filesystem":
92
+ action = args.get("action", "")
93
+ path = args.get("path", "")
94
+ content = args.get("content", "")
95
+
96
+ if action == "write":
97
+ lines = content.count("\n") + 1 if content else 0
98
+ summary = f"[cyan]write[/cyan] {lines} lines to [bold]{path}[/bold]"
99
+ elif action == "read":
100
+ summary = f"[cyan]read[/cyan] [bold]{path}[/bold]"
101
+ elif action == "list":
102
+ summary = f"[cyan]list[/cyan] [bold]{path}[/bold]"
103
+ else:
104
+ summary = f"[cyan]{action}[/cyan] [bold]{path}[/bold]"
105
+
106
+ console.print(f" [dim]>[/dim] {summary}")
107
+
108
+ elif call.name == "search":
109
+ pattern = args.get("pattern", "")
110
+ path = args.get("path", ".")
111
+ console.print(f" [dim]>[/dim] [cyan]search[/cyan] [bold]{pattern}[/bold] in {path}")
112
+
113
+ elif call.name == "grep":
114
+ pattern = args.get("pattern", "")
115
+ path = args.get("path", ".")
116
+ console.print(f" [dim]>[/dim] [cyan]grep[/cyan] [bold]{pattern}[/bold] in {path}")
117
+
118
+ elif call.name == "edit":
119
+ path = args.get("path", "")
120
+ console.print(f" [dim]>[/dim] [cyan]edit[/cyan] [bold]{path}[/bold]")
121
+
122
+ elif call.name == "task":
123
+ # Task tool - don't show anything, the task panel will display
124
+ pass
125
+
126
+ elif call.name == "websearch":
127
+ query = args.get("query", "")
128
+ console.print(f" [dim]>[/dim] [cyan]searching web[/cyan] [bold]{query}[/bold]")
129
+
130
+ elif call.name == "webfetch":
131
+ url = args.get("url", "")
132
+ # Truncate long URLs
133
+ display_url = url[:60] + "..." if len(url) > 60 else url
134
+ console.print(f" [dim]>[/dim] [cyan]fetching[/cyan] [bold]{display_url}[/bold]")
135
+
136
+ else:
137
+ # Generic fallback - show tool name and brief args
138
+ brief_args = {
139
+ k: (v[:50] + "..." if isinstance(v, str) and len(v) > 50 else v)
140
+ for k, v in args.items()
141
+ }
142
+ console.print(f" [dim]>[/dim] [cyan]{call.name}[/cyan] {brief_args}")
143
+
144
+ def _display_tool_result(self, tool_name: str, result: str):
145
+ """Display tool result in a compact, user-friendly format."""
146
+ result_str = str(result)
147
+
148
+ # Skip display for task tool (it shows its own panel)
149
+ if tool_name == "task":
150
+ return
151
+
152
+ # For filesystem writes, just show success
153
+ if "Successfully wrote" in result_str or "Successfully created" in result_str:
154
+ console.print(f" [green]✓[/green] [dim]{result_str}[/dim]")
155
+ return
156
+
157
+ # For successful file reads with long content, truncate
158
+ if tool_name == "filesystem" and len(result_str) > 200 and "Error" not in result_str:
159
+ lines = result_str.count("\n")
160
+ console.print(f" [green]✓[/green] [dim]Read {lines} lines[/dim]")
161
+ return
162
+
163
+ # For search/grep results (file search, not web search)
164
+ if tool_name in ("search", "grep") and "Error" not in result_str:
165
+ matches = result_str.strip().split("\n")
166
+ count = len([m for m in matches if m.strip()])
167
+ if count > 5:
168
+ console.print(f" [green]✓[/green] [dim]Found {count} matches[/dim]")
169
+ return
170
+
171
+ # For websearch results, show them nicely
172
+ if tool_name == "websearch" and "Search results for:" in result_str:
173
+ # Show a brief summary, full results go to the AI
174
+ lines = result_str.strip().split("\n")
175
+ result_count = sum(
176
+ 1 for line in lines if line.strip().startswith(("1.", "2.", "3.", "4.", "5."))
177
+ )
178
+ console.print(f" [green]✓[/green] [dim]Found {result_count} web results[/dim]")
179
+ return
180
+
181
+ # For webfetch results
182
+ if tool_name == "webfetch" and "Content from" in result_str:
183
+ lines = result_str.count("\n")
184
+ console.print(f" [green]✓[/green] [dim]Fetched page ({lines} lines)[/dim]")
185
+ return
186
+
187
+ # Check for errors
188
+ if "Error:" in result_str or result_str.startswith("Error"):
189
+ console.print(
190
+ Panel(
191
+ Text(result_str[:300], style="red"),
192
+ title="[bold red]Error[/bold red]",
193
+ border_style="red",
194
+ padding=(0, 1),
195
+ )
196
+ )
197
+ return
198
+
199
+ # Shell output - show in panel
200
+ if result_str.startswith("STDOUT:") or result_str.startswith("STDERR:"):
201
+ # Truncate long output
202
+ max_display = 400
203
+ if len(result_str) > max_display:
204
+ display_result = (
205
+ result_str[:max_display]
206
+ + f"\n[dim]... ({len(result_str) - max_display} more chars)[/dim]"
207
+ )
208
+ else:
209
+ display_result = result_str
210
+
211
+ console.print(
212
+ Panel(
213
+ Syntax(
214
+ display_result, "text", theme="monokai", line_numbers=False, word_wrap=True
215
+ ),
216
+ title="[bold green]Output[/bold green]",
217
+ border_style="green",
218
+ padding=(0, 1),
219
+ )
220
+ )
221
+ return
222
+
223
+ # Brief result for simple operations
224
+ if len(result_str) < 100:
225
+ console.print(f" [green]✓[/green] [dim]{result_str}[/dim]")
226
+ else:
227
+ # Truncate longer results
228
+ console.print(f" [green]✓[/green] [dim]{result_str[:100]}...[/dim]")
229
+
230
+ def _save_message(self, message: Message):
231
+ """Save a message to history and session storage."""
232
+ self.history.append(message)
233
+ if self.session_manager:
234
+ self.session_manager.save_message(message)
235
+
236
+ def _generate_summary(self) -> str:
237
+ """Generate a summary of the current conversation."""
238
+ # Build messages for summarization (without tools)
239
+ summary_messages = [
240
+ Message(
241
+ role="system", content="You are a helpful assistant that summarizes conversations."
242
+ ),
243
+ ]
244
+
245
+ # Add conversation history (simplified)
246
+ for msg in self.history:
247
+ if msg.role in ("user", "assistant"):
248
+ summary_messages.append(msg)
249
+
250
+ summary_messages.append(Message(role="user", content=SUMMARIZATION_PROMPT))
251
+
252
+ # Generate summary without tools
253
+ with console.status("[bold yellow]Summarizing conversation...", spinner="dots"):
254
+ response = self.provider.generate(summary_messages, tools=None)
255
+
256
+ return response.message.content
257
+
258
+ def _check_and_handle_session_limit(self) -> bool:
259
+ """Check if session needs summarization and handle it. Returns True if continued."""
260
+ if not self.session_manager:
261
+ return False
262
+
263
+ if not self.session_manager.should_summarize():
264
+ return False
265
+
266
+ print_warning(f"Session approaching limit ({self.session_manager.message_limit} messages)")
267
+ print_info("Summarizing conversation and creating continuation session...")
268
+
269
+ # Generate summary
270
+ summary = self._generate_summary()
271
+
272
+ # Create continuation session
273
+ new_session_id = self.session_manager.create_continuation_session(
274
+ provider=self.provider.name, model=self.provider.model, summary=summary
275
+ )
276
+
277
+ print_info(f"Continued to new session: {new_session_id}")
278
+
279
+ # Reset history with summary as context
280
+ self.history = []
281
+ self._context_summary = summary
282
+
283
+ # Save a system message with the summary context
284
+ context_message = Message(
285
+ role="assistant",
286
+ content=f"[Previous conversation summary]\n{summary}\n\n[Continuing conversation...]",
287
+ )
288
+ self._save_message(context_message)
289
+
290
+ # Notify callback if set
291
+ if self.on_session_continue:
292
+ self.on_session_continue(new_session_id)
293
+
294
+ return True
295
+
296
+ def _handle_plan_approval(self, user_input: str) -> str | None:
297
+ """Handle plan approval responses. Returns response or None to continue."""
298
+ input_lower = user_input.strip().lower()
299
+
300
+ if input_lower in ("yes", "y", "proceed", "go", "ok", "continue"):
301
+ self._awaiting_plan_approval = False
302
+ print_success("Plan approved! Executing...")
303
+ # Switch to auto mode temporarily for this execution
304
+ return None # Continue with execution
305
+
306
+ elif input_lower in ("no", "n", "cancel", "stop", "abort"):
307
+ self._awaiting_plan_approval = False
308
+ self._pending_plan_response = None
309
+ return "Plan cancelled. What would you like me to do instead?"
310
+
311
+ elif input_lower.startswith("modify") or input_lower.startswith("change"):
312
+ self._awaiting_plan_approval = False
313
+ # User wants to modify, treat the rest as new instructions
314
+ modification = (
315
+ user_input[6:].strip()
316
+ if input_lower.startswith("modify")
317
+ else user_input[6:].strip()
318
+ )
319
+ if modification:
320
+ return self.run(f"Please modify the plan: {modification}", max_iterations=15)
321
+ return "What changes would you like me to make to the plan?"
322
+
323
+ else:
324
+ # Treat as modification request
325
+ return self.run(user_input, max_iterations=15)
326
+
327
+ def run(self, user_input: str, max_iterations: int = 15):
328
+ # Check if we're awaiting plan approval
329
+ if self._awaiting_plan_approval:
330
+ result = self._handle_plan_approval(user_input)
331
+ if result is not None:
332
+ return result
333
+ # If None, continue with the pending execution
334
+
335
+ # Check session limit before processing
336
+ self._check_and_handle_session_limit()
337
+
338
+ # Save user message
339
+ user_message = Message(role="user", content=user_input)
340
+ self._save_message(user_message)
341
+
342
+ iteration = 0
343
+ is_plan_mode = self.mode_manager.mode == AgentMode.PLAN
344
+ plan_created = False
345
+
346
+ while iteration < max_iterations:
347
+ planned_messages = self.planner.plan(self.history)
348
+
349
+ # Inject context summary if available
350
+ if self._context_summary and iteration == 0:
351
+ # Add summary context after system message
352
+ for i, msg in enumerate(planned_messages):
353
+ if msg.role == "system":
354
+ context_msg = Message(
355
+ role="system",
356
+ content=f"\n\n[Previous conversation context]\n{self._context_summary}",
357
+ )
358
+ planned_messages.insert(i + 1, context_msg)
359
+ break
360
+
361
+ # In plan mode, inject plan prompt on first iteration
362
+ if is_plan_mode and iteration == 0 and not plan_created:
363
+ for i, msg in enumerate(planned_messages):
364
+ if msg.role == "system":
365
+ plan_msg = Message(role="system", content=f"\n\n{PLAN_MODE_PROMPT}")
366
+ planned_messages.insert(i + 1, plan_msg)
367
+ break
368
+
369
+ # Determine which tools to provide based on mode
370
+ if is_plan_mode and not plan_created:
371
+ # In planning phase, don't provide tools so LLM creates plan first
372
+ tools_for_call = None
373
+ status_msg = "[bold yellow]DevOrch is planning..."
374
+ else:
375
+ tools_for_call = [tool.schema() for tool in self.tools]
376
+ status_msg = "[bold blue]DevOrch is thinking..."
377
+
378
+ with console.status(status_msg, spinner="dots"):
379
+ response = self.provider.generate(
380
+ planned_messages,
381
+ tools=tools_for_call,
382
+ )
383
+
384
+ # Save assistant message (include tool_calls in metadata for providers like Mistral)
385
+ if response.tool_calls:
386
+ # Store tool_calls info in metadata for conversation reconstruction
387
+ tool_calls_data = [
388
+ {
389
+ "id": tc.id,
390
+ "type": "function",
391
+ "function": {"name": tc.name, "arguments": json.dumps(tc.arguments)},
392
+ }
393
+ for tc in response.tool_calls
394
+ ]
395
+ response.message.metadata = response.message.metadata or {}
396
+ response.message.metadata["tool_calls"] = tool_calls_data
397
+
398
+ self._save_message(response.message)
399
+
400
+ # In plan mode, check if this is a plan response
401
+ if is_plan_mode and not plan_created and not response.tool_calls:
402
+ plan_created = True
403
+ self._awaiting_plan_approval = True
404
+ self._pending_plan_response = response.message.content
405
+ # Check session limit after response
406
+ self._check_and_handle_session_limit()
407
+ return response.message.content
408
+
409
+ if not response.tool_calls:
410
+ # The LLM didn't call any tools, so we have a final answer
411
+ # Check session limit after response
412
+ self._check_and_handle_session_limit()
413
+ return response.message.content
414
+
415
+ for call in response.tool_calls:
416
+ # Display tool call in a nice panel
417
+ self._display_tool_call(call)
418
+
419
+ # Execute without spinner - the spinner blocks input for permission prompts
420
+ result = self.executor.execute(call.name, call.arguments)
421
+
422
+ # Display result in a nice panel
423
+ self._display_tool_result(call.name, result)
424
+
425
+ # Save tool result message
426
+ tool_message = Message(
427
+ role="tool", content=str(result), name=call.name, tool_call_id=call.id
428
+ )
429
+ self._save_message(tool_message)
430
+
431
+ iteration += 1
432
+
433
+ return "Error: Maximum iterations reached without a final answer."
core/context.py ADDED
File without changes
core/executor.py ADDED
@@ -0,0 +1,213 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import TYPE_CHECKING, Any, Optional
3
+
4
+ import questionary
5
+ from questionary import Style as QStyle
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+
9
+ from config.permissions import PermissionChoice, PermissionLevel, Permissions, get_permissions
10
+ from tools.base import Tool
11
+ from utils.logger import get_console, print_success, print_warning
12
+
13
+ if TYPE_CHECKING:
14
+ from core.modes import ModeManager
15
+
16
+ console = get_console()
17
+
18
+ # Custom style for questionary prompts
19
+ PROMPT_STYLE = QStyle(
20
+ [
21
+ ("qmark", "fg:yellow bold"),
22
+ ("question", "fg:white bold"),
23
+ ("answer", "fg:green bold"),
24
+ ("pointer", "fg:cyan bold"),
25
+ ("highlighted", "noreverse fg:cyan bold bg:default"), # cyan text like pointer, no box
26
+ ("selected", "noreverse fg:cyan bold bg:default"), # default item — same as highlighted
27
+ ("text", "fg:white"), # plain items
28
+ ]
29
+ )
30
+
31
+
32
+ class Executor(ABC):
33
+ """
34
+ Executes tool calls safely.
35
+ """
36
+
37
+ @abstractmethod
38
+ def execute(self, tool_name: str, arguments: dict[str, Any]) -> Any:
39
+ pass
40
+
41
+
42
+ class ToolExecutor(Executor):
43
+ def __init__(
44
+ self,
45
+ tools: list[Tool],
46
+ require_confirmation: bool = True,
47
+ permissions: Permissions | None = None,
48
+ mode_manager: Optional["ModeManager"] = None,
49
+ ):
50
+ self.tools = {tool.name: tool for tool in tools}
51
+ self.require_confirmation = require_confirmation
52
+ self.permissions = permissions or get_permissions()
53
+ self.mode_manager = mode_manager
54
+
55
+ def _get_command_description(self, tool_name: str, arguments: dict[str, Any]) -> str:
56
+ """Get a human-readable description of the command."""
57
+ if tool_name == "shell":
58
+ return arguments.get("command", "")
59
+ elif tool_name == "filesystem":
60
+ action = arguments.get("action", "")
61
+ path = arguments.get("path", "")
62
+ if action == "write":
63
+ return f"write to {path}"
64
+ elif action == "read":
65
+ return f"read {path}"
66
+ elif action == "list":
67
+ return f"list {path}"
68
+ return f"{action} {path}"
69
+ else:
70
+ return str(arguments)
71
+
72
+ def _ask_permission(
73
+ self, tool_name: str, command: str, reason: str | None = None
74
+ ) -> PermissionChoice:
75
+ """Ask user for permission to execute a command using interactive selection."""
76
+ console.print()
77
+
78
+ # Create a nice panel for the command
79
+ command_display = Text()
80
+ command_display.append("Tool: ", style="dim")
81
+ command_display.append(f"{tool_name}\n", style="bold yellow")
82
+ command_display.append("Command: ", style="dim")
83
+ command_display.append(command, style="bold cyan")
84
+
85
+ if reason:
86
+ command_display.append(f"\n{reason}", style="dim italic")
87
+
88
+ panel = Panel(
89
+ command_display,
90
+ title="[bold yellow]Permission Required[/bold yellow]",
91
+ border_style="yellow",
92
+ padding=(0, 1),
93
+ )
94
+ console.print(panel)
95
+
96
+ # Use questionary for interactive selection
97
+ choices = [
98
+ questionary.Choice("Allow once", value=PermissionChoice.ALLOW_ONCE),
99
+ questionary.Choice("Allow for this session", value=PermissionChoice.ALLOW_SESSION),
100
+ questionary.Choice(
101
+ "Always allow (save to config)", value=PermissionChoice.ALLOW_ALWAYS
102
+ ),
103
+ questionary.Choice("Deny", value=PermissionChoice.DENY),
104
+ ]
105
+
106
+ try:
107
+ result = questionary.select(
108
+ "Choose an action:",
109
+ choices=choices,
110
+ default=choices[0],
111
+ style=PROMPT_STYLE,
112
+ instruction="(Use arrow keys to navigate, Enter to select)",
113
+ ).ask()
114
+
115
+ if result is None: # User pressed Ctrl+C
116
+ return PermissionChoice.DENY
117
+
118
+ return result
119
+
120
+ except (KeyboardInterrupt, EOFError):
121
+ return PermissionChoice.DENY
122
+
123
+ def _handle_permission_choice(
124
+ self, choice: PermissionChoice, tool_name: str, command: str
125
+ ) -> bool:
126
+ """Handle the user's permission choice. Returns True if allowed."""
127
+ if choice == PermissionChoice.ALLOW_ONCE:
128
+ print_success("Allowed once")
129
+ return True
130
+
131
+ elif choice == PermissionChoice.ALLOW_SESSION:
132
+ # Create a pattern from the command
133
+ pattern = self._create_pattern(command)
134
+ self.permissions.add_allowed_pattern(tool_name, pattern, session_only=True)
135
+ print_success(f"Allowed for session: {pattern}")
136
+ return True
137
+
138
+ elif choice == PermissionChoice.ALLOW_ALWAYS:
139
+ pattern = self._create_pattern(command)
140
+ self.permissions.add_allowed_pattern(tool_name, pattern, session_only=False)
141
+ print_success(f"Saved to config: always allow '{pattern}'")
142
+ return True
143
+
144
+ else: # DENY
145
+ print_warning("Command denied")
146
+ return False
147
+
148
+ def _create_pattern(self, command: str) -> str:
149
+ """Create a pattern from a command for future matching."""
150
+ # For simple commands, use the exact command
151
+ # For commands with arguments, use the base command + wildcard
152
+ parts = command.strip().split()
153
+ if len(parts) <= 1:
154
+ return command.strip()
155
+
156
+ # Common patterns: use first 1-2 words + wildcard
157
+ base = parts[0]
158
+
159
+ # Git commands: git <subcommand> *
160
+ if base == "git" and len(parts) > 1:
161
+ return f"git {parts[1]}*"
162
+
163
+ # npm/pip commands: npm <subcommand> *
164
+ if base in ("npm", "pip", "pip3", "cargo", "go") and len(parts) > 1:
165
+ return f"{base} {parts[1]}*"
166
+
167
+ # Python/node: python <script>
168
+ if base in ("python", "python3", "node"):
169
+ return f"{base}*"
170
+
171
+ # Default: first word + wildcard
172
+ return f"{base}*"
173
+
174
+ def execute(self, tool_name: str, arguments: dict[str, Any]) -> str:
175
+ if tool_name not in self.tools:
176
+ return f"Error: Tool '{tool_name}' not found."
177
+
178
+ tool = self.tools[tool_name]
179
+ command = self._get_command_description(tool_name, arguments)
180
+
181
+ try:
182
+ # Check if we should ask for permission based on mode
183
+ should_ask = self.require_confirmation
184
+ if self.mode_manager and not self.mode_manager.should_ask_permission():
185
+ should_ask = False
186
+
187
+ # Check permissions if confirmation is required
188
+ if should_ask:
189
+ perm_level, reason = self.permissions.check_permission(tool_name, command)
190
+
191
+ if perm_level == PermissionLevel.DENY:
192
+ print_warning(f"Command blocked: {reason or 'denied by policy'}")
193
+ return f"Error: Command denied - {reason or 'blocked by permission policy'}"
194
+
195
+ elif perm_level == PermissionLevel.ASK:
196
+ choice = self._ask_permission(tool_name, command, reason)
197
+ if not self._handle_permission_choice(choice, tool_name, command):
198
+ return "Error: User denied permission to execute this command."
199
+
200
+ # ALLOW - proceed silently
201
+ else:
202
+ # Even in auto mode, check for dangerous commands
203
+ perm_level, reason = self.permissions.check_permission(tool_name, command)
204
+ if perm_level == PermissionLevel.DENY:
205
+ print_warning(f"Command blocked (dangerous): {reason or 'denied by policy'}")
206
+ return f"Error: Command denied - {reason or 'blocked by permission policy'}"
207
+
208
+ # Execute the tool
209
+ result = tool.run(arguments)
210
+ return str(result)
211
+
212
+ except Exception as e:
213
+ return f"Error executing {tool_name}: {str(e)}"