forked from Doriandarko/claude-engineer
-
Notifications
You must be signed in to change notification settings - Fork 0
/
ce3.py
529 lines (456 loc) · 20.9 KB
/
ce3.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
# ce3.py
import anthropic
from rich.console import Console
from rich.markdown import Markdown
from rich.live import Live
from rich.spinner import Spinner
from rich.panel import Panel
from typing import List, Dict, Any
import importlib
import inspect
import pkgutil
import os
import json
import sys
import logging
from config import Config
from tools.base import BaseTool
from prompt_toolkit import prompt
from prompt_toolkit.styles import Style
from prompts.system_prompts import SystemPrompts
# Configure logging to only show ERROR level and above
logging.basicConfig(
level=logging.ERROR,
format='%(levelname)s: %(message)s'
)
class Assistant:
"""
The Assistant class manages:
- Loading of tools from a specified directory.
- Interaction with the Anthropics API (message completion).
- Handling user commands such as 'refresh' and 'reset'.
- Token usage tracking and display.
- Tool execution upon request from model responses.
"""
def __init__(self):
if not getattr(Config, 'ANTHROPIC_API_KEY', None):
raise ValueError("No ANTHROPIC_API_KEY found in environment variables")
# Initialize Anthropics client
self.client = anthropic.Anthropic(api_key=Config.ANTHROPIC_API_KEY)
self.conversation_history: List[Dict[str, Any]] = []
self.console = Console()
self.thinking_enabled = getattr(Config, 'ENABLE_THINKING', False)
self.temperature = getattr(Config, 'DEFAULT_TEMPERATURE', 0.7)
self.total_tokens_used = 0
self.tools = self._load_tools()
def _execute_uv_install(self, package_name: str) -> bool:
"""
Execute the uvpackagemanager tool directly to install the missing package.
Returns True if installation seems successful (no errors in output), otherwise False.
"""
class ToolUseMock:
name = "uvpackagemanager"
input = {
"command": "install",
"packages": [package_name]
}
result = self._execute_tool(ToolUseMock())
if "Error" not in result and "failed" not in result.lower():
self.console.print("[green]The package was installed successfully.[/green]")
return True
else:
self.console.print(f"[red]Failed to install {package_name}. Output:[/red] {result}")
return False
def _load_tools(self) -> List[Dict[str, Any]]:
"""
Dynamically load all tool classes from the tools directory.
If a dependency is missing, prompt the user to install it via uvpackagemanager.
Returns:
A list of tools (dicts) containing their 'name', 'description', and 'input_schema'.
"""
tools = []
tools_path = getattr(Config, 'TOOLS_DIR', None)
if tools_path is None:
self.console.print("[red]TOOLS_DIR not set in Config[/red]")
return tools
# Clear cached tool modules for fresh import
for module_name in list(sys.modules.keys()):
if module_name.startswith('tools.') and module_name != 'tools.base':
del sys.modules[module_name]
try:
for module_info in pkgutil.iter_modules([str(tools_path)]):
if module_info.name == 'base':
continue
# Attempt loading the tool module
try:
module = importlib.import_module(f'tools.{module_info.name}')
self._extract_tools_from_module(module, tools)
except ImportError as e:
# Handle missing dependencies
missing_module = self._parse_missing_dependency(str(e))
self.console.print(f"\n[yellow]Missing dependency:[/yellow] {missing_module} for tool {module_info.name}")
user_response = input(f"Would you like to install {missing_module}? (y/n): ").lower()
if user_response == 'y':
success = self._execute_uv_install(missing_module)
if success:
# Retry loading the module after installation
try:
module = importlib.import_module(f'tools.{module_info.name}')
self._extract_tools_from_module(module, tools)
except Exception as retry_err:
self.console.print(f"[red]Failed to load tool after installation: {str(retry_err)}[/red]")
else:
self.console.print(f"[red]Installation of {missing_module} failed. Skipping this tool.[/red]")
else:
self.console.print(f"[yellow]Skipping tool {module_info.name} due to missing dependency[/yellow]")
except Exception as mod_err:
self.console.print(f"[red]Error loading module {module_info.name}:[/red] {str(mod_err)}")
except Exception as overall_err:
self.console.print(f"[red]Error in tool loading process:[/red] {str(overall_err)}")
return tools
def _parse_missing_dependency(self, error_str: str) -> str:
"""
Parse the missing dependency name from an ImportError string.
"""
if "No module named" in error_str:
parts = error_str.split("No module named")
missing_module = parts[-1].strip(" '\"")
else:
missing_module = error_str
return missing_module
def _extract_tools_from_module(self, module, tools: List[Dict[str, Any]]) -> None:
"""
Given a tool module, find and instantiate all tool classes (subclasses of BaseTool).
Append them to the 'tools' list.
"""
for name, obj in inspect.getmembers(module):
if (inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool):
try:
tool_instance = obj()
tools.append({
"name": tool_instance.name,
"description": tool_instance.description,
"input_schema": tool_instance.input_schema
})
self.console.print(f"[green]Loaded tool:[/green] {tool_instance.name}")
except Exception as tool_init_err:
self.console.print(f"[red]Error initializing tool {name}:[/red] {str(tool_init_err)}")
def refresh_tools(self):
"""
Refresh the list of tools and show newly discovered tools.
"""
current_tool_names = {tool['name'] for tool in self.tools}
self.tools = self._load_tools()
new_tool_names = {tool['name'] for tool in self.tools}
new_tools = new_tool_names - current_tool_names
if new_tools:
self.console.print("\n")
for tool_name in new_tools:
tool_info = next((t for t in self.tools if t['name'] == tool_name), None)
if tool_info:
description_lines = tool_info['description'].strip().split('\n')
formatted_description = '\n '.join(line.strip() for line in description_lines)
self.console.print(f"[bold green]NEW[/bold green] 🔧 [cyan]{tool_name}[/cyan]:\n {formatted_description}")
else:
self.console.print("\n[yellow]No new tools found[/yellow]")
def display_available_tools(self):
"""
Print a list of currently loaded tools.
"""
self.console.print("\n[bold cyan]Available tools:[/bold cyan]")
tool_names = [tool['name'] for tool in self.tools]
if tool_names:
formatted_tools = ", ".join([f"🔧 [cyan]{name}[/cyan]" for name in tool_names])
else:
formatted_tools = "No tools available."
self.console.print(formatted_tools)
self.console.print("\n---")
def _display_tool_usage(self, tool_name: str, input_data: Dict, result: str):
"""
If SHOW_TOOL_USAGE is enabled, display the input and result of a tool execution.
Handles special cases like image data and large outputs for cleaner display.
"""
if not getattr(Config, 'SHOW_TOOL_USAGE', False):
return
# Clean up input data by removing any large binary/base64 content
cleaned_input = self._clean_data_for_display(input_data)
# Clean up result data
cleaned_result = self._clean_data_for_display(result)
tool_info = f"""[cyan]📥 Input:[/cyan] {json.dumps(cleaned_input, indent=2)}
[cyan]📤 Result:[/cyan] {cleaned_result}"""
panel = Panel(
tool_info,
title=f"Tool used: {tool_name}",
title_align="left",
border_style="cyan",
padding=(1, 2)
)
self.console.print(panel)
def _clean_data_for_display(self, data):
"""
Helper method to clean data for display by handling various data types
and removing/replacing large content like base64 strings.
"""
if isinstance(data, str):
try:
# Try to parse as JSON first
parsed_data = json.loads(data)
return self._clean_parsed_data(parsed_data)
except json.JSONDecodeError:
# If it's a long string, check for base64 patterns
if len(data) > 1000 and ';base64,' in data:
return "[base64 data omitted]"
return data
elif isinstance(data, dict):
return self._clean_parsed_data(data)
else:
return data
def _clean_parsed_data(self, data):
"""
Recursively clean parsed JSON/dict data, handling nested structures
and replacing large data with placeholders.
"""
if isinstance(data, dict):
cleaned = {}
for key, value in data.items():
# Handle image data in various formats
if key in ['data', 'image', 'source'] and isinstance(value, str):
if len(value) > 1000 and (';base64,' in value or value.startswith('data:')):
cleaned[key] = "[base64 data omitted]"
else:
cleaned[key] = value
else:
cleaned[key] = self._clean_parsed_data(value)
return cleaned
elif isinstance(data, list):
return [self._clean_parsed_data(item) for item in data]
elif isinstance(data, str) and len(data) > 1000 and ';base64,' in data:
return "[base64 data omitted]"
return data
def _execute_tool(self, tool_use):
"""
Given a tool usage request (with tool name and inputs),
dynamically load and execute the corresponding tool.
"""
tool_name = tool_use.name
tool_input = tool_use.input or {}
tool_result = None
try:
module = importlib.import_module(f'tools.{tool_name}')
tool_instance = self._find_tool_instance_in_module(module, tool_name)
if not tool_instance:
tool_result = f"Tool not found: {tool_name}"
else:
# Execute the tool with the provided input
try:
result = tool_instance.execute(**tool_input)
# Keep structured data intact
tool_result = result
except Exception as exec_err:
tool_result = f"Error executing tool '{tool_name}': {str(exec_err)}"
except ImportError:
tool_result = f"Failed to import tool: {tool_name}"
except Exception as e:
tool_result = f"Error executing tool: {str(e)}"
# Display tool usage with proper handling of structured data
self._display_tool_usage(tool_name, tool_input,
json.dumps(tool_result) if not isinstance(tool_result, str) else tool_result)
return tool_result
def _find_tool_instance_in_module(self, module, tool_name: str):
"""
Search a given module for a tool class matching tool_name and return an instance of it.
"""
for name, obj in inspect.getmembers(module):
if (inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool):
candidate_tool = obj()
if candidate_tool.name == tool_name:
return candidate_tool
return None
def _display_token_usage(self, usage):
"""
Display a visual representation of token usage and remaining tokens.
Uses only the tracked total_tokens_used.
"""
used_percentage = (self.total_tokens_used / Config.MAX_CONVERSATION_TOKENS) * 100
remaining_tokens = max(0, Config.MAX_CONVERSATION_TOKENS - self.total_tokens_used)
self.console.print(f"\nTotal used: {self.total_tokens_used:,} / {Config.MAX_CONVERSATION_TOKENS:,}")
bar_width = 40
filled = int(used_percentage / 100 * bar_width)
bar = "█" * filled + "░" * (bar_width - filled)
color = "green"
if used_percentage > 75:
color = "yellow"
if used_percentage > 90:
color = "red"
self.console.print(f"[{color}][{bar}] {used_percentage:.1f}%[/{color}]")
if remaining_tokens < 20000:
self.console.print(f"[bold red]Warning: Only {remaining_tokens:,} tokens remaining![/bold red]")
self.console.print("---")
def _get_completion(self):
"""
Get a completion from the Anthropic API.
Handles both text-only and multimodal messages.
"""
try:
response = self.client.messages.create(
model=Config.MODEL,
max_tokens=min(
Config.MAX_TOKENS,
Config.MAX_CONVERSATION_TOKENS - self.total_tokens_used
),
temperature=self.temperature,
tools=self.tools,
messages=self.conversation_history,
system=f"{SystemPrompts.DEFAULT}\n\n{SystemPrompts.TOOL_USAGE}"
)
# Update token usage based on response usage
if hasattr(response, 'usage') and response.usage:
message_tokens = response.usage.input_tokens + response.usage.output_tokens
self.total_tokens_used += message_tokens
self._display_token_usage(response.usage)
if self.total_tokens_used >= Config.MAX_CONVERSATION_TOKENS:
self.console.print("\n[bold red]Token limit reached! Please reset the conversation.[/bold red]")
return "Token limit reached! Please type 'reset' to start a new conversation."
if response.stop_reason == "tool_use":
self.console.print("\n[bold yellow] Handling Tool Use...[/bold yellow]\n")
tool_results = []
if getattr(response, 'content', None) and isinstance(response.content, list):
# Execute each tool in the response content
for content_block in response.content:
if content_block.type == "tool_use":
result = self._execute_tool(content_block)
# Handle structured data (like image blocks) vs text
if isinstance(result, (list, dict)):
tool_results.append({
"type": "tool_result",
"tool_use_id": content_block.id,
"content": result # Keep structured data intact
})
else:
# Convert text results to proper content blocks
tool_results.append({
"type": "tool_result",
"tool_use_id": content_block.id,
"content": [{"type": "text", "text": str(result)}]
})
# Append tool usage to conversation and continue
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
self.conversation_history.append({
"role": "user",
"content": tool_results
})
return self._get_completion() # Recursive call to continue the conversation
else:
self.console.print("[red]No tool content received despite 'tool_use' stop reason.[/red]")
return "Error: No tool content received"
# Final assistant response
if (getattr(response, 'content', None) and
isinstance(response.content, list) and
response.content):
final_content = response.content[0].text
self.conversation_history.append({
"role": "assistant",
"content": response.content
})
return final_content
else:
self.console.print("[red]No content in final response.[/red]")
return "No response content available."
except Exception as e:
logging.error(f"Error in _get_completion: {str(e)}")
return f"Error: {str(e)}"
def chat(self, user_input):
"""
Process a chat message from the user.
user_input can be either a string (text-only) or a list (multimodal message)
"""
# Handle special commands only for text-only messages
if isinstance(user_input, str):
if user_input.lower() == 'refresh':
self.refresh_tools()
return "Tools refreshed successfully!"
elif user_input.lower() == 'reset':
self.reset()
return "Conversation reset!"
elif user_input.lower() == 'quit':
return "Goodbye!"
try:
# Add user message to conversation history
self.conversation_history.append({
"role": "user",
"content": user_input # This can be either string or list
})
# Show thinking indicator if enabled
if self.thinking_enabled:
with Live(Spinner('dots', text='Thinking...', style="cyan"),
refresh_per_second=10, transient=True):
response = self._get_completion()
else:
response = self._get_completion()
return response
except Exception as e:
logging.error(f"Error in chat: {str(e)}")
return f"Error: {str(e)}"
def reset(self):
"""
Reset the assistant's memory and token usage.
"""
self.conversation_history = []
self.total_tokens_used = 0
self.console.print("\n[bold green]🔄 Assistant memory has been reset![/bold green]")
welcome_text = """
# Claude Engineer v3. A self-improving assistant framework with tool creation
Type 'refresh' to reload available tools
Type 'reset' to clear conversation history
Type 'quit' to exit
Available tools:
"""
self.console.print(Markdown(welcome_text))
self.display_available_tools()
def main():
"""
Entry point for the assistant CLI loop.
Provides a prompt for user input and handles 'quit' and 'reset' commands.
"""
console = Console()
style = Style.from_dict({'prompt': 'orange'})
try:
assistant = Assistant()
except ValueError as e:
console.print(f"[bold red]Error:[/bold red] {str(e)}")
console.print("Please ensure ANTHROPIC_API_KEY is set correctly.")
return
welcome_text = """
# Claude Engineer v3. A self-improving assistant framework with tool creation
Type 'refresh' to reload available tools
Type 'reset' to clear conversation history
Type 'quit' to exit
Available tools:
"""
console.print(Markdown(welcome_text))
assistant.display_available_tools()
while True:
try:
user_input = prompt("You: ", style=style).strip()
if user_input.lower() == 'quit':
console.print("\n[bold blue]👋 Goodbye![/bold blue]")
break
elif user_input.lower() == 'reset':
assistant.reset()
continue
response = assistant.chat(user_input)
console.print("\n[bold purple]Claude Engineer:[/bold purple]")
if isinstance(response, str):
safe_response = response.replace('[', '\\[').replace(']', '\\]')
console.print(safe_response)
else:
console.print(str(response))
except KeyboardInterrupt:
continue
except EOFError:
break
if __name__ == "__main__":
main()