todo-agent 0.1.1__py3-none-any.whl → 0.2.3__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/__init__.py +3 -3
- todo_agent/core/conversation_manager.py +39 -17
- todo_agent/core/todo_manager.py +40 -30
- todo_agent/infrastructure/__init__.py +1 -1
- todo_agent/infrastructure/config.py +7 -5
- todo_agent/infrastructure/inference.py +109 -54
- todo_agent/infrastructure/llm_client_factory.py +13 -9
- todo_agent/infrastructure/logger.py +38 -41
- todo_agent/infrastructure/ollama_client.py +22 -15
- todo_agent/infrastructure/openrouter_client.py +37 -26
- todo_agent/infrastructure/todo_shell.py +12 -10
- todo_agent/infrastructure/token_counter.py +39 -38
- todo_agent/interface/__init__.py +16 -1
- todo_agent/interface/cli.py +119 -65
- todo_agent/interface/formatters.py +399 -0
- todo_agent/interface/tools.py +47 -40
- todo_agent/main.py +11 -3
- {todo_agent-0.1.1.dist-info → todo_agent-0.2.3.dist-info}/METADATA +72 -32
- todo_agent-0.2.3.dist-info/RECORD +28 -0
- todo_agent-0.1.1.dist-info/RECORD +0 -27
- {todo_agent-0.1.1.dist-info → todo_agent-0.2.3.dist-info}/WHEEL +0 -0
- {todo_agent-0.1.1.dist-info → todo_agent-0.2.3.dist-info}/entry_points.txt +0 -0
- {todo_agent-0.1.1.dist-info → todo_agent-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {todo_agent-0.1.1.dist-info → todo_agent-0.2.3.dist-info}/top_level.txt +0 -0
@@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
|
|
8
8
|
try:
|
9
9
|
import tiktoken
|
10
10
|
except ImportError:
|
11
|
-
tiktoken = None
|
11
|
+
tiktoken = None # type: ignore
|
12
12
|
|
13
13
|
|
14
14
|
class TokenCounter:
|
@@ -17,12 +17,12 @@ class TokenCounter:
|
|
17
17
|
def __init__(self, model: str = "gpt-4"):
|
18
18
|
"""
|
19
19
|
Initialize token counter for a specific model.
|
20
|
-
|
20
|
+
|
21
21
|
Args:
|
22
22
|
model: Model name to use for tokenization (default: gpt-4)
|
23
23
|
"""
|
24
24
|
self.model = model
|
25
|
-
self._encoder = None
|
25
|
+
self._encoder: Optional[Any] = None
|
26
26
|
self._initialize_encoder()
|
27
27
|
|
28
28
|
def _initialize_encoder(self) -> None:
|
@@ -32,152 +32,153 @@ class TokenCounter:
|
|
32
32
|
"tiktoken library is required for accurate token counting. "
|
33
33
|
"Install it with: pip install tiktoken"
|
34
34
|
)
|
35
|
-
|
36
35
|
|
37
36
|
self._encoder = tiktoken.get_encoding("cl100k_base")
|
38
|
-
|
37
|
+
|
39
38
|
def count_tokens(self, text: str) -> int:
|
40
39
|
"""
|
41
40
|
Count tokens in text using accurate tokenization.
|
42
|
-
|
41
|
+
|
43
42
|
Args:
|
44
43
|
text: Text to count tokens for
|
45
|
-
|
44
|
+
|
46
45
|
Returns:
|
47
46
|
Number of tokens
|
48
47
|
"""
|
49
48
|
if not text:
|
50
49
|
return 0
|
51
|
-
|
50
|
+
|
51
|
+
if self._encoder is None:
|
52
|
+
raise RuntimeError("Encoder not initialized")
|
52
53
|
return len(self._encoder.encode(text))
|
53
54
|
|
54
55
|
def count_message_tokens(self, message: Dict[str, Any]) -> int:
|
55
56
|
"""
|
56
57
|
Count tokens in a single message (including role, content, and tool calls).
|
57
|
-
|
58
|
+
|
58
59
|
Args:
|
59
60
|
message: Message dictionary with role, content, etc.
|
60
|
-
|
61
|
+
|
61
62
|
Returns:
|
62
63
|
Number of tokens
|
63
64
|
"""
|
64
65
|
tokens = 0
|
65
|
-
|
66
|
+
|
66
67
|
# Count role tokens (typically 1-2 tokens)
|
67
68
|
role = message.get("role", "")
|
68
69
|
tokens += self.count_tokens(role)
|
69
|
-
|
70
|
+
|
70
71
|
# Count content tokens
|
71
72
|
content = message.get("content", "")
|
72
73
|
if content:
|
73
74
|
tokens += self.count_tokens(content)
|
74
|
-
|
75
|
+
|
75
76
|
# Count tool calls tokens
|
76
77
|
tool_calls = message.get("tool_calls", [])
|
77
78
|
for tool_call in tool_calls:
|
78
79
|
tokens += self.count_tool_call_tokens(tool_call)
|
79
|
-
|
80
|
+
|
80
81
|
# Count tool call ID if present
|
81
82
|
tool_call_id = message.get("tool_call_id", "")
|
82
83
|
if tool_call_id:
|
83
84
|
tokens += self.count_tokens(tool_call_id)
|
84
|
-
|
85
|
+
|
85
86
|
return tokens
|
86
87
|
|
87
88
|
def count_tool_call_tokens(self, tool_call: Dict[str, Any]) -> int:
|
88
89
|
"""
|
89
90
|
Count tokens in a tool call.
|
90
|
-
|
91
|
+
|
91
92
|
Args:
|
92
93
|
tool_call: Tool call dictionary
|
93
|
-
|
94
|
+
|
94
95
|
Returns:
|
95
96
|
Number of tokens
|
96
97
|
"""
|
97
98
|
tokens = 0
|
98
|
-
|
99
|
+
|
99
100
|
# Count tool call ID
|
100
101
|
tool_call_id = tool_call.get("id", "")
|
101
102
|
tokens += self.count_tokens(tool_call_id)
|
102
|
-
|
103
|
+
|
103
104
|
# Count function call
|
104
105
|
function = tool_call.get("function", {})
|
105
106
|
if function:
|
106
107
|
# Count function name
|
107
108
|
function_name = function.get("name", "")
|
108
109
|
tokens += self.count_tokens(function_name)
|
109
|
-
|
110
|
+
|
110
111
|
# Count function arguments
|
111
112
|
arguments = function.get("arguments", "")
|
112
113
|
if arguments:
|
113
114
|
tokens += self.count_tokens(arguments)
|
114
|
-
|
115
|
+
|
115
116
|
return tokens
|
116
117
|
|
117
118
|
def count_messages_tokens(self, messages: List[Dict[str, Any]]) -> int:
|
118
119
|
"""
|
119
120
|
Count total tokens in a list of messages.
|
120
|
-
|
121
|
+
|
121
122
|
Args:
|
122
123
|
messages: List of message dictionaries
|
123
|
-
|
124
|
+
|
124
125
|
Returns:
|
125
126
|
Total number of tokens
|
126
127
|
"""
|
127
128
|
total_tokens = 0
|
128
|
-
|
129
|
+
|
129
130
|
for message in messages:
|
130
131
|
total_tokens += self.count_message_tokens(message)
|
131
|
-
|
132
|
+
|
132
133
|
return total_tokens
|
133
134
|
|
134
135
|
def count_tools_tokens(self, tools: List[Dict[str, Any]]) -> int:
|
135
136
|
"""
|
136
137
|
Count tokens in tool definitions.
|
137
|
-
|
138
|
+
|
138
139
|
Args:
|
139
140
|
tools: List of tool definition dictionaries
|
140
|
-
|
141
|
+
|
141
142
|
Returns:
|
142
143
|
Number of tokens
|
143
144
|
"""
|
144
145
|
if not tools:
|
145
146
|
return 0
|
146
|
-
|
147
|
+
|
147
148
|
# Convert tools to JSON string and count tokens
|
148
|
-
tools_json = json.dumps(tools, separators=(
|
149
|
+
tools_json = json.dumps(tools, separators=(",", ":"))
|
149
150
|
return self.count_tokens(tools_json)
|
150
151
|
|
151
152
|
def count_request_tokens(
|
152
|
-
self,
|
153
|
-
messages: List[Dict[str, Any]],
|
154
|
-
tools: Optional[List[Dict[str, Any]]] = None
|
153
|
+
self,
|
154
|
+
messages: List[Dict[str, Any]],
|
155
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
155
156
|
) -> int:
|
156
157
|
"""
|
157
158
|
Count total tokens in a complete request (messages + tools).
|
158
|
-
|
159
|
+
|
159
160
|
Args:
|
160
161
|
messages: List of message dictionaries
|
161
162
|
tools: Optional list of tool definitions
|
162
|
-
|
163
|
+
|
163
164
|
Returns:
|
164
165
|
Total number of tokens
|
165
166
|
"""
|
166
167
|
total_tokens = self.count_messages_tokens(messages)
|
167
|
-
|
168
|
+
|
168
169
|
if tools:
|
169
170
|
total_tokens += self.count_tools_tokens(tools)
|
170
|
-
|
171
|
+
|
171
172
|
return total_tokens
|
172
173
|
|
173
174
|
|
174
175
|
def get_token_counter(model: str = "gpt-4") -> TokenCounter:
|
175
176
|
"""
|
176
177
|
Get a token counter instance for the specified model.
|
177
|
-
|
178
|
+
|
178
179
|
Args:
|
179
180
|
model: Model name to use for tokenization
|
180
|
-
|
181
|
+
|
181
182
|
Returns:
|
182
183
|
TokenCounter instance
|
183
184
|
"""
|
todo_agent/interface/__init__.py
CHANGED
@@ -5,6 +5,21 @@ This module contains user interfaces and presentation logic.
|
|
5
5
|
"""
|
6
6
|
|
7
7
|
from .cli import CLI
|
8
|
+
from .formatters import (
|
9
|
+
PanelFormatter,
|
10
|
+
ResponseFormatter,
|
11
|
+
StatsFormatter,
|
12
|
+
TableFormatter,
|
13
|
+
TaskFormatter,
|
14
|
+
)
|
8
15
|
from .tools import ToolCallHandler
|
9
16
|
|
10
|
-
__all__ = [
|
17
|
+
__all__ = [
|
18
|
+
"CLI",
|
19
|
+
"PanelFormatter",
|
20
|
+
"ResponseFormatter",
|
21
|
+
"StatsFormatter",
|
22
|
+
"TableFormatter",
|
23
|
+
"TaskFormatter",
|
24
|
+
"ToolCallHandler",
|
25
|
+
]
|
todo_agent/interface/cli.py
CHANGED
@@ -2,42 +2,60 @@
|
|
2
2
|
Command-line interface for todo.sh LLM agent.
|
3
3
|
"""
|
4
4
|
|
5
|
-
import threading
|
6
|
-
import time
|
7
|
-
from typing import Optional
|
8
|
-
|
9
5
|
try:
|
6
|
+
import readline
|
7
|
+
|
8
|
+
from rich.align import Align
|
10
9
|
from rich.console import Console
|
11
10
|
from rich.live import Live
|
12
11
|
from rich.spinner import Spinner
|
13
12
|
from rich.text import Text
|
13
|
+
|
14
14
|
from todo_agent.core.todo_manager import TodoManager
|
15
15
|
from todo_agent.infrastructure.config import Config
|
16
|
-
from todo_agent.infrastructure.todo_shell import TodoShell
|
17
|
-
from todo_agent.infrastructure.logger import Logger
|
18
16
|
from todo_agent.infrastructure.inference import Inference
|
17
|
+
from todo_agent.infrastructure.logger import Logger
|
18
|
+
from todo_agent.infrastructure.todo_shell import TodoShell
|
19
|
+
from todo_agent.interface.formatters import (
|
20
|
+
CLI_WIDTH,
|
21
|
+
PanelFormatter,
|
22
|
+
ResponseFormatter,
|
23
|
+
TableFormatter,
|
24
|
+
TaskFormatter,
|
25
|
+
)
|
19
26
|
from todo_agent.interface.tools import ToolCallHandler
|
20
27
|
except ImportError:
|
28
|
+
from core.todo_manager import TodoManager # type: ignore[no-redef]
|
29
|
+
from infrastructure.config import Config # type: ignore[no-redef]
|
30
|
+
from infrastructure.inference import Inference # type: ignore[no-redef]
|
31
|
+
from infrastructure.logger import Logger # type: ignore[no-redef]
|
32
|
+
from infrastructure.todo_shell import TodoShell # type: ignore[no-redef]
|
33
|
+
from interface.formatters import ( # type: ignore[no-redef]
|
34
|
+
CLI_WIDTH,
|
35
|
+
PanelFormatter,
|
36
|
+
ResponseFormatter,
|
37
|
+
TableFormatter,
|
38
|
+
TaskFormatter,
|
39
|
+
)
|
40
|
+
from interface.tools import ToolCallHandler # type: ignore[no-redef]
|
41
|
+
from rich.align import Align
|
21
42
|
from rich.console import Console
|
22
43
|
from rich.live import Live
|
23
44
|
from rich.spinner import Spinner
|
24
45
|
from rich.text import Text
|
25
|
-
from core.todo_manager import TodoManager
|
26
|
-
from infrastructure.config import Config
|
27
|
-
from infrastructure.todo_shell import TodoShell
|
28
|
-
from infrastructure.logger import Logger
|
29
|
-
from infrastructure.inference import Inference
|
30
|
-
from interface.tools import ToolCallHandler
|
31
46
|
|
32
47
|
|
33
48
|
class CLI:
|
34
49
|
"""User interaction loop and input/output handling."""
|
35
50
|
|
36
|
-
def __init__(self):
|
51
|
+
def __init__(self) -> None:
|
52
|
+
# Initialize readline for arrow key navigation
|
53
|
+
readline.set_history_length(50) # Match existing conversation cap
|
54
|
+
|
37
55
|
# Initialize logger first
|
38
56
|
self.logger = Logger("cli")
|
39
57
|
self.logger.info("Initializing CLI")
|
40
|
-
|
58
|
+
|
41
59
|
self.config = Config()
|
42
60
|
self.config.validate()
|
43
61
|
self.logger.debug("Configuration validated")
|
@@ -58,8 +76,8 @@ class CLI:
|
|
58
76
|
self.inference = Inference(self.config, self.tool_handler, self.logger)
|
59
77
|
self.logger.debug("Inference engine initialized")
|
60
78
|
|
61
|
-
# Initialize rich console for animations
|
62
|
-
self.console = Console()
|
79
|
+
# Initialize rich console for animations with consistent width
|
80
|
+
self.console = Console(width=CLI_WIDTH)
|
63
81
|
|
64
82
|
self.logger.info("CLI initialization completed")
|
65
83
|
|
@@ -85,22 +103,51 @@ class CLI:
|
|
85
103
|
initial_spinner = self._create_thinking_spinner("Thinking...")
|
86
104
|
return Live(initial_spinner, console=self.console, refresh_per_second=10)
|
87
105
|
|
88
|
-
|
89
|
-
|
90
|
-
|
106
|
+
def _print_header(self) -> None:
|
107
|
+
"""Print the application header with unicode borders."""
|
108
|
+
header_panel = PanelFormatter.create_header_panel()
|
109
|
+
self.console.print(header_panel)
|
110
|
+
|
111
|
+
subtitle = Text(
|
112
|
+
"Type your request naturally, or enter 'quit' to exit, or 'help' for commands",
|
113
|
+
style="dim",
|
114
|
+
)
|
115
|
+
self.console.print(Align.center(subtitle), style="dim")
|
116
|
+
|
117
|
+
def _print_help(self) -> None:
|
118
|
+
"""Print help information in a formatted table."""
|
119
|
+
table = TableFormatter.create_command_table()
|
120
|
+
self.console.print(table)
|
121
|
+
self.console.print("Or just type your request naturally!", style="italic green")
|
122
|
+
|
123
|
+
def _print_about(self) -> None:
|
124
|
+
"""Print about information in a formatted panel."""
|
125
|
+
about_panel = PanelFormatter.create_about_panel()
|
126
|
+
self.console.print(about_panel)
|
127
|
+
|
128
|
+
def _print_stats(self, summary: dict) -> None:
|
129
|
+
"""Print conversation statistics in a formatted table."""
|
130
|
+
table = TableFormatter.create_stats_table(summary)
|
131
|
+
self.console.print(table)
|
132
|
+
|
133
|
+
def run(self) -> None:
|
91
134
|
"""Main CLI interaction loop."""
|
92
135
|
self.logger.info("Starting CLI interaction loop")
|
93
|
-
|
94
|
-
|
95
|
-
|
136
|
+
|
137
|
+
# Print header
|
138
|
+
self._print_header()
|
139
|
+
|
140
|
+
# Print separator
|
141
|
+
self.console.print("─" * CLI_WIDTH, style="dim")
|
96
142
|
|
97
143
|
while True:
|
98
144
|
try:
|
99
|
-
|
145
|
+
# Print prompt with unicode character
|
146
|
+
user_input = self.console.input("\n[bold cyan]▶[/bold cyan] ").strip()
|
100
147
|
|
101
148
|
if user_input.lower() in ["quit", "exit", "q"]:
|
102
149
|
self.logger.info("User requested exit")
|
103
|
-
print("Goodbye!")
|
150
|
+
self.console.print("\n[bold green]Goodbye! 👋[/bold green]")
|
104
151
|
break
|
105
152
|
|
106
153
|
if not user_input:
|
@@ -110,61 +157,64 @@ class CLI:
|
|
110
157
|
if user_input.lower() == "clear":
|
111
158
|
self.logger.info("User requested conversation clear")
|
112
159
|
self.inference.clear_conversation()
|
113
|
-
print(
|
160
|
+
self.console.print(
|
161
|
+
ResponseFormatter.format_success(
|
162
|
+
"Conversation history cleared."
|
163
|
+
)
|
164
|
+
)
|
114
165
|
continue
|
115
166
|
|
116
|
-
if user_input.lower() == "
|
117
|
-
self.logger.debug("User requested conversation
|
167
|
+
if user_input.lower() == "stats":
|
168
|
+
self.logger.debug("User requested conversation stats")
|
118
169
|
summary = self.inference.get_conversation_summary()
|
119
|
-
|
120
|
-
print(f" Total messages: {summary['total_messages']}")
|
121
|
-
print(f" User messages: {summary['user_messages']}")
|
122
|
-
print(f" Assistant messages: {summary['assistant_messages']}")
|
123
|
-
print(f" Tool messages: {summary['tool_messages']}")
|
124
|
-
print(f" Estimated tokens: {summary['estimated_tokens']}")
|
125
|
-
|
126
|
-
# Display thinking time statistics if available
|
127
|
-
if 'thinking_time_count' in summary and summary['thinking_time_count'] > 0:
|
128
|
-
print(f" Thinking time stats:")
|
129
|
-
print(f" Total thinking time: {summary['total_thinking_time']:.2f}s")
|
130
|
-
print(f" Average thinking time: {summary['average_thinking_time']:.2f}s")
|
131
|
-
print(f" Min thinking time: {summary['min_thinking_time']:.2f}s")
|
132
|
-
print(f" Max thinking time: {summary['max_thinking_time']:.2f}s")
|
133
|
-
print(f" Requests with timing: {summary['thinking_time_count']}")
|
170
|
+
self._print_stats(summary)
|
134
171
|
continue
|
135
172
|
|
136
173
|
if user_input.lower() == "help":
|
137
174
|
self.logger.debug("User requested help")
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
print(" Or just type your request naturally!")
|
175
|
+
self._print_help()
|
176
|
+
continue
|
177
|
+
|
178
|
+
if user_input.lower() == "about":
|
179
|
+
self.logger.debug("User requested about information")
|
180
|
+
self._print_about()
|
145
181
|
continue
|
146
182
|
|
147
183
|
if user_input.lower() == "list":
|
148
184
|
self.logger.debug("User requested task list")
|
149
185
|
try:
|
150
186
|
output = self.todo_shell.list_tasks()
|
151
|
-
|
187
|
+
formatted_output = TaskFormatter.format_task_list(output)
|
188
|
+
task_panel = PanelFormatter.create_task_panel(formatted_output)
|
189
|
+
self.console.print(task_panel)
|
152
190
|
except Exception as e:
|
153
|
-
self.logger.error(f"Error listing tasks: {
|
154
|
-
|
191
|
+
self.logger.error(f"Error listing tasks: {e!s}")
|
192
|
+
error_msg = ResponseFormatter.format_error(
|
193
|
+
f"Failed to list tasks: {e!s}"
|
194
|
+
)
|
195
|
+
self.console.print(error_msg)
|
155
196
|
continue
|
156
197
|
|
157
|
-
self.logger.info(
|
198
|
+
self.logger.info(
|
199
|
+
f"Processing user request: {user_input[:50]}{'...' if len(user_input) > 50 else ''}"
|
200
|
+
)
|
158
201
|
response = self.handle_request(user_input)
|
159
|
-
|
202
|
+
|
203
|
+
# Format the response and create a panel
|
204
|
+
formatted_response = ResponseFormatter.format_response(response)
|
205
|
+
response_panel = PanelFormatter.create_response_panel(
|
206
|
+
formatted_response
|
207
|
+
)
|
208
|
+
self.console.print(response_panel)
|
160
209
|
|
161
210
|
except KeyboardInterrupt:
|
162
211
|
self.logger.info("User interrupted with Ctrl+C")
|
163
|
-
print("\
|
212
|
+
self.console.print("\n[bold green]Goodbye! 👋[/bold green]")
|
164
213
|
break
|
165
214
|
except Exception as e:
|
166
|
-
self.logger.error(f"Error in CLI loop: {
|
167
|
-
|
215
|
+
self.logger.error(f"Error in CLI loop: {e!s}")
|
216
|
+
error_msg = ResponseFormatter.format_error(str(e))
|
217
|
+
self.console.print(error_msg)
|
168
218
|
|
169
219
|
def handle_request(self, user_input: str) -> str:
|
170
220
|
"""
|
@@ -181,20 +231,22 @@ class CLI:
|
|
181
231
|
try:
|
182
232
|
# Process request through inference engine
|
183
233
|
response, thinking_time = self.inference.process_request(user_input)
|
184
|
-
|
234
|
+
|
185
235
|
# Update spinner with completion message and thinking time
|
186
|
-
live.update(
|
187
|
-
|
236
|
+
live.update(
|
237
|
+
self._create_thinking_spinner(f"(thought for {thinking_time:.1f}s)")
|
238
|
+
)
|
239
|
+
|
188
240
|
return response
|
189
241
|
except Exception as e:
|
190
242
|
# Update spinner with error message
|
191
243
|
live.update(self._create_thinking_spinner("Request failed"))
|
192
|
-
|
244
|
+
|
193
245
|
# Log the error
|
194
|
-
self.logger.error(f"Error in handle_request: {
|
195
|
-
|
246
|
+
self.logger.error(f"Error in handle_request: {e!s}")
|
247
|
+
|
196
248
|
# Return error message
|
197
|
-
return
|
249
|
+
return ResponseFormatter.format_error(str(e))
|
198
250
|
|
199
251
|
def run_single_request(self, user_input: str) -> str:
|
200
252
|
"""
|
@@ -206,5 +258,7 @@ class CLI:
|
|
206
258
|
Returns:
|
207
259
|
Formatted response
|
208
260
|
"""
|
209
|
-
self.logger.info(
|
261
|
+
self.logger.info(
|
262
|
+
f"Running single request: {user_input[:50]}{'...' if len(user_input) > 50 else ''}"
|
263
|
+
)
|
210
264
|
return self.handle_request(user_input)
|