#!/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)