tunacode-cli 0.0.41__py3-none-any.whl → 0.0.43__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.
Potentially problematic release.
This version of tunacode-cli might be problematic. Click here for more details.
- tunacode/cli/repl.py +8 -4
- tunacode/configuration/defaults.py +1 -0
- tunacode/constants.py +6 -1
- tunacode/core/agents/dspy_integration.py +223 -0
- tunacode/core/agents/dspy_tunacode.py +458 -0
- tunacode/core/agents/main.py +156 -27
- tunacode/core/agents/utils.py +54 -6
- tunacode/core/recursive/__init__.py +18 -0
- tunacode/core/recursive/aggregator.py +467 -0
- tunacode/core/recursive/budget.py +414 -0
- tunacode/core/recursive/decomposer.py +398 -0
- tunacode/core/recursive/executor.py +467 -0
- tunacode/core/recursive/hierarchy.py +487 -0
- tunacode/core/state.py +41 -0
- tunacode/exceptions.py +23 -0
- tunacode/prompts/dspy_task_planning.md +45 -0
- tunacode/prompts/dspy_tool_selection.md +58 -0
- tunacode/ui/console.py +1 -1
- tunacode/ui/output.py +2 -1
- tunacode/ui/panels.py +4 -1
- tunacode/ui/recursive_progress.py +380 -0
- tunacode/ui/tool_ui.py +24 -6
- tunacode/ui/utils.py +1 -1
- tunacode/utils/retry.py +163 -0
- {tunacode_cli-0.0.41.dist-info → tunacode_cli-0.0.43.dist-info}/METADATA +3 -1
- {tunacode_cli-0.0.41.dist-info → tunacode_cli-0.0.43.dist-info}/RECORD +30 -18
- {tunacode_cli-0.0.41.dist-info → tunacode_cli-0.0.43.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.41.dist-info → tunacode_cli-0.0.43.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.41.dist-info → tunacode_cli-0.0.43.dist-info}/licenses/LICENSE +0 -0
- {tunacode_cli-0.0.41.dist-info → tunacode_cli-0.0.43.dist-info}/top_level.txt +0 -0
tunacode/ui/panels.py
CHANGED
|
@@ -86,7 +86,10 @@ class StreamingAgentPanel:
|
|
|
86
86
|
|
|
87
87
|
def _create_panel(self) -> Panel:
|
|
88
88
|
"""Create a Rich panel with current content."""
|
|
89
|
-
|
|
89
|
+
# Use the UI_THINKING_MESSAGE constant instead of hardcoded text
|
|
90
|
+
from tunacode.constants import UI_THINKING_MESSAGE
|
|
91
|
+
|
|
92
|
+
markdown_content = Markdown(self.content or UI_THINKING_MESSAGE)
|
|
90
93
|
panel_obj = Panel(
|
|
91
94
|
Padding(markdown_content, (0, 1, 0, 1)),
|
|
92
95
|
title=f"[bold]{self.title}[/bold]",
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Module: tunacode.ui.recursive_progress
|
|
2
|
+
|
|
3
|
+
Visual feedback and progress tracking UI for recursive task execution.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from rich.columns import Columns
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.live import Live
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.progress import (
|
|
14
|
+
BarColumn,
|
|
15
|
+
Progress,
|
|
16
|
+
SpinnerColumn,
|
|
17
|
+
TaskID,
|
|
18
|
+
TextColumn,
|
|
19
|
+
TimeElapsedColumn,
|
|
20
|
+
TimeRemainingColumn,
|
|
21
|
+
)
|
|
22
|
+
from rich.table import Table
|
|
23
|
+
from rich.tree import Tree
|
|
24
|
+
|
|
25
|
+
from tunacode.core.recursive.budget import BudgetManager
|
|
26
|
+
from tunacode.core.recursive.executor import TaskNode
|
|
27
|
+
from tunacode.core.recursive.hierarchy import TaskHierarchy
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RecursiveProgressUI:
|
|
31
|
+
"""UI components for visualizing recursive execution progress."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, console: Optional[Console] = None):
|
|
34
|
+
"""Initialize the progress UI.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
console: Rich console instance (creates new if not provided)
|
|
38
|
+
"""
|
|
39
|
+
self.console = console or Console()
|
|
40
|
+
self.progress = Progress(
|
|
41
|
+
SpinnerColumn(),
|
|
42
|
+
TextColumn("[progress.description]{task.description}"),
|
|
43
|
+
BarColumn(),
|
|
44
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
45
|
+
TimeElapsedColumn(),
|
|
46
|
+
TimeRemainingColumn(),
|
|
47
|
+
console=self.console,
|
|
48
|
+
)
|
|
49
|
+
self._task_progress_map: Dict[str, TaskID] = {}
|
|
50
|
+
self._live: Optional[Live] = None
|
|
51
|
+
|
|
52
|
+
def start_live_display(self) -> None:
|
|
53
|
+
"""Start live display for real-time updates."""
|
|
54
|
+
if not self._live:
|
|
55
|
+
self._live = Live(
|
|
56
|
+
self.get_dashboard(),
|
|
57
|
+
console=self.console,
|
|
58
|
+
refresh_per_second=2,
|
|
59
|
+
vertical_overflow="visible",
|
|
60
|
+
)
|
|
61
|
+
self._live.start()
|
|
62
|
+
|
|
63
|
+
def stop_live_display(self) -> None:
|
|
64
|
+
"""Stop the live display."""
|
|
65
|
+
if self._live:
|
|
66
|
+
self._live.stop()
|
|
67
|
+
self._live = None
|
|
68
|
+
|
|
69
|
+
def update_task_progress(
|
|
70
|
+
self, task_id: str, description: str, completed: int, total: int
|
|
71
|
+
) -> None:
|
|
72
|
+
"""Update progress for a specific task.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
task_id: Task identifier
|
|
76
|
+
description: Task description
|
|
77
|
+
completed: Number of completed iterations
|
|
78
|
+
total: Total iterations
|
|
79
|
+
"""
|
|
80
|
+
if task_id not in self._task_progress_map:
|
|
81
|
+
# Create new progress task
|
|
82
|
+
progress_id = self.progress.add_task(description, total=total, completed=completed)
|
|
83
|
+
self._task_progress_map[task_id] = progress_id
|
|
84
|
+
else:
|
|
85
|
+
# Update existing task
|
|
86
|
+
progress_id = self._task_progress_map[task_id]
|
|
87
|
+
self.progress.update(
|
|
88
|
+
progress_id, description=description, completed=completed, total=total
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def display_task_hierarchy(self, hierarchy: TaskHierarchy) -> Tree:
|
|
92
|
+
"""Create a tree visualization of task hierarchy.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
hierarchy: TaskHierarchy instance
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Rich Tree object
|
|
99
|
+
"""
|
|
100
|
+
tree = Tree("🎯 Task Hierarchy", guide_style="blue")
|
|
101
|
+
|
|
102
|
+
# Find root tasks
|
|
103
|
+
all_tasks = hierarchy._tasks
|
|
104
|
+
root_tasks = [task_id for task_id in all_tasks if hierarchy.get_parent(task_id) is None]
|
|
105
|
+
|
|
106
|
+
def build_tree_node(parent_node: Tree, task_id: str, depth: int = 0):
|
|
107
|
+
"""Recursively build tree nodes."""
|
|
108
|
+
task = hierarchy.get_task(task_id)
|
|
109
|
+
if not task:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Determine status icon
|
|
113
|
+
status = task.get("status", "pending")
|
|
114
|
+
icon = {"completed": "✅", "failed": "❌", "in_progress": "🔄", "pending": "⏳"}.get(
|
|
115
|
+
status, "❓"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Create node label
|
|
119
|
+
title = task.get("title", "Untitled")[:50]
|
|
120
|
+
label = f"{icon} [{task_id}] {title}"
|
|
121
|
+
|
|
122
|
+
# Add progress if in progress
|
|
123
|
+
if status == "in_progress":
|
|
124
|
+
label += " [yellow](running)[/yellow]"
|
|
125
|
+
|
|
126
|
+
# Add node to tree
|
|
127
|
+
node = parent_node.add(label)
|
|
128
|
+
|
|
129
|
+
# Add children
|
|
130
|
+
children = hierarchy.get_children(task_id)
|
|
131
|
+
for child_id in children:
|
|
132
|
+
build_tree_node(node, child_id, depth + 1)
|
|
133
|
+
|
|
134
|
+
# Build tree from roots
|
|
135
|
+
for root_id in root_tasks:
|
|
136
|
+
build_tree_node(tree, root_id)
|
|
137
|
+
|
|
138
|
+
return tree
|
|
139
|
+
|
|
140
|
+
def display_budget_status(self, budget_manager: BudgetManager) -> Table:
|
|
141
|
+
"""Create a table showing budget allocation and usage.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
budget_manager: BudgetManager instance
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Rich Table object
|
|
148
|
+
"""
|
|
149
|
+
table = Table(title="💰 Budget Status", show_header=True, header_style="bold cyan")
|
|
150
|
+
table.add_column("Task ID", style="dim", width=12)
|
|
151
|
+
table.add_column("Allocated", justify="right")
|
|
152
|
+
table.add_column("Used", justify="right")
|
|
153
|
+
table.add_column("Remaining", justify="right")
|
|
154
|
+
table.add_column("Utilization", justify="right")
|
|
155
|
+
table.add_column("Status", justify="center")
|
|
156
|
+
|
|
157
|
+
summary = budget_manager.get_budget_summary()
|
|
158
|
+
|
|
159
|
+
for task_id, details in summary["task_details"].items():
|
|
160
|
+
utilization = details["utilization"]
|
|
161
|
+
|
|
162
|
+
# Color code utilization
|
|
163
|
+
if utilization > 0.9:
|
|
164
|
+
util_color = "red"
|
|
165
|
+
status = "⚠️ High"
|
|
166
|
+
elif utilization > 0.7:
|
|
167
|
+
util_color = "yellow"
|
|
168
|
+
status = "📊 Normal"
|
|
169
|
+
else:
|
|
170
|
+
util_color = "green"
|
|
171
|
+
status = "✅ Good"
|
|
172
|
+
|
|
173
|
+
table.add_row(
|
|
174
|
+
task_id[:8] + "...",
|
|
175
|
+
str(details["allocated"]),
|
|
176
|
+
str(details["consumed"]),
|
|
177
|
+
str(details["remaining"]),
|
|
178
|
+
f"[{util_color}]{utilization:.1%}[/{util_color}]",
|
|
179
|
+
status,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# Add summary row
|
|
183
|
+
table.add_section()
|
|
184
|
+
table.add_row(
|
|
185
|
+
"TOTAL",
|
|
186
|
+
str(summary["allocated_budget"]),
|
|
187
|
+
str(summary["consumed_budget"]),
|
|
188
|
+
str(summary["available_budget"]),
|
|
189
|
+
f"{summary['utilization_rate']:.1%}",
|
|
190
|
+
"📈",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return table
|
|
194
|
+
|
|
195
|
+
def display_execution_stack(self, stack: List[str]) -> Panel:
|
|
196
|
+
"""Display current execution stack.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
stack: List of task IDs in execution order
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Rich Panel object
|
|
203
|
+
"""
|
|
204
|
+
if not stack:
|
|
205
|
+
content = "[dim]No tasks in execution[/dim]"
|
|
206
|
+
else:
|
|
207
|
+
# Show stack with depth indicators
|
|
208
|
+
lines = []
|
|
209
|
+
for i, task_id in enumerate(stack):
|
|
210
|
+
indent = " " * i
|
|
211
|
+
arrow = "└─" if i == len(stack) - 1 else "├─"
|
|
212
|
+
lines.append(f"{indent}{arrow} {task_id}")
|
|
213
|
+
content = "\n".join(lines)
|
|
214
|
+
|
|
215
|
+
return Panel(content, title="📚 Execution Stack", border_style="green")
|
|
216
|
+
|
|
217
|
+
def display_task_details(self, task_node: TaskNode) -> Panel:
|
|
218
|
+
"""Display detailed information about a task.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
task_node: TaskNode instance
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Rich Panel object
|
|
225
|
+
"""
|
|
226
|
+
details = Table(show_header=False, box=None, padding=0)
|
|
227
|
+
details.add_column("Property", style="bold cyan")
|
|
228
|
+
details.add_column("Value")
|
|
229
|
+
|
|
230
|
+
details.add_row("Title", task_node.title or "Untitled")
|
|
231
|
+
details.add_row("Status", f"[yellow]{task_node.status}[/yellow]")
|
|
232
|
+
details.add_row("Complexity", f"{task_node.complexity_score:.2f}")
|
|
233
|
+
details.add_row("Depth", str(task_node.depth))
|
|
234
|
+
details.add_row("Budget", f"{task_node.iteration_budget} iterations")
|
|
235
|
+
|
|
236
|
+
if task_node.subtasks:
|
|
237
|
+
details.add_row("Subtasks", f"{len(task_node.subtasks)} tasks")
|
|
238
|
+
|
|
239
|
+
if task_node.error:
|
|
240
|
+
details.add_row("Error", f"[red]{task_node.error}[/red]")
|
|
241
|
+
|
|
242
|
+
return Panel(details, title=f"🔍 Task Details: {task_node.id[:8]}...", border_style="blue")
|
|
243
|
+
|
|
244
|
+
def get_dashboard(
|
|
245
|
+
self,
|
|
246
|
+
hierarchy: Optional[TaskHierarchy] = None,
|
|
247
|
+
budget_manager: Optional[BudgetManager] = None,
|
|
248
|
+
current_task: Optional[TaskNode] = None,
|
|
249
|
+
execution_stack: Optional[List[str]] = None,
|
|
250
|
+
) -> Panel:
|
|
251
|
+
"""Create a comprehensive dashboard view.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
hierarchy: Optional TaskHierarchy instance
|
|
255
|
+
budget_manager: Optional BudgetManager instance
|
|
256
|
+
current_task: Optional current TaskNode
|
|
257
|
+
execution_stack: Optional execution stack
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Rich Panel containing the dashboard
|
|
261
|
+
"""
|
|
262
|
+
components = []
|
|
263
|
+
|
|
264
|
+
# Add progress bars
|
|
265
|
+
components.append(Panel(self.progress, title="⏳ Task Progress", border_style="green"))
|
|
266
|
+
|
|
267
|
+
# Add hierarchy tree if available
|
|
268
|
+
if hierarchy:
|
|
269
|
+
components.append(self.display_task_hierarchy(hierarchy))
|
|
270
|
+
|
|
271
|
+
# Add budget status if available
|
|
272
|
+
if budget_manager:
|
|
273
|
+
components.append(self.display_budget_status(budget_manager))
|
|
274
|
+
|
|
275
|
+
# Add execution stack if available
|
|
276
|
+
if execution_stack is not None:
|
|
277
|
+
components.append(self.display_execution_stack(execution_stack))
|
|
278
|
+
|
|
279
|
+
# Add current task details if available
|
|
280
|
+
if current_task:
|
|
281
|
+
components.append(self.display_task_details(current_task))
|
|
282
|
+
|
|
283
|
+
# Arrange components in columns
|
|
284
|
+
if len(components) > 2:
|
|
285
|
+
# Use columns for better layout
|
|
286
|
+
left_col = components[: len(components) // 2]
|
|
287
|
+
right_col = components[len(components) // 2 :]
|
|
288
|
+
|
|
289
|
+
content = Columns(
|
|
290
|
+
["\n\n".join(str(c) for c in left_col), "\n\n".join(str(c) for c in right_col)],
|
|
291
|
+
equal=True,
|
|
292
|
+
expand=True,
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
content = "\n\n".join(str(c) for c in components)
|
|
296
|
+
|
|
297
|
+
return Panel(
|
|
298
|
+
content,
|
|
299
|
+
title="🔄 Recursive Execution Dashboard",
|
|
300
|
+
subtitle=f"Updated: {datetime.now().strftime('%H:%M:%S')}",
|
|
301
|
+
border_style="bold blue",
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
async def show_completion_summary(
|
|
305
|
+
self,
|
|
306
|
+
total_tasks: int,
|
|
307
|
+
completed_tasks: int,
|
|
308
|
+
failed_tasks: int,
|
|
309
|
+
total_time: float,
|
|
310
|
+
total_iterations: int,
|
|
311
|
+
) -> None:
|
|
312
|
+
"""Display a summary when execution completes.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
total_tasks: Total number of tasks
|
|
316
|
+
completed_tasks: Number of completed tasks
|
|
317
|
+
failed_tasks: Number of failed tasks
|
|
318
|
+
total_time: Total execution time in seconds
|
|
319
|
+
total_iterations: Total iterations used
|
|
320
|
+
"""
|
|
321
|
+
summary = Table(title="📊 Execution Summary", show_header=True, header_style="bold green")
|
|
322
|
+
summary.add_column("Metric", style="cyan")
|
|
323
|
+
summary.add_column("Value", justify="right")
|
|
324
|
+
|
|
325
|
+
summary.add_row("Total Tasks", str(total_tasks))
|
|
326
|
+
summary.add_row("Completed", f"[green]{completed_tasks}[/green]")
|
|
327
|
+
summary.add_row("Failed", f"[red]{failed_tasks}[/red]")
|
|
328
|
+
summary.add_row("Success Rate", f"{(completed_tasks / total_tasks) * 100:.1f}%")
|
|
329
|
+
summary.add_row("Total Time", f"{total_time:.2f}s")
|
|
330
|
+
summary.add_row("Total Iterations", str(total_iterations))
|
|
331
|
+
summary.add_row("Avg Time/Task", f"{total_time / total_tasks:.2f}s")
|
|
332
|
+
|
|
333
|
+
self.console.print("\n")
|
|
334
|
+
self.console.print(summary)
|
|
335
|
+
self.console.print("\n")
|
|
336
|
+
|
|
337
|
+
# Show completion message
|
|
338
|
+
if failed_tasks == 0:
|
|
339
|
+
self.console.print("[bold green]✅ All tasks completed successfully![/bold green]")
|
|
340
|
+
else:
|
|
341
|
+
self.console.print(
|
|
342
|
+
f"[bold yellow]⚠️ Completed with {failed_tasks} failures[/bold yellow]"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# Convenience functions for integration
|
|
347
|
+
async def show_recursive_progress(
|
|
348
|
+
console: Console, message: str, task_id: Optional[str] = None, depth: int = 0
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Show progress message for recursive execution.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
console: Rich console instance
|
|
354
|
+
message: Progress message
|
|
355
|
+
task_id: Optional task ID
|
|
356
|
+
depth: Recursion depth
|
|
357
|
+
"""
|
|
358
|
+
indent = " " * depth
|
|
359
|
+
prefix = f"[dim][{task_id[:8]}...][/dim]" if task_id else ""
|
|
360
|
+
|
|
361
|
+
console.print(f"{indent}🔄 {prefix} {message}")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def show_task_completion(
|
|
365
|
+
console: Console, task_id: str, success: bool, depth: int = 0
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Show task completion status.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
console: Rich console instance
|
|
371
|
+
task_id: Task ID
|
|
372
|
+
success: Whether task succeeded
|
|
373
|
+
depth: Recursion depth
|
|
374
|
+
"""
|
|
375
|
+
indent = " " * depth
|
|
376
|
+
icon = "✅" if success else "❌"
|
|
377
|
+
status = "completed" if success else "failed"
|
|
378
|
+
color = "green" if success else "red"
|
|
379
|
+
|
|
380
|
+
console.print(f"{indent}{icon} Task [{task_id[:8]}...] [{color}]{status}[/{color}]")
|
tunacode/ui/tool_ui.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Tool confirmation UI components, separated from business logic.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
+
from rich.box import ROUNDED
|
|
5
6
|
from rich.markdown import Markdown
|
|
6
7
|
from rich.padding import Padding
|
|
7
8
|
from rich.panel import Panel
|
|
@@ -111,9 +112,9 @@ class ToolUI:
|
|
|
111
112
|
if request.filepath:
|
|
112
113
|
await ui.usage(f"File: {request.filepath}")
|
|
113
114
|
|
|
114
|
-
await ui.print(" 1
|
|
115
|
-
await ui.print(" 2
|
|
116
|
-
await ui.print(f" 3
|
|
115
|
+
await ui.print(" [1] Yes (default)")
|
|
116
|
+
await ui.print(" [2] Yes, and don't ask again for commands like this")
|
|
117
|
+
await ui.print(f" [3] No, and tell {APP_NAME} what to do differently")
|
|
117
118
|
resp = (
|
|
118
119
|
await ui.input(
|
|
119
120
|
session_key="tool_confirm",
|
|
@@ -146,10 +147,27 @@ class ToolUI:
|
|
|
146
147
|
# Display styled confirmation panel using direct console output
|
|
147
148
|
# Avoid using sync wrappers that might create event loop conflicts
|
|
148
149
|
panel_obj = Panel(
|
|
149
|
-
Padding(content, 1
|
|
150
|
+
Padding(content, (0, 1, 0, 1)),
|
|
151
|
+
title=title,
|
|
152
|
+
title_align="left",
|
|
153
|
+
border_style=self.colors.warning,
|
|
154
|
+
padding=(0, 1),
|
|
155
|
+
box=ROUNDED,
|
|
150
156
|
)
|
|
151
157
|
# Add consistent spacing above panels
|
|
152
|
-
|
|
158
|
+
from .constants import DEFAULT_PANEL_PADDING
|
|
159
|
+
|
|
160
|
+
ui.console.print(
|
|
161
|
+
Padding(
|
|
162
|
+
panel_obj,
|
|
163
|
+
(
|
|
164
|
+
DEFAULT_PANEL_PADDING["top"],
|
|
165
|
+
DEFAULT_PANEL_PADDING["right"],
|
|
166
|
+
DEFAULT_PANEL_PADDING["bottom"],
|
|
167
|
+
DEFAULT_PANEL_PADDING["left"],
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
)
|
|
153
171
|
|
|
154
172
|
if request.filepath:
|
|
155
173
|
ui.console.print(f"File: {request.filepath}", style=self.colors.muted)
|
|
@@ -160,7 +178,7 @@ class ToolUI:
|
|
|
160
178
|
resp = input(" Choose an option [1/2/3]: ").strip() or "1"
|
|
161
179
|
|
|
162
180
|
# Add spacing after user choice for better readability
|
|
163
|
-
print()
|
|
181
|
+
ui.console.print()
|
|
164
182
|
|
|
165
183
|
if resp == "2":
|
|
166
184
|
return ToolConfirmationResponse(approved=True, skip_future=True)
|
tunacode/ui/utils.py
CHANGED
tunacode/utils/retry.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Retry utilities for handling transient failures."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def retry_on_json_error(
|
|
14
|
+
max_retries: int = 10,
|
|
15
|
+
base_delay: float = 0.1,
|
|
16
|
+
max_delay: float = 5.0,
|
|
17
|
+
logger_name: Optional[str] = None,
|
|
18
|
+
) -> Callable:
|
|
19
|
+
"""Decorator to retry function calls that fail with JSON parsing errors.
|
|
20
|
+
|
|
21
|
+
Implements exponential backoff with configurable parameters.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
max_retries: Maximum number of retry attempts (default: 10)
|
|
25
|
+
base_delay: Initial delay between retries in seconds (default: 0.1)
|
|
26
|
+
max_delay: Maximum delay between retries in seconds (default: 5.0)
|
|
27
|
+
logger_name: Logger name for retry logging (default: uses module logger)
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Decorated function that retries on JSONDecodeError
|
|
31
|
+
"""
|
|
32
|
+
retry_logger = logging.getLogger(logger_name) if logger_name else logger
|
|
33
|
+
|
|
34
|
+
def decorator(func: Callable) -> Callable:
|
|
35
|
+
@functools.wraps(func)
|
|
36
|
+
async def async_wrapper(*args, **kwargs) -> Any:
|
|
37
|
+
last_exception = None
|
|
38
|
+
|
|
39
|
+
for attempt in range(max_retries + 1):
|
|
40
|
+
try:
|
|
41
|
+
return await func(*args, **kwargs)
|
|
42
|
+
except json.JSONDecodeError as e:
|
|
43
|
+
last_exception = e
|
|
44
|
+
|
|
45
|
+
if attempt == max_retries:
|
|
46
|
+
# Final attempt failed
|
|
47
|
+
retry_logger.error(f"JSON parsing failed after {max_retries} retries: {e}")
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
# Calculate delay with exponential backoff
|
|
51
|
+
delay = min(base_delay * (2**attempt), max_delay)
|
|
52
|
+
|
|
53
|
+
retry_logger.warning(
|
|
54
|
+
f"JSON parsing error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
|
55
|
+
f"Retrying in {delay:.2f}s..."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
await asyncio.sleep(delay)
|
|
59
|
+
|
|
60
|
+
# Should never reach here, but just in case
|
|
61
|
+
if last_exception:
|
|
62
|
+
raise last_exception
|
|
63
|
+
|
|
64
|
+
@functools.wraps(func)
|
|
65
|
+
def sync_wrapper(*args, **kwargs) -> Any:
|
|
66
|
+
last_exception = None
|
|
67
|
+
|
|
68
|
+
for attempt in range(max_retries + 1):
|
|
69
|
+
try:
|
|
70
|
+
return func(*args, **kwargs)
|
|
71
|
+
except json.JSONDecodeError as e:
|
|
72
|
+
last_exception = e
|
|
73
|
+
|
|
74
|
+
if attempt == max_retries:
|
|
75
|
+
# Final attempt failed
|
|
76
|
+
retry_logger.error(f"JSON parsing failed after {max_retries} retries: {e}")
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
# Calculate delay with exponential backoff
|
|
80
|
+
delay = min(base_delay * (2**attempt), max_delay)
|
|
81
|
+
|
|
82
|
+
retry_logger.warning(
|
|
83
|
+
f"JSON parsing error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
|
|
84
|
+
f"Retrying in {delay:.2f}s..."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
time.sleep(delay)
|
|
88
|
+
|
|
89
|
+
# Should never reach here, but just in case
|
|
90
|
+
if last_exception:
|
|
91
|
+
raise last_exception
|
|
92
|
+
|
|
93
|
+
# Return appropriate wrapper based on function type
|
|
94
|
+
if asyncio.iscoroutinefunction(func):
|
|
95
|
+
return async_wrapper
|
|
96
|
+
else:
|
|
97
|
+
return sync_wrapper
|
|
98
|
+
|
|
99
|
+
return decorator
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def retry_json_parse(
|
|
103
|
+
json_string: str,
|
|
104
|
+
max_retries: int = 10,
|
|
105
|
+
base_delay: float = 0.1,
|
|
106
|
+
max_delay: float = 5.0,
|
|
107
|
+
) -> Any:
|
|
108
|
+
"""Parse JSON with automatic retry on failure.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
json_string: JSON string to parse
|
|
112
|
+
max_retries: Maximum number of retry attempts
|
|
113
|
+
base_delay: Initial delay between retries in seconds
|
|
114
|
+
max_delay: Maximum delay between retries in seconds
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Parsed JSON object
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
json.JSONDecodeError: If parsing fails after all retries
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
@retry_on_json_error(
|
|
124
|
+
max_retries=max_retries,
|
|
125
|
+
base_delay=base_delay,
|
|
126
|
+
max_delay=max_delay,
|
|
127
|
+
)
|
|
128
|
+
def _parse():
|
|
129
|
+
return json.loads(json_string)
|
|
130
|
+
|
|
131
|
+
return _parse()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def retry_json_parse_async(
|
|
135
|
+
json_string: str,
|
|
136
|
+
max_retries: int = 10,
|
|
137
|
+
base_delay: float = 0.1,
|
|
138
|
+
max_delay: float = 5.0,
|
|
139
|
+
) -> Any:
|
|
140
|
+
"""Asynchronously parse JSON with automatic retry on failure.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
json_string: JSON string to parse
|
|
144
|
+
max_retries: Maximum number of retry attempts
|
|
145
|
+
base_delay: Initial delay between retries in seconds
|
|
146
|
+
max_delay: Maximum delay between retries in seconds
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Parsed JSON object
|
|
150
|
+
|
|
151
|
+
Raises:
|
|
152
|
+
json.JSONDecodeError: If parsing fails after all retries
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
@retry_on_json_error(
|
|
156
|
+
max_retries=max_retries,
|
|
157
|
+
base_delay=base_delay,
|
|
158
|
+
max_delay=max_delay,
|
|
159
|
+
)
|
|
160
|
+
async def _parse():
|
|
161
|
+
return json.loads(json_string)
|
|
162
|
+
|
|
163
|
+
return await _parse()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tunacode-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.43
|
|
4
4
|
Summary: Your agentic CLI developer.
|
|
5
5
|
Author-email: larock22 <noreply@github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -25,6 +25,8 @@ Requires-Dist: pydantic-ai[logfire]==0.2.6
|
|
|
25
25
|
Requires-Dist: pygments==2.19.1
|
|
26
26
|
Requires-Dist: rich==14.0.0
|
|
27
27
|
Requires-Dist: tiktoken>=0.5.2
|
|
28
|
+
Requires-Dist: dspy-ai>=0.1.0
|
|
29
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
30
|
Provides-Extra: dev
|
|
29
31
|
Requires-Dist: build; extra == "dev"
|
|
30
32
|
Requires-Dist: ruff; extra == "dev"
|