todo-agent 0.2.6__py3-none-any.whl → 0.2.9__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 +57 -6
- todo_agent/core/todo_manager.py +139 -0
- todo_agent/infrastructure/inference.py +23 -1
- todo_agent/infrastructure/ollama_client.py +2 -2
- todo_agent/infrastructure/openrouter_client.py +26 -20
- todo_agent/infrastructure/prompts/system_prompt.txt +413 -160
- todo_agent/infrastructure/todo_shell.py +332 -9
- todo_agent/interface/cli.py +8 -8
- todo_agent/interface/formatters.py +3 -9
- todo_agent/interface/tools.py +265 -9
- {todo_agent-0.2.6.dist-info → todo_agent-0.2.9.dist-info}/METADATA +33 -65
- todo_agent-0.2.9.dist-info/RECORD +29 -0
- todo_agent-0.2.6.dist-info/RECORD +0 -29
- {todo_agent-0.2.6.dist-info → todo_agent-0.2.9.dist-info}/WHEEL +0 -0
- {todo_agent-0.2.6.dist-info → todo_agent-0.2.9.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.2.6.dist-info → todo_agent-0.2.9.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.2.6.dist-info → todo_agent-0.2.9.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, 2,
|
31
|
+
__version__ = version = '0.2.9'
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 9)
|
33
33
|
|
34
34
|
__commit_id__ = commit_id = None
|
@@ -35,14 +35,16 @@ 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 = 32000, max_messages: int = 50, model: str = "gpt-4"
|
39
39
|
):
|
40
40
|
self.history: List[ConversationMessage] = []
|
41
41
|
self.max_tokens = max_tokens
|
42
42
|
self.max_messages = max_messages
|
43
43
|
self.system_prompt: Optional[str] = None
|
44
44
|
self.token_counter = get_token_counter(model)
|
45
|
-
self._total_tokens = 0 # Running total of tokens in conversation
|
45
|
+
self._total_tokens: int = 0 # Running total of tokens in conversation
|
46
|
+
self._tools_tokens: int = 0 # Cache for tools token count
|
47
|
+
self._last_tools_hash: Optional[int] = None # Track if tools have changed
|
46
48
|
|
47
49
|
def add_message(
|
48
50
|
self,
|
@@ -140,10 +142,44 @@ class ConversationManager:
|
|
140
142
|
self._total_tokens -= message.token_count
|
141
143
|
self.history.pop(index)
|
142
144
|
|
143
|
-
def
|
145
|
+
def get_request_tokens(self, tools: List[Dict[str, Any]]) -> int:
|
146
|
+
"""
|
147
|
+
Get total request tokens (conversation + tools).
|
148
|
+
|
149
|
+
Args:
|
150
|
+
tools: Current tool definitions
|
151
|
+
|
152
|
+
Returns:
|
153
|
+
Total request tokens
|
154
|
+
"""
|
155
|
+
# Check if tools have changed
|
156
|
+
tools_hash = hash(str(tools))
|
157
|
+
if tools_hash != self._last_tools_hash:
|
158
|
+
self._tools_tokens = self.token_counter.count_tools_tokens(tools)
|
159
|
+
self._last_tools_hash = tools_hash
|
160
|
+
|
161
|
+
return self._total_tokens + self._tools_tokens
|
162
|
+
|
163
|
+
def update_request_tokens(self, actual_prompt_tokens: int) -> None:
|
164
|
+
"""
|
165
|
+
Update the conversation with actual token count from API response.
|
166
|
+
|
167
|
+
Args:
|
168
|
+
actual_prompt_tokens: Actual prompt tokens used by the API
|
169
|
+
"""
|
170
|
+
# Calculate tools tokens by subtracting conversation tokens
|
171
|
+
tools_tokens = actual_prompt_tokens - self._total_tokens
|
172
|
+
if tools_tokens >= 0:
|
173
|
+
self._tools_tokens = tools_tokens
|
174
|
+
# Note: logger not available in this context, so we'll handle logging in the calling code
|
175
|
+
|
176
|
+
def _trim_if_needed(self, tools: Optional[List[Dict[str, Any]]] = None) -> None:
|
144
177
|
"""
|
145
178
|
Trim conversation history if it exceeds token or message limits.
|
146
179
|
Preserves most recent messages and system prompt.
|
180
|
+
|
181
|
+
Args:
|
182
|
+
tools: Optional tools for request token calculation
|
147
183
|
"""
|
148
184
|
# Check message count limit
|
149
185
|
if len(self.history) > self.max_messages:
|
@@ -160,8 +196,10 @@ class ConversationManager:
|
|
160
196
|
# Recalculate total tokens after message count trimming
|
161
197
|
self._recalculate_total_tokens()
|
162
198
|
|
163
|
-
# Check token limit
|
164
|
-
|
199
|
+
# Check token limit using request tokens if tools provided
|
200
|
+
current_tokens = self.get_request_tokens(tools) if tools else self._total_tokens
|
201
|
+
|
202
|
+
while current_tokens > self.max_tokens and len(self.history) > 2:
|
165
203
|
# Find oldest non-system message to remove
|
166
204
|
for i, msg in enumerate(self.history):
|
167
205
|
if msg.role != MessageRole.SYSTEM:
|
@@ -171,6 +209,11 @@ class ConversationManager:
|
|
171
209
|
# No non-system messages found, break to avoid infinite loop
|
172
210
|
break
|
173
211
|
|
212
|
+
# Recalculate request tokens
|
213
|
+
current_tokens = (
|
214
|
+
self.get_request_tokens(tools) if tools else self._total_tokens
|
215
|
+
)
|
216
|
+
|
174
217
|
def _recalculate_total_tokens(self) -> None:
|
175
218
|
"""Recalculate total token count from scratch (used after major restructuring)."""
|
176
219
|
self._total_tokens = 0
|
@@ -219,10 +262,15 @@ class ConversationManager:
|
|
219
262
|
self.history.insert(0, system_message)
|
220
263
|
self._total_tokens += token_count
|
221
264
|
|
222
|
-
def get_conversation_summary(
|
265
|
+
def get_conversation_summary(
|
266
|
+
self, tools: Optional[List[Dict[str, Any]]] = None
|
267
|
+
) -> Dict[str, Any]:
|
223
268
|
"""
|
224
269
|
Get conversation statistics and summary.
|
225
270
|
|
271
|
+
Args:
|
272
|
+
tools: Optional tools for request token calculation
|
273
|
+
|
226
274
|
Returns:
|
227
275
|
Dictionary with conversation metrics
|
228
276
|
"""
|
@@ -256,6 +304,9 @@ class ConversationManager:
|
|
256
304
|
return {
|
257
305
|
"total_messages": len(self.history),
|
258
306
|
"estimated_tokens": self._total_tokens,
|
307
|
+
"request_tokens": self.get_request_tokens(tools)
|
308
|
+
if tools
|
309
|
+
else self._total_tokens,
|
259
310
|
"user_messages": len(
|
260
311
|
[msg for msg in self.history if msg.role == MessageRole.USER]
|
261
312
|
),
|
todo_agent/core/todo_manager.py
CHANGED
@@ -19,6 +19,7 @@ class TodoManager:
|
|
19
19
|
project: Optional[str] = None,
|
20
20
|
context: Optional[str] = None,
|
21
21
|
due: Optional[str] = None,
|
22
|
+
recurring: Optional[str] = None,
|
22
23
|
) -> str:
|
23
24
|
"""Add new task with explicit project/context parameters."""
|
24
25
|
# Validate and sanitize inputs
|
@@ -54,6 +55,33 @@ class TodoManager:
|
|
54
55
|
f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
|
55
56
|
)
|
56
57
|
|
58
|
+
if recurring:
|
59
|
+
# Validate recurring format
|
60
|
+
if not recurring.startswith("rec:"):
|
61
|
+
raise ValueError(
|
62
|
+
f"Invalid recurring format '{recurring}'. Must start with 'rec:'."
|
63
|
+
)
|
64
|
+
# Basic validation of recurring syntax
|
65
|
+
parts = recurring.split(":")
|
66
|
+
if len(parts) < 2 or len(parts) > 3:
|
67
|
+
raise ValueError(
|
68
|
+
f"Invalid recurring format '{recurring}'. Expected 'rec:frequency' or 'rec:frequency:interval'."
|
69
|
+
)
|
70
|
+
frequency = parts[1]
|
71
|
+
if frequency not in ["daily", "weekly", "monthly", "yearly"]:
|
72
|
+
raise ValueError(
|
73
|
+
f"Invalid frequency '{frequency}'. Must be one of: daily, weekly, monthly, yearly."
|
74
|
+
)
|
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
|
+
|
57
85
|
# Build the full task description with priority, project, and context
|
58
86
|
full_description = description
|
59
87
|
|
@@ -69,6 +97,9 @@ class TodoManager:
|
|
69
97
|
if due:
|
70
98
|
full_description = f"{full_description} due:{due}"
|
71
99
|
|
100
|
+
if recurring:
|
101
|
+
full_description = f"{full_description} {recurring}"
|
102
|
+
|
72
103
|
self.todo_shell.add(full_description)
|
73
104
|
return f"Added task: {full_description}"
|
74
105
|
|
@@ -138,6 +169,114 @@ class TodoManager:
|
|
138
169
|
result = self.todo_shell.remove_priority(task_number)
|
139
170
|
return f"Removed priority from task {task_number}: {result}"
|
140
171
|
|
172
|
+
def set_due_date(self, task_number: int, due_date: str) -> str:
|
173
|
+
"""
|
174
|
+
Set or update due date for a task by intelligently rewriting it.
|
175
|
+
|
176
|
+
Args:
|
177
|
+
task_number: The task number to modify
|
178
|
+
due_date: Due date in YYYY-MM-DD format, or empty string to remove due date
|
179
|
+
|
180
|
+
Returns:
|
181
|
+
Confirmation message with the updated task
|
182
|
+
"""
|
183
|
+
# Validate due date format only if not empty
|
184
|
+
if due_date.strip():
|
185
|
+
try:
|
186
|
+
datetime.strptime(due_date, "%Y-%m-%d")
|
187
|
+
except ValueError:
|
188
|
+
raise ValueError(
|
189
|
+
f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
|
190
|
+
)
|
191
|
+
|
192
|
+
result = self.todo_shell.set_due_date(task_number, due_date)
|
193
|
+
if due_date.strip():
|
194
|
+
return f"Set due date {due_date} for task {task_number}: {result}"
|
195
|
+
else:
|
196
|
+
return f"Removed due date from task {task_number}: {result}"
|
197
|
+
|
198
|
+
def set_context(self, task_number: int, context: str) -> str:
|
199
|
+
"""
|
200
|
+
Set or update context for a task by intelligently rewriting it.
|
201
|
+
|
202
|
+
Args:
|
203
|
+
task_number: The task number to modify
|
204
|
+
context: Context name (without @ symbol), or empty string to remove context
|
205
|
+
|
206
|
+
Returns:
|
207
|
+
Confirmation message with the updated task
|
208
|
+
"""
|
209
|
+
# Validate context name if not empty
|
210
|
+
if context.strip():
|
211
|
+
# Remove any existing @ symbols to prevent duplication
|
212
|
+
clean_context = context.strip().lstrip("@")
|
213
|
+
if not clean_context:
|
214
|
+
raise ValueError(
|
215
|
+
"Context name cannot be empty after removing @ symbol."
|
216
|
+
)
|
217
|
+
|
218
|
+
result = self.todo_shell.set_context(task_number, context)
|
219
|
+
if context.strip():
|
220
|
+
clean_context = context.strip().lstrip("@")
|
221
|
+
return f"Set context @{clean_context} for task {task_number}: {result}"
|
222
|
+
else:
|
223
|
+
return f"Removed context from task {task_number}: {result}"
|
224
|
+
|
225
|
+
def set_project(self, task_number: int, projects: list) -> str:
|
226
|
+
"""
|
227
|
+
Set or update projects for a task by intelligently rewriting it.
|
228
|
+
|
229
|
+
Args:
|
230
|
+
task_number: The task number to modify
|
231
|
+
projects: List of project operations. Each item can be:
|
232
|
+
- "project" (add project)
|
233
|
+
- "-project" (remove project)
|
234
|
+
- Empty string removes all projects
|
235
|
+
|
236
|
+
Returns:
|
237
|
+
Confirmation message with the updated task
|
238
|
+
"""
|
239
|
+
# Validate project names if not empty
|
240
|
+
if projects:
|
241
|
+
for project in projects:
|
242
|
+
if project.strip() and not project.startswith("-"):
|
243
|
+
# Remove any existing + symbols to prevent duplication
|
244
|
+
clean_project = project.strip().lstrip("+")
|
245
|
+
if not clean_project:
|
246
|
+
raise ValueError(
|
247
|
+
"Project name cannot be empty after removing + symbol."
|
248
|
+
)
|
249
|
+
elif project.startswith("-"):
|
250
|
+
clean_project = project[1:].strip().lstrip("+")
|
251
|
+
if not clean_project:
|
252
|
+
raise ValueError(
|
253
|
+
"Project name cannot be empty after removing - and + symbols."
|
254
|
+
)
|
255
|
+
|
256
|
+
result = self.todo_shell.set_project(task_number, projects)
|
257
|
+
|
258
|
+
if not projects:
|
259
|
+
return f"No project changes made to task {task_number}: {result}"
|
260
|
+
else:
|
261
|
+
# Build operation description
|
262
|
+
operations = []
|
263
|
+
for project in projects:
|
264
|
+
if not project.strip():
|
265
|
+
# Empty string is a NOOP - skip
|
266
|
+
continue
|
267
|
+
elif project.startswith("-"):
|
268
|
+
clean_project = project[1:].strip().lstrip("+")
|
269
|
+
operations.append(f"removed +{clean_project}")
|
270
|
+
else:
|
271
|
+
clean_project = project.strip().lstrip("+")
|
272
|
+
operations.append(f"added +{clean_project}")
|
273
|
+
|
274
|
+
if not operations:
|
275
|
+
return f"No project changes made to task {task_number}: {result}"
|
276
|
+
else:
|
277
|
+
operation_desc = ", ".join(operations)
|
278
|
+
return f"Updated projects for task {task_number} ({operation_desc}): {result}"
|
279
|
+
|
141
280
|
def list_projects(self, **kwargs: Any) -> str:
|
142
281
|
"""List all available projects in todo.txt."""
|
143
282
|
result = self.todo_shell.list_projects()
|
@@ -187,6 +187,18 @@ class Inference:
|
|
187
187
|
messages=messages, tools=self.tool_handler.tools
|
188
188
|
)
|
189
189
|
|
190
|
+
# Extract actual token usage from API response
|
191
|
+
usage = response.get("usage", {})
|
192
|
+
actual_prompt_tokens = usage.get("prompt_tokens", 0)
|
193
|
+
actual_completion_tokens = usage.get("completion_tokens", 0)
|
194
|
+
actual_total_tokens = usage.get("total_tokens", 0)
|
195
|
+
|
196
|
+
# Update conversation manager with actual token count
|
197
|
+
self.conversation_manager.update_request_tokens(actual_prompt_tokens)
|
198
|
+
self.logger.debug(
|
199
|
+
f"Updated with actual API tokens: prompt={actual_prompt_tokens}, completion={actual_completion_tokens}, total={actual_total_tokens}"
|
200
|
+
)
|
201
|
+
|
190
202
|
# Handle multiple tool calls in sequence
|
191
203
|
tool_call_count = 0
|
192
204
|
while True:
|
@@ -237,6 +249,14 @@ class Inference:
|
|
237
249
|
messages=messages, tools=self.tool_handler.tools
|
238
250
|
)
|
239
251
|
|
252
|
+
# Update with actual tokens from subsequent API calls
|
253
|
+
usage = response.get("usage", {})
|
254
|
+
actual_prompt_tokens = usage.get("prompt_tokens", 0)
|
255
|
+
self.conversation_manager.update_request_tokens(actual_prompt_tokens)
|
256
|
+
self.logger.debug(
|
257
|
+
f"Updated with actual API tokens after tool calls: prompt={actual_prompt_tokens}"
|
258
|
+
)
|
259
|
+
|
240
260
|
# Calculate and log total thinking time
|
241
261
|
end_time = time.time()
|
242
262
|
thinking_time = end_time - start_time
|
@@ -270,7 +290,9 @@ class Inference:
|
|
270
290
|
Returns:
|
271
291
|
Dictionary with conversation metrics
|
272
292
|
"""
|
273
|
-
return self.conversation_manager.get_conversation_summary(
|
293
|
+
return self.conversation_manager.get_conversation_summary(
|
294
|
+
self.tool_handler.tools
|
295
|
+
)
|
274
296
|
|
275
297
|
def clear_conversation(self) -> None:
|
276
298
|
"""Clear conversation history."""
|
@@ -72,12 +72,12 @@ class OllamaClient(LLMClient):
|
|
72
72
|
if "message" in response and "tool_calls" in response["message"]:
|
73
73
|
tool_calls = response["message"]["tool_calls"]
|
74
74
|
self.logger.info(f"Response contains {len(tool_calls)} tool calls")
|
75
|
-
|
75
|
+
|
76
76
|
# Log thinking content (response body) if present
|
77
77
|
content = response["message"].get("content", "")
|
78
78
|
if content and content.strip():
|
79
79
|
self.logger.info(f"LLM thinking before tool calls: {content}")
|
80
|
-
|
80
|
+
|
81
81
|
for i, tool_call in enumerate(tool_calls):
|
82
82
|
tool_name = tool_call.get("function", {}).get("name", "unknown")
|
83
83
|
self.logger.info(f" Tool call {i + 1}: {tool_name}")
|
@@ -72,26 +72,32 @@ class OpenRouterClient(LLMClient):
|
|
72
72
|
f"Token usage - Prompt: {prompt_tokens}, Completion: {completion_tokens}, Total: {total_tokens}"
|
73
73
|
)
|
74
74
|
|
75
|
-
#
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
75
|
+
# Extract and log choice details
|
76
|
+
choices = response.get("choices", [])
|
77
|
+
if not choices:
|
78
|
+
return
|
79
|
+
|
80
|
+
choice = choices[0]
|
81
|
+
message = choice.get("message", {})
|
82
|
+
|
83
|
+
# Always log reasoning and content if present
|
84
|
+
reasoning = message.get("reasoning", "")
|
85
|
+
if reasoning:
|
86
|
+
self.logger.info(f"LLM reasoning: {reasoning}")
|
87
|
+
|
88
|
+
content = message.get("content", "")
|
89
|
+
if content:
|
90
|
+
self.logger.info(f"LLM content: {content}")
|
91
|
+
|
92
|
+
# Handle tool calls
|
93
|
+
tool_calls = message.get("tool_calls", [])
|
94
|
+
if tool_calls:
|
95
|
+
self.logger.info(f"Response contains {len(tool_calls)} tool calls")
|
96
|
+
|
97
|
+
# Log each tool call
|
98
|
+
for i, tool_call in enumerate(tool_calls, 1):
|
99
|
+
tool_name = tool_call.get("function", {}).get("name", "unknown")
|
100
|
+
self.logger.info(f" Tool call {i}: {tool_name}")
|
95
101
|
|
96
102
|
self.logger.debug(f"Raw response: {json.dumps(response, indent=2)}")
|
97
103
|
|