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
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
"""Module: tunacode.core.recursive.budget
|
|
2
|
+
|
|
3
|
+
Budget management system for allocating and tracking computational resources across recursive executions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AllocationStrategy(Enum):
|
|
16
|
+
"""Budget allocation strategies."""
|
|
17
|
+
|
|
18
|
+
EQUAL = "equal" # Equal distribution
|
|
19
|
+
WEIGHTED = "weighted" # Based on complexity scores
|
|
20
|
+
ADAPTIVE = "adaptive" # Adjusts based on consumption
|
|
21
|
+
PRIORITY = "priority" # Based on task priority
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BudgetAllocation:
|
|
26
|
+
"""Represents a budget allocation for a task."""
|
|
27
|
+
|
|
28
|
+
task_id: str
|
|
29
|
+
allocated_budget: int
|
|
30
|
+
consumed_budget: int = 0
|
|
31
|
+
remaining_budget: int = field(init=False)
|
|
32
|
+
allocation_time: datetime = field(default_factory=datetime.now)
|
|
33
|
+
last_update: datetime = field(default_factory=datetime.now)
|
|
34
|
+
|
|
35
|
+
def __post_init__(self):
|
|
36
|
+
self.remaining_budget = self.allocated_budget - self.consumed_budget
|
|
37
|
+
|
|
38
|
+
def consume(self, amount: int) -> bool:
|
|
39
|
+
"""Consume budget and return if successful.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
amount: Amount to consume
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if budget was available and consumed
|
|
46
|
+
"""
|
|
47
|
+
if amount <= self.remaining_budget:
|
|
48
|
+
self.consumed_budget += amount
|
|
49
|
+
self.remaining_budget -= amount
|
|
50
|
+
self.last_update = datetime.now()
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def get_utilization_rate(self) -> float:
|
|
55
|
+
"""Get the budget utilization rate (0.0 to 1.0)."""
|
|
56
|
+
if self.allocated_budget == 0:
|
|
57
|
+
return 0.0
|
|
58
|
+
return self.consumed_budget / self.allocated_budget
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class BudgetSnapshot:
|
|
63
|
+
"""Snapshot of budget state at a point in time."""
|
|
64
|
+
|
|
65
|
+
timestamp: datetime
|
|
66
|
+
total_allocated: int
|
|
67
|
+
total_consumed: int
|
|
68
|
+
active_tasks: int
|
|
69
|
+
task_allocations: Dict[str, BudgetAllocation]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class BudgetManager:
|
|
73
|
+
"""Manages iteration budget allocation and tracking across tasks."""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self, total_budget: int = 100, min_task_budget: int = 2, reallocation_threshold: float = 0.2
|
|
77
|
+
):
|
|
78
|
+
"""Initialize the BudgetManager.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
total_budget: Total iteration budget available
|
|
82
|
+
min_task_budget: Minimum budget to allocate to any task
|
|
83
|
+
reallocation_threshold: Utilization threshold for reallocation
|
|
84
|
+
"""
|
|
85
|
+
self.total_budget = total_budget
|
|
86
|
+
self.min_task_budget = min_task_budget
|
|
87
|
+
self.reallocation_threshold = reallocation_threshold
|
|
88
|
+
|
|
89
|
+
self._allocations: Dict[str, BudgetAllocation] = {}
|
|
90
|
+
self._available_budget = total_budget
|
|
91
|
+
self._allocation_history: List[BudgetSnapshot] = []
|
|
92
|
+
self._reallocation_count = 0
|
|
93
|
+
|
|
94
|
+
def allocate_budget(
|
|
95
|
+
self,
|
|
96
|
+
task_ids: List[str],
|
|
97
|
+
complexity_scores: Optional[List[float]] = None,
|
|
98
|
+
priorities: Optional[List[str]] = None,
|
|
99
|
+
strategy: AllocationStrategy = AllocationStrategy.WEIGHTED,
|
|
100
|
+
) -> Dict[str, int]:
|
|
101
|
+
"""Allocate budget to multiple tasks based on strategy.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
task_ids: List of task IDs to allocate budget to
|
|
105
|
+
complexity_scores: Optional complexity scores (0.0-1.0) for each task
|
|
106
|
+
priorities: Optional priority levels for each task
|
|
107
|
+
strategy: Allocation strategy to use
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Dictionary mapping task IDs to allocated budgets
|
|
111
|
+
"""
|
|
112
|
+
if not task_ids:
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
n_tasks = len(task_ids)
|
|
116
|
+
|
|
117
|
+
# Determine allocation based on strategy
|
|
118
|
+
if strategy == AllocationStrategy.EQUAL:
|
|
119
|
+
allocations = self._allocate_equal(n_tasks)
|
|
120
|
+
elif strategy == AllocationStrategy.WEIGHTED and complexity_scores:
|
|
121
|
+
allocations = self._allocate_weighted(complexity_scores)
|
|
122
|
+
elif strategy == AllocationStrategy.PRIORITY and priorities:
|
|
123
|
+
allocations = self._allocate_by_priority(priorities)
|
|
124
|
+
else:
|
|
125
|
+
# Fallback to equal allocation
|
|
126
|
+
allocations = self._allocate_equal(n_tasks)
|
|
127
|
+
|
|
128
|
+
# Apply allocations
|
|
129
|
+
result = {}
|
|
130
|
+
for task_id, budget in zip(task_ids, allocations):
|
|
131
|
+
if budget > 0:
|
|
132
|
+
self._allocations[task_id] = BudgetAllocation(
|
|
133
|
+
task_id=task_id, allocated_budget=budget
|
|
134
|
+
)
|
|
135
|
+
self._available_budget -= budget
|
|
136
|
+
result[task_id] = budget
|
|
137
|
+
logger.debug(f"Allocated {budget} iterations to task {task_id}")
|
|
138
|
+
|
|
139
|
+
# Take snapshot
|
|
140
|
+
self._take_snapshot()
|
|
141
|
+
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
def consume_budget(self, task_id: str, amount: int = 1) -> bool:
|
|
145
|
+
"""Consume budget for a task.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
task_id: Task ID
|
|
149
|
+
amount: Amount to consume (default: 1)
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
True if budget was available and consumed
|
|
153
|
+
"""
|
|
154
|
+
if task_id not in self._allocations:
|
|
155
|
+
logger.warning(f"No budget allocation found for task {task_id}")
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
allocation = self._allocations[task_id]
|
|
159
|
+
success = allocation.consume(amount)
|
|
160
|
+
|
|
161
|
+
if not success:
|
|
162
|
+
logger.warning(
|
|
163
|
+
f"Insufficient budget for task {task_id}: "
|
|
164
|
+
f"requested {amount}, available {allocation.remaining_budget}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return success
|
|
168
|
+
|
|
169
|
+
def get_remaining_budget(self, task_id: str) -> int:
|
|
170
|
+
"""Get remaining budget for a task.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
task_id: Task ID
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Remaining budget or 0 if not found
|
|
177
|
+
"""
|
|
178
|
+
if task_id in self._allocations:
|
|
179
|
+
return self._allocations[task_id].remaining_budget
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
def get_utilization(self, task_id: Optional[str] = None) -> float:
|
|
183
|
+
"""Get budget utilization rate.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
task_id: Optional task ID for specific utilization
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Utilization rate (0.0-1.0)
|
|
190
|
+
"""
|
|
191
|
+
if task_id:
|
|
192
|
+
if task_id in self._allocations:
|
|
193
|
+
return self._allocations[task_id].get_utilization_rate()
|
|
194
|
+
return 0.0
|
|
195
|
+
|
|
196
|
+
# Overall utilization
|
|
197
|
+
total_allocated = sum(a.allocated_budget for a in self._allocations.values())
|
|
198
|
+
total_consumed = sum(a.consumed_budget for a in self._allocations.values())
|
|
199
|
+
|
|
200
|
+
if total_allocated == 0:
|
|
201
|
+
return 0.0
|
|
202
|
+
return total_consumed / total_allocated
|
|
203
|
+
|
|
204
|
+
def reallocate_unused_budget(self, force: bool = False) -> Dict[str, int]:
|
|
205
|
+
"""Reallocate unused budget from completed/failed tasks.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
force: Force reallocation regardless of threshold
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Dictionary of new allocations made
|
|
212
|
+
"""
|
|
213
|
+
# Find tasks with low utilization or completed tasks
|
|
214
|
+
unused_budget = 0
|
|
215
|
+
tasks_to_reclaim = []
|
|
216
|
+
|
|
217
|
+
for task_id, allocation in self._allocations.items():
|
|
218
|
+
utilization = allocation.get_utilization_rate()
|
|
219
|
+
|
|
220
|
+
# Reclaim from tasks with very low utilization
|
|
221
|
+
if utilization < self.reallocation_threshold or force:
|
|
222
|
+
unused = allocation.remaining_budget
|
|
223
|
+
if unused > 0:
|
|
224
|
+
tasks_to_reclaim.append((task_id, unused))
|
|
225
|
+
unused_budget += unused
|
|
226
|
+
|
|
227
|
+
if unused_budget == 0:
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
# Find tasks that need more budget (high utilization)
|
|
231
|
+
high_utilization_tasks = []
|
|
232
|
+
for task_id, allocation in self._allocations.items():
|
|
233
|
+
if task_id not in [t[0] for t in tasks_to_reclaim]:
|
|
234
|
+
utilization = allocation.get_utilization_rate()
|
|
235
|
+
if utilization > 0.8: # High utilization threshold
|
|
236
|
+
high_utilization_tasks.append(task_id)
|
|
237
|
+
|
|
238
|
+
if not high_utilization_tasks:
|
|
239
|
+
# Return budget to available pool
|
|
240
|
+
self._available_budget += unused_budget
|
|
241
|
+
for task_id, amount in tasks_to_reclaim:
|
|
242
|
+
self._allocations[task_id].allocated_budget -= amount
|
|
243
|
+
self._allocations[task_id].remaining_budget = 0
|
|
244
|
+
return {}
|
|
245
|
+
|
|
246
|
+
# Reallocate to high utilization tasks
|
|
247
|
+
per_task_addition = unused_budget // len(high_utilization_tasks)
|
|
248
|
+
remainder = unused_budget % len(high_utilization_tasks)
|
|
249
|
+
|
|
250
|
+
reallocations = {}
|
|
251
|
+
for i, task_id in enumerate(high_utilization_tasks):
|
|
252
|
+
additional = per_task_addition + (1 if i < remainder else 0)
|
|
253
|
+
if additional > 0:
|
|
254
|
+
self._allocations[task_id].allocated_budget += additional
|
|
255
|
+
self._allocations[task_id].remaining_budget += additional
|
|
256
|
+
reallocations[task_id] = additional
|
|
257
|
+
|
|
258
|
+
# Update reclaimed tasks
|
|
259
|
+
for task_id, amount in tasks_to_reclaim:
|
|
260
|
+
self._allocations[task_id].allocated_budget -= amount
|
|
261
|
+
self._allocations[task_id].remaining_budget = 0
|
|
262
|
+
|
|
263
|
+
self._reallocation_count += 1
|
|
264
|
+
logger.info(f"Reallocated {unused_budget} budget units to {len(reallocations)} tasks")
|
|
265
|
+
|
|
266
|
+
return reallocations
|
|
267
|
+
|
|
268
|
+
def release_task_budget(self, task_id: str) -> int:
|
|
269
|
+
"""Release all remaining budget for a task.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
task_id: Task ID
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
Amount of budget released
|
|
276
|
+
"""
|
|
277
|
+
if task_id not in self._allocations:
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
allocation = self._allocations[task_id]
|
|
281
|
+
released = allocation.remaining_budget
|
|
282
|
+
|
|
283
|
+
if released > 0:
|
|
284
|
+
self._available_budget += released
|
|
285
|
+
allocation.remaining_budget = 0
|
|
286
|
+
logger.debug(f"Released {released} budget units from task {task_id}")
|
|
287
|
+
|
|
288
|
+
return released
|
|
289
|
+
|
|
290
|
+
def _allocate_equal(self, n_tasks: int) -> List[int]:
|
|
291
|
+
"""Allocate budget equally among tasks.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
n_tasks: Number of tasks
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
List of budget allocations
|
|
298
|
+
"""
|
|
299
|
+
if n_tasks == 0:
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
available = min(self._available_budget, self.total_budget)
|
|
303
|
+
per_task = max(self.min_task_budget, available // n_tasks)
|
|
304
|
+
|
|
305
|
+
# Ensure we don't exceed available budget
|
|
306
|
+
if per_task * n_tasks > available:
|
|
307
|
+
per_task = available // n_tasks
|
|
308
|
+
|
|
309
|
+
allocations = [per_task] * n_tasks
|
|
310
|
+
|
|
311
|
+
# Distribute remainder
|
|
312
|
+
remainder = available - (per_task * n_tasks)
|
|
313
|
+
for i in range(min(remainder, n_tasks)):
|
|
314
|
+
allocations[i] += 1
|
|
315
|
+
|
|
316
|
+
return allocations
|
|
317
|
+
|
|
318
|
+
def _allocate_weighted(self, complexity_scores: List[float]) -> List[int]:
|
|
319
|
+
"""Allocate budget based on complexity scores.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
complexity_scores: List of complexity scores (0.0-1.0)
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
List of budget allocations
|
|
326
|
+
"""
|
|
327
|
+
if not complexity_scores:
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
# Normalize scores
|
|
331
|
+
total_score = sum(complexity_scores)
|
|
332
|
+
if total_score == 0:
|
|
333
|
+
return self._allocate_equal(len(complexity_scores))
|
|
334
|
+
|
|
335
|
+
available = min(self._available_budget, self.total_budget)
|
|
336
|
+
allocations = []
|
|
337
|
+
|
|
338
|
+
for score in complexity_scores:
|
|
339
|
+
# Weighted allocation with minimum guarantee
|
|
340
|
+
weight = score / total_score
|
|
341
|
+
allocation = max(self.min_task_budget, int(available * weight))
|
|
342
|
+
allocations.append(allocation)
|
|
343
|
+
|
|
344
|
+
# Adjust if we exceeded budget
|
|
345
|
+
total_allocated = sum(allocations)
|
|
346
|
+
if total_allocated > available:
|
|
347
|
+
# Scale down proportionally
|
|
348
|
+
scale = available / total_allocated
|
|
349
|
+
allocations = [max(self.min_task_budget, int(a * scale)) for a in allocations]
|
|
350
|
+
|
|
351
|
+
return allocations
|
|
352
|
+
|
|
353
|
+
def _allocate_by_priority(self, priorities: List[str]) -> List[int]:
|
|
354
|
+
"""Allocate budget based on priority levels.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
priorities: List of priority levels (high, medium, low)
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
List of budget allocations
|
|
361
|
+
"""
|
|
362
|
+
# Priority weights
|
|
363
|
+
weights = {"high": 3, "medium": 2, "low": 1}
|
|
364
|
+
|
|
365
|
+
# Convert to scores
|
|
366
|
+
scores = [weights.get(p.lower(), 1) for p in priorities]
|
|
367
|
+
total_weight = sum(scores)
|
|
368
|
+
|
|
369
|
+
if total_weight == 0:
|
|
370
|
+
return self._allocate_equal(len(priorities))
|
|
371
|
+
|
|
372
|
+
# Convert to normalized complexity scores
|
|
373
|
+
complexity_scores = [s / total_weight for s in scores]
|
|
374
|
+
|
|
375
|
+
return self._allocate_weighted(complexity_scores)
|
|
376
|
+
|
|
377
|
+
def _take_snapshot(self):
|
|
378
|
+
"""Take a snapshot of current budget state."""
|
|
379
|
+
snapshot = BudgetSnapshot(
|
|
380
|
+
timestamp=datetime.now(),
|
|
381
|
+
total_allocated=sum(a.allocated_budget for a in self._allocations.values()),
|
|
382
|
+
total_consumed=sum(a.consumed_budget for a in self._allocations.values()),
|
|
383
|
+
active_tasks=len(self._allocations),
|
|
384
|
+
task_allocations=self._allocations.copy(),
|
|
385
|
+
)
|
|
386
|
+
self._allocation_history.append(snapshot)
|
|
387
|
+
|
|
388
|
+
def get_budget_summary(self) -> Dict[str, any]:
|
|
389
|
+
"""Get a summary of budget state.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
Dictionary with budget statistics
|
|
393
|
+
"""
|
|
394
|
+
total_allocated = sum(a.allocated_budget for a in self._allocations.values())
|
|
395
|
+
total_consumed = sum(a.consumed_budget for a in self._allocations.values())
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
"total_budget": self.total_budget,
|
|
399
|
+
"available_budget": self._available_budget,
|
|
400
|
+
"allocated_budget": total_allocated,
|
|
401
|
+
"consumed_budget": total_consumed,
|
|
402
|
+
"utilization_rate": total_consumed / total_allocated if total_allocated > 0 else 0.0,
|
|
403
|
+
"active_tasks": len(self._allocations),
|
|
404
|
+
"reallocation_count": self._reallocation_count,
|
|
405
|
+
"task_details": {
|
|
406
|
+
task_id: {
|
|
407
|
+
"allocated": alloc.allocated_budget,
|
|
408
|
+
"consumed": alloc.consumed_budget,
|
|
409
|
+
"remaining": alloc.remaining_budget,
|
|
410
|
+
"utilization": alloc.get_utilization_rate(),
|
|
411
|
+
}
|
|
412
|
+
for task_id, alloc in self._allocations.items()
|
|
413
|
+
},
|
|
414
|
+
}
|