todo-agent 0.2.9__py3-none-any.whl → 0.3.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.
- todo_agent/_version.py +2 -2
- todo_agent/core/conversation_manager.py +1 -1
- todo_agent/core/exceptions.py +54 -3
- todo_agent/core/todo_manager.py +142 -44
- todo_agent/infrastructure/calendar_utils.py +2 -4
- todo_agent/infrastructure/inference.py +99 -53
- todo_agent/infrastructure/llm_client.py +224 -1
- todo_agent/infrastructure/ollama_client.py +68 -77
- todo_agent/infrastructure/openrouter_client.py +75 -78
- todo_agent/infrastructure/prompts/system_prompt.txt +429 -401
- todo_agent/infrastructure/todo_shell.py +28 -28
- todo_agent/interface/cli.py +110 -18
- todo_agent/interface/formatters.py +22 -0
- todo_agent/interface/progress.py +58 -0
- todo_agent/interface/tools.py +211 -139
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/METADATA +3 -3
- todo_agent-0.3.2.dist-info/RECORD +30 -0
- todo_agent-0.2.9.dist-info/RECORD +0 -29
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/WHEEL +0 -0
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.2.9.dist-info → todo_agent-0.3.2.dist-info}/top_level.txt +0 -0
todo_agent/_version.py
CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
28
28
|
commit_id: COMMIT_ID
|
29
29
|
__commit_id__: COMMIT_ID
|
30
30
|
|
31
|
-
__version__ = version = '0.2
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
31
|
+
__version__ = version = '0.3.2'
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 2)
|
33
33
|
|
34
34
|
__commit_id__ = commit_id = None
|
@@ -35,7 +35,7 @@ class ConversationManager:
|
|
35
35
|
"""Manages conversation state and memory for LLM interactions."""
|
36
36
|
|
37
37
|
def __init__(
|
38
|
-
self, max_tokens: int =
|
38
|
+
self, max_tokens: int = 64000, max_messages: int = 100, model: str = "gpt-4"
|
39
39
|
):
|
40
40
|
self.history: List[ConversationMessage] = []
|
41
41
|
self.max_tokens = max_tokens
|
todo_agent/core/exceptions.py
CHANGED
@@ -12,16 +12,67 @@ class TodoError(Exception):
|
|
12
12
|
class TaskNotFoundError(TodoError):
|
13
13
|
"""Task not found in todo file."""
|
14
14
|
|
15
|
-
|
15
|
+
def __init__(self, message: str = "Task not found"):
|
16
|
+
super().__init__(message)
|
17
|
+
self.message = message
|
16
18
|
|
17
19
|
|
18
20
|
class InvalidTaskFormatError(TodoError):
|
19
21
|
"""Invalid task format."""
|
20
22
|
|
21
|
-
|
23
|
+
def __init__(self, message: str = "Invalid task format"):
|
24
|
+
super().__init__(message)
|
25
|
+
self.message = message
|
22
26
|
|
23
27
|
|
24
28
|
class TodoShellError(TodoError):
|
25
29
|
"""Subprocess execution error."""
|
26
30
|
|
27
|
-
|
31
|
+
def __init__(self, message: str = "Todo.sh command failed"):
|
32
|
+
super().__init__(message)
|
33
|
+
self.message = message
|
34
|
+
|
35
|
+
|
36
|
+
class ProviderError(Exception):
|
37
|
+
"""Base exception for LLM provider errors."""
|
38
|
+
|
39
|
+
def __init__(self, message: str, error_type: str, provider: str):
|
40
|
+
super().__init__(message)
|
41
|
+
self.message = message
|
42
|
+
self.error_type = error_type
|
43
|
+
self.provider = provider
|
44
|
+
|
45
|
+
|
46
|
+
class MalformedResponseError(ProviderError):
|
47
|
+
"""Provider returned malformed or invalid response."""
|
48
|
+
|
49
|
+
def __init__(self, message: str, provider: str):
|
50
|
+
super().__init__(message, "malformed_response", provider)
|
51
|
+
|
52
|
+
|
53
|
+
class RateLimitError(ProviderError):
|
54
|
+
"""Provider rate limit exceeded."""
|
55
|
+
|
56
|
+
def __init__(self, message: str, provider: str):
|
57
|
+
super().__init__(message, "rate_limit", provider)
|
58
|
+
|
59
|
+
|
60
|
+
class AuthenticationError(ProviderError):
|
61
|
+
"""Provider authentication failed."""
|
62
|
+
|
63
|
+
def __init__(self, message: str, provider: str):
|
64
|
+
super().__init__(message, "auth_error", provider)
|
65
|
+
|
66
|
+
|
67
|
+
class TimeoutError(ProviderError):
|
68
|
+
"""Provider request timed out."""
|
69
|
+
|
70
|
+
def __init__(self, message: str, provider: str):
|
71
|
+
super().__init__(message, "timeout", provider)
|
72
|
+
|
73
|
+
|
74
|
+
class GeneralProviderError(ProviderError):
|
75
|
+
"""General provider error."""
|
76
|
+
|
77
|
+
def __init__(self, message: str, provider: str):
|
78
|
+
super().__init__(message, "general_error", provider)
|
todo_agent/core/todo_manager.py
CHANGED
@@ -19,7 +19,7 @@ class TodoManager:
|
|
19
19
|
project: Optional[str] = None,
|
20
20
|
context: Optional[str] = None,
|
21
21
|
due: Optional[str] = None,
|
22
|
-
|
22
|
+
duration: Optional[str] = None,
|
23
23
|
) -> str:
|
24
24
|
"""Add new task with explicit project/context parameters."""
|
25
25
|
# Validate and sanitize inputs
|
@@ -55,32 +55,31 @@ class TodoManager:
|
|
55
55
|
f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
|
56
56
|
)
|
57
57
|
|
58
|
-
if
|
59
|
-
# Validate
|
60
|
-
if not
|
61
|
-
raise ValueError(
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
parts = recurring.split(":")
|
66
|
-
if len(parts) < 2 or len(parts) > 3:
|
58
|
+
if duration is not None:
|
59
|
+
# Validate duration format (e.g., "30m", "2h", "1d")
|
60
|
+
if not duration or not isinstance(duration, str):
|
61
|
+
raise ValueError("Duration must be a non-empty string.")
|
62
|
+
|
63
|
+
# Check if duration ends with a valid unit
|
64
|
+
if not any(duration.endswith(unit) for unit in ["m", "h", "d"]):
|
67
65
|
raise ValueError(
|
68
|
-
f"Invalid
|
66
|
+
f"Invalid duration format '{duration}'. Must end with m (minutes), h (hours), or d (days)."
|
69
67
|
)
|
70
|
-
|
71
|
-
|
68
|
+
|
69
|
+
# Extract the numeric part and validate it
|
70
|
+
value = duration[:-1]
|
71
|
+
if not value:
|
72
|
+
raise ValueError("Duration value cannot be empty.")
|
73
|
+
|
74
|
+
try:
|
75
|
+
# Check if the value is a valid positive number
|
76
|
+
numeric_value = float(value)
|
77
|
+
if numeric_value <= 0:
|
78
|
+
raise ValueError("Duration value must be positive.")
|
79
|
+
except ValueError:
|
72
80
|
raise ValueError(
|
73
|
-
f"Invalid
|
81
|
+
f"Invalid duration value '{value}'. Must be a positive number."
|
74
82
|
)
|
75
|
-
if len(parts) == 3:
|
76
|
-
try:
|
77
|
-
interval = int(parts[2])
|
78
|
-
if interval < 1:
|
79
|
-
raise ValueError("Interval must be at least 1.")
|
80
|
-
except ValueError:
|
81
|
-
raise ValueError(
|
82
|
-
f"Invalid interval '{parts[2]}'. Must be a positive integer."
|
83
|
-
)
|
84
83
|
|
85
84
|
# Build the full task description with priority, project, and context
|
86
85
|
full_description = description
|
@@ -97,8 +96,8 @@ class TodoManager:
|
|
97
96
|
if due:
|
98
97
|
full_description = f"{full_description} due:{due}"
|
99
98
|
|
100
|
-
if
|
101
|
-
full_description = f"{full_description} {
|
99
|
+
if duration:
|
100
|
+
full_description = f"{full_description} duration:{duration}"
|
102
101
|
|
103
102
|
self.todo_shell.add(full_description)
|
104
103
|
return f"Added task: {full_description}"
|
@@ -118,24 +117,6 @@ class TodoManager:
|
|
118
117
|
result = self.todo_shell.complete(task_number)
|
119
118
|
return f"Completed task {task_number}: {result}"
|
120
119
|
|
121
|
-
def get_overview(self, **kwargs: Any) -> str:
|
122
|
-
"""Show current task statistics."""
|
123
|
-
tasks = self.todo_shell.list_tasks()
|
124
|
-
completed = self.todo_shell.list_completed()
|
125
|
-
|
126
|
-
task_count = (
|
127
|
-
len([line for line in tasks.split("\n") if line.strip()])
|
128
|
-
if tasks.strip()
|
129
|
-
else 0
|
130
|
-
)
|
131
|
-
completed_count = (
|
132
|
-
len([line for line in completed.split("\n") if line.strip()])
|
133
|
-
if completed.strip()
|
134
|
-
else 0
|
135
|
-
)
|
136
|
-
|
137
|
-
return f"Task Overview:\n- Active tasks: {task_count}\n- Completed tasks: {completed_count}"
|
138
|
-
|
139
120
|
def replace_task(self, task_number: int, new_description: str) -> str:
|
140
121
|
"""Replace entire task content."""
|
141
122
|
result = self.todo_shell.replace(task_number, new_description)
|
@@ -373,4 +354,121 @@ class TodoManager:
|
|
373
354
|
def get_current_datetime(self, **kwargs: Any) -> str:
|
374
355
|
"""Get the current date and time."""
|
375
356
|
now = datetime.now()
|
376
|
-
|
357
|
+
week_number = now.isocalendar()[1]
|
358
|
+
timezone = now.astimezone().tzinfo
|
359
|
+
return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S')} {timezone} ({now.strftime('%A, %B %d, %Y at %I:%M %p')}) - Week {week_number}"
|
360
|
+
|
361
|
+
def created_completed_task(
|
362
|
+
self,
|
363
|
+
description: str,
|
364
|
+
completion_date: Optional[str] = None,
|
365
|
+
project: Optional[str] = None,
|
366
|
+
context: Optional[str] = None,
|
367
|
+
) -> str:
|
368
|
+
"""
|
369
|
+
Create a task and immediately mark it as completed.
|
370
|
+
|
371
|
+
This is a convenience method for handling "I did X on [date]" statements.
|
372
|
+
The task is created with the specified completion date and immediately marked complete.
|
373
|
+
|
374
|
+
Args:
|
375
|
+
description: The task description of what was completed
|
376
|
+
completion_date: Completion date in YYYY-MM-DD format (defaults to today)
|
377
|
+
project: Optional project name (without the + symbol)
|
378
|
+
context: Optional context name (without the @ symbol)
|
379
|
+
|
380
|
+
Returns:
|
381
|
+
Confirmation message with the completed task details
|
382
|
+
"""
|
383
|
+
# Set default completion date to today if not provided
|
384
|
+
if not completion_date:
|
385
|
+
completion_date = datetime.now().strftime("%Y-%m-%d")
|
386
|
+
|
387
|
+
# Validate completion date format
|
388
|
+
try:
|
389
|
+
datetime.strptime(completion_date, "%Y-%m-%d")
|
390
|
+
except ValueError:
|
391
|
+
raise ValueError(
|
392
|
+
f"Invalid completion date format '{completion_date}'. Must be YYYY-MM-DD."
|
393
|
+
)
|
394
|
+
|
395
|
+
# Build the task description with project and context
|
396
|
+
full_description = description
|
397
|
+
|
398
|
+
if project:
|
399
|
+
# Remove any existing + symbols to prevent duplication
|
400
|
+
clean_project = project.strip().lstrip("+")
|
401
|
+
if not clean_project:
|
402
|
+
raise ValueError(
|
403
|
+
"Project name cannot be empty after removing + symbol."
|
404
|
+
)
|
405
|
+
full_description = f"{full_description} +{clean_project}"
|
406
|
+
|
407
|
+
if context:
|
408
|
+
# Remove any existing @ symbols to prevent duplication
|
409
|
+
clean_context = context.strip().lstrip("@")
|
410
|
+
if not clean_context:
|
411
|
+
raise ValueError(
|
412
|
+
"Context name cannot be empty after removing @ symbol."
|
413
|
+
)
|
414
|
+
full_description = f"{full_description} @{clean_context}"
|
415
|
+
|
416
|
+
# Add the task first
|
417
|
+
self.todo_shell.add(full_description)
|
418
|
+
|
419
|
+
# Get the task number by finding the newly added task
|
420
|
+
tasks = self.todo_shell.list_tasks()
|
421
|
+
task_lines = [line.strip() for line in tasks.split("\n") if line.strip()]
|
422
|
+
if not task_lines:
|
423
|
+
raise RuntimeError("Failed to add task - no tasks found after addition")
|
424
|
+
|
425
|
+
# Find the task that matches our description (it should be the last one added)
|
426
|
+
# Look for the task that contains our description
|
427
|
+
task_number = None
|
428
|
+
for i, line in enumerate(task_lines, 1): # Start from 1 for todo.sh numbering
|
429
|
+
if description in line:
|
430
|
+
task_number = i
|
431
|
+
break
|
432
|
+
|
433
|
+
if task_number is None:
|
434
|
+
# Fallback: use the last task number if we can't find a match
|
435
|
+
task_number = len(task_lines)
|
436
|
+
# Log a warning that we're using fallback logic
|
437
|
+
import logging
|
438
|
+
|
439
|
+
logging.warning(
|
440
|
+
f"Could not find exact match for '{description}', using fallback task number {task_number}"
|
441
|
+
)
|
442
|
+
|
443
|
+
# Mark it as complete
|
444
|
+
self.todo_shell.complete(task_number)
|
445
|
+
|
446
|
+
return f"Created and completed task: {full_description} (completed on {completion_date})"
|
447
|
+
|
448
|
+
def restore_completed_task(self, task_number: int) -> str:
|
449
|
+
"""
|
450
|
+
Restore a completed task from done.txt back to todo.txt.
|
451
|
+
|
452
|
+
This method moves a completed task from done.txt back to todo.txt,
|
453
|
+
effectively restoring it to active status.
|
454
|
+
|
455
|
+
Args:
|
456
|
+
task_number: The line number of the completed task in done.txt to restore
|
457
|
+
|
458
|
+
Returns:
|
459
|
+
Confirmation message with the restored task details
|
460
|
+
"""
|
461
|
+
# Validate task number
|
462
|
+
if task_number <= 0:
|
463
|
+
raise ValueError("Task number must be a positive integer")
|
464
|
+
|
465
|
+
# Use the move command to restore the task from done.txt to todo.txt
|
466
|
+
result = self.todo_shell.move(task_number, "todo.txt", "done.txt")
|
467
|
+
|
468
|
+
# Extract the task description from the result for confirmation
|
469
|
+
# The result format is typically: "TODO: X moved from '.../done.txt' to '.../todo.txt'."
|
470
|
+
if "moved from" in result and "to" in result:
|
471
|
+
# Try to extract the task description if possible
|
472
|
+
return f"Restored completed task {task_number} to active status: {result}"
|
473
|
+
else:
|
474
|
+
return f"Restored completed task {task_number} to active status"
|
@@ -15,10 +15,8 @@ def get_calendar_output() -> str:
|
|
15
15
|
Formatted calendar string showing three months side by side
|
16
16
|
"""
|
17
17
|
try:
|
18
|
-
# Use cal
|
19
|
-
result = subprocess.run(
|
20
|
-
["cal", "-3"], capture_output=True, text=True, check=True
|
21
|
-
)
|
18
|
+
# Use cal to get current month calendar
|
19
|
+
result = subprocess.run(["cal"], capture_output=True, text=True, check=True)
|
22
20
|
return result.stdout.strip()
|
23
21
|
except (subprocess.SubprocessError, FileNotFoundError):
|
24
22
|
# Fallback to Python calendar module
|
@@ -4,6 +4,7 @@ LLM inference engine for todo.sh agent.
|
|
4
4
|
|
5
5
|
import os
|
6
6
|
import time
|
7
|
+
from datetime import datetime
|
7
8
|
from typing import Any, Dict, Optional
|
8
9
|
|
9
10
|
try:
|
@@ -12,6 +13,7 @@ try:
|
|
12
13
|
from todo_agent.infrastructure.llm_client_factory import LLMClientFactory
|
13
14
|
from todo_agent.infrastructure.logger import Logger
|
14
15
|
from todo_agent.interface.tools import ToolCallHandler
|
16
|
+
from todo_agent.interface.progress import ToolCallProgress, NoOpProgress
|
15
17
|
except ImportError:
|
16
18
|
from core.conversation_manager import ( # type: ignore[no-redef]
|
17
19
|
ConversationManager,
|
@@ -23,6 +25,7 @@ except ImportError:
|
|
23
25
|
)
|
24
26
|
from infrastructure.logger import Logger # type: ignore[no-redef]
|
25
27
|
from interface.tools import ToolCallHandler # type: ignore[no-redef]
|
28
|
+
from interface.progress import ToolCallProgress, NoOpProgress # type: ignore[no-redef]
|
26
29
|
|
27
30
|
|
28
31
|
class Inference:
|
@@ -67,13 +70,10 @@ class Inference:
|
|
67
70
|
|
68
71
|
def _load_system_prompt(self) -> str:
|
69
72
|
"""Load and format the system prompt from file."""
|
70
|
-
# Generate tools section programmatically
|
71
|
-
tools_section = self._generate_tools_section()
|
72
|
-
|
73
73
|
# Get current datetime for interpolation
|
74
|
-
|
75
|
-
|
76
|
-
current_datetime =
|
74
|
+
now = datetime.now()
|
75
|
+
timezone_info = time.tzname[time.daylight]
|
76
|
+
current_datetime = f"{now.strftime('%Y-%m-%d %H:%M:%S')} {timezone_info}"
|
77
77
|
|
78
78
|
# Get calendar output
|
79
79
|
from .calendar_utils import get_calendar_output
|
@@ -93,9 +93,8 @@ class Inference:
|
|
93
93
|
with open(prompt_file_path, encoding="utf-8") as f:
|
94
94
|
system_prompt_template = f.read()
|
95
95
|
|
96
|
-
# Format the template with
|
96
|
+
# Format the template with current datetime and calendar
|
97
97
|
return system_prompt_template.format(
|
98
|
-
tools_section=tools_section,
|
99
98
|
current_datetime=current_datetime,
|
100
99
|
calendar_output=calendar_output,
|
101
100
|
)
|
@@ -107,64 +106,28 @@ class Inference:
|
|
107
106
|
self.logger.error(f"Error loading system prompt: {e!s}")
|
108
107
|
raise
|
109
108
|
|
110
|
-
def _generate_tools_section(self) -> str:
|
111
|
-
"""Generate the AVAILABLE TOOLS section with strategic categorization."""
|
112
|
-
tool_categories = {
|
113
|
-
"Discovery Tools": [
|
114
|
-
"list_projects",
|
115
|
-
"list_contexts",
|
116
|
-
"list_tasks",
|
117
|
-
"list_completed_tasks",
|
118
|
-
],
|
119
|
-
"Modification Tools": [
|
120
|
-
"add_task",
|
121
|
-
"complete_task",
|
122
|
-
"replace_task",
|
123
|
-
"append_to_task",
|
124
|
-
"prepend_to_task",
|
125
|
-
],
|
126
|
-
"Management Tools": [
|
127
|
-
"delete_task",
|
128
|
-
"set_priority",
|
129
|
-
"remove_priority",
|
130
|
-
"move_task",
|
131
|
-
],
|
132
|
-
"Maintenance Tools": ["archive_tasks", "deduplicate_tasks", "get_overview"],
|
133
|
-
}
|
134
|
-
|
135
|
-
tools_section = []
|
136
|
-
for category, tool_names in tool_categories.items():
|
137
|
-
tools_section.append(f"\n**{category}:**")
|
138
|
-
for tool_name in tool_names:
|
139
|
-
tool_info = next(
|
140
|
-
(
|
141
|
-
t
|
142
|
-
for t in self.tool_handler.tools
|
143
|
-
if t["function"]["name"] == tool_name
|
144
|
-
),
|
145
|
-
None,
|
146
|
-
)
|
147
|
-
if tool_info:
|
148
|
-
# Get first sentence of description for concise overview
|
149
|
-
first_sentence = (
|
150
|
-
tool_info["function"]["description"].split(".")[0] + "."
|
151
|
-
)
|
152
|
-
tools_section.append(f"- {tool_name}(): {first_sentence}")
|
153
109
|
|
154
|
-
return "\n".join(tools_section)
|
155
110
|
|
156
|
-
def process_request(self, user_input: str) -> tuple[str, float]:
|
111
|
+
def process_request(self, user_input: str, progress_callback: Optional[ToolCallProgress] = None) -> tuple[str, float]:
|
157
112
|
"""
|
158
113
|
Process a user request through the LLM with tool orchestration.
|
159
114
|
|
160
115
|
Args:
|
161
116
|
user_input: Natural language user request
|
117
|
+
progress_callback: Optional progress callback for tool call tracking
|
162
118
|
|
163
119
|
Returns:
|
164
120
|
Tuple of (formatted response for user, thinking time in seconds)
|
165
121
|
"""
|
166
122
|
# Start timing the request
|
167
123
|
start_time = time.time()
|
124
|
+
|
125
|
+
# Initialize progress callback if not provided
|
126
|
+
if progress_callback is None:
|
127
|
+
progress_callback = NoOpProgress()
|
128
|
+
|
129
|
+
# Notify progress callback that thinking has started
|
130
|
+
progress_callback.on_thinking_start()
|
168
131
|
|
169
132
|
try:
|
170
133
|
self.logger.debug(
|
@@ -187,6 +150,30 @@ class Inference:
|
|
187
150
|
messages=messages, tools=self.tool_handler.tools
|
188
151
|
)
|
189
152
|
|
153
|
+
# Check for provider errors
|
154
|
+
if response.get("error", False):
|
155
|
+
error_type = response.get("error_type", "general_error")
|
156
|
+
provider = response.get("provider", "unknown")
|
157
|
+
self.logger.error(f"Provider error from {provider}: {error_type}")
|
158
|
+
|
159
|
+
# Import here to avoid circular imports
|
160
|
+
try:
|
161
|
+
from todo_agent.interface.formatters import get_provider_error_message
|
162
|
+
error_message = get_provider_error_message(error_type)
|
163
|
+
except ImportError:
|
164
|
+
from interface.formatters import get_provider_error_message
|
165
|
+
error_message = get_provider_error_message(error_type)
|
166
|
+
|
167
|
+
# Add error message to conversation
|
168
|
+
self.conversation_manager.add_message(MessageRole.ASSISTANT, error_message)
|
169
|
+
|
170
|
+
# Calculate thinking time and return
|
171
|
+
end_time = time.time()
|
172
|
+
thinking_time = end_time - start_time
|
173
|
+
progress_callback.on_thinking_complete(thinking_time)
|
174
|
+
|
175
|
+
return error_message, thinking_time
|
176
|
+
|
190
177
|
# Extract actual token usage from API response
|
191
178
|
usage = response.get("usage", {})
|
192
179
|
actual_prompt_tokens = usage.get("prompt_tokens", 0)
|
@@ -201,6 +188,8 @@ class Inference:
|
|
201
188
|
|
202
189
|
# Handle multiple tool calls in sequence
|
203
190
|
tool_call_count = 0
|
191
|
+
total_sequences = 0 # We'll track this as we go
|
192
|
+
|
204
193
|
while True:
|
205
194
|
tool_calls = self.llm_client.extract_tool_calls(response)
|
206
195
|
|
@@ -211,6 +200,9 @@ class Inference:
|
|
211
200
|
self.logger.debug(
|
212
201
|
f"Executing tool call sequence #{tool_call_count} with {len(tool_calls)} tools"
|
213
202
|
)
|
203
|
+
|
204
|
+
# Notify progress callback of sequence start
|
205
|
+
progress_callback.on_sequence_complete(tool_call_count, 0) # We don't know total yet
|
214
206
|
|
215
207
|
# Execute all tool calls and collect results
|
216
208
|
tool_results = []
|
@@ -224,6 +216,14 @@ class Inference:
|
|
224
216
|
self.logger.debug(f"Tool Call ID: {tool_call_id}")
|
225
217
|
self.logger.debug(f"Raw tool call: {tool_call}")
|
226
218
|
|
219
|
+
# Get progress description for the tool
|
220
|
+
progress_description = self._get_tool_progress_description(tool_name)
|
221
|
+
|
222
|
+
# Notify progress callback of tool call start
|
223
|
+
progress_callback.on_tool_call_start(
|
224
|
+
tool_name, progress_description, tool_call_count, 0 # We don't know total yet
|
225
|
+
)
|
226
|
+
|
227
227
|
result = self.tool_handler.execute_tool(tool_call)
|
228
228
|
|
229
229
|
# Log tool execution result (success or error)
|
@@ -249,6 +249,30 @@ class Inference:
|
|
249
249
|
messages=messages, tools=self.tool_handler.tools
|
250
250
|
)
|
251
251
|
|
252
|
+
# Check for provider errors in continuation
|
253
|
+
if response.get("error", False):
|
254
|
+
error_type = response.get("error_type", "general_error")
|
255
|
+
provider = response.get("provider", "unknown")
|
256
|
+
self.logger.error(f"Provider error in continuation from {provider}: {error_type}")
|
257
|
+
|
258
|
+
# Import here to avoid circular imports
|
259
|
+
try:
|
260
|
+
from todo_agent.interface.formatters import get_provider_error_message
|
261
|
+
error_message = get_provider_error_message(error_type)
|
262
|
+
except ImportError:
|
263
|
+
from interface.formatters import get_provider_error_message
|
264
|
+
error_message = get_provider_error_message(error_type)
|
265
|
+
|
266
|
+
# Add error message to conversation
|
267
|
+
self.conversation_manager.add_message(MessageRole.ASSISTANT, error_message)
|
268
|
+
|
269
|
+
# Calculate thinking time and return
|
270
|
+
end_time = time.time()
|
271
|
+
thinking_time = end_time - start_time
|
272
|
+
progress_callback.on_thinking_complete(thinking_time)
|
273
|
+
|
274
|
+
return error_message, thinking_time
|
275
|
+
|
252
276
|
# Update with actual tokens from subsequent API calls
|
253
277
|
usage = response.get("usage", {})
|
254
278
|
actual_prompt_tokens = usage.get("prompt_tokens", 0)
|
@@ -260,6 +284,9 @@ class Inference:
|
|
260
284
|
# Calculate and log total thinking time
|
261
285
|
end_time = time.time()
|
262
286
|
thinking_time = end_time - start_time
|
287
|
+
|
288
|
+
# Notify progress callback that thinking is complete
|
289
|
+
progress_callback.on_thinking_complete(thinking_time)
|
263
290
|
|
264
291
|
# Add final assistant response to conversation with thinking time
|
265
292
|
final_content = self.llm_client.extract_content(response)
|
@@ -294,6 +321,25 @@ class Inference:
|
|
294
321
|
self.tool_handler.tools
|
295
322
|
)
|
296
323
|
|
324
|
+
def _get_tool_progress_description(self, tool_name: str) -> str:
|
325
|
+
"""
|
326
|
+
Get user-friendly progress description for a tool.
|
327
|
+
|
328
|
+
Args:
|
329
|
+
tool_name: Name of the tool
|
330
|
+
|
331
|
+
Returns:
|
332
|
+
Progress description string
|
333
|
+
"""
|
334
|
+
tool_def = next((t for t in self.tool_handler.tools
|
335
|
+
if t.get("function", {}).get("name") == tool_name), None)
|
336
|
+
|
337
|
+
if tool_def and "progress_description" in tool_def:
|
338
|
+
return tool_def["progress_description"]
|
339
|
+
|
340
|
+
# Fallback to generic description
|
341
|
+
return f"🔧 Executing {tool_name}..."
|
342
|
+
|
297
343
|
def clear_conversation(self) -> None:
|
298
344
|
"""Clear conversation history."""
|
299
345
|
self.conversation_manager.clear_conversation()
|