todo-agent 0.2.4__tar.gz → 0.2.6__tar.gz
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-0.2.4 → todo_agent-0.2.6}/PKG-INFO +1 -1
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_core/test_todo_manager.py +57 -0
- todo_agent-0.2.6/tests/test_infrastructure/test_calendar_utils.py +81 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_interface/test_cli.py +45 -0
- todo_agent-0.2.6/tests/test_interface/test_formatters.py +75 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_interface/test_tools.py +59 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/_version.py +3 -3
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/core/todo_manager.py +33 -0
- todo_agent-0.2.6/todo_agent/infrastructure/calendar_utils.py +64 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/inference.py +13 -2
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/llm_client_factory.py +4 -2
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/ollama_client.py +6 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/openrouter_client.py +6 -0
- todo_agent-0.2.6/todo_agent/infrastructure/prompts/system_prompt.txt +160 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/interface/cli.py +31 -11
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/interface/formatters.py +144 -48
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/interface/tools.py +80 -8
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent.egg-info/PKG-INFO +1 -1
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent.egg-info/SOURCES.txt +3 -0
- todo_agent-0.2.4/todo_agent/infrastructure/prompts/system_prompt.txt +0 -51
- {todo_agent-0.2.4 → todo_agent-0.2.6}/.gitignore +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/LICENSE +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/MANIFEST.in +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/Makefile +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/README.md +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/docs/publishing.md +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/pyproject.toml +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/requirements-dev.txt +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/requirements.txt +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/setup.cfg +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_core/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_core/test_conversation_manager.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/test_config.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/test_inference.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/test_llm_client_factory.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/test_ollama_client.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/test_openrouter_client.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/test_todo_shell.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_infrastructure/test_token_counter.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_interface/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_linting.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_logger.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/tests/test_main.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/core/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/core/conversation_manager.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/core/exceptions.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/config.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/llm_client.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/logger.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/todo_shell.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/infrastructure/token_counter.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/interface/__init__.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent/main.py +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent.egg-info/dependency_links.txt +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent.egg-info/entry_points.txt +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent.egg-info/requires.txt +0 -0
- {todo_agent-0.2.4 → todo_agent-0.2.6}/todo_agent.egg-info/top_level.txt +0 -0
@@ -32,6 +32,63 @@ class TestTodoManager(unittest.TestCase):
|
|
32
32
|
self.assertEqual(result, "Added task: Test task")
|
33
33
|
self.todo_shell.add.assert_called_once_with("Test task")
|
34
34
|
|
35
|
+
def test_add_task_with_invalid_priority(self):
|
36
|
+
"""Test adding a task with invalid priority raises ValueError."""
|
37
|
+
with self.assertRaises(ValueError) as context:
|
38
|
+
self.todo_manager.add_task("Test task", priority="invalid")
|
39
|
+
self.assertIn("Invalid priority", str(context.exception))
|
40
|
+
|
41
|
+
def test_add_task_with_invalid_priority_lowercase(self):
|
42
|
+
"""Test adding a task with lowercase priority raises ValueError."""
|
43
|
+
with self.assertRaises(ValueError) as context:
|
44
|
+
self.todo_manager.add_task("Test task", priority="a")
|
45
|
+
self.assertIn("Invalid priority", str(context.exception))
|
46
|
+
|
47
|
+
def test_add_task_with_invalid_priority_multiple_chars(self):
|
48
|
+
"""Test adding a task with multiple character priority raises ValueError."""
|
49
|
+
with self.assertRaises(ValueError) as context:
|
50
|
+
self.todo_manager.add_task("Test task", priority="AB")
|
51
|
+
self.assertIn("Invalid priority", str(context.exception))
|
52
|
+
|
53
|
+
def test_add_task_sanitizes_project_with_plus(self):
|
54
|
+
"""Test that project with existing + symbol is properly sanitized."""
|
55
|
+
self.todo_shell.add.return_value = "Test task"
|
56
|
+
result = self.todo_manager.add_task("Test task", project="+work")
|
57
|
+
self.assertEqual(result, "Added task: Test task +work")
|
58
|
+
self.todo_shell.add.assert_called_once_with("Test task +work")
|
59
|
+
|
60
|
+
def test_add_task_sanitizes_context_with_at(self):
|
61
|
+
"""Test that context with existing @ symbol is properly sanitized."""
|
62
|
+
self.todo_shell.add.return_value = "Test task"
|
63
|
+
result = self.todo_manager.add_task("Test task", context="@office")
|
64
|
+
self.assertEqual(result, "Added task: Test task @office")
|
65
|
+
self.todo_shell.add.assert_called_once_with("Test task @office")
|
66
|
+
|
67
|
+
def test_add_task_with_empty_project_after_sanitization(self):
|
68
|
+
"""Test adding a task with empty project after removing + raises ValueError."""
|
69
|
+
with self.assertRaises(ValueError) as context:
|
70
|
+
self.todo_manager.add_task("Test task", project="+")
|
71
|
+
self.assertIn("Project name cannot be empty", str(context.exception))
|
72
|
+
|
73
|
+
def test_add_task_with_empty_context_after_sanitization(self):
|
74
|
+
"""Test adding a task with empty context after removing @ raises ValueError."""
|
75
|
+
with self.assertRaises(ValueError) as context:
|
76
|
+
self.todo_manager.add_task("Test task", context="@")
|
77
|
+
self.assertIn("Context name cannot be empty", str(context.exception))
|
78
|
+
|
79
|
+
def test_add_task_with_invalid_due_date(self):
|
80
|
+
"""Test adding a task with invalid due date format raises ValueError."""
|
81
|
+
with self.assertRaises(ValueError) as context:
|
82
|
+
self.todo_manager.add_task("Test task", due="invalid-date")
|
83
|
+
self.assertIn("Invalid due date format", str(context.exception))
|
84
|
+
|
85
|
+
def test_add_task_with_valid_due_date(self):
|
86
|
+
"""Test adding a task with valid due date format."""
|
87
|
+
self.todo_shell.add.return_value = "Test task"
|
88
|
+
result = self.todo_manager.add_task("Test task", due="2024-01-15")
|
89
|
+
self.assertEqual(result, "Added task: Test task due:2024-01-15")
|
90
|
+
self.todo_shell.add.assert_called_once_with("Test task due:2024-01-15")
|
91
|
+
|
35
92
|
def test_list_tasks(self):
|
36
93
|
"""Test listing tasks."""
|
37
94
|
self.todo_shell.list_tasks.return_value = "1. Task 1\n2. Task 2"
|
@@ -0,0 +1,81 @@
|
|
1
|
+
"""
|
2
|
+
Tests for calendar utilities.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from unittest.mock import Mock, patch
|
6
|
+
|
7
|
+
import pytest
|
8
|
+
|
9
|
+
from todo_agent.infrastructure.calendar_utils import (
|
10
|
+
get_calendar_output,
|
11
|
+
get_current_month_calendar,
|
12
|
+
)
|
13
|
+
|
14
|
+
|
15
|
+
class TestCalendarUtils:
|
16
|
+
"""Test calendar utility functions."""
|
17
|
+
|
18
|
+
def test_get_calendar_output_success(self):
|
19
|
+
"""Test successful calendar output generation."""
|
20
|
+
mock_output = "Calendar output from cal -3"
|
21
|
+
|
22
|
+
with patch("subprocess.run") as mock_run:
|
23
|
+
mock_result = Mock()
|
24
|
+
mock_result.stdout = mock_output
|
25
|
+
mock_run.return_value = mock_result
|
26
|
+
|
27
|
+
result = get_calendar_output()
|
28
|
+
|
29
|
+
assert result == mock_output
|
30
|
+
assert len(result) > 0
|
31
|
+
mock_run.assert_called_once_with(
|
32
|
+
["cal", "-3"], capture_output=True, text=True, check=True
|
33
|
+
)
|
34
|
+
|
35
|
+
def test_get_calendar_output_fallback(self):
|
36
|
+
"""Test calendar output fallback to Python calendar."""
|
37
|
+
with patch("subprocess.run", side_effect=FileNotFoundError("cal not found")):
|
38
|
+
result = get_calendar_output()
|
39
|
+
|
40
|
+
# Should return a string with calendar data
|
41
|
+
assert isinstance(result, str)
|
42
|
+
assert len(result) > 0
|
43
|
+
|
44
|
+
def test_get_current_month_calendar_success(self):
|
45
|
+
"""Test successful current month calendar generation."""
|
46
|
+
mock_output = "Current month calendar"
|
47
|
+
|
48
|
+
with patch("subprocess.run") as mock_run:
|
49
|
+
mock_result = Mock()
|
50
|
+
mock_result.stdout = mock_output
|
51
|
+
mock_run.return_value = mock_result
|
52
|
+
|
53
|
+
result = get_current_month_calendar()
|
54
|
+
|
55
|
+
assert result == mock_output
|
56
|
+
assert len(result) > 0
|
57
|
+
mock_run.assert_called_once_with(
|
58
|
+
["cal"], capture_output=True, text=True, check=True
|
59
|
+
)
|
60
|
+
|
61
|
+
def test_get_current_month_calendar_fallback(self):
|
62
|
+
"""Test current month calendar fallback to Python calendar."""
|
63
|
+
with patch("subprocess.run", side_effect=FileNotFoundError("cal not found")):
|
64
|
+
result = get_current_month_calendar()
|
65
|
+
|
66
|
+
# Should return a string with calendar data
|
67
|
+
assert isinstance(result, str)
|
68
|
+
assert len(result) > 0
|
69
|
+
|
70
|
+
def test_calendar_output_contains_expected_content(self):
|
71
|
+
"""Test that calendar output contains expected content."""
|
72
|
+
with patch("subprocess.run") as mock_run:
|
73
|
+
mock_result = Mock()
|
74
|
+
mock_result.stdout = "Calendar with July August September 2025"
|
75
|
+
mock_run.return_value = mock_result
|
76
|
+
|
77
|
+
result = get_calendar_output()
|
78
|
+
|
79
|
+
# Should contain some calendar content
|
80
|
+
assert len(result) > 0
|
81
|
+
assert isinstance(result, str)
|
@@ -295,6 +295,49 @@ class TestCLI:
|
|
295
295
|
f"Error: Failed to list tasks: {error_message}"
|
296
296
|
)
|
297
297
|
|
298
|
+
def test_done_command_success(self):
|
299
|
+
"""Test successful done command execution."""
|
300
|
+
expected_output = (
|
301
|
+
"x 2025-08-29 2025-08-28 Buy groceries\nx 2025-08-28 2025-08-27 Call mom"
|
302
|
+
)
|
303
|
+
|
304
|
+
# Mock the todo_shell.list_completed method
|
305
|
+
self.cli.todo_shell.list_completed.return_value = expected_output
|
306
|
+
|
307
|
+
with patch("builtins.print") as mock_print:
|
308
|
+
# Simulate the done command logic
|
309
|
+
try:
|
310
|
+
output = self.cli.todo_shell.list_completed()
|
311
|
+
print(output)
|
312
|
+
except Exception as e:
|
313
|
+
print(f"Error: Failed to list completed tasks: {e!s}")
|
314
|
+
|
315
|
+
# Verify todo_shell.list_completed was called
|
316
|
+
self.cli.todo_shell.list_completed.assert_called_once()
|
317
|
+
|
318
|
+
# Verify print was called with the expected output
|
319
|
+
mock_print.assert_called_once_with(expected_output)
|
320
|
+
|
321
|
+
def test_done_command_exception_handling(self):
|
322
|
+
"""Test done command handles exceptions properly."""
|
323
|
+
error_message = "Database connection failed"
|
324
|
+
|
325
|
+
# Mock exception in todo_shell.list_completed
|
326
|
+
self.cli.todo_shell.list_completed.side_effect = Exception(error_message)
|
327
|
+
|
328
|
+
with patch("builtins.print") as mock_print:
|
329
|
+
# Simulate the done command logic with error
|
330
|
+
try:
|
331
|
+
output = self.cli.todo_shell.list_completed()
|
332
|
+
print(output)
|
333
|
+
except Exception as e:
|
334
|
+
print(f"Error: Failed to list completed tasks: {e!s}")
|
335
|
+
|
336
|
+
# Verify error was handled and formatted correctly
|
337
|
+
mock_print.assert_called_once_with(
|
338
|
+
f"Error: Failed to list completed tasks: {error_message}"
|
339
|
+
)
|
340
|
+
|
298
341
|
def test_help_command_displays_available_commands(self):
|
299
342
|
"""Test that help command displays all available commands."""
|
300
343
|
with patch("builtins.print") as mock_print:
|
@@ -304,6 +347,7 @@ class TestCLI:
|
|
304
347
|
print(" history - Show conversation statistics")
|
305
348
|
print(" help - Show this help message")
|
306
349
|
print(" list - List all tasks (no LLM interaction)")
|
350
|
+
print(" done - List completed tasks (no LLM interaction)")
|
307
351
|
print(" quit - Exit the application")
|
308
352
|
print(" Or just type your request naturally!")
|
309
353
|
|
@@ -314,6 +358,7 @@ class TestCLI:
|
|
314
358
|
" history - Show conversation statistics",
|
315
359
|
" help - Show this help message",
|
316
360
|
" list - List all tasks (no LLM interaction)",
|
361
|
+
" done - List completed tasks (no LLM interaction)",
|
317
362
|
" quit - Exit the application",
|
318
363
|
" Or just type your request naturally!",
|
319
364
|
]
|
@@ -0,0 +1,75 @@
|
|
1
|
+
"""
|
2
|
+
Tests for formatters module.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import pytest
|
6
|
+
from rich.text import Text
|
7
|
+
|
8
|
+
from todo_agent.interface.formatters import TaskFormatter
|
9
|
+
|
10
|
+
|
11
|
+
class TestTaskFormatter:
|
12
|
+
"""Test TaskFormatter functionality."""
|
13
|
+
|
14
|
+
def test_format_task_list_preserves_ansi_codes(self):
|
15
|
+
"""Test that format_task_list preserves ANSI color codes."""
|
16
|
+
# Sample output with ANSI color codes (simulating todo.sh output)
|
17
|
+
raw_tasks = "\033[1;33m1\033[0m (A) \033[1;32m2025-08-29\033[0m Clean cat box \033[1;34m@home\033[0m \033[1;35m+chores\033[0m \033[1;31mdue:2025-08-29\033[0m"
|
18
|
+
|
19
|
+
result = TaskFormatter.format_task_list(raw_tasks)
|
20
|
+
|
21
|
+
# The result should be a Rich Text object
|
22
|
+
assert isinstance(result, Text)
|
23
|
+
|
24
|
+
# Check that the Rich Text object contains the original ANSI codes
|
25
|
+
# We can check this by looking at the raw text content
|
26
|
+
result_str = result.plain
|
27
|
+
assert "1" in result_str # Task number should be present
|
28
|
+
assert "(A)" in result_str # Priority should be present
|
29
|
+
assert "2025-08-29" in result_str # Date should be present
|
30
|
+
assert "@home" in result_str # Context should be present
|
31
|
+
assert "+chores" in result_str # Project should be present
|
32
|
+
assert "due:2025-08-29" in result_str # Due date should be present
|
33
|
+
|
34
|
+
def test_format_completed_tasks_preserves_ansi_codes(self):
|
35
|
+
"""Test that format_completed_tasks preserves ANSI color codes."""
|
36
|
+
# Sample completed task output with ANSI color codes
|
37
|
+
raw_tasks = "\033[1;32mx\033[0m \033[1;32m2025-08-29\033[0m \033[1;32m2025-08-28\033[0m Clean cat box \033[1;34m@home\033[0m \033[1;35m+chores\033[0m"
|
38
|
+
|
39
|
+
result = TaskFormatter.format_completed_tasks(raw_tasks)
|
40
|
+
|
41
|
+
# The result should be a Rich Text object
|
42
|
+
assert isinstance(result, Text)
|
43
|
+
|
44
|
+
# Check that the Rich Text object contains the original content
|
45
|
+
# We can check this by looking at the raw text content
|
46
|
+
result_str = result.plain
|
47
|
+
assert "x" in result_str # Completion marker should be present
|
48
|
+
assert "2025-08-29" in result_str # Completion date should be present
|
49
|
+
assert "2025-08-28" in result_str # Creation date should be present
|
50
|
+
assert "@home" in result_str # Context should be present
|
51
|
+
assert "+chores" in result_str # Project should be present
|
52
|
+
|
53
|
+
def test_format_task_list_handles_empty_input(self):
|
54
|
+
"""Test that format_task_list handles empty input gracefully."""
|
55
|
+
result = TaskFormatter.format_task_list("")
|
56
|
+
assert isinstance(result, Text)
|
57
|
+
assert "No tasks found" in str(result)
|
58
|
+
|
59
|
+
def test_format_completed_tasks_handles_empty_input(self):
|
60
|
+
"""Test that format_completed_tasks handles empty input gracefully."""
|
61
|
+
result = TaskFormatter.format_completed_tasks("")
|
62
|
+
assert isinstance(result, Text)
|
63
|
+
assert "No completed tasks found" in str(result)
|
64
|
+
|
65
|
+
def test_format_task_list_handles_whitespace_only(self):
|
66
|
+
"""Test that format_task_list handles whitespace-only input."""
|
67
|
+
result = TaskFormatter.format_task_list(" \n \t ")
|
68
|
+
assert isinstance(result, Text)
|
69
|
+
assert "No tasks found" in str(result)
|
70
|
+
|
71
|
+
def test_format_completed_tasks_handles_whitespace_only(self):
|
72
|
+
"""Test that format_completed_tasks handles whitespace-only input."""
|
73
|
+
result = TaskFormatter.format_completed_tasks(" \n \t ")
|
74
|
+
assert isinstance(result, Text)
|
75
|
+
assert "No completed tasks found" in str(result)
|
@@ -178,3 +178,62 @@ class TestToolErrorHandling:
|
|
178
178
|
|
179
179
|
assert result["error"] is False
|
180
180
|
assert result["output"] == "1. Test task"
|
181
|
+
|
182
|
+
|
183
|
+
class TestCalendarTool:
|
184
|
+
"""Test calendar tool functionality."""
|
185
|
+
|
186
|
+
def setup_method(self):
|
187
|
+
"""Set up test fixtures."""
|
188
|
+
self.mock_todo_manager = Mock(spec=TodoManager)
|
189
|
+
self.mock_logger = Mock()
|
190
|
+
self.tool_handler = ToolCallHandler(self.mock_todo_manager, self.mock_logger)
|
191
|
+
|
192
|
+
@patch("subprocess.run")
|
193
|
+
def test_get_calendar_success(self, mock_run):
|
194
|
+
"""Test successful calendar retrieval using system cal command."""
|
195
|
+
# Mock the subprocess.run to return a calendar
|
196
|
+
mock_run.return_value.stdout = " January 2025\nSu Mo Tu We Th Fr Sa\n 1 2 3 4\n 5 6 7 8 9 10 11\n12 13 14 15 16 17 18\n19 20 21 22 23 24 25\n26 27 28 29 30 31\n"
|
197
|
+
mock_run.return_value.returncode = 0
|
198
|
+
|
199
|
+
tool_call = {
|
200
|
+
"function": {
|
201
|
+
"name": "get_calendar",
|
202
|
+
"arguments": '{"month": 1, "year": 2025}',
|
203
|
+
},
|
204
|
+
"id": "test_id",
|
205
|
+
}
|
206
|
+
|
207
|
+
result = self.tool_handler.execute_tool(tool_call)
|
208
|
+
|
209
|
+
assert result["error"] is False
|
210
|
+
assert "January 2025" in result["output"]
|
211
|
+
assert result["tool_call_id"] == "test_id"
|
212
|
+
assert result["name"] == "get_calendar"
|
213
|
+
|
214
|
+
# Verify subprocess.run was called with correct arguments
|
215
|
+
mock_run.assert_called_once_with(
|
216
|
+
["cal", "1", "2025"], capture_output=True, text=True, check=True
|
217
|
+
)
|
218
|
+
|
219
|
+
@patch("subprocess.run")
|
220
|
+
def test_get_calendar_fallback_to_python(self, mock_run):
|
221
|
+
"""Test calendar fallback to Python calendar module when cal command fails."""
|
222
|
+
# Mock subprocess.run to raise FileNotFoundError
|
223
|
+
mock_run.side_effect = FileNotFoundError("cal command not found")
|
224
|
+
|
225
|
+
tool_call = {
|
226
|
+
"function": {
|
227
|
+
"name": "get_calendar",
|
228
|
+
"arguments": '{"month": 1, "year": 2025}',
|
229
|
+
},
|
230
|
+
"id": "test_id",
|
231
|
+
}
|
232
|
+
|
233
|
+
result = self.tool_handler.execute_tool(tool_call)
|
234
|
+
|
235
|
+
assert result["error"] is False
|
236
|
+
assert "January" in result["output"]
|
237
|
+
assert "2025" in result["output"]
|
238
|
+
assert result["tool_call_id"] == "test_id"
|
239
|
+
assert result["name"] == "get_calendar"
|
@@ -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.6'
|
32
|
+
__version_tuple__ = version_tuple = (0, 2, 6)
|
33
33
|
|
34
|
-
__commit_id__ = commit_id = '
|
34
|
+
__commit_id__ = commit_id = 'g670ed96a8'
|
@@ -21,6 +21,39 @@ class TodoManager:
|
|
21
21
|
due: Optional[str] = None,
|
22
22
|
) -> str:
|
23
23
|
"""Add new task with explicit project/context parameters."""
|
24
|
+
# Validate and sanitize inputs
|
25
|
+
if priority and not (
|
26
|
+
len(priority) == 1 and priority.isalpha() and priority.isupper()
|
27
|
+
):
|
28
|
+
raise ValueError(
|
29
|
+
f"Invalid priority '{priority}'. Must be a single uppercase letter (A-Z)."
|
30
|
+
)
|
31
|
+
|
32
|
+
if project:
|
33
|
+
# Remove any existing + symbols to prevent duplication
|
34
|
+
project = project.strip().lstrip("+")
|
35
|
+
if not project:
|
36
|
+
raise ValueError(
|
37
|
+
"Project name cannot be empty after removing + symbol."
|
38
|
+
)
|
39
|
+
|
40
|
+
if context:
|
41
|
+
# Remove any existing @ symbols to prevent duplication
|
42
|
+
context = context.strip().lstrip("@")
|
43
|
+
if not context:
|
44
|
+
raise ValueError(
|
45
|
+
"Context name cannot be empty after removing @ symbol."
|
46
|
+
)
|
47
|
+
|
48
|
+
if due:
|
49
|
+
# Basic date format validation
|
50
|
+
try:
|
51
|
+
datetime.strptime(due, "%Y-%m-%d")
|
52
|
+
except ValueError:
|
53
|
+
raise ValueError(
|
54
|
+
f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
|
55
|
+
)
|
56
|
+
|
24
57
|
# Build the full task description with priority, project, and context
|
25
58
|
full_description = description
|
26
59
|
|
@@ -0,0 +1,64 @@
|
|
1
|
+
"""
|
2
|
+
Calendar utilities for generating calendar output in system prompts.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import calendar
|
6
|
+
import subprocess
|
7
|
+
from datetime import datetime, timedelta
|
8
|
+
|
9
|
+
|
10
|
+
def get_calendar_output() -> str:
|
11
|
+
"""
|
12
|
+
Generate calendar output for previous, current, and next month.
|
13
|
+
|
14
|
+
Returns:
|
15
|
+
Formatted calendar string showing three months side by side
|
16
|
+
"""
|
17
|
+
try:
|
18
|
+
# Use cal -3 to get three months side by side
|
19
|
+
result = subprocess.run(
|
20
|
+
["cal", "-3"], capture_output=True, text=True, check=True
|
21
|
+
)
|
22
|
+
return result.stdout.strip()
|
23
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
24
|
+
# Fallback to Python calendar module
|
25
|
+
return _get_python_cal_output()
|
26
|
+
|
27
|
+
|
28
|
+
def _get_python_cal_output() -> str:
|
29
|
+
"""
|
30
|
+
Generate calendar output using Python calendar module as fallback.
|
31
|
+
|
32
|
+
Returns:
|
33
|
+
Calendar output formatted similar to cal command
|
34
|
+
"""
|
35
|
+
current_date = datetime.now()
|
36
|
+
|
37
|
+
# Calculate previous, current, and next month
|
38
|
+
prev_month = current_date - timedelta(days=current_date.day)
|
39
|
+
next_month = current_date.replace(day=1) + timedelta(days=32)
|
40
|
+
next_month = next_month.replace(day=1)
|
41
|
+
|
42
|
+
calendars = []
|
43
|
+
|
44
|
+
for date in [prev_month, current_date, next_month]:
|
45
|
+
cal = calendar.month(date.year, date.month)
|
46
|
+
calendars.append(cal.strip())
|
47
|
+
|
48
|
+
return "\n\n".join(calendars)
|
49
|
+
|
50
|
+
|
51
|
+
def get_current_month_calendar() -> str:
|
52
|
+
"""
|
53
|
+
Get calendar for current month only.
|
54
|
+
|
55
|
+
Returns:
|
56
|
+
Calendar output for current month
|
57
|
+
"""
|
58
|
+
try:
|
59
|
+
result = subprocess.run(["cal"], capture_output=True, text=True, check=True)
|
60
|
+
return result.stdout.strip()
|
61
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
62
|
+
# Fallback to Python calendar
|
63
|
+
current_date = datetime.now()
|
64
|
+
return calendar.month(current_date.year, current_date.month).strip()
|
@@ -75,6 +75,15 @@ class Inference:
|
|
75
75
|
|
76
76
|
current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
77
77
|
|
78
|
+
# Get calendar output
|
79
|
+
from .calendar_utils import get_calendar_output
|
80
|
+
|
81
|
+
try:
|
82
|
+
calendar_output = get_calendar_output()
|
83
|
+
except Exception as e:
|
84
|
+
self.logger.warning(f"Failed to get calendar output: {e!s}")
|
85
|
+
calendar_output = "Calendar unavailable"
|
86
|
+
|
78
87
|
# Load system prompt from file
|
79
88
|
prompt_file_path = os.path.join(
|
80
89
|
os.path.dirname(__file__), "prompts", "system_prompt.txt"
|
@@ -84,9 +93,11 @@ class Inference:
|
|
84
93
|
with open(prompt_file_path, encoding="utf-8") as f:
|
85
94
|
system_prompt_template = f.read()
|
86
95
|
|
87
|
-
# Format the template with the tools section
|
96
|
+
# Format the template with the tools section, current datetime, and calendar
|
88
97
|
return system_prompt_template.format(
|
89
|
-
tools_section=tools_section,
|
98
|
+
tools_section=tools_section,
|
99
|
+
current_datetime=current_datetime,
|
100
|
+
calendar_output=calendar_output,
|
90
101
|
)
|
91
102
|
|
92
103
|
except FileNotFoundError:
|
@@ -2,6 +2,8 @@
|
|
2
2
|
Factory for creating LLM clients based on configuration.
|
3
3
|
"""
|
4
4
|
|
5
|
+
# mypy: disable-error-code="no-redef"
|
6
|
+
|
5
7
|
from typing import Optional
|
6
8
|
|
7
9
|
try:
|
@@ -15,8 +17,8 @@ except ImportError:
|
|
15
17
|
from infrastructure.llm_client import LLMClient # type: ignore[no-redef]
|
16
18
|
from infrastructure.logger import Logger # type: ignore[no-redef]
|
17
19
|
from infrastructure.ollama_client import OllamaClient # type: ignore[no-redef]
|
18
|
-
from infrastructure.openrouter_client import (
|
19
|
-
OpenRouterClient,
|
20
|
+
from infrastructure.openrouter_client import (
|
21
|
+
OpenRouterClient, # type: ignore[no-redef, misc]
|
20
22
|
)
|
21
23
|
|
22
24
|
|
@@ -72,6 +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
|
+
|
76
|
+
# Log thinking content (response body) if present
|
77
|
+
content = response["message"].get("content", "")
|
78
|
+
if content and content.strip():
|
79
|
+
self.logger.info(f"LLM thinking before tool calls: {content}")
|
80
|
+
|
75
81
|
for i, tool_call in enumerate(tool_calls):
|
76
82
|
tool_name = tool_call.get("function", {}).get("name", "unknown")
|
77
83
|
self.logger.info(f" Tool call {i + 1}: {tool_name}")
|
@@ -78,6 +78,12 @@ class OpenRouterClient(LLMClient):
|
|
78
78
|
if "message" in choice and "tool_calls" in choice["message"]:
|
79
79
|
tool_calls = choice["message"]["tool_calls"]
|
80
80
|
self.logger.info(f"Response contains {len(tool_calls)} tool calls")
|
81
|
+
|
82
|
+
# Log thinking content (response body) if present
|
83
|
+
content = choice["message"].get("content", "")
|
84
|
+
if content and content.strip():
|
85
|
+
self.logger.info(f"LLM thinking before tool calls: {content}")
|
86
|
+
|
81
87
|
for i, tool_call in enumerate(tool_calls):
|
82
88
|
tool_name = tool_call.get("function", {}).get("name", "unknown")
|
83
89
|
self.logger.info(f" Tool call {i + 1}: {tool_name}")
|