Clover/ai_agent.py
Jarian Cottingham 392bcfa2ef feat: Implement AI agent with multi-turn conversation and tool calling
Major enhancements to Clover CLI:

 New Features:
- AI agent with multi-turn conversation capabilities
- Tool calling system with 11+ tools for file operations, Git, linting, etc.
- Step-by-step AI assistance with play-by-play commentary
- Enhanced interactive mode with better UX

🔧 Core Components Added:
- ai_agent.py: Main AI agent with conversation management
- models/: API client and model management system
- Comprehensive tool system for development tasks

🛠️ Tools Available:
- File operations (create, read, update, delete)
- Command execution with safety checks
- Git operations (status, diff, commit, push)
- Code linting and formatting
- Project structure analysis
- Security scanning and dependency management

💡 User Experience:
- Real-time tool execution summaries
- File creation with full path visibility
- Error handling and retry mechanisms
- Clean conversation flow until task completion

🧹 Repository Cleanup:
- Added comprehensive .gitignore
- Removed __pycache__ directories and build artifacts
- Organized project structure

The AI can now actually create files, run commands, and work through complex
development tasks step-by-step with full transparency.
2026-01-15 01:23:40 -06:00

683 lines
28 KiB
Python

#!/usr/bin/env python3
"""
AI Agent for Clover - A conversational AI that can use tools and have multi-turn conversations
"""
import json
import os
import sys
from typing import Any, Dict, List, Optional
from config.settings import load_config
from models.model_manager import ModelManager
from tools.commandline_tool import commandline, safe_execute
from tools.file_tools import (
create_file,
delete_file,
list_files,
read_file,
update_file,
)
from tools.git_tools import git_commit, git_diff, git_push, git_status
from tools.lint_format_tools import format_code, lint_code
from tools.project_tools import get_project_structure, summarize_file
class AIAgent:
"""
AI Agent that can use tools and have multi-turn conversations to solve problems
"""
def __init__(self):
"""Initialize the AI agent"""
self.config = load_config()
self.model_manager = ModelManager()
self.conversation_history = []
self.available_tools = self._setup_tools()
def _setup_tools(self):
"""Setup available tools for the AI agent"""
return {
"create_file": {
"function": create_file,
"description": "Create a new file with content",
"parameters": {
"filepath": "Path to the file to create",
"content": "Content to write to the file",
},
},
"read_file": {
"function": read_file,
"description": "Read content from a file",
"parameters": {"filepath": "Path to the file to read"},
},
"update_file": {
"function": update_file,
"description": "Update an existing file's content",
"parameters": {
"filepath": "Path to the file to update",
"content": "New content to write",
"start_line": "Starting line number (optional)",
"end_line": "Ending line number (optional)",
},
},
"delete_file": {
"function": delete_file,
"description": "Delete a file",
"parameters": {"filepath": "Path to the file to delete"},
},
"list_files": {
"function": list_files,
"description": "List files in a directory",
"parameters": {
"directory": "Directory to list files from (default: current)",
"recursive": "Whether to list recursively (default: false)",
},
},
"run_command": {
"function": commandline,
"description": "Execute a command line operation",
"parameters": {"command": "Command to execute"},
},
"git_status": {
"function": git_status,
"description": "Check Git repository status",
"parameters": {},
},
"git_diff": {
"function": git_diff,
"description": "Show Git diff",
"parameters": {"file_path": "Specific file to diff (optional)"},
},
"lint_code": {
"function": lint_code,
"description": "Lint code files for errors",
"parameters": {"file_paths": "List of file paths to lint"},
},
"get_project_structure": {
"function": get_project_structure,
"description": "Get the project directory structure",
"parameters": {"directory": "Directory to analyze (default: current)"},
},
"get_project_context": {
"function": self._get_project_context,
"description": "Get comprehensive project context including current directory, files, and environment",
"parameters": {},
},
}
def _create_system_prompt(self):
"""Create the system prompt with tool information"""
import os
# Get current working context
current_dir = os.getcwd()
project_name = os.path.basename(current_dir)
# Get directory listing for context
try:
files = os.listdir(current_dir)
files_list = ", ".join([f for f in files[:10] if not f.startswith(".")])
if len(files) > 10:
files_list += "..."
except:
files_list = "Unable to read directory"
tool_descriptions = []
for name, tool in self.available_tools.items():
params = ", ".join([f"{k}: {v}" for k, v in tool["parameters"].items()])
tool_descriptions.append(f"- {name}({params}): {tool['description']}")
tools_text = "\n".join(tool_descriptions)
return f"""You are Clover, an AI assistant designed to help with software development and project management. You have access to various tools to interact with files, run commands, and manage projects.
CURRENT WORKING CONTEXT:
- Working Directory: {current_dir}
- Project Name: {project_name}
- Existing Files: {files_list}
FILE PLACEMENT GUIDELINES:
- Create new files in the current directory ({current_dir}) unless specified otherwise
- Use clear, descriptive filenames (e.g., "timer.py", "calculator.py", "web_server.py")
- For Python files, use .py extension
- For scripts, make them executable with appropriate shebang lines
- Always verify file creation by reading the file back after creating it
- Use get_project_context tool to understand the current working environment
AVAILABLE TOOLS:
- get_project_context(): Get current directory info, file listings, and environment details (USE THIS FIRST!)
{tools_text}
INSTRUCTIONS:
1. You can use tools by responding in this format:
TOOL_CALL: tool_name
PARAMETERS: {{"param1": "value1", "param2": "value2"}}
2. IMPORTANT: Use only ONE tool call per response. If you need multiple tools, explain what you're doing, then use one tool, wait for the result, then continue.
3. Always explain what you're doing before using tools
4. After using tools, analyze the results and continue working toward solving the user's problem
5. Always verify your work by reading files back or checking status
6. Continue the conversation until the problem is fully solved
7. When creating files, use relative paths from the current directory
8. After creating executable files, test them to ensure they work
DEVELOPMENT WORKFLOW:
1. Understand the user's request
2. Plan the solution (explain your approach)
3. Create necessary files with appropriate names
4. Test the files to ensure they work
5. Fix any issues that arise
6. Verify the final solution works as requested
EXAMPLE TOOL USAGE:
TOOL_CALL: create_file
PARAMETERS: {{"filepath": "example.py", "content": "#!/usr/bin/env python3\\nprint('Hello, World!')"}}
IMPORTANT: Use only ONE tool call per response! If you need to create a file AND test it, first create the file, wait for confirmation, then in your next response test it.
When using tools, be methodical and explain each step. Always test your creations to ensure they work properly."""
def _parse_tool_call(self, response_text: str) -> Optional[Dict]:
"""Parse tool call from AI response - returns FIRST valid tool call only"""
lines = response_text.strip().split("\n")
# Find the first complete tool call
for i, line in enumerate(lines):
if line.startswith("TOOL_CALL:"):
tool_name = line.replace("TOOL_CALL:", "").strip()
# Look for the corresponding PARAMETERS line
for j in range(i + 1, len(lines)):
if lines[j].startswith("PARAMETERS:"):
params_text = lines[j].replace("PARAMETERS:", "").strip()
try:
parameters = json.loads(params_text)
result = {"tool": tool_name, "parameters": parameters}
return result
except json.JSONDecodeError:
# Try to find JSON on subsequent lines
json_lines = [params_text]
for k in range(j + 1, len(lines)):
if lines[k].startswith("TOOL_CALL:"):
# Stop if we hit another tool call
break
json_lines.append(lines[k])
try:
full_json = "\n".join(json_lines)
parameters = json.loads(full_json)
result = {
"tool": tool_name,
"parameters": parameters,
}
return result
except json.JSONDecodeError:
continue
break
return None
def _execute_tool(self, tool_name: str, parameters: Dict) -> Dict[str, Any]:
"""Execute a tool with given parameters"""
if tool_name not in self.available_tools:
return {"error": f"Unknown tool: {tool_name}"}
tool = self.available_tools[tool_name]
try:
# Handle special cases for different parameter formats
if tool_name == "list_files":
directory = parameters.get("directory", ".")
recursive = parameters.get("recursive", False)
result = tool["function"](directory, recursive)
elif tool_name == "git_diff":
file_path = parameters.get("file_path")
if file_path:
result = tool["function"](file_path)
else:
result = tool["function"]()
elif tool_name == "git_status":
result = tool["function"]()
elif tool_name == "run_command":
command = parameters.get("command")
result = tool["function"](command)
elif tool_name == "lint_code":
file_paths = parameters.get("file_paths", [])
if isinstance(file_paths, str):
file_paths = [file_paths]
result = tool["function"](file_paths)
else:
# Standard function call with keyword arguments
result = tool["function"](**parameters)
# For file operations, add extra verification
if tool_name == "create_file":
import os
filepath = parameters.get("filepath")
if filepath and result:
# Verify the file was actually created
if not os.path.exists(filepath):
return {
"error": f"File '{filepath}' was not created successfully"
}
return {"success": True, "result": result}
except Exception as e:
return {"error": f"Tool execution failed: {str(e)}"}
def chat(self, user_message: str) -> str:
"""
Have a conversation with the user, using tools as needed
Args:
user_message (str): User's message
Returns:
str: AI's response
"""
# Add user message to conversation history
self.conversation_history.append({"role": "user", "content": user_message})
max_turns = 10 # Prevent infinite loops
turn_count = 0
while turn_count < max_turns:
turn_count += 1
# Prepare messages for AI
messages = [{"role": "system", "content": self._create_system_prompt()}]
messages.extend(self.conversation_history)
# Get AI response
try:
response = self.model_manager.api_client.chat_completion(
messages, model=self.config.get("model", "qwen3-coder:30b")
)
if "error" in response:
return f"Error communicating with AI: {response['error']}"
# Extract AI response
ai_response = ""
if "choices" in response and len(response["choices"]) > 0:
ai_response = response["choices"][0]["message"]["content"]
elif "response" in response:
ai_response = response["response"]
else:
return "Received empty response from AI"
# Parse AI response for better display
tool_call = self._parse_tool_call(ai_response)
if tool_call:
# Extract the explanation part (before tool call)
explanation = ai_response.split("TOOL_CALL:")[0].strip()
if explanation:
print(f"\n🤖 AI Plan (Turn {turn_count}):")
print(explanation)
# Show tool execution summary
self._print_tool_summary(tool_call)
# Execute the tool
tool_result = self._execute_tool(
tool_call["tool"], tool_call["parameters"]
)
# Add AI response and tool result to conversation
self.conversation_history.append(
{"role": "assistant", "content": ai_response}
)
# Format tool result for the AI and user
if "error" in tool_result:
tool_message = f"TOOL_ERROR: {tool_result['error']}"
print(f"\n❌ Tool Failed: {tool_result['error']}")
else:
tool_message = f"TOOL_RESULT: {json.dumps(tool_result['result'], indent=2)}"
print(f"\n✅ Tool Completed Successfully")
self._print_tool_result_summary(
tool_call["tool"],
tool_call["parameters"],
tool_result["result"],
)
self.conversation_history.append(
{"role": "user", "content": tool_message}
)
# Continue the loop to get AI's next response
continue
else:
# No tool call, AI is done with this response
print(f"\n🤖 AI Response (Turn {turn_count}):")
print(ai_response)
self.conversation_history.append(
{"role": "assistant", "content": ai_response}
)
# Check if AI indicates the task is complete
completion_phrases = [
"task completed",
"problem solved",
"finished",
"done!",
"successfully created",
"all set",
"task is complete",
"no further action",
"ready to use",
"fully functional",
]
if any(
phrase in ai_response.lower() for phrase in completion_phrases
):
print("\n🎉 AI indicates the task is complete!")
print(
"📋 Summary: The AI believes the requested task has been finished."
)
break
# Ask if user wants to continue
try:
print("\n" + "=" * 60)
print("🔄 CONTINUE WORKING?")
continue_input = (
input("Continue working on this task? (y/N/q=quit): ")
.strip()
.lower()
)
if continue_input in ["q", "quit"]:
print("🛑 User chose to quit.")
break
elif continue_input not in ["y", "yes"]:
print("⏹️ User chose to stop.")
break
# Get follow-up from user
print("\n💭 NEXT STEPS:")
follow_up = input(
"Any specific modifications or next steps? (Enter to auto-continue): "
).strip()
if follow_up:
print(f"📝 User provided feedback: {follow_up}")
self.conversation_history.append(
{"role": "user", "content": follow_up}
)
else:
print("🤖 AI will continue automatically...")
self.conversation_history.append(
{
"role": "user",
"content": "Please continue working on this task. Verify that everything is working correctly or implement any missing features.",
}
)
except (KeyboardInterrupt, EOFError):
print("\n\n🛑 Conversation interrupted by user.")
break
except Exception as e:
return f"Error during conversation: {str(e)}"
if turn_count >= max_turns:
print(f"\n⚠️ Reached maximum turns ({max_turns}). Ending conversation.")
print(
"📋 The AI worked through multiple iterations but may need more time to complete the task."
)
print("\n" + "=" * 60)
print("🏁 CONVERSATION COMPLETE")
print("=" * 60)
return "Conversation complete."
def _print_tool_summary(self, tool_call):
"""Print a clear summary of what tool is being executed and why"""
tool_name = tool_call["tool"]
params = tool_call["parameters"]
print(f"\n🔧 EXECUTING TOOL: {tool_name.upper()}")
print("=" * 50)
if tool_name == "create_file":
import os
filepath = params.get("filepath", "unknown")
abs_path = os.path.abspath(filepath)
content = params.get("content", "")
content_preview = content[:100] + ("..." if len(content) > 100 else "")
lines_count = content.count("\n") + 1 if content else 0
print(f"📁 Creating file: {filepath}")
print(f"📍 Full path: {abs_path}")
print(f"📏 Content: {lines_count} lines, {len(content)} characters")
print(f"📝 Preview: {content_preview}")
elif tool_name == "read_file":
filepath = params.get("filepath", "unknown")
print(f"📖 Reading file: {filepath}")
print("🎯 Purpose: Verify file contents or check current state")
elif tool_name == "update_file":
filepath = params.get("filepath", "unknown")
start_line = params.get("start_line", "N/A")
end_line = params.get("end_line", "N/A")
print(f"✏️ Updating file: {filepath}")
print(f"📍 Lines: {start_line} to {end_line}")
elif tool_name == "delete_file":
filepath = params.get("filepath", "unknown")
print(f"🗑️ Deleting file: {filepath}")
elif tool_name == "list_files":
directory = params.get("directory", ".")
recursive = params.get("recursive", False)
print(f"📂 Listing files in: {directory}")
print(f"🔍 Recursive: {recursive}")
elif tool_name == "run_command":
command = params.get("command", "unknown")
print(f"⚡ Running command: {command}")
print("🎯 Purpose: Execute system command or test functionality")
elif tool_name in ["git_status", "git_diff"]:
print("🔗 Git operation: Checking repository status or changes")
elif tool_name == "lint_code":
files = params.get("file_paths", [])
print(f"🔍 Linting files: {files}")
print("🎯 Purpose: Check code quality and syntax")
elif tool_name == "get_project_structure":
directory = params.get("directory", ".")
print(f"🏗️ Analyzing project structure in: {directory}")
elif tool_name == "get_project_context":
print("🔍 Getting comprehensive project context")
print("📊 Analyzing current directory, files, and environment")
print("-" * 50)
def _print_tool_result_summary(self, tool_name, params, result):
"""Print a summary of tool execution results"""
print("📋 RESULT SUMMARY:")
if tool_name == "create_file":
import os
filepath = params.get("filepath", "unknown")
abs_path = os.path.abspath(filepath)
if result:
print(f"✅ File '{filepath}' created successfully")
print(f"📍 Location: {abs_path}")
# Verify file exists
if os.path.exists(filepath):
size = os.path.getsize(filepath)
print(f"📏 File size: {size} bytes")
else:
print(f"⚠️ Warning: File not found after creation")
else:
print(f"❌ Failed to create file '{filepath}'")
print(f"📍 Attempted location: {abs_path}")
elif tool_name == "read_file":
filepath = params.get("filepath", "unknown")
if isinstance(result, str):
lines = result.count("\n") + 1
chars = len(result)
print(f"📄 Read '{filepath}': {lines} lines, {chars} characters")
if result.strip():
preview = result.strip()[:100] + (
"..." if len(result.strip()) > 100 else ""
)
print(f"📖 Content preview: {preview}")
else:
print(f"❌ Could not read file '{filepath}'")
elif tool_name == "run_command":
command = params.get("command", "unknown")
if isinstance(result, dict):
success = result.get("success", False)
return_code = result.get("return_code", "N/A")
output = result.get("output", "")
print(f"⚡ Command '{command}' completed")
print(f"📊 Exit code: {return_code}")
if output:
output_preview = output[:200] + ("..." if len(output) > 200 else "")
print(f"📺 Output: {output_preview}")
else:
print(f"⚡ Command '{command}' executed")
elif tool_name == "list_files":
if isinstance(result, list):
count = len(result)
print(f"📂 Found {count} items")
if result and count <= 10:
print(f"📋 Items: {', '.join(result[:10])}")
elif result:
print(f"📋 First 5 items: {', '.join(result[:5])}")
elif tool_name in ["git_status", "git_diff"]:
if isinstance(result, dict):
if "staged" in result:
staged = len(result.get("staged", []))
unstaged = len(result.get("unstaged", []))
untracked = len(result.get("untracked", []))
print(
f"🔗 Git status: {staged} staged, {unstaged} unstaged, {untracked} untracked"
)
elif tool_name == "get_project_context":
if isinstance(result, dict):
working_dir = result.get("working_directory", {})
files_info = result.get("files_and_directories", {})
environment = result.get("environment", {})
project_files = result.get("project_indicators", [])
print(f"📂 Working Directory: {working_dir.get('name', 'unknown')}")
print(f"📍 Path: {working_dir.get('path', 'unknown')}")
if files_info.get("files"):
file_count = files_info.get("total_files", 0)
dir_count = files_info.get("total_directories", 0)
print(f"📊 Contents: {file_count} files, {dir_count} directories")
if project_files:
print(f"🔧 Project files found: {', '.join(project_files[:5])}")
platform_info = environment.get("platform", "unknown")
python_ver = environment.get("python_version", "unknown")
print(f"💻 Environment: {platform_info}, Python {python_ver}")
print("=" * 50)
def _get_project_context(self):
"""Get comprehensive project context for the AI"""
import os
import platform
context = {
"working_directory": {
"path": os.getcwd(),
"name": os.path.basename(os.getcwd()),
"absolute_path": os.path.abspath("."),
},
"files_and_directories": {},
"environment": {
"platform": platform.system(),
"python_version": platform.python_version(),
"user": os.getenv("USER", "unknown"),
},
}
# Get directory contents
try:
items = os.listdir(".")
files = []
directories = []
for item in sorted(items):
if os.path.isfile(item):
size = os.path.getsize(item)
files.append({"name": item, "size": size, "type": "file"})
elif os.path.isdir(item) and not item.startswith("."):
directories.append({"name": item, "type": "directory"})
context["files_and_directories"] = {
"files": files[:20], # Limit to first 20 files
"directories": directories[:10], # Limit to first 10 directories
"total_files": len(files),
"total_directories": len(directories),
}
except Exception as e:
context["files_and_directories"] = {"error": str(e)}
# Check for common project files
common_files = [
"requirements.txt",
"setup.py",
"pyproject.toml",
"Pipfile",
"package.json",
"Cargo.toml",
"go.mod",
"Dockerfile",
"README.md",
"LICENSE",
".gitignore",
]
found_project_files = []
for file in common_files:
if os.path.exists(file):
found_project_files.append(file)
context["project_indicators"] = found_project_files
return context
def reset_conversation(self):
"""Reset the conversation history"""
self.conversation_history = []
print("🔄 Conversation history cleared.")
def get_conversation_summary(self) -> str:
"""Get a summary of the current conversation"""
if not self.conversation_history:
return "No conversation history."
summary_parts = []
for i, message in enumerate(self.conversation_history[-6:]): # Last 6 messages
role = message["role"].upper()
content = message["content"][:100] + (
"..." if len(message["content"]) > 100 else ""
)
summary_parts.append(f"{role}: {content}")
return "\n".join(summary_parts)