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.
- cli/commands/ask.py +0 -0
- cli/commands/edit.py +0 -0
- cli/commands/run.py +0 -0
- cli/main.py +1527 -0
- config/permissions.py +285 -0
- config/profiles.py +0 -0
- config/settings.py +185 -0
- core/agent.py +433 -0
- core/context.py +0 -0
- core/executor.py +213 -0
- core/modes.py +141 -0
- core/planner.py +16 -0
- core/sessions.py +271 -0
- core/tasks.py +142 -0
- devorch-0.1.2.dist-info/METADATA +528 -0
- devorch-0.1.2.dist-info/RECORD +50 -0
- devorch-0.1.2.dist-info/WHEEL +4 -0
- devorch-0.1.2.dist-info/entry_points.txt +2 -0
- providers/__init__.py +130 -0
- providers/anthropic.py +127 -0
- providers/base.py +51 -0
- providers/custom.py +186 -0
- providers/deepseek.py +170 -0
- providers/gemini.py +192 -0
- providers/github_copilot.py +196 -0
- providers/groq.py +158 -0
- providers/kimi.py +181 -0
- providers/lmstudio.py +147 -0
- providers/local.py +214 -0
- providers/mistral.py +161 -0
- providers/openai.py +114 -0
- providers/openrouter.py +195 -0
- providers/together.py +159 -0
- schemas/message.py +32 -0
- schemas/task.py +115 -0
- schemas/tool.py +0 -0
- tools/__init__.py +30 -0
- tools/base.py +40 -0
- tools/edit.py +236 -0
- tools/filesystem.py +224 -0
- tools/git.py +0 -0
- tools/grep.py +280 -0
- tools/search.py +150 -0
- tools/shell.py +55 -0
- tools/task.py +91 -0
- tools/terminal.py +123 -0
- tools/terminal_session.py +298 -0
- tools/tests.py +0 -0
- tools/websearch.py +166 -0
- utils/logger.py +52 -0
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)}"
|