tunacode-cli 0.0.48__py3-none-any.whl → 0.0.49__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.
- api/auth.py +13 -0
- api/users.py +8 -0
- tunacode/__init__.py +4 -0
- tunacode/cli/main.py +4 -0
- tunacode/cli/repl.py +39 -6
- tunacode/configuration/defaults.py +0 -1
- tunacode/constants.py +7 -1
- tunacode/core/agents/main.py +268 -245
- tunacode/core/agents/utils.py +54 -6
- tunacode/core/logging/__init__.py +29 -0
- tunacode/core/logging/config.py +28 -0
- tunacode/core/logging/formatters.py +48 -0
- tunacode/core/logging/handlers.py +83 -0
- tunacode/core/logging/logger.py +8 -0
- 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 +470 -0
- tunacode/core/recursive/hierarchy.py +488 -0
- tunacode/core/state.py +45 -0
- tunacode/exceptions.py +23 -0
- tunacode/tools/base.py +7 -1
- tunacode/types.py +1 -1
- tunacode/ui/completers.py +2 -2
- tunacode/ui/console.py +30 -9
- tunacode/ui/input.py +2 -1
- tunacode/ui/keybindings.py +58 -1
- tunacode/ui/logging_compat.py +44 -0
- tunacode/ui/output.py +7 -6
- tunacode/ui/panels.py +30 -5
- tunacode/ui/recursive_progress.py +380 -0
- tunacode/utils/retry.py +163 -0
- tunacode/utils/security.py +3 -2
- tunacode/utils/token_counter.py +1 -2
- {tunacode_cli-0.0.48.dist-info → tunacode_cli-0.0.49.dist-info}/METADATA +2 -2
- {tunacode_cli-0.0.48.dist-info → tunacode_cli-0.0.49.dist-info}/RECORD +41 -29
- {tunacode_cli-0.0.48.dist-info → tunacode_cli-0.0.49.dist-info}/top_level.txt +1 -0
- tunacode/core/agents/dspy_integration.py +0 -223
- tunacode/core/agents/dspy_tunacode.py +0 -458
- tunacode/prompts/dspy_task_planning.md +0 -45
- tunacode/prompts/dspy_tool_selection.md +0 -58
- {tunacode_cli-0.0.48.dist-info → tunacode_cli-0.0.49.dist-info}/WHEEL +0 -0
- {tunacode_cli-0.0.48.dist-info → tunacode_cli-0.0.49.dist-info}/entry_points.txt +0 -0
- {tunacode_cli-0.0.48.dist-info → tunacode_cli-0.0.49.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Module: tunacode.core.recursive.hierarchy
|
|
2
|
+
|
|
3
|
+
Hierarchical task management system for maintaining parent-child relationships and execution state.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
from collections import defaultdict, deque
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any, Dict, List, Optional, Set
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TaskExecutionContext:
|
|
17
|
+
"""Context information for task execution."""
|
|
18
|
+
|
|
19
|
+
task_id: str
|
|
20
|
+
parent_id: Optional[str] = None
|
|
21
|
+
depth: int = 0
|
|
22
|
+
inherited_context: Dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
local_context: Dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
started_at: Optional[datetime] = None
|
|
25
|
+
completed_at: Optional[datetime] = None
|
|
26
|
+
|
|
27
|
+
def get_full_context(self) -> Dict[str, Any]:
|
|
28
|
+
"""Get merged context (inherited + local)."""
|
|
29
|
+
return {**self.inherited_context, **self.local_context}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class TaskRelationship:
|
|
34
|
+
"""Represents a relationship between tasks."""
|
|
35
|
+
|
|
36
|
+
parent_id: str
|
|
37
|
+
child_id: str
|
|
38
|
+
relationship_type: str = "subtask" # subtask, dependency, etc.
|
|
39
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TaskHierarchy:
|
|
43
|
+
"""Manages hierarchical task relationships and execution state."""
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
"""Initialize the task hierarchy manager."""
|
|
47
|
+
# Core data structures
|
|
48
|
+
self._tasks: Dict[str, Dict[str, Any]] = {}
|
|
49
|
+
self._parent_to_children: Dict[str, List[str]] = defaultdict(list)
|
|
50
|
+
self._child_to_parent: Dict[str, str] = {}
|
|
51
|
+
self._task_dependencies: Dict[str, Set[str]] = defaultdict(set)
|
|
52
|
+
self._reverse_dependencies: Dict[str, Set[str]] = defaultdict(set)
|
|
53
|
+
self._execution_contexts: Dict[str, TaskExecutionContext] = {}
|
|
54
|
+
self._execution_order: List[str] = []
|
|
55
|
+
self._completed_tasks: Set[str] = set()
|
|
56
|
+
self._failed_tasks: Set[str] = set()
|
|
57
|
+
|
|
58
|
+
def add_task(
|
|
59
|
+
self,
|
|
60
|
+
task_id: str,
|
|
61
|
+
task_data: Dict[str, Any],
|
|
62
|
+
parent_id: Optional[str] = None,
|
|
63
|
+
dependencies: Optional[List[str]] = None,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""Add a task to the hierarchy.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
task_id: Unique identifier for the task
|
|
69
|
+
task_data: Task information (title, description, etc.)
|
|
70
|
+
parent_id: Optional parent task ID
|
|
71
|
+
dependencies: Optional list of task IDs this task depends on
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if task was added successfully, False otherwise
|
|
75
|
+
"""
|
|
76
|
+
if task_id in self._tasks:
|
|
77
|
+
logger.warning(f"Task {task_id} already exists")
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
# Store task data
|
|
81
|
+
self._tasks[task_id] = {
|
|
82
|
+
"id": task_id,
|
|
83
|
+
"parent_id": parent_id,
|
|
84
|
+
"created_at": datetime.now(),
|
|
85
|
+
**task_data,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Set up parent-child relationships
|
|
89
|
+
if parent_id:
|
|
90
|
+
if parent_id not in self._tasks:
|
|
91
|
+
logger.error(f"Parent task {parent_id} does not exist")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
self._parent_to_children[parent_id].append(task_id)
|
|
95
|
+
self._child_to_parent[task_id] = parent_id
|
|
96
|
+
|
|
97
|
+
# Set up dependencies
|
|
98
|
+
if dependencies:
|
|
99
|
+
for dep_id in dependencies:
|
|
100
|
+
if dep_id not in self._tasks:
|
|
101
|
+
logger.warning(f"Dependency {dep_id} does not exist yet")
|
|
102
|
+
self.add_dependency(task_id, dep_id)
|
|
103
|
+
|
|
104
|
+
logger.debug(f"Added task {task_id} to hierarchy")
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def add_dependency(self, task_id: str, depends_on: str) -> bool:
|
|
108
|
+
"""Add a dependency relationship between tasks.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
task_id: Task that has the dependency
|
|
112
|
+
depends_on: Task that must complete first
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if dependency was added, False if it would create a cycle
|
|
116
|
+
"""
|
|
117
|
+
# Check if adding this would create a cycle
|
|
118
|
+
if self._would_create_cycle(task_id, depends_on):
|
|
119
|
+
logger.error(f"Adding dependency {task_id} -> {depends_on} would create a cycle")
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
self._task_dependencies[task_id].add(depends_on)
|
|
123
|
+
self._reverse_dependencies[depends_on].add(task_id)
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def remove_dependency(self, task_id: str, depends_on: str) -> bool:
|
|
127
|
+
"""Remove a dependency relationship.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
task_id: Task that has the dependency
|
|
131
|
+
depends_on: Task to remove from dependencies
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
True if dependency was removed
|
|
135
|
+
"""
|
|
136
|
+
if depends_on in self._task_dependencies.get(task_id, set()):
|
|
137
|
+
self._task_dependencies[task_id].remove(depends_on)
|
|
138
|
+
self._reverse_dependencies[depends_on].discard(task_id)
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
|
|
143
|
+
"""Get task information.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
task_id: Task identifier
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Task data or None if not found
|
|
150
|
+
"""
|
|
151
|
+
return self._tasks.get(task_id)
|
|
152
|
+
|
|
153
|
+
def get_children(self, parent_id: str) -> List[str]:
|
|
154
|
+
"""Get all direct children of a task.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
parent_id: Parent task ID
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
List of child task IDs
|
|
161
|
+
"""
|
|
162
|
+
return self._parent_to_children.get(parent_id, []).copy()
|
|
163
|
+
|
|
164
|
+
def get_parent(self, task_id: str) -> Optional[str]:
|
|
165
|
+
"""Get the parent of a task.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
task_id: Task ID
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Parent task ID or None
|
|
172
|
+
"""
|
|
173
|
+
return self._child_to_parent.get(task_id)
|
|
174
|
+
|
|
175
|
+
def get_ancestors(self, task_id: str) -> List[str]:
|
|
176
|
+
"""Get all ancestors of a task (parent, grandparent, etc.).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
task_id: Task ID
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
List of ancestor IDs from immediate parent to root
|
|
183
|
+
"""
|
|
184
|
+
ancestors = []
|
|
185
|
+
current = self._child_to_parent.get(task_id)
|
|
186
|
+
|
|
187
|
+
while current:
|
|
188
|
+
ancestors.append(current)
|
|
189
|
+
current = self._child_to_parent.get(current)
|
|
190
|
+
|
|
191
|
+
return ancestors
|
|
192
|
+
|
|
193
|
+
def get_depth(self, task_id: str) -> int:
|
|
194
|
+
"""Get the depth of a task in the hierarchy.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
task_id: Task ID
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Depth (0 for root tasks)
|
|
201
|
+
"""
|
|
202
|
+
return len(self.get_ancestors(task_id))
|
|
203
|
+
|
|
204
|
+
def get_dependencies(self, task_id: str) -> Set[str]:
|
|
205
|
+
"""Get tasks that must complete before this task.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
task_id: Task ID
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Set of dependency task IDs
|
|
212
|
+
"""
|
|
213
|
+
return self._task_dependencies.get(task_id, set()).copy()
|
|
214
|
+
|
|
215
|
+
def get_dependents(self, task_id: str) -> Set[str]:
|
|
216
|
+
"""Get tasks that depend on this task.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
task_id: Task ID
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Set of dependent task IDs
|
|
223
|
+
"""
|
|
224
|
+
return self._reverse_dependencies.get(task_id, set()).copy()
|
|
225
|
+
|
|
226
|
+
def can_execute(self, task_id: str) -> bool:
|
|
227
|
+
"""Check if a task can be executed (all dependencies met).
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
task_id: Task ID
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if task can be executed
|
|
234
|
+
"""
|
|
235
|
+
if task_id not in self._tasks:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
# Check if all dependencies are completed
|
|
239
|
+
dependencies = self._task_dependencies.get(task_id, set())
|
|
240
|
+
return all(dep in self._completed_tasks for dep in dependencies)
|
|
241
|
+
|
|
242
|
+
def get_executable_tasks(self) -> List[str]:
|
|
243
|
+
"""Get all tasks that can currently be executed.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
List of task IDs that have all dependencies met
|
|
247
|
+
"""
|
|
248
|
+
executable = []
|
|
249
|
+
|
|
250
|
+
for task_id in self._tasks:
|
|
251
|
+
if (
|
|
252
|
+
task_id not in self._completed_tasks
|
|
253
|
+
and task_id not in self._failed_tasks
|
|
254
|
+
and self.can_execute(task_id)
|
|
255
|
+
):
|
|
256
|
+
executable.append(task_id)
|
|
257
|
+
|
|
258
|
+
return executable
|
|
259
|
+
|
|
260
|
+
def mark_completed(self, task_id: str, result: Any = None) -> None:
|
|
261
|
+
"""Mark a task as completed.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
task_id: Task ID
|
|
265
|
+
result: Optional result data
|
|
266
|
+
"""
|
|
267
|
+
if task_id in self._tasks:
|
|
268
|
+
self._completed_tasks.add(task_id)
|
|
269
|
+
self._tasks[task_id]["status"] = "completed"
|
|
270
|
+
self._tasks[task_id]["result"] = result
|
|
271
|
+
self._tasks[task_id]["completed_at"] = datetime.now()
|
|
272
|
+
|
|
273
|
+
# Update execution context if exists
|
|
274
|
+
if task_id in self._execution_contexts:
|
|
275
|
+
self._execution_contexts[task_id].completed_at = datetime.now()
|
|
276
|
+
|
|
277
|
+
def mark_failed(self, task_id: str, error: str) -> None:
|
|
278
|
+
"""Mark a task as failed.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
task_id: Task ID
|
|
282
|
+
error: Error message
|
|
283
|
+
"""
|
|
284
|
+
if task_id in self._tasks:
|
|
285
|
+
self._failed_tasks.add(task_id)
|
|
286
|
+
self._tasks[task_id]["status"] = "failed"
|
|
287
|
+
self._tasks[task_id]["error"] = error
|
|
288
|
+
self._tasks[task_id]["failed_at"] = datetime.now()
|
|
289
|
+
|
|
290
|
+
def create_execution_context(
|
|
291
|
+
self, task_id: str, parent_context: Optional[Dict[str, Any]] = None
|
|
292
|
+
) -> TaskExecutionContext:
|
|
293
|
+
"""Create an execution context for a task.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
task_id: Task ID
|
|
297
|
+
parent_context: Optional parent context to inherit
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
New execution context
|
|
301
|
+
"""
|
|
302
|
+
parent_id = self._child_to_parent.get(task_id)
|
|
303
|
+
depth = self.get_depth(task_id)
|
|
304
|
+
|
|
305
|
+
context = TaskExecutionContext(
|
|
306
|
+
task_id=task_id,
|
|
307
|
+
parent_id=parent_id,
|
|
308
|
+
depth=depth,
|
|
309
|
+
inherited_context=parent_context or {},
|
|
310
|
+
started_at=datetime.now(),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
self._execution_contexts[task_id] = context
|
|
314
|
+
self._execution_order.append(task_id)
|
|
315
|
+
|
|
316
|
+
return context
|
|
317
|
+
|
|
318
|
+
def get_execution_context(self, task_id: str) -> Optional[TaskExecutionContext]:
|
|
319
|
+
"""Get the execution context for a task.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
task_id: Task ID
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
Execution context or None
|
|
326
|
+
"""
|
|
327
|
+
return self._execution_contexts.get(task_id)
|
|
328
|
+
|
|
329
|
+
def propagate_context(
|
|
330
|
+
self, from_task: str, to_task: str, context_update: Dict[str, Any]
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Propagate context from one task to another.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
from_task: Source task ID (unused, kept for API consistency)
|
|
336
|
+
to_task: Target task ID
|
|
337
|
+
context_update: Context to propagate
|
|
338
|
+
"""
|
|
339
|
+
_ = from_task # Unused but kept for API consistency
|
|
340
|
+
if to_task in self._execution_contexts:
|
|
341
|
+
self._execution_contexts[to_task].inherited_context.update(context_update)
|
|
342
|
+
|
|
343
|
+
def aggregate_child_results(self, parent_id: str) -> Dict[str, Any]:
|
|
344
|
+
"""Aggregate results from all children of a task.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
parent_id: Parent task ID
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Aggregated results dictionary
|
|
351
|
+
"""
|
|
352
|
+
children = self._parent_to_children.get(parent_id, [])
|
|
353
|
+
|
|
354
|
+
results = {"completed": [], "failed": [], "pending": [], "aggregated_data": {}}
|
|
355
|
+
|
|
356
|
+
for child_id in children:
|
|
357
|
+
child_task = self._tasks.get(child_id, {})
|
|
358
|
+
status = child_task.get("status", "pending")
|
|
359
|
+
|
|
360
|
+
if status == "completed":
|
|
361
|
+
results["completed"].append({"id": child_id, "result": child_task.get("result")})
|
|
362
|
+
elif status == "failed":
|
|
363
|
+
results["failed"].append({"id": child_id, "error": child_task.get("error")})
|
|
364
|
+
else:
|
|
365
|
+
results["pending"].append(child_id)
|
|
366
|
+
|
|
367
|
+
return results
|
|
368
|
+
|
|
369
|
+
def get_execution_path(self, task_id: str) -> List[str]:
|
|
370
|
+
"""Get the full execution path from root to this task.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
task_id: Task ID
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
List of task IDs from root to this task
|
|
377
|
+
"""
|
|
378
|
+
ancestors = self.get_ancestors(task_id)
|
|
379
|
+
ancestors.reverse() # Root to task order
|
|
380
|
+
ancestors.append(task_id)
|
|
381
|
+
return ancestors
|
|
382
|
+
|
|
383
|
+
def _would_create_cycle(self, task_id: str, depends_on: str) -> bool:
|
|
384
|
+
"""Check if adding a dependency would create a cycle.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
task_id: Task that would have the dependency
|
|
388
|
+
depends_on: Task it would depend on
|
|
389
|
+
|
|
390
|
+
Returns:
|
|
391
|
+
True if this would create a cycle
|
|
392
|
+
"""
|
|
393
|
+
# BFS to check if we can reach task_id from depends_on
|
|
394
|
+
visited = set()
|
|
395
|
+
queue = deque([depends_on])
|
|
396
|
+
|
|
397
|
+
while queue:
|
|
398
|
+
current = queue.popleft()
|
|
399
|
+
if current == task_id:
|
|
400
|
+
return True
|
|
401
|
+
|
|
402
|
+
if current in visited:
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
visited.add(current)
|
|
406
|
+
|
|
407
|
+
# Add all tasks that depend on current
|
|
408
|
+
for dependent in self._reverse_dependencies.get(current, []):
|
|
409
|
+
if dependent not in visited:
|
|
410
|
+
queue.append(dependent)
|
|
411
|
+
|
|
412
|
+
return False
|
|
413
|
+
|
|
414
|
+
def get_topological_order(self) -> List[str]:
|
|
415
|
+
"""Get a valid execution order respecting all dependencies.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
List of task IDs in valid execution order
|
|
419
|
+
"""
|
|
420
|
+
# Kahn's algorithm for topological sort
|
|
421
|
+
in_degree = {}
|
|
422
|
+
for task_id in self._tasks:
|
|
423
|
+
in_degree[task_id] = len(self._task_dependencies.get(task_id, set()))
|
|
424
|
+
|
|
425
|
+
queue = deque([task_id for task_id, degree in in_degree.items() if degree == 0])
|
|
426
|
+
order = []
|
|
427
|
+
|
|
428
|
+
while queue:
|
|
429
|
+
current = queue.popleft()
|
|
430
|
+
order.append(current)
|
|
431
|
+
|
|
432
|
+
# Reduce in-degree for all dependents
|
|
433
|
+
for dependent in self._reverse_dependencies.get(current, []):
|
|
434
|
+
in_degree[dependent] -= 1
|
|
435
|
+
if in_degree[dependent] == 0:
|
|
436
|
+
queue.append(dependent)
|
|
437
|
+
|
|
438
|
+
# If we couldn't process all tasks, there's a cycle
|
|
439
|
+
if len(order) != len(self._tasks):
|
|
440
|
+
logger.error("Dependency cycle detected")
|
|
441
|
+
# Return partial order
|
|
442
|
+
remaining = [t for t in self._tasks if t not in order]
|
|
443
|
+
order.extend(remaining)
|
|
444
|
+
|
|
445
|
+
return order
|
|
446
|
+
|
|
447
|
+
def visualize_hierarchy(self, show_dependencies: bool = True) -> str:
|
|
448
|
+
"""Generate a text visualization of the task hierarchy.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
show_dependencies: Whether to show dependency relationships
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
String representation of the hierarchy
|
|
455
|
+
"""
|
|
456
|
+
lines = []
|
|
457
|
+
|
|
458
|
+
# Find root tasks (no parent)
|
|
459
|
+
roots = [task_id for task_id in self._tasks if task_id not in self._child_to_parent]
|
|
460
|
+
|
|
461
|
+
def build_tree(task_id: str, prefix: str = "", is_last: bool = True):
|
|
462
|
+
task = self._tasks[task_id]
|
|
463
|
+
status = task.get("status", "pending")
|
|
464
|
+
|
|
465
|
+
# Build current line
|
|
466
|
+
connector = "└── " if is_last else "├── "
|
|
467
|
+
status_icon = "✓" if status == "completed" else "✗" if status == "failed" else "○"
|
|
468
|
+
line = f"{prefix}{connector}[{status_icon}] {task_id}: {task.get('title', 'Untitled')[:50]}"
|
|
469
|
+
|
|
470
|
+
# Add dependencies if requested
|
|
471
|
+
if show_dependencies:
|
|
472
|
+
deps = self._task_dependencies.get(task_id, set())
|
|
473
|
+
if deps:
|
|
474
|
+
line += f" (deps: {', '.join(deps)})"
|
|
475
|
+
|
|
476
|
+
lines.append(line)
|
|
477
|
+
|
|
478
|
+
# Process children
|
|
479
|
+
children = self._parent_to_children.get(task_id, [])
|
|
480
|
+
for i, child_id in enumerate(children):
|
|
481
|
+
extension = " " if is_last else "│ "
|
|
482
|
+
build_tree(child_id, prefix + extension, i == len(children) - 1)
|
|
483
|
+
|
|
484
|
+
# Build tree for each root
|
|
485
|
+
for i, root_id in enumerate(roots):
|
|
486
|
+
build_tree(root_id, "", i == len(roots) - 1)
|
|
487
|
+
|
|
488
|
+
return "\n".join(lines) if lines else "Empty hierarchy"
|
tunacode/core/state.py
CHANGED
|
@@ -41,6 +41,10 @@ class SessionState:
|
|
|
41
41
|
input_sessions: InputSessions = field(default_factory=dict)
|
|
42
42
|
current_task: Optional[Any] = None
|
|
43
43
|
todos: list[TodoItem] = field(default_factory=list)
|
|
44
|
+
# ESC key tracking for double-press functionality
|
|
45
|
+
esc_press_count: int = 0
|
|
46
|
+
last_esc_time: Optional[float] = None
|
|
47
|
+
operation_cancelled: bool = False
|
|
44
48
|
# Enhanced tracking for thoughts display
|
|
45
49
|
files_in_context: set[str] = field(default_factory=set)
|
|
46
50
|
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
@@ -68,6 +72,13 @@ class SessionState:
|
|
|
68
72
|
"cost": 0.0,
|
|
69
73
|
}
|
|
70
74
|
)
|
|
75
|
+
# Recursive execution tracking
|
|
76
|
+
current_recursion_depth: int = 0
|
|
77
|
+
max_recursion_depth: int = 5
|
|
78
|
+
parent_task_id: Optional[str] = None
|
|
79
|
+
task_hierarchy: dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
iteration_budgets: dict[str, int] = field(default_factory=dict)
|
|
81
|
+
recursive_context_stack: list[dict[str, Any]] = field(default_factory=list)
|
|
71
82
|
|
|
72
83
|
def update_token_count(self):
|
|
73
84
|
"""Calculates the total token count from messages and files in context."""
|
|
@@ -98,6 +109,40 @@ class StateManager:
|
|
|
98
109
|
todo.completed_at = datetime.now()
|
|
99
110
|
break
|
|
100
111
|
|
|
112
|
+
def push_recursive_context(self, context: dict[str, Any]) -> None:
|
|
113
|
+
"""Push a new context onto the recursive execution stack."""
|
|
114
|
+
self._session.recursive_context_stack.append(context)
|
|
115
|
+
self._session.current_recursion_depth += 1
|
|
116
|
+
|
|
117
|
+
def pop_recursive_context(self) -> Optional[dict[str, Any]]:
|
|
118
|
+
"""Pop the current context from the recursive execution stack."""
|
|
119
|
+
if self._session.recursive_context_stack:
|
|
120
|
+
self._session.current_recursion_depth = max(
|
|
121
|
+
0, self._session.current_recursion_depth - 1
|
|
122
|
+
)
|
|
123
|
+
return self._session.recursive_context_stack.pop()
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def set_task_iteration_budget(self, task_id: str, budget: int) -> None:
|
|
127
|
+
"""Set the iteration budget for a specific task."""
|
|
128
|
+
self._session.iteration_budgets[task_id] = budget
|
|
129
|
+
|
|
130
|
+
def get_task_iteration_budget(self, task_id: str) -> int:
|
|
131
|
+
"""Get the iteration budget for a specific task."""
|
|
132
|
+
return self._session.iteration_budgets.get(task_id, 10) # Default to 10
|
|
133
|
+
|
|
134
|
+
def can_recurse_deeper(self) -> bool:
|
|
135
|
+
"""Check if we can recurse deeper without exceeding limits."""
|
|
136
|
+
return self._session.current_recursion_depth < self._session.max_recursion_depth
|
|
137
|
+
|
|
138
|
+
def reset_recursive_state(self) -> None:
|
|
139
|
+
"""Reset all recursive execution state."""
|
|
140
|
+
self._session.current_recursion_depth = 0
|
|
141
|
+
self._session.parent_task_id = None
|
|
142
|
+
self._session.task_hierarchy.clear()
|
|
143
|
+
self._session.iteration_budgets.clear()
|
|
144
|
+
self._session.recursive_context_stack.clear()
|
|
145
|
+
|
|
101
146
|
def remove_todo(self, todo_id: str) -> None:
|
|
102
147
|
self._session.todos = [todo for todo in self._session.todos if todo.id != todo_id]
|
|
103
148
|
|
tunacode/exceptions.py
CHANGED
|
@@ -114,3 +114,26 @@ class TooBroadPatternError(ToolExecutionError):
|
|
|
114
114
|
f"Pattern '{pattern}' is too broad - no matches found within {timeout_seconds}s. "
|
|
115
115
|
"Please use a more specific pattern.",
|
|
116
116
|
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ToolBatchingJSONError(TunaCodeError):
|
|
120
|
+
"""Raised when JSON parsing fails during tool batching after all retries are exhausted."""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
json_content: str,
|
|
125
|
+
retry_count: int,
|
|
126
|
+
original_error: OriginalError = None,
|
|
127
|
+
):
|
|
128
|
+
self.json_content = json_content
|
|
129
|
+
self.retry_count = retry_count
|
|
130
|
+
self.original_error = original_error
|
|
131
|
+
|
|
132
|
+
# Truncate JSON content for display if too long
|
|
133
|
+
display_content = json_content[:100] + "..." if len(json_content) > 100 else json_content
|
|
134
|
+
|
|
135
|
+
super().__init__(
|
|
136
|
+
f"The model is having issues with tool batching. "
|
|
137
|
+
f"JSON parsing failed after {retry_count} retries. "
|
|
138
|
+
f"Invalid JSON: {display_content}"
|
|
139
|
+
)
|
tunacode/tools/base.py
CHANGED
|
@@ -8,6 +8,7 @@ from abc import ABC, abstractmethod
|
|
|
8
8
|
|
|
9
9
|
from pydantic_ai.exceptions import ModelRetry
|
|
10
10
|
|
|
11
|
+
from tunacode.core.logging.logger import get_logger
|
|
11
12
|
from tunacode.exceptions import FileOperationError, ToolExecutionError
|
|
12
13
|
from tunacode.types import FilePath, ToolName, ToolResult, UILogger
|
|
13
14
|
|
|
@@ -22,6 +23,7 @@ class BaseTool(ABC):
|
|
|
22
23
|
ui_logger: UI logger instance for displaying messages
|
|
23
24
|
"""
|
|
24
25
|
self.ui = ui_logger
|
|
26
|
+
self.logger = get_logger(self.__class__.__name__)
|
|
25
27
|
|
|
26
28
|
async def execute(self, *args, **kwargs) -> ToolResult:
|
|
27
29
|
"""Execute the tool with error handling and logging.
|
|
@@ -39,14 +41,17 @@ class BaseTool(ABC):
|
|
|
39
41
|
ToolExecutionError: Raised for all other errors with structured information
|
|
40
42
|
"""
|
|
41
43
|
try:
|
|
44
|
+
msg = f"{self.tool_name}({self._format_args(*args, **kwargs)})"
|
|
42
45
|
if self.ui:
|
|
43
|
-
await self.ui.info(
|
|
46
|
+
await self.ui.info(msg)
|
|
47
|
+
self.logger.info(msg)
|
|
44
48
|
result = await self._execute(*args, **kwargs)
|
|
45
49
|
return result
|
|
46
50
|
except ModelRetry as e:
|
|
47
51
|
# Log as warning and re-raise for pydantic-ai
|
|
48
52
|
if self.ui:
|
|
49
53
|
await self.ui.warning(str(e))
|
|
54
|
+
self.logger.warning(f"ModelRetry: {e}")
|
|
50
55
|
raise
|
|
51
56
|
except ToolExecutionError:
|
|
52
57
|
# Already properly formatted, just re-raise
|
|
@@ -90,6 +95,7 @@ class BaseTool(ABC):
|
|
|
90
95
|
err_msg = f"Error {self._get_error_context(*args, **kwargs)}: {error}"
|
|
91
96
|
if self.ui:
|
|
92
97
|
await self.ui.error(err_msg)
|
|
98
|
+
self.logger.error(err_msg)
|
|
93
99
|
|
|
94
100
|
# Raise proper exception instead of returning string
|
|
95
101
|
raise ToolExecutionError(tool_name=self.tool_name, message=str(error), original_error=error)
|
tunacode/types.py
CHANGED
|
@@ -13,7 +13,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Literal, Optional, Prot
|
|
|
13
13
|
# Try to import pydantic-ai types if available
|
|
14
14
|
try:
|
|
15
15
|
from pydantic_ai import Agent
|
|
16
|
-
from pydantic_ai.messages import ModelRequest,
|
|
16
|
+
from pydantic_ai.messages import ModelRequest, ToolReturnPart
|
|
17
17
|
|
|
18
18
|
PydanticAgent = Agent
|
|
19
19
|
MessagePart = Union[ToolReturnPart, Any]
|
tunacode/ui/completers.py
CHANGED
|
@@ -17,7 +17,7 @@ class CommandCompleter(Completer):
|
|
|
17
17
|
self.command_registry = command_registry
|
|
18
18
|
|
|
19
19
|
def get_completions(
|
|
20
|
-
self, document: Document,
|
|
20
|
+
self, document: Document, _complete_event: CompleteEvent
|
|
21
21
|
) -> Iterable[Completion]:
|
|
22
22
|
"""Get completions for slash commands."""
|
|
23
23
|
# Get the text before cursor
|
|
@@ -65,7 +65,7 @@ class FileReferenceCompleter(Completer):
|
|
|
65
65
|
"""Completer for @file references that provides file path suggestions."""
|
|
66
66
|
|
|
67
67
|
def get_completions(
|
|
68
|
-
self, document: Document,
|
|
68
|
+
self, document: Document, _complete_event: CompleteEvent
|
|
69
69
|
) -> Iterable[Completion]:
|
|
70
70
|
"""Get completions for @file references."""
|
|
71
71
|
# Get the word before cursor
|