feat(Makefile): add commands for code checking and fixing using ruff (#63)

refactor(ra_aid/__init__.py): reorganize imports for better readability
refactor(ra_aid/__main__.py): clean up imports and improve structure
refactor(ra_aid/agent_utils.py): streamline imports and improve organization
refactor(ra_aid/agents/ciayn_agent.py): enhance code structure and readability
refactor(ra_aid/chat_models/deepseek_chat.py): tidy up imports for clarity
refactor(ra_aid/config.py): maintain consistent formatting and organization
refactor(ra_aid/console/__init__.py): improve import structure for clarity
refactor(ra_aid/console/cowboy_messages.py): enhance code readability
refactor(ra_aid/console/formatting.py): clean up formatting functions for consistency
refactor(ra_aid/console/output.py): improve output handling for better clarity
refactor(ra_aid/dependencies.py): enhance dependency checking structure
refactor(ra_aid/env.py): streamline environment validation logic
refactor(ra_aid/exceptions.py): improve exception handling structure
refactor(ra_aid/file_listing.py): enhance file listing functionality
refactor(ra_aid/llm.py): improve language model initialization logic
refactor(ra_aid/logging_config.py): tidy up logging configuration
refactor(ra_aid/models_tokens.py): maintain consistent formatting
refactor(ra_aid/proc/interactive.py): enhance subprocess handling
refactor(ra_aid/project_info.py): improve project information handling
refactor(ra_aid/project_state.py): streamline project state management
refactor(ra_aid/provider_strategy.py): enhance provider validation logic
refactor(ra_aid/tests/test_env.py): improve test structure and readability
refactor(ra_aid/text/__init__.py): maintain consistent import structure
refactor(ra_aid/text/processing.py): enhance text processing functions
refactor(ra_aid/tool_configs.py): improve tool configuration structure
refactor(ra_aid/tools/__init__.py): tidy up tool imports for clarity
refactor(ra_aid/tools/agent.py): enhance agent tool functionality
refactor(ra_aid/tools/expert.py): improve expert tool handling
refactor(ra_aid/tools/file_str_replace.py): streamline file string replacement logic
refactor(ra_aid/tools/fuzzy_find.py): enhance fuzzy find functionality
refactor(ra_aid/tools/handle_user_defined_test_cmd_execution.py): improve test command execution logic
refactor(ra_aid/tools/human.py): enhance human interaction handling
refactor(ra_aid/tools/list_directory.py): improve directory listing functionality
refactor(ra_aid/tools/memory.py): streamline memory management functions
refactor(ra_aid/tools/programmer.py): enhance programming task handling
refactor(ra_aid/tools/read_file.py): improve file reading functionality
refactor(ra_aid/tools/reflection.py): maintain consistent function structure
refactor(ra_aid/tools/research.py): enhance research tool handling
refactor(ra_aid/tools/ripgrep.py): improve ripgrep search functionality
refactor(ra_aid/tools/ripgrep.py): enhance ripgrep search command handling
refactor(ra_aid/tools/ripgrep.py): improve search result handling
refactor(ra_aid/tools/ripgrep.py): streamline search parameters handling
refactor(ra_aid/tools/ripgrep.py): enhance error handling for search operations
refactor(ra_aid/tools/ripgrep.py): improve output formatting for search results
refactor(ra_aid/tools/ripgrep.py): maintain consistent command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search result display
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(ra_aid/tools/ripgrep.py): enhance search command execution logic
refactor(ra_aid/tools/ripgrep.py): improve search command output formatting
refactor(ra_aid/tools/ripgrep.py): streamline search command construction
refactor(ra_aid/tools/ripgrep.py): enhance search command error handling
refactor(ra_aid/tools/ripgrep.py): improve search command output handling
refactor(ra_aid/tools/ripgrep.py): maintain consistent search command structure
refactor(

style(server.ts): update variable naming from lowercase 'port' to uppercase 'PORT' for consistency and clarity
feat(server.ts): allow server to listen on a configurable port using process.env.PORT or default to PORT
style(shell.py): reorder import statements for better organization and readability
style(shell.py): remove unnecessary blank lines for cleaner code
style(web_search_tavily.py): reorder import statements for better organization
style(write_file.py): format code for consistency and readability
style(extract_changelog.py): format code for consistency and readability
style(generate_swebench_dataset.py): format code for consistency and readability
style(test_ciayn_agent.py): format code for consistency and readability
style(test_cowboy_messages.py): format code for consistency and readability
style(test_interactive.py): format code for consistency and readability
style(test_agent_utils.py): format code for consistency and readability
style(test_default_provider.py): format code for consistency and readability
style(test_env.py): format code for consistency and readability
style(test_llm.py): format code for consistency and readability
style(test_main.py): format code for consistency and readability
style(test_programmer.py): format code for consistency and readability
style(test_provider_integration.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_utils.py): format code for consistency and readability
style(test_execution.py): format code for consistency and readability
style(test_expert.py): format code for consistency and readability
style(test_file_str_replace.py): format code for consistency and readability
style(test_fuzzy_find.py): format code for consistency and readability
style(test_handle_user_defined_test_cmd_execution.py): format code for consistency and readability
style(test_list_directory.py): format code for consistency and readability
style(test_user_defined_test_cmd_execution.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and readability
style(test_tool_configs.py): format code for consistency and

style(tests): standardize string quotes to double quotes for consistency across test files
style(tests): format code for better readability by adjusting spacing and line breaks
style(tests): remove unnecessary blank lines to improve code cleanliness
style(tests): ensure consistent use of whitespace around operators and after commas
style(tests): align comments and docstrings for better visual structure and clarity
This commit is contained in:
Ariel Frischer 2025-01-29 13:38:58 -08:00 committed by GitHub
parent b44f1c73eb
commit b00fd47573
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
72 changed files with 2743 additions and 2161 deletions

View File

@ -9,3 +9,14 @@ setup-dev:
setup-hooks: setup-dev setup-hooks: setup-dev
pre-commit install pre-commit install
check:
ruff check
fix:
ruff check . --select I --fix # First sort imports
ruff format .
ruff check --fix
fix-basic:
ruff check --fix

View File

@ -1,16 +1,21 @@
from .__version__ import __version__ from .__version__ import __version__
from .console.formatting import print_stage_header, print_task_header, print_error, print_interrupt from .agent_utils import run_agent_with_retry
from .console.formatting import (
print_error,
print_interrupt,
print_stage_header,
print_task_header,
)
from .console.output import print_agent_output from .console.output import print_agent_output
from .text.processing import truncate_output from .text.processing import truncate_output
from .agent_utils import run_agent_with_retry
__all__ = [ __all__ = [
'print_stage_header', "print_stage_header",
'print_task_header', "print_task_header",
'print_agent_output', "print_agent_output",
'truncate_output', "truncate_output",
'print_error', "print_error",
'print_interrupt', "print_interrupt",
'run_agent_with_retry', "run_agent_with_retry",
'__version__' "__version__",
] ]

View File

@ -1,30 +1,32 @@
import argparse import argparse
import os
import sys import sys
import uuid import uuid
from datetime import datetime from datetime import datetime
from rich.panel import Panel
from rich.console import Console
from langgraph.checkpoint.memory import MemorySaver from langgraph.checkpoint.memory import MemorySaver
from ra_aid.config import DEFAULT_MAX_TEST_CMD_RETRIES, DEFAULT_RECURSION_LIMIT from rich.console import Console
from ra_aid.env import validate_environment from rich.panel import Panel
from ra_aid.project_info import get_project_info, format_project_info
from ra_aid.tools.memory import _global_memory from ra_aid import print_error, print_stage_header
from ra_aid.tools.human import ask_human
from ra_aid import print_stage_header, print_error
from ra_aid.__version__ import __version__ from ra_aid.__version__ import __version__
from ra_aid.agent_utils import ( from ra_aid.agent_utils import (
AgentInterrupt, AgentInterrupt,
run_agent_with_retry,
run_research_agent,
run_planning_agent,
create_agent, create_agent,
run_agent_with_retry,
run_planning_agent,
run_research_agent,
) )
from ra_aid.prompts import CHAT_PROMPT, WEB_RESEARCH_PROMPT_SECTION_CHAT from ra_aid.config import DEFAULT_MAX_TEST_CMD_RETRIES, DEFAULT_RECURSION_LIMIT
from ra_aid.llm import initialize_llm
from ra_aid.logging_config import setup_logging, get_logger
from ra_aid.tool_configs import get_chat_tools
from ra_aid.dependencies import check_dependencies from ra_aid.dependencies import check_dependencies
import os from ra_aid.env import validate_environment
from ra_aid.llm import initialize_llm
from ra_aid.logging_config import get_logger, setup_logging
from ra_aid.project_info import format_project_info, get_project_info
from ra_aid.prompts import CHAT_PROMPT, WEB_RESEARCH_PROMPT_SECTION_CHAT
from ra_aid.tool_configs import get_chat_tools
from ra_aid.tools.human import ask_human
from ra_aid.tools.memory import _global_memory
logger = get_logger(__name__) logger = get_logger(__name__)
@ -151,12 +153,12 @@ Examples:
parser.add_argument( parser.add_argument(
"--test-cmd", "--test-cmd",
type=str, type=str,
help="Test command to run before completing tasks (e.g. 'pytest tests/')" help="Test command to run before completing tasks (e.g. 'pytest tests/')",
) )
parser.add_argument( parser.add_argument(
"--auto-test", "--auto-test",
action="store_true", action="store_true",
help="Automatically run tests before completing tasks" help="Automatically run tests before completing tasks",
) )
parser.add_argument( parser.add_argument(
"--max-test-cmd-retries", "--max-test-cmd-retries",

View File

@ -1,73 +1,65 @@
"""Utility functions for working with agents.""" """Utility functions for working with agents."""
import signal
import sys import sys
import threading
import time import time
import uuid import uuid
from typing import Optional, Any, List, Dict, Sequence from typing import Any, Dict, List, Optional, Sequence
from langchain_core.messages import BaseMessage, trim_messages
from litellm import get_model_info
import litellm import litellm
from anthropic import APIError, APITimeoutError, InternalServerError, RateLimitError
import signal
from langgraph.checkpoint.memory import MemorySaver
from langgraph.prebuilt.chat_agent_executor import AgentState
from ra_aid.config import DEFAULT_MAX_TEST_CMD_RETRIES, DEFAULT_RECURSION_LIMIT
from ra_aid.models_tokens import DEFAULT_TOKEN_LIMIT, models_tokens
from ra_aid.agents.ciayn_agent import CiaynAgent
import threading
from ra_aid.project_info import (
get_project_info,
format_project_info,
display_project_status,
)
from langgraph.prebuilt import create_react_agent
from ra_aid.console.formatting import print_stage_header, print_error
from langchain_core.language_models import BaseChatModel from langchain_core.language_models import BaseChatModel
from langchain_core.messages import BaseMessage, HumanMessage, trim_messages
from langchain_core.tools import tool from langchain_core.tools import tool
from ra_aid.console.output import print_agent_output from langgraph.checkpoint.memory import MemorySaver
from ra_aid.logging_config import get_logger from langgraph.prebuilt import create_react_agent
from ra_aid.exceptions import AgentInterrupt from langgraph.prebuilt.chat_agent_executor import AgentState
from ra_aid.tool_configs import ( from litellm import get_model_info
get_implementation_tools,
get_research_tools,
get_planning_tools,
get_web_research_tools,
)
from ra_aid.prompts import (
IMPLEMENTATION_PROMPT,
EXPERT_PROMPT_SECTION_IMPLEMENTATION,
HUMAN_PROMPT_SECTION_IMPLEMENTATION,
EXPERT_PROMPT_SECTION_RESEARCH,
WEB_RESEARCH_PROMPT_SECTION_RESEARCH,
WEB_RESEARCH_PROMPT_SECTION_CHAT,
WEB_RESEARCH_PROMPT_SECTION_PLANNING,
RESEARCH_PROMPT,
RESEARCH_ONLY_PROMPT,
HUMAN_PROMPT_SECTION_RESEARCH,
PLANNING_PROMPT,
EXPERT_PROMPT_SECTION_PLANNING,
HUMAN_PROMPT_SECTION_PLANNING,
WEB_RESEARCH_PROMPT,
)
from langchain_core.messages import HumanMessage
from anthropic import APIError, APITimeoutError, RateLimitError, InternalServerError
from ra_aid.tools.human import ask_human
from ra_aid.tools.shell import run_shell_command
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
from ra_aid.agents.ciayn_agent import CiaynAgent
from ra_aid.config import DEFAULT_MAX_TEST_CMD_RETRIES, DEFAULT_RECURSION_LIMIT
from ra_aid.console.formatting import print_error, print_stage_header
from ra_aid.console.output import print_agent_output
from ra_aid.exceptions import AgentInterrupt
from ra_aid.logging_config import get_logger
from ra_aid.models_tokens import DEFAULT_TOKEN_LIMIT, models_tokens
from ra_aid.project_info import (
display_project_status,
format_project_info,
get_project_info,
)
from ra_aid.prompts import (
EXPERT_PROMPT_SECTION_IMPLEMENTATION,
EXPERT_PROMPT_SECTION_PLANNING,
EXPERT_PROMPT_SECTION_RESEARCH,
HUMAN_PROMPT_SECTION_IMPLEMENTATION,
HUMAN_PROMPT_SECTION_PLANNING,
HUMAN_PROMPT_SECTION_RESEARCH,
IMPLEMENTATION_PROMPT,
PLANNING_PROMPT,
RESEARCH_ONLY_PROMPT,
RESEARCH_PROMPT,
WEB_RESEARCH_PROMPT,
WEB_RESEARCH_PROMPT_SECTION_CHAT,
WEB_RESEARCH_PROMPT_SECTION_PLANNING,
WEB_RESEARCH_PROMPT_SECTION_RESEARCH,
)
from ra_aid.tool_configs import (
get_implementation_tools,
get_planning_tools,
get_research_tools,
get_web_research_tools,
)
from ra_aid.tools.handle_user_defined_test_cmd_execution import execute_test_command
from ra_aid.tools.memory import ( from ra_aid.tools.memory import (
_global_memory, _global_memory,
get_memory_value, get_memory_value,
get_related_files, get_related_files,
) )
from ra_aid.tools.handle_user_defined_test_cmd_execution import execute_test_command
console = Console() console = Console()
@ -723,7 +715,7 @@ def run_agent_with_retry(agent, prompt: str, config: dict) -> Optional[str]:
max_retries = 20 max_retries = 20
base_delay = 1 base_delay = 1
test_attempts = 0 test_attempts = 0
max_test_retries = config.get("max_test_cmd_retries", DEFAULT_MAX_TEST_CMD_RETRIES) _max_test_retries = config.get("max_test_cmd_retries", DEFAULT_MAX_TEST_CMD_RETRIES)
auto_test = config.get("auto_test", False) auto_test = config.get("auto_test", False)
original_prompt = prompt original_prompt = prompt
@ -754,11 +746,10 @@ def run_agent_with_retry(agent, prompt: str, config: dict) -> Optional[str]:
break break
# Execute test command if configured # Execute test command if configured
should_break, prompt, auto_test, test_attempts = execute_test_command( should_break, prompt, auto_test, test_attempts = (
config, execute_test_command(
original_prompt, config, original_prompt, test_attempts, auto_test
test_attempts, )
auto_test
) )
if should_break: if should_break:
break break

View File

@ -1,21 +1,23 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, Any, Generator, List, Optional, Union from typing import Any, Dict, Generator, List, Optional, Union
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from ra_aid.exceptions import ToolExecutionError
from ra_aid.logging_config import get_logger
from ra_aid.models_tokens import DEFAULT_TOKEN_LIMIT from ra_aid.models_tokens import DEFAULT_TOKEN_LIMIT
from ra_aid.tools.reflection import get_function_info from ra_aid.tools.reflection import get_function_info
from langchain_core.messages import AIMessage, HumanMessage, BaseMessage, SystemMessage
from ra_aid.exceptions import ToolExecutionError
from ra_aid.logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@dataclass @dataclass
class ChunkMessage: class ChunkMessage:
content: str content: str
status: str status: str
def validate_function_call_pattern(s: str) -> bool: def validate_function_call_pattern(s: str) -> bool:
"""Check if a string matches the expected function call pattern. """Check if a string matches the expected function call pattern.
@ -34,6 +36,7 @@ def validate_function_call_pattern(s: str) -> bool:
pattern = r"^\s*[\w_\-]+\s*\([^)(]*(?:\([^)(]*\)[^)(]*)*\)\s*$" pattern = r"^\s*[\w_\-]+\s*\([^)(]*(?:\([^)(]*\)[^)(]*)*\)\s*$"
return not re.match(pattern, s, re.DOTALL) return not re.match(pattern, s, re.DOTALL)
class CiaynAgent: class CiaynAgent:
"""Code Is All You Need (CIAYN) agent that uses generated Python code for tool interaction. """Code Is All You Need (CIAYN) agent that uses generated Python code for tool interaction.
@ -65,8 +68,13 @@ class CiaynAgent:
- Memory management with configurable limits - Memory management with configurable limits
""" """
def __init__(
def __init__(self, model, tools: list, max_history_messages: int = 50, max_tokens: Optional[int] = DEFAULT_TOKEN_LIMIT): self,
model,
tools: list,
max_history_messages: int = 50,
max_tokens: Optional[int] = DEFAULT_TOKEN_LIMIT,
):
"""Initialize the agent with a model and list of tools. """Initialize the agent with a model and list of tools.
Args: Args:
@ -93,7 +101,8 @@ class CiaynAgent:
functions_list = "\n\n".join(self.available_functions) functions_list = "\n\n".join(self.available_functions)
# Build the complete prompt without f-strings for the static parts # Build the complete prompt without f-strings for the static parts
base_prompt += """ base_prompt += (
"""
<agent instructions> <agent instructions>
You are a ReAct agent. You run in a loop and use ONE of the available functions per iteration. You are a ReAct agent. You run in a loop and use ONE of the available functions per iteration.
@ -111,7 +120,9 @@ You typically don't want to keep calling the same function over and over with th
You must ONLY use ONE of the following functions (these are the ONLY functions that exist): You must ONLY use ONE of the following functions (these are the ONLY functions that exist):
<available functions>""" + functions_list + """ <available functions>"""
+ functions_list
+ """
</available functions> </available functions>
You may use any of the above functions to complete your job. Use the best one for the current step you are on. Be efficient, avoid getting stuck in repetitive loops, and do not hesitate to call functions which delegate your work to make your life easier. You may use any of the above functions to complete your job. Use the best one for the current step you are on. Be efficient, avoid getting stuck in repetitive loops, and do not hesitate to call functions which delegate your work to make your life easier.
@ -202,15 +213,13 @@ You have often been criticized for:
DO NOT CLAIM YOU ARE FINISHED UNTIL YOU ACTUALLY ARE! DO NOT CLAIM YOU ARE FINISHED UNTIL YOU ACTUALLY ARE!
Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**""" Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**"""
)
return base_prompt return base_prompt
def _execute_tool(self, code: str) -> str: def _execute_tool(self, code: str) -> str:
"""Execute a tool call and return its result.""" """Execute a tool call and return its result."""
globals_dict = { globals_dict = {tool.func.__name__: tool.func for tool in self.tools}
tool.func.__name__: tool.func
for tool in self.tools
}
try: try:
code = code.strip() code = code.strip()
@ -229,20 +238,12 @@ Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**"""
def _create_agent_chunk(self, content: str) -> Dict[str, Any]: def _create_agent_chunk(self, content: str) -> Dict[str, Any]:
"""Create an agent chunk in the format expected by print_agent_output.""" """Create an agent chunk in the format expected by print_agent_output."""
return { return {"agent": {"messages": [AIMessage(content=content)]}}
"agent": {
"messages": [AIMessage(content=content)]
}
}
def _create_error_chunk(self, content: str) -> Dict[str, Any]: def _create_error_chunk(self, content: str) -> Dict[str, Any]:
"""Create an error chunk in the format expected by print_agent_output.""" """Create an error chunk in the format expected by print_agent_output."""
message = ChunkMessage(content=content, status="error") message = ChunkMessage(content=content, status="error")
return { return {"tools": {"messages": [message]}}
"tools": {
"messages": [message]
}
}
@staticmethod @staticmethod
def _estimate_tokens(content: Optional[Union[str, BaseMessage]]) -> int: def _estimate_tokens(content: Optional[Union[str, BaseMessage]]) -> int:
@ -271,9 +272,11 @@ Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**"""
if not text: if not text:
return 0 return 0
return len(text.encode('utf-8')) // 4 return len(text.encode("utf-8")) // 4
def _trim_chat_history(self, initial_messages: List[Any], chat_history: List[Any]) -> List[Any]: def _trim_chat_history(
self, initial_messages: List[Any], chat_history: List[Any]
) -> List[Any]:
"""Trim chat history based on message count and token limits while preserving initial messages. """Trim chat history based on message count and token limits while preserving initial messages.
Applies both message count and token limits (if configured) to chat_history, Applies both message count and token limits (if configured) to chat_history,
@ -288,7 +291,7 @@ Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**"""
""" """
# First apply message count limit # First apply message count limit
if len(chat_history) > self.max_history_messages: if len(chat_history) > self.max_history_messages:
chat_history = chat_history[-self.max_history_messages:] chat_history = chat_history[-self.max_history_messages :]
# Skip token limiting if max_tokens is None # Skip token limiting if max_tokens is None
if self.max_tokens is None: if self.max_tokens is None:
@ -299,14 +302,18 @@ Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**"""
# Remove messages from start of chat_history until under token limit # Remove messages from start of chat_history until under token limit
while chat_history: while chat_history:
total_tokens = initial_tokens + sum(self._estimate_tokens(msg) for msg in chat_history) total_tokens = initial_tokens + sum(
self._estimate_tokens(msg) for msg in chat_history
)
if total_tokens <= self.max_tokens: if total_tokens <= self.max_tokens:
break break
chat_history.pop(0) chat_history.pop(0)
return initial_messages + chat_history return initial_messages + chat_history
def stream(self, messages_dict: Dict[str, List[Any]], config: Dict[str, Any] = None) -> Generator[Dict[str, Any], None, None]: def stream(
self, messages_dict: Dict[str, List[Any]], config: Dict[str, Any] = None
) -> Generator[Dict[str, Any], None, None]:
"""Stream agent responses in a format compatible with print_agent_output.""" """Stream agent responses in a format compatible with print_agent_output."""
initial_messages = messages_dict.get("messages", []) initial_messages = messages_dict.get("messages", [])
chat_history = [] chat_history = []
@ -318,7 +325,14 @@ Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**"""
chat_history.append(HumanMessage(content=base_prompt)) chat_history.append(HumanMessage(content=base_prompt))
full_history = self._trim_chat_history(initial_messages, chat_history) full_history = self._trim_chat_history(initial_messages, chat_history)
response = self.model.invoke([SystemMessage("Execute efficiently yet completely as a fully autonomous agent.")] + full_history) response = self.model.invoke(
[
SystemMessage(
"Execute efficiently yet completely as a fully autonomous agent."
)
]
+ full_history
)
try: try:
logger.debug(f"Code generated by agent: {response.content}") logger.debug(f"Code generated by agent: {response.content}")
@ -328,9 +342,14 @@ Output **ONLY THE CODE** and **NO MARKDOWN BACKTICKS**"""
yield {} yield {}
except ToolExecutionError as e: except ToolExecutionError as e:
chat_history.append(HumanMessage(content=f"Your tool call caused an error: {e}\n\nPlease correct your tool call and try again.")) chat_history.append(
HumanMessage(
content=f"Your tool call caused an error: {e}\n\nPlease correct your tool call and try again."
)
)
yield self._create_error_chunk(str(e)) yield self._create_error_chunk(str(e))
def _extract_tool_call(code: str, functions_list: str) -> str: def _extract_tool_call(code: str, functions_list: str) -> str:
from ra_aid.tools.expert import get_model from ra_aid.tools.expert import get_model

View File

@ -1,8 +1,9 @@
from typing import Any, Dict, List, Optional
from langchain_core.callbacks import CallbackManagerForLLMRun from langchain_core.callbacks import CallbackManagerForLLMRun
from langchain_core.messages import BaseMessage from langchain_core.messages import BaseMessage
from langchain_core.outputs import ChatResult from langchain_core.outputs import ChatResult
from langchain_openai import ChatOpenAI from langchain_openai import ChatOpenAI
from typing import Any, List, Optional, Dict
# Docs: https://api-docs.deepseek.com/guides/reasoning_model # Docs: https://api-docs.deepseek.com/guides/reasoning_model

View File

@ -1,4 +1,17 @@
from .formatting import print_stage_header, print_task_header, print_error, print_interrupt, console from .formatting import (
console,
print_error,
print_interrupt,
print_stage_header,
print_task_header,
)
from .output import print_agent_output from .output import print_agent_output
__all__ = ['print_stage_header', 'print_task_header', 'print_agent_output', 'console', 'print_error', 'print_interrupt'] __all__ = [
"print_stage_header",
"print_task_header",
"print_agent_output",
"console",
"print_error",
"print_interrupt",
]

View File

@ -7,9 +7,10 @@ COWBOY_MESSAGES = [
"This ain't my first rodeo! 🤠", "This ain't my first rodeo! 🤠",
"Lock and load, partner! 🤠", "Lock and load, partner! 🤠",
"I'm just a baby 👶", "I'm just a baby 👶",
"I'll try not to destroy everything 😏" "I'll try not to destroy everything 😏",
] ]
def get_cowboy_message() -> str: def get_cowboy_message() -> str:
"""Randomly select and return a cowboy message. """Randomly select and return a cowboy message.

View File

@ -1,9 +1,10 @@
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
console = Console() console = Console()
def print_stage_header(stage: str) -> None: def print_stage_header(stage: str) -> None:
"""Print a stage header with stage-specific styling and icons. """Print a stage header with stage-specific styling and icons.
@ -12,14 +13,14 @@ def print_stage_header(stage: str) -> None:
""" """
# Define stage icons mapping - using single-width emojis to prevent line wrapping issues # Define stage icons mapping - using single-width emojis to prevent line wrapping issues
icons = { icons = {
'research stage': '🔎', "research stage": "🔎",
'planning stage': '📝', "planning stage": "📝",
'implementation stage': '🔧', # Changed from 🛠️ to prevent wrapping "implementation stage": "🔧", # Changed from 🛠️ to prevent wrapping
'task completed': '', "task completed": "",
'debug stage': '🐛', "debug stage": "🐛",
'testing stage': '🧪', "testing stage": "🧪",
'research subtasks': '📚', "research subtasks": "📚",
'skipping implementation stage': '⏭️' "skipping implementation stage": "⏭️",
} }
# Format stage name to Title Case and normalize for mapping lookup # Format stage name to Title Case and normalize for mapping lookup
@ -27,12 +28,13 @@ def print_stage_header(stage: str) -> None:
stage_key = stage.lower() stage_key = stage.lower()
# Get appropriate icon with fallback # Get appropriate icon with fallback
icon = icons.get(stage_key, '🚀') icon = icons.get(stage_key, "🚀")
# Create styled panel with icon # Create styled panel with icon
panel_content = f"{icon} {stage_title}" panel_content = f"{icon} {stage_title}"
console.print(Panel(panel_content, style="green bold", padding=0)) console.print(Panel(panel_content, style="green bold", padding=0))
def print_task_header(task: str) -> None: def print_task_header(task: str) -> None:
"""Print a task header with yellow styling and wrench emoji. Content is rendered as Markdown. """Print a task header with yellow styling and wrench emoji. Content is rendered as Markdown.
@ -41,6 +43,7 @@ def print_task_header(task: str) -> None:
""" """
console.print(Panel(Markdown(task), title="🔧 Task", border_style="yellow bold")) console.print(Panel(Markdown(task), title="🔧 Task", border_style="yellow bold"))
def print_error(message: str) -> None: def print_error(message: str) -> None:
"""Print an error message in a red-bordered panel with warning emoji. """Print an error message in a red-bordered panel with warning emoji.
@ -49,6 +52,7 @@ def print_error(message: str) -> None:
""" """
console.print(Panel(Markdown(message), title="Error", border_style="red bold")) console.print(Panel(Markdown(message), title="Error", border_style="red bold"))
def print_interrupt(message: str) -> None: def print_interrupt(message: str) -> None:
"""Print an interrupt message in a yellow-bordered panel with stop emoji. """Print an interrupt message in a yellow-bordered panel with stop emoji.
@ -56,5 +60,6 @@ def print_interrupt(message: str) -> None:
message: The interrupt message to display (supports Markdown formatting) message: The interrupt message to display (supports Markdown formatting)
""" """
print() # Add spacing for ^C print() # Add spacing for ^C
console.print(Panel(Markdown(message), title="⛔ Interrupt", border_style="yellow bold")) console.print(
Panel(Markdown(message), title="⛔ Interrupt", border_style="yellow bold")
)

View File

@ -1,31 +1,42 @@
from typing import Any, Dict from typing import Any, Dict
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from langchain_core.messages import AIMessage from langchain_core.messages import AIMessage
from rich.markdown import Markdown
from rich.panel import Panel
# Import shared console instance # Import shared console instance
from .formatting import console from .formatting import console
def print_agent_output(chunk: Dict[str, Any]) -> None: def print_agent_output(chunk: Dict[str, Any]) -> None:
"""Print only the agent's message content, not tool calls. """Print only the agent's message content, not tool calls.
Args: Args:
chunk: A dictionary containing agent or tool messages chunk: A dictionary containing agent or tool messages
""" """
if 'agent' in chunk and 'messages' in chunk['agent']: if "agent" in chunk and "messages" in chunk["agent"]:
messages = chunk['agent']['messages'] messages = chunk["agent"]["messages"]
for msg in messages: for msg in messages:
if isinstance(msg, AIMessage): if isinstance(msg, AIMessage):
# Handle text content # Handle text content
if isinstance(msg.content, list): if isinstance(msg.content, list):
for content in msg.content: for content in msg.content:
if content['type'] == 'text' and content['text'].strip(): if content["type"] == "text" and content["text"].strip():
console.print(Panel(Markdown(content['text']), title="🤖 Assistant")) console.print(
Panel(Markdown(content["text"]), title="🤖 Assistant")
)
else: else:
if msg.content.strip(): if msg.content.strip():
console.print(Panel(Markdown(msg.content.strip()), title="🤖 Assistant")) console.print(
elif 'tools' in chunk and 'messages' in chunk['tools']: Panel(Markdown(msg.content.strip()), title="🤖 Assistant")
for msg in chunk['tools']['messages']: )
if msg.status == 'error' and msg.content: elif "tools" in chunk and "messages" in chunk["tools"]:
console.print(Panel(Markdown(msg.content.strip()), title="❌ Tool Error", border_style="red bold")) for msg in chunk["tools"]["messages"]:
if msg.status == "error" and msg.content:
console.print(
Panel(
Markdown(msg.content.strip()),
title="❌ Tool Error",
border_style="red bold",
)
)

View File

@ -1,21 +1,27 @@
"""Module for checking system dependencies required by RA.Aid.""" """Module for checking system dependencies required by RA.Aid."""
import os import os
import sys import sys
from ra_aid import print_error
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ra_aid import print_error
class Dependency(ABC): class Dependency(ABC):
"""Base class for system dependencies.""" """Base class for system dependencies."""
@abstractmethod @abstractmethod
def check(self): def check(self):
"""Check if the dependency is installed.""" """Check if the dependency is installed."""
pass pass
class RipGrepDependency(Dependency): class RipGrepDependency(Dependency):
"""Dependency checker for ripgrep.""" """Dependency checker for ripgrep."""
def check(self): def check(self):
"""Check if ripgrep is installed.""" """Check if ripgrep is installed."""
result = os.system('rg --version > /dev/null 2>&1') result = os.system("rg --version > /dev/null 2>&1")
if result != 0: if result != 0:
print_error("Required dependency 'ripgrep' is not installed.") print_error("Required dependency 'ripgrep' is not installed.")
print("Please install ripgrep:") print("Please install ripgrep:")
@ -25,6 +31,7 @@ class RipGrepDependency(Dependency):
print(" - Other: https://github.com/BurntSushi/ripgrep#installation") print(" - Other: https://github.com/BurntSushi/ripgrep#installation")
sys.exit(1) sys.exit(1)
def check_dependencies(): def check_dependencies():
"""Check if required system dependencies are installed.""" """Check if required system dependencies are installed."""
dependencies = [RipGrepDependency()] # Create instances dependencies = [RipGrepDependency()] # Create instances

View File

@ -2,17 +2,10 @@
import os import os
import sys import sys
from dataclasses import dataclass from typing import Any, List
from typing import Tuple, List, Any
from ra_aid import print_error
from ra_aid.provider_strategy import ProviderFactory, ValidationResult from ra_aid.provider_strategy import ProviderFactory, ValidationResult
@dataclass
class ValidationResult:
"""Result of validation."""
valid: bool
missing_vars: List[str]
def validate_provider(provider: str) -> ValidationResult: def validate_provider(provider: str) -> ValidationResult:
"""Validate provider configuration.""" """Validate provider configuration."""
@ -20,9 +13,12 @@ def validate_provider(provider: str) -> ValidationResult:
return ValidationResult(valid=False, missing_vars=["No provider specified"]) return ValidationResult(valid=False, missing_vars=["No provider specified"])
strategy = ProviderFactory.create(provider) strategy = ProviderFactory.create(provider)
if not strategy: if not strategy:
return ValidationResult(valid=False, missing_vars=[f"Unknown provider: {provider}"]) return ValidationResult(
valid=False, missing_vars=[f"Unknown provider: {provider}"]
)
return strategy.validate() return strategy.validate()
def copy_base_to_expert_vars(base_provider: str, expert_provider: str) -> None: def copy_base_to_expert_vars(base_provider: str, expert_provider: str) -> None:
"""Copy base provider environment variables to expert provider if not set. """Copy base provider environment variables to expert provider if not set.
@ -32,28 +28,24 @@ def copy_base_to_expert_vars(base_provider: str, expert_provider: str) -> None:
""" """
# Map of base to expert environment variables for each provider # Map of base to expert environment variables for each provider
provider_vars = { provider_vars = {
'openai': { "openai": {
'OPENAI_API_KEY': 'EXPERT_OPENAI_API_KEY', "OPENAI_API_KEY": "EXPERT_OPENAI_API_KEY",
'OPENAI_API_BASE': 'EXPERT_OPENAI_API_BASE' "OPENAI_API_BASE": "EXPERT_OPENAI_API_BASE",
}, },
'openai-compatible': { "openai-compatible": {
'OPENAI_API_KEY': 'EXPERT_OPENAI_API_KEY', "OPENAI_API_KEY": "EXPERT_OPENAI_API_KEY",
'OPENAI_API_BASE': 'EXPERT_OPENAI_API_BASE' "OPENAI_API_BASE": "EXPERT_OPENAI_API_BASE",
}, },
'anthropic': { "anthropic": {
'ANTHROPIC_API_KEY': 'EXPERT_ANTHROPIC_API_KEY', "ANTHROPIC_API_KEY": "EXPERT_ANTHROPIC_API_KEY",
'ANTHROPIC_MODEL': 'EXPERT_ANTHROPIC_MODEL' "ANTHROPIC_MODEL": "EXPERT_ANTHROPIC_MODEL",
}, },
'openrouter': { "openrouter": {"OPENROUTER_API_KEY": "EXPERT_OPENROUTER_API_KEY"},
'OPENROUTER_API_KEY': 'EXPERT_OPENROUTER_API_KEY' "gemini": {
"GEMINI_API_KEY": "EXPERT_GEMINI_API_KEY",
"GEMINI_MODEL": "EXPERT_GEMINI_MODEL",
}, },
'gemini': { "deepseek": {"DEEPSEEK_API_KEY": "EXPERT_DEEPSEEK_API_KEY"},
'GEMINI_API_KEY': 'EXPERT_GEMINI_API_KEY',
'GEMINI_MODEL': 'EXPERT_GEMINI_MODEL'
},
'deepseek': {
'DEEPSEEK_API_KEY': 'EXPERT_DEEPSEEK_API_KEY'
}
} }
# Get the variables to copy based on the expert provider # Get the variables to copy based on the expert provider
@ -63,6 +55,7 @@ def copy_base_to_expert_vars(base_provider: str, expert_provider: str) -> None:
if not os.environ.get(expert_var) and os.environ.get(base_var): if not os.environ.get(expert_var) and os.environ.get(base_var):
os.environ[expert_var] = os.environ[base_var] os.environ[expert_var] = os.environ[base_var]
def validate_expert_provider(provider: str) -> ValidationResult: def validate_expert_provider(provider: str) -> ValidationResult:
"""Validate expert provider configuration with fallback.""" """Validate expert provider configuration with fallback."""
if not provider: if not provider:
@ -70,7 +63,9 @@ def validate_expert_provider(provider: str) -> ValidationResult:
strategy = ProviderFactory.create(provider) strategy = ProviderFactory.create(provider)
if not strategy: if not strategy:
return ValidationResult(valid=False, missing_vars=[f"Unknown expert provider: {provider}"]) return ValidationResult(
valid=False, missing_vars=[f"Unknown expert provider: {provider}"]
)
# Copy base vars to expert vars for fallback # Copy base vars to expert vars for fallback
copy_base_to_expert_vars(provider, provider) copy_base_to_expert_vars(provider, provider)
@ -87,20 +82,25 @@ def validate_expert_provider(provider: str) -> ValidationResult:
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
def validate_web_research() -> ValidationResult: def validate_web_research() -> ValidationResult:
"""Validate web research configuration.""" """Validate web research configuration."""
key = "TAVILY_API_KEY" key = "TAVILY_API_KEY"
return ValidationResult( return ValidationResult(
valid=bool(os.environ.get(key)), valid=bool(os.environ.get(key)),
missing_vars=[] if os.environ.get(key) else [f"{key} environment variable is not set"] missing_vars=[]
if os.environ.get(key)
else [f"{key} environment variable is not set"],
) )
def print_missing_dependencies(missing_vars: List[str]) -> None: def print_missing_dependencies(missing_vars: List[str]) -> None:
"""Print missing dependencies and exit.""" """Print missing dependencies and exit."""
for var in missing_vars: for var in missing_vars:
print(f"Error: {var}", file=sys.stderr) print(f"Error: {var}", file=sys.stderr)
sys.exit(1) sys.exit(1)
def validate_research_only_provider(args: Any) -> None: def validate_research_only_provider(args: Any) -> None:
"""Validate provider and model for research-only mode. """Validate provider and model for research-only mode.
@ -111,16 +111,17 @@ def validate_research_only_provider(args: Any) -> None:
SystemExit: If provider or model validation fails SystemExit: If provider or model validation fails
""" """
# Get provider from args # Get provider from args
provider = args.provider if args and hasattr(args, 'provider') else None provider = args.provider if args and hasattr(args, "provider") else None
if not provider: if not provider:
sys.exit("No provider specified") sys.exit("No provider specified")
# For non-Anthropic providers in research-only mode, model must be specified # For non-Anthropic providers in research-only mode, model must be specified
if provider != 'anthropic': if provider != "anthropic":
model = args.model if hasattr(args, 'model') and args.model else None model = args.model if hasattr(args, "model") and args.model else None
if not model: if not model:
sys.exit("Model is required for non-Anthropic providers") sys.exit("Model is required for non-Anthropic providers")
def validate_research_only(args: Any) -> tuple[bool, list[str], bool, list[str]]: def validate_research_only(args: Any) -> tuple[bool, list[str], bool, list[str]]:
"""Validate environment variables for research-only mode. """Validate environment variables for research-only mode.
@ -141,14 +142,15 @@ def validate_research_only(args: Any) -> tuple[bool, list[str], bool, list[str]]
web_research_missing = [] web_research_missing = []
# Validate web research dependencies # Validate web research dependencies
tavily_key = os.environ.get('TAVILY_API_KEY') tavily_key = os.environ.get("TAVILY_API_KEY")
if not tavily_key: if not tavily_key:
web_research_missing.append('TAVILY_API_KEY environment variable is not set') web_research_missing.append("TAVILY_API_KEY environment variable is not set")
else: else:
web_research_enabled = True web_research_enabled = True
return expert_enabled, expert_missing, web_research_enabled, web_research_missing return expert_enabled, expert_missing, web_research_enabled, web_research_missing
def validate_environment(args: Any) -> tuple[bool, list[str], bool, list[str]]: def validate_environment(args: Any) -> tuple[bool, list[str], bool, list[str]]:
"""Validate environment variables for providers and web research tools. """Validate environment variables for providers and web research tools.
@ -163,9 +165,9 @@ def validate_environment(args: Any) -> tuple[bool, list[str], bool, list[str]]:
- web_research_missing: List of missing web research dependencies - web_research_missing: List of missing web research dependencies
""" """
# For research-only mode, use separate validation # For research-only mode, use separate validation
if hasattr(args, 'research_only') and args.research_only: if hasattr(args, "research_only") and args.research_only:
# Only validate provider and model when testing provider validation # Only validate provider and model when testing provider validation
if hasattr(args, 'model') and args.model is None: if hasattr(args, "model") and args.model is None:
validate_research_only_provider(args) validate_research_only_provider(args)
return validate_research_only(args) return validate_research_only(args)
@ -176,7 +178,7 @@ def validate_environment(args: Any) -> tuple[bool, list[str], bool, list[str]]:
web_research_missing = [] web_research_missing = []
# Get provider from args # Get provider from args
provider = args.provider if args and hasattr(args, 'provider') else None provider = args.provider if args and hasattr(args, "provider") else None
if not provider: if not provider:
sys.exit("No provider specified") sys.exit("No provider specified")

View File

@ -1,11 +1,13 @@
"""Custom exceptions for RA.Aid.""" """Custom exceptions for RA.Aid."""
class AgentInterrupt(Exception): class AgentInterrupt(Exception):
"""Exception raised when an agent's execution is interrupted. """Exception raised when an agent's execution is interrupted.
This exception is used for internal agent interruption handling, This exception is used for internal agent interruption handling,
separate from KeyboardInterrupt which is reserved for top-level handling. separate from KeyboardInterrupt which is reserved for top-level handling.
""" """
pass pass
@ -15,4 +17,5 @@ class ToolExecutionError(Exception):
This exception is used to distinguish tool execution failures This exception is used to distinguish tool execution failures
from other types of errors in the agent system. from other types of errors in the agent system.
""" """
pass pass

View File

@ -7,21 +7,25 @@ from typing import List, Optional, Tuple
class FileListerError(Exception): class FileListerError(Exception):
"""Base exception for file listing related errors.""" """Base exception for file listing related errors."""
pass pass
class GitCommandError(FileListerError): class GitCommandError(FileListerError):
"""Raised when a git command fails.""" """Raised when a git command fails."""
pass pass
class DirectoryNotFoundError(FileListerError): class DirectoryNotFoundError(FileListerError):
"""Raised when the specified directory does not exist.""" """Raised when the specified directory does not exist."""
pass pass
class DirectoryAccessError(FileListerError): class DirectoryAccessError(FileListerError):
"""Raised when the directory cannot be accessed due to permissions.""" """Raised when the directory cannot be accessed due to permissions."""
pass pass
@ -51,7 +55,7 @@ def is_git_repo(directory: str) -> bool:
["git", "rev-parse", "--git-dir"], ["git", "rev-parse", "--git-dir"],
cwd=str(path), cwd=str(path),
capture_output=True, capture_output=True,
text=True text=True,
) )
return result.returncode == 0 return result.returncode == 0
@ -65,7 +69,9 @@ def is_git_repo(directory: str) -> bool:
raise FileListerError(f"Error checking git repository: {e}") raise FileListerError(f"Error checking git repository: {e}")
def get_file_listing(directory: str, limit: Optional[int] = None) -> Tuple[List[str], int]: def get_file_listing(
directory: str, limit: Optional[int] = None
) -> Tuple[List[str], int]:
""" """
Get a list of tracked files in a git repository. Get a list of tracked files in a git repository.
@ -99,15 +105,11 @@ def get_file_listing(directory: str, limit: Optional[int] = None) -> Tuple[List[
cwd=directory, cwd=directory,
capture_output=True, capture_output=True,
text=True, text=True,
check=True check=True,
) )
# Process the output # Process the output
files = [ files = [line.strip() for line in result.stdout.splitlines() if line.strip()]
line.strip()
for line in result.stdout.splitlines()
if line.strip()
]
# Deduplicate and sort for consistency # Deduplicate and sort for consistency
files = list(dict.fromkeys(files)) # Remove duplicates while preserving order files = list(dict.fromkeys(files)) # Remove duplicates while preserving order

View File

@ -1,14 +1,17 @@
import os import os
from typing import Optional, Dict, Any from typing import Any, Dict, Optional
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic from langchain_anthropic import ChatAnthropic
from langchain_core.language_models import BaseChatModel from langchain_core.language_models import BaseChatModel
from langchain_google_genai import ChatGoogleGenerativeAI from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_openai import ChatOpenAI
from ra_aid.chat_models.deepseek_chat import ChatDeepseekReasoner from ra_aid.chat_models.deepseek_chat import ChatDeepseekReasoner
from ra_aid.logging_config import get_logger from ra_aid.logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
def get_env_var(name: str, expert: bool = False) -> Optional[str]: def get_env_var(name: str, expert: bool = False) -> Optional[str]:
"""Get environment variable with optional expert prefix and fallback.""" """Get environment variable with optional expert prefix and fallback."""
prefix = "EXPERT_" if expert else "" prefix = "EXPERT_" if expert else ""
@ -129,7 +132,7 @@ def create_llm_client(
provider, provider,
model_name, model_name,
temperature, temperature,
is_expert is_expert,
) )
# Handle temperature settings # Handle temperature settings

View File

@ -2,6 +2,7 @@ import logging
import sys import sys
from typing import Optional from typing import Optional
def setup_logging(verbose: bool = False) -> None: def setup_logging(verbose: bool = False) -> None:
logger = logging.getLogger("ra_aid") logger = logging.getLogger("ra_aid")
logger.setLevel(logging.DEBUG if verbose else logging.INFO) logger.setLevel(logging.DEBUG if verbose else logging.INFO)
@ -14,5 +15,6 @@ def setup_logging(verbose: bool = False) -> None:
handler.setFormatter(formatter) handler.setFormatter(formatter)
logger.addHandler(handler) logger.addHandler(handler)
def get_logger(name: Optional[str] = None) -> logging.Logger: def get_logger(name: Optional[str] = None) -> logging.Logger:
return logging.getLogger(f"ra_aid.{name}" if name else "ra_aid") return logging.getLogger(f"ra_aid.{name}" if name else "ra_aid")

View File

@ -5,7 +5,6 @@ List of model tokens
DEFAULT_TOKEN_LIMIT = 100000 DEFAULT_TOKEN_LIMIT = 100000
models_tokens = { models_tokens = {
"openai": { "openai": {
"gpt-3.5-turbo-0125": 16385, "gpt-3.5-turbo-0125": 16385,

View File

@ -4,15 +4,15 @@ Module for running interactive subprocesses with output capture.
import os import os
import re import re
import tempfile
import shlex import shlex
import shutil import shutil
import tempfile
from typing import List, Tuple from typing import List, Tuple
# Add macOS detection # Add macOS detection
IS_MACOS = os.uname().sysname == "Darwin" IS_MACOS = os.uname().sysname == "Darwin"
def run_interactive_command(cmd: List[str]) -> Tuple[bytes, int]: def run_interactive_command(cmd: List[str]) -> Tuple[bytes, int]:
""" """
Runs an interactive command with a pseudo-tty, capturing combined output. Runs an interactive command with a pseudo-tty, capturing combined output.
@ -45,7 +45,7 @@ def run_interactive_command(cmd: List[str]) -> Tuple[bytes, int]:
retcode_file.close() retcode_file.close()
# Quote arguments for safety # Quote arguments for safety
quoted_cmd = ' '.join(shlex.quote(c) for c in cmd) quoted_cmd = " ".join(shlex.quote(c) for c in cmd)
# Use script to capture output with TTY and save return code # Use script to capture output with TTY and save return code
shell_cmd = f"{quoted_cmd}; echo $? > {shlex.quote(retcode_path)}" shell_cmd = f"{quoted_cmd}; echo $? > {shlex.quote(retcode_path)}"
@ -56,22 +56,24 @@ def run_interactive_command(cmd: List[str]) -> Tuple[bytes, int]:
try: try:
# Disable pagers by setting environment variables # Disable pagers by setting environment variables
os.environ['GIT_PAGER'] = '' os.environ["GIT_PAGER"] = ""
os.environ['PAGER'] = '' os.environ["PAGER"] = ""
# Run command with script for TTY and output capture # Run command with script for TTY and output capture
if IS_MACOS: if IS_MACOS:
os.system(f"script -q {shlex.quote(output_path)} {shell_cmd}") os.system(f"script -q {shlex.quote(output_path)} {shell_cmd}")
else: else:
os.system(f"script -q -c {shlex.quote(shell_cmd)} {shlex.quote(output_path)}") os.system(
f"script -q -c {shlex.quote(shell_cmd)} {shlex.quote(output_path)}"
)
# Read and clean the output # Read and clean the output
with open(output_path, "rb") as f: with open(output_path, "rb") as f:
output = f.read() output = f.read()
# Clean ANSI escape sequences and control characters # Clean ANSI escape sequences and control characters
output = re.sub(rb'\x1b\[[0-9;]*[a-zA-Z]', b'', output) # ANSI escape sequences output = re.sub(rb"\x1b\[[0-9;]*[a-zA-Z]", b"", output) # ANSI escape sequences
output = re.sub(rb'[\x00-\x08\x0b\x0c\x0e-\x1f]', b'', output) # Control chars output = re.sub(rb"[\x00-\x08\x0b\x0c\x0e-\x1f]", b"", output) # Control chars
# Get the return code # Get the return code
with open(retcode_path, "r") as f: with open(retcode_path, "r") as f:

View File

@ -2,14 +2,21 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional from typing import List, Optional
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
__all__ = ['ProjectInfo', 'ProjectInfoError', 'get_project_info', 'format_project_info', 'display_project_status'] __all__ = [
"ProjectInfo",
"ProjectInfoError",
"get_project_info",
"format_project_info",
"display_project_status",
]
from ra_aid.project_state import is_new_project, ProjectStateError from ra_aid.file_listing import FileListerError, get_file_listing
from ra_aid.file_listing import get_file_listing, FileListerError from ra_aid.project_state import ProjectStateError, is_new_project
@dataclass @dataclass
@ -21,6 +28,7 @@ class ProjectInfo:
files: List of tracked files in the project files: List of tracked files in the project
total_files: Total number of tracked files (before any limit) total_files: Total number of tracked files (before any limit)
""" """
is_new: bool is_new: bool
files: List[str] files: List[str]
total_files: int total_files: int
@ -28,6 +36,7 @@ class ProjectInfo:
class ProjectInfoError(Exception): class ProjectInfoError(Exception):
"""Base exception for project info related errors.""" """Base exception for project info related errors."""
pass pass
@ -54,13 +63,9 @@ def get_project_info(directory: str, file_limit: Optional[int] = None) -> Projec
# Get file listing # Get file listing
files, total = get_file_listing(directory, limit=file_limit) files, total = get_file_listing(directory, limit=file_limit)
return ProjectInfo( return ProjectInfo(is_new=new_status, files=files, total_files=total)
is_new=new_status,
files=files,
total_files=total
)
except (ProjectStateError, FileListerError) as e: except (ProjectStateError, FileListerError):
# Re-raise known errors # Re-raise known errors
raise raise
except Exception as e: except Exception as e:
@ -85,7 +90,11 @@ def format_project_info(info: ProjectInfo) -> str:
return f"Project Status: {status}\nTotal Files: 0\nFiles: None" return f"Project Status: {status}\nTotal Files: 0\nFiles: None"
# Format file count with truncation notice if needed # Format file count with truncation notice if needed
file_count = f"{len(info.files)} of {info.total_files}" if len(info.files) < info.total_files else str(info.total_files) file_count = (
f"{len(info.files)} of {info.total_files}"
if len(info.files) < info.total_files
else str(info.total_files)
)
file_count_line = f"Total Files: {file_count}" file_count_line = f"Total Files: {file_count}"
# Format file listing # Format file listing
@ -93,7 +102,9 @@ def format_project_info(info: ProjectInfo) -> str:
# Add truncation notice if list was truncated # Add truncation notice if list was truncated
if len(info.files) < info.total_files: if len(info.files) < info.total_files:
files_section += f"\n[Note: Showing {len(info.files)} of {info.total_files} total files]" files_section += (
f"\n[Note: Showing {len(info.files)} of {info.total_files} total files]"
)
return f"Project Status: {status}\n{file_count_line}\n{files_section}" return f"Project Status: {status}\n{file_count_line}\n{files_section}"
@ -108,7 +119,11 @@ def display_project_status(info: ProjectInfo) -> None:
status = "**New/empty project**" if info.is_new else "**Existing project**" status = "**New/empty project**" if info.is_new else "**Existing project**"
# Format file count (with truncation notice if needed) # Format file count (with truncation notice if needed)
file_count = f"{len(info.files)} of {info.total_files}" if len(info.files) < info.total_files else str(info.total_files) file_count = (
f"{len(info.files)} of {info.total_files}"
if len(info.files) < info.total_files
else str(info.total_files)
)
# Build status text with markdown # Build status text with markdown
status_text = f""" status_text = f"""

View File

@ -6,16 +6,19 @@ from typing import Set
class ProjectStateError(Exception): class ProjectStateError(Exception):
"""Base exception for project state related errors.""" """Base exception for project state related errors."""
pass pass
class DirectoryNotFoundError(ProjectStateError): class DirectoryNotFoundError(ProjectStateError):
"""Raised when the specified directory does not exist.""" """Raised when the specified directory does not exist."""
pass pass
class DirectoryAccessError(ProjectStateError): class DirectoryAccessError(ProjectStateError):
"""Raised when the directory cannot be accessed due to permissions.""" """Raised when the directory cannot be accessed due to permissions."""
pass pass
@ -47,18 +50,18 @@ def is_new_project(directory: str) -> bool:
raise DirectoryNotFoundError(f"Path is not a directory: {directory}") raise DirectoryNotFoundError(f"Path is not a directory: {directory}")
# Get all files/dirs in the directory, excluding contents of .git # Get all files/dirs in the directory, excluding contents of .git
allowed_items: Set[str] = {'.git', '.gitignore'} _allowed_items: Set[str] = {".git", ".gitignore"}
try: try:
contents = set() contents = set()
for item in path.iterdir(): for item in path.iterdir():
# Only consider top-level items # Only consider top-level items
if item.name != '.git': if item.name != ".git":
contents.add(item.name) contents.add(item.name)
except PermissionError as e: except PermissionError as e:
raise DirectoryAccessError(f"Cannot access directory {directory}: {e}") raise DirectoryAccessError(f"Cannot access directory {directory}: {e}")
# Directory is new if empty or only contains .gitignore # Directory is new if empty or only contains .gitignore
return len(contents) == 0 or contents.issubset({'.gitignore'}) return len(contents) == 0 or contents.issubset({".gitignore"})
except Exception as e: except Exception as e:
if isinstance(e, ProjectStateError): if isinstance(e, ProjectStateError):

View File

@ -1,17 +1,20 @@
"""Provider validation strategies.""" """Provider validation strategies."""
from abc import ABC, abstractmethod
import os import os
import re import re
from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional, List, Any from typing import Any, List, Optional
@dataclass @dataclass
class ValidationResult: class ValidationResult:
"""Result of validation.""" """Result of validation."""
valid: bool valid: bool
missing_vars: List[str] missing_vars: List[str]
class ProviderStrategy(ABC): class ProviderStrategy(ABC):
"""Abstract base class for provider validation strategies.""" """Abstract base class for provider validation strategies."""
@ -20,6 +23,7 @@ class ProviderStrategy(ABC):
"""Validate provider environment variables.""" """Validate provider environment variables."""
pass pass
class OpenAIStrategy(ProviderStrategy): class OpenAIStrategy(ProviderStrategy):
"""OpenAI provider validation strategy.""" """OpenAI provider validation strategy."""
@ -28,41 +32,50 @@ class OpenAIStrategy(ProviderStrategy):
missing = [] missing = []
# Check if we're validating expert config # Check if we're validating expert config
if args and hasattr(args, 'expert_provider') and args.expert_provider == 'openai': if (
key = os.environ.get('EXPERT_OPENAI_API_KEY') args
if not key or key == '': and hasattr(args, "expert_provider")
and args.expert_provider == "openai"
):
key = os.environ.get("EXPERT_OPENAI_API_KEY")
if not key or key == "":
# Try to copy from base if not set # Try to copy from base if not set
base_key = os.environ.get('OPENAI_API_KEY') base_key = os.environ.get("OPENAI_API_KEY")
if base_key: if base_key:
os.environ['EXPERT_OPENAI_API_KEY'] = base_key os.environ["EXPERT_OPENAI_API_KEY"] = base_key
key = base_key key = base_key
if not key: if not key:
missing.append('EXPERT_OPENAI_API_KEY environment variable is not set') missing.append("EXPERT_OPENAI_API_KEY environment variable is not set")
# Check expert model only for research-only mode # Check expert model only for research-only mode
if hasattr(args, 'research_only') and args.research_only: if hasattr(args, "research_only") and args.research_only:
model = args.expert_model if hasattr(args, 'expert_model') else None model = args.expert_model if hasattr(args, "expert_model") else None
if not model: if not model:
model = os.environ.get('EXPERT_OPENAI_MODEL') model = os.environ.get("EXPERT_OPENAI_MODEL")
if not model: if not model:
model = os.environ.get('OPENAI_MODEL') model = os.environ.get("OPENAI_MODEL")
if not model: if not model:
missing.append('Model is required for OpenAI provider in research-only mode') missing.append(
"Model is required for OpenAI provider in research-only mode"
)
else: else:
key = os.environ.get('OPENAI_API_KEY') key = os.environ.get("OPENAI_API_KEY")
if not key: if not key:
missing.append('OPENAI_API_KEY environment variable is not set') missing.append("OPENAI_API_KEY environment variable is not set")
# Check model only for research-only mode # Check model only for research-only mode
if hasattr(args, 'research_only') and args.research_only: if hasattr(args, "research_only") and args.research_only:
model = args.model if hasattr(args, 'model') else None model = args.model if hasattr(args, "model") else None
if not model: if not model:
model = os.environ.get('OPENAI_MODEL') model = os.environ.get("OPENAI_MODEL")
if not model: if not model:
missing.append('Model is required for OpenAI provider in research-only mode') missing.append(
"Model is required for OpenAI provider in research-only mode"
)
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
class OpenAICompatibleStrategy(ProviderStrategy): class OpenAICompatibleStrategy(ProviderStrategy):
"""OpenAI-compatible provider validation strategy.""" """OpenAI-compatible provider validation strategy."""
@ -71,84 +84,97 @@ class OpenAICompatibleStrategy(ProviderStrategy):
missing = [] missing = []
# Check if we're validating expert config # Check if we're validating expert config
if args and hasattr(args, 'expert_provider') and args.expert_provider == 'openai-compatible': if (
key = os.environ.get('EXPERT_OPENAI_API_KEY') args
base = os.environ.get('EXPERT_OPENAI_API_BASE') and hasattr(args, "expert_provider")
and args.expert_provider == "openai-compatible"
):
key = os.environ.get("EXPERT_OPENAI_API_KEY")
base = os.environ.get("EXPERT_OPENAI_API_BASE")
# Try to copy from base if not set # Try to copy from base if not set
if not key or key == '': if not key or key == "":
base_key = os.environ.get('OPENAI_API_KEY') base_key = os.environ.get("OPENAI_API_KEY")
if base_key: if base_key:
os.environ['EXPERT_OPENAI_API_KEY'] = base_key os.environ["EXPERT_OPENAI_API_KEY"] = base_key
key = base_key key = base_key
if not base or base == '': if not base or base == "":
base_base = os.environ.get('OPENAI_API_BASE') base_base = os.environ.get("OPENAI_API_BASE")
if base_base: if base_base:
os.environ['EXPERT_OPENAI_API_BASE'] = base_base os.environ["EXPERT_OPENAI_API_BASE"] = base_base
base = base_base base = base_base
if not key: if not key:
missing.append('EXPERT_OPENAI_API_KEY environment variable is not set') missing.append("EXPERT_OPENAI_API_KEY environment variable is not set")
if not base: if not base:
missing.append('EXPERT_OPENAI_API_BASE environment variable is not set') missing.append("EXPERT_OPENAI_API_BASE environment variable is not set")
# Check expert model only for research-only mode # Check expert model only for research-only mode
if hasattr(args, 'research_only') and args.research_only: if hasattr(args, "research_only") and args.research_only:
model = args.expert_model if hasattr(args, 'expert_model') else None model = args.expert_model if hasattr(args, "expert_model") else None
if not model: if not model:
model = os.environ.get('EXPERT_OPENAI_MODEL') model = os.environ.get("EXPERT_OPENAI_MODEL")
if not model: if not model:
model = os.environ.get('OPENAI_MODEL') model = os.environ.get("OPENAI_MODEL")
if not model: if not model:
missing.append('Model is required for OpenAI-compatible provider in research-only mode') missing.append(
"Model is required for OpenAI-compatible provider in research-only mode"
)
else: else:
key = os.environ.get('OPENAI_API_KEY') key = os.environ.get("OPENAI_API_KEY")
base = os.environ.get('OPENAI_API_BASE') base = os.environ.get("OPENAI_API_BASE")
if not key: if not key:
missing.append('OPENAI_API_KEY environment variable is not set') missing.append("OPENAI_API_KEY environment variable is not set")
if not base: if not base:
missing.append('OPENAI_API_BASE environment variable is not set') missing.append("OPENAI_API_BASE environment variable is not set")
# Check model only for research-only mode # Check model only for research-only mode
if hasattr(args, 'research_only') and args.research_only: if hasattr(args, "research_only") and args.research_only:
model = args.model if hasattr(args, 'model') else None model = args.model if hasattr(args, "model") else None
if not model: if not model:
model = os.environ.get('OPENAI_MODEL') model = os.environ.get("OPENAI_MODEL")
if not model: if not model:
missing.append('Model is required for OpenAI-compatible provider in research-only mode') missing.append(
"Model is required for OpenAI-compatible provider in research-only mode"
)
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
class AnthropicStrategy(ProviderStrategy): class AnthropicStrategy(ProviderStrategy):
"""Anthropic provider validation strategy.""" """Anthropic provider validation strategy."""
VALID_MODELS = [ VALID_MODELS = ["claude-"]
"claude-"
]
def validate(self, args: Optional[Any] = None) -> ValidationResult: def validate(self, args: Optional[Any] = None) -> ValidationResult:
"""Validate Anthropic environment variables and model.""" """Validate Anthropic environment variables and model."""
missing = [] missing = []
# Check if we're validating expert config # Check if we're validating expert config
is_expert = args and hasattr(args, 'expert_provider') and args.expert_provider == 'anthropic' is_expert = (
args
and hasattr(args, "expert_provider")
and args.expert_provider == "anthropic"
)
# Check API key # Check API key
if is_expert: if is_expert:
key = os.environ.get('EXPERT_ANTHROPIC_API_KEY') key = os.environ.get("EXPERT_ANTHROPIC_API_KEY")
if not key or key == '': if not key or key == "":
# Try to copy from base if not set # Try to copy from base if not set
base_key = os.environ.get('ANTHROPIC_API_KEY') base_key = os.environ.get("ANTHROPIC_API_KEY")
if base_key: if base_key:
os.environ['EXPERT_ANTHROPIC_API_KEY'] = base_key os.environ["EXPERT_ANTHROPIC_API_KEY"] = base_key
key = base_key key = base_key
if not key: if not key:
missing.append('EXPERT_ANTHROPIC_API_KEY environment variable is not set') missing.append(
"EXPERT_ANTHROPIC_API_KEY environment variable is not set"
)
else: else:
key = os.environ.get('ANTHROPIC_API_KEY') key = os.environ.get("ANTHROPIC_API_KEY")
if not key: if not key:
missing.append('ANTHROPIC_API_KEY environment variable is not set') missing.append("ANTHROPIC_API_KEY environment variable is not set")
# Check model # Check model
model_matched = False model_matched = False
@ -156,25 +182,25 @@ class AnthropicStrategy(ProviderStrategy):
# First check command line argument # First check command line argument
if is_expert: if is_expert:
if hasattr(args, 'expert_model') and args.expert_model: if hasattr(args, "expert_model") and args.expert_model:
model_to_check = args.expert_model model_to_check = args.expert_model
else: else:
# If no expert model, check environment variable # If no expert model, check environment variable
model_to_check = os.environ.get('EXPERT_ANTHROPIC_MODEL') model_to_check = os.environ.get("EXPERT_ANTHROPIC_MODEL")
if not model_to_check or model_to_check == '': if not model_to_check or model_to_check == "":
# Try to copy from base if not set # Try to copy from base if not set
base_model = os.environ.get('ANTHROPIC_MODEL') base_model = os.environ.get("ANTHROPIC_MODEL")
if base_model: if base_model:
os.environ['EXPERT_ANTHROPIC_MODEL'] = base_model os.environ["EXPERT_ANTHROPIC_MODEL"] = base_model
model_to_check = base_model model_to_check = base_model
else: else:
if hasattr(args, 'model') and args.model: if hasattr(args, "model") and args.model:
model_to_check = args.model model_to_check = args.model
else: else:
model_to_check = os.environ.get('ANTHROPIC_MODEL') model_to_check = os.environ.get("ANTHROPIC_MODEL")
if not model_to_check: if not model_to_check:
missing.append('ANTHROPIC_MODEL environment variable is not set') missing.append("ANTHROPIC_MODEL environment variable is not set")
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
# Validate model format # Validate model format
@ -184,10 +210,13 @@ class AnthropicStrategy(ProviderStrategy):
break break
if not model_matched: if not model_matched:
missing.append(f'Invalid Anthropic model: {model_to_check}. Must match one of these patterns: {", ".join(self.VALID_MODELS)}') missing.append(
f'Invalid Anthropic model: {model_to_check}. Must match one of these patterns: {", ".join(self.VALID_MODELS)}'
)
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
class OpenRouterStrategy(ProviderStrategy): class OpenRouterStrategy(ProviderStrategy):
"""OpenRouter provider validation strategy.""" """OpenRouter provider validation strategy."""
@ -196,23 +225,30 @@ class OpenRouterStrategy(ProviderStrategy):
missing = [] missing = []
# Check if we're validating expert config # Check if we're validating expert config
if args and hasattr(args, 'expert_provider') and args.expert_provider == 'openrouter': if (
key = os.environ.get('EXPERT_OPENROUTER_API_KEY') args
if not key or key == '': and hasattr(args, "expert_provider")
and args.expert_provider == "openrouter"
):
key = os.environ.get("EXPERT_OPENROUTER_API_KEY")
if not key or key == "":
# Try to copy from base if not set # Try to copy from base if not set
base_key = os.environ.get('OPENROUTER_API_KEY') base_key = os.environ.get("OPENROUTER_API_KEY")
if base_key: if base_key:
os.environ['EXPERT_OPENROUTER_API_KEY'] = base_key os.environ["EXPERT_OPENROUTER_API_KEY"] = base_key
key = base_key key = base_key
if not key: if not key:
missing.append('EXPERT_OPENROUTER_API_KEY environment variable is not set') missing.append(
"EXPERT_OPENROUTER_API_KEY environment variable is not set"
)
else: else:
key = os.environ.get('OPENROUTER_API_KEY') key = os.environ.get("OPENROUTER_API_KEY")
if not key: if not key:
missing.append('OPENROUTER_API_KEY environment variable is not set') missing.append("OPENROUTER_API_KEY environment variable is not set")
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
class GeminiStrategy(ProviderStrategy): class GeminiStrategy(ProviderStrategy):
"""Gemini provider validation strategy.""" """Gemini provider validation strategy."""
@ -221,20 +257,24 @@ class GeminiStrategy(ProviderStrategy):
missing = [] missing = []
# Check if we're validating expert config # Check if we're validating expert config
if args and hasattr(args, 'expert_provider') and args.expert_provider == 'gemini': if (
key = os.environ.get('EXPERT_GEMINI_API_KEY') args
if not key or key == '': and hasattr(args, "expert_provider")
and args.expert_provider == "gemini"
):
key = os.environ.get("EXPERT_GEMINI_API_KEY")
if not key or key == "":
# Try to copy from base if not set # Try to copy from base if not set
base_key = os.environ.get('GEMINI_API_KEY') base_key = os.environ.get("GEMINI_API_KEY")
if base_key: if base_key:
os.environ['EXPERT_GEMINI_API_KEY'] = base_key os.environ["EXPERT_GEMINI_API_KEY"] = base_key
key = base_key key = base_key
if not key: if not key:
missing.append('EXPERT_GEMINI_API_KEY environment variable is not set') missing.append("EXPERT_GEMINI_API_KEY environment variable is not set")
else: else:
key = os.environ.get('GEMINI_API_KEY') key = os.environ.get("GEMINI_API_KEY")
if not key: if not key:
missing.append('GEMINI_API_KEY environment variable is not set') missing.append("GEMINI_API_KEY environment variable is not set")
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
@ -246,20 +286,26 @@ class DeepSeekStrategy(ProviderStrategy):
"""Validate DeepSeek environment variables.""" """Validate DeepSeek environment variables."""
missing = [] missing = []
if args and hasattr(args, 'expert_provider') and args.expert_provider == 'deepseek': if (
key = os.environ.get('EXPERT_DEEPSEEK_API_KEY') args
if not key or key == '': and hasattr(args, "expert_provider")
and args.expert_provider == "deepseek"
):
key = os.environ.get("EXPERT_DEEPSEEK_API_KEY")
if not key or key == "":
# Try to copy from base if not set # Try to copy from base if not set
base_key = os.environ.get('DEEPSEEK_API_KEY') base_key = os.environ.get("DEEPSEEK_API_KEY")
if base_key: if base_key:
os.environ['EXPERT_DEEPSEEK_API_KEY'] = base_key os.environ["EXPERT_DEEPSEEK_API_KEY"] = base_key
key = base_key key = base_key
if not key: if not key:
missing.append('EXPERT_DEEPSEEK_API_KEY environment variable is not set') missing.append(
"EXPERT_DEEPSEEK_API_KEY environment variable is not set"
)
else: else:
key = os.environ.get('DEEPSEEK_API_KEY') key = os.environ.get("DEEPSEEK_API_KEY")
if not key: if not key:
missing.append('DEEPSEEK_API_KEY environment variable is not set') missing.append("DEEPSEEK_API_KEY environment variable is not set")
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
@ -271,12 +317,13 @@ class OllamaStrategy(ProviderStrategy):
"""Validate Ollama environment variables.""" """Validate Ollama environment variables."""
missing = [] missing = []
base_url = os.environ.get('OLLAMA_BASE_URL') base_url = os.environ.get("OLLAMA_BASE_URL")
if not base_url: if not base_url:
missing.append('OLLAMA_BASE_URL environment variable is not set') missing.append("OLLAMA_BASE_URL environment variable is not set")
return ValidationResult(valid=len(missing) == 0, missing_vars=missing) return ValidationResult(valid=len(missing) == 0, missing_vars=missing)
class ProviderFactory: class ProviderFactory:
"""Factory for creating provider validation strategies.""" """Factory for creating provider validation strategies."""
@ -292,13 +339,13 @@ class ProviderFactory:
Provider validation strategy or None if provider not found Provider validation strategy or None if provider not found
""" """
strategies = { strategies = {
'openai': OpenAIStrategy(), "openai": OpenAIStrategy(),
'openai-compatible': OpenAICompatibleStrategy(), "openai-compatible": OpenAICompatibleStrategy(),
'anthropic': AnthropicStrategy(), "anthropic": AnthropicStrategy(),
'openrouter': OpenRouterStrategy(), "openrouter": OpenRouterStrategy(),
'gemini': GeminiStrategy(), "gemini": GeminiStrategy(),
'ollama': OllamaStrategy(), "ollama": OllamaStrategy(),
'deepseek': DeepSeekStrategy() "deepseek": DeepSeekStrategy(),
} }
strategy = strategies.get(provider) strategy = strategies.get(provider)
return strategy return strategy

View File

@ -1,41 +1,42 @@
"""Unit tests for environment validation.""" """Unit tests for environment validation."""
import pytest
from dataclasses import dataclass from dataclasses import dataclass
from typing import List
import pytest
from ra_aid.env import validate_environment from ra_aid.env import validate_environment
@dataclass @dataclass
class MockArgs: class MockArgs:
"""Mock arguments for testing.""" """Mock arguments for testing."""
research_only: bool research_only: bool
provider: str provider: str
expert_provider: str = None expert_provider: str = None
TEST_CASES = [ TEST_CASES = [
pytest.param( pytest.param(
"research_only_no_model", "research_only_no_model",
MockArgs(research_only=True, provider="openai"), MockArgs(research_only=True, provider="openai"),
(False, [], False, ["TAVILY_API_KEY environment variable is not set"]), (False, [], False, ["TAVILY_API_KEY environment variable is not set"]),
{}, {},
id="research_only_no_model" id="research_only_no_model",
), ),
pytest.param( pytest.param(
"research_only_with_model", "research_only_with_model",
MockArgs(research_only=True, provider="openai"), MockArgs(research_only=True, provider="openai"),
(False, [], True, []), (False, [], True, []),
{"TAVILY_API_KEY": "test_key"}, {"TAVILY_API_KEY": "test_key"},
id="research_only_with_model" id="research_only_with_model",
) ),
] ]
@pytest.mark.parametrize("test_name,args,expected,env_vars", TEST_CASES) @pytest.mark.parametrize("test_name,args,expected,env_vars", TEST_CASES)
def test_validate_environment_research_only( def test_validate_environment_research_only(
test_name: str, test_name: str, args: MockArgs, expected: tuple, env_vars: dict, monkeypatch
args: MockArgs,
expected: tuple,
env_vars: dict,
monkeypatch
): ):
"""Test validate_environment with research_only flag.""" """Test validate_environment with research_only flag."""
# Clear any existing environment variables # Clear any existing environment variables

View File

@ -1,3 +1,3 @@
from .processing import truncate_output from .processing import truncate_output
__all__ = ['truncate_output'] __all__ = ["truncate_output"]

View File

@ -1,5 +1,6 @@
from typing import Optional from typing import Optional
def truncate_output(output: str, max_lines: Optional[int] = 5000) -> str: def truncate_output(output: str, max_lines: Optional[int] = 5000) -> str:
"""Truncate output string to keep only the most recent lines if it exceeds max_lines. """Truncate output string to keep only the most recent lines if it exceeds max_lines.

View File

@ -1,17 +1,41 @@
from ra_aid.tools import ( from ra_aid.tools import (
ask_expert, ask_human, run_shell_command, run_programming_task, ask_expert,
emit_research_notes, emit_plan, emit_related_files, ask_human,
emit_expert_context, emit_key_facts, delete_key_facts, delete_key_facts,
emit_key_snippets, delete_key_snippets, deregister_related_files, read_file_tool, delete_key_snippets,
fuzzy_find_project_files, ripgrep_search, list_directory_tree, deregister_related_files,
monorepo_detected, ui_detected, emit_expert_context,
task_completed, plan_implementation_completed, web_search_tavily emit_key_facts,
emit_key_snippets,
emit_plan,
emit_related_files,
emit_research_notes,
fuzzy_find_project_files,
list_directory_tree,
monorepo_detected,
plan_implementation_completed,
read_file_tool,
ripgrep_search,
run_programming_task,
run_shell_command,
task_completed,
ui_detected,
web_search_tavily,
)
from ra_aid.tools.agent import (
request_implementation,
request_research,
request_research_and_implementation,
request_task_implementation,
request_web_research,
) )
from ra_aid.tools.memory import one_shot_completed from ra_aid.tools.memory import one_shot_completed
from ra_aid.tools.agent import request_research, request_implementation, request_research_and_implementation, request_task_implementation, request_web_research
# Read-only tools that don't modify system state # Read-only tools that don't modify system state
def get_read_only_tools(human_interaction: bool = False, web_research_enabled: bool = False) -> list: def get_read_only_tools(
human_interaction: bool = False, web_research_enabled: bool = False
) -> list:
"""Get the list of read-only tools, optionally including human interaction tools. """Get the list of read-only tools, optionally including human interaction tools.
Args: Args:
@ -32,7 +56,7 @@ def get_read_only_tools(human_interaction: bool = False, web_research_enabled: b
read_file_tool, read_file_tool,
fuzzy_find_project_files, fuzzy_find_project_files,
ripgrep_search, ripgrep_search,
run_shell_command # can modify files, but we still need it for read-only tasks. run_shell_command, # can modify files, but we still need it for read-only tasks.
] ]
if web_research_enabled: if web_research_enabled:
@ -43,6 +67,7 @@ def get_read_only_tools(human_interaction: bool = False, web_research_enabled: b
return tools return tools
# Define constant tool groups # Define constant tool groups
READ_ONLY_TOOLS = get_read_only_tools() READ_ONLY_TOOLS = get_read_only_tools()
MODIFICATION_TOOLS = [run_programming_task] MODIFICATION_TOOLS = [run_programming_task]
@ -52,10 +77,16 @@ RESEARCH_TOOLS = [
emit_research_notes, emit_research_notes,
one_shot_completed, one_shot_completed,
monorepo_detected, monorepo_detected,
ui_detected ui_detected,
] ]
def get_research_tools(research_only: bool = False, expert_enabled: bool = True, human_interaction: bool = False, web_research_enabled: bool = False) -> list:
def get_research_tools(
research_only: bool = False,
expert_enabled: bool = True,
human_interaction: bool = False,
web_research_enabled: bool = False,
) -> list:
"""Get the list of research tools based on mode and whether expert is enabled. """Get the list of research tools based on mode and whether expert is enabled.
Args: Args:
@ -83,7 +114,10 @@ def get_research_tools(research_only: bool = False, expert_enabled: bool = True,
return tools return tools
def get_planning_tools(expert_enabled: bool = True, web_research_enabled: bool = False) -> list:
def get_planning_tools(
expert_enabled: bool = True, web_research_enabled: bool = False
) -> list:
"""Get the list of planning tools based on whether expert is enabled. """Get the list of planning tools based on whether expert is enabled.
Args: Args:
@ -97,7 +131,7 @@ def get_planning_tools(expert_enabled: bool = True, web_research_enabled: bool =
planning_tools = [ planning_tools = [
emit_plan, emit_plan,
request_task_implementation, request_task_implementation,
plan_implementation_completed plan_implementation_completed,
] ]
tools.extend(planning_tools) tools.extend(planning_tools)
@ -107,7 +141,10 @@ def get_planning_tools(expert_enabled: bool = True, web_research_enabled: bool =
return tools return tools
def get_implementation_tools(expert_enabled: bool = True, web_research_enabled: bool = False) -> list:
def get_implementation_tools(
expert_enabled: bool = True, web_research_enabled: bool = False
) -> list:
"""Get the list of implementation tools based on whether expert is enabled. """Get the list of implementation tools based on whether expert is enabled.
Args: Args:
@ -119,9 +156,7 @@ def get_implementation_tools(expert_enabled: bool = True, web_research_enabled:
# Add modification tools since it's not research-only # Add modification tools since it's not research-only
tools.extend(MODIFICATION_TOOLS) tools.extend(MODIFICATION_TOOLS)
tools.extend([ tools.extend([task_completed])
task_completed
])
# Add expert tools if enabled # Add expert tools if enabled
if expert_enabled: if expert_enabled:
@ -129,6 +164,7 @@ def get_implementation_tools(expert_enabled: bool = True, web_research_enabled:
return tools return tools
def get_web_research_tools(expert_enabled: bool = True) -> list: def get_web_research_tools(expert_enabled: bool = True) -> list:
"""Get the list of tools available for web research. """Get the list of tools available for web research.
@ -140,11 +176,7 @@ def get_web_research_tools(expert_enabled: bool = True) -> list:
Returns: Returns:
list: List of tools configured for web research list: List of tools configured for web research
""" """
tools = [ tools = [web_search_tavily, emit_research_notes, task_completed]
web_search_tavily,
emit_research_notes,
task_completed
]
if expert_enabled: if expert_enabled:
tools.append(emit_expert_context) tools.append(emit_expert_context)
@ -152,7 +184,10 @@ def get_web_research_tools(expert_enabled: bool = True) -> list:
return tools return tools
def get_chat_tools(expert_enabled: bool = True, web_research_enabled: bool = False) -> list:
def get_chat_tools(
expert_enabled: bool = True, web_research_enabled: bool = False
) -> list:
"""Get the list of tools available in chat mode. """Get the list of tools available in chat mode.
Chat mode includes research and implementation capabilities but excludes Chat mode includes research and implementation capabilities but excludes
@ -169,7 +204,7 @@ def get_chat_tools(expert_enabled: bool = True, web_research_enabled: bool = Fal
emit_key_facts, emit_key_facts,
delete_key_facts, delete_key_facts,
delete_key_snippets, delete_key_snippets,
deregister_related_files deregister_related_files,
] ]
if web_research_enabled: if web_research_enabled:

View File

@ -1,51 +1,62 @@
from .expert import ask_expert, emit_expert_context
from .file_str_replace import file_str_replace
from .fuzzy_find import fuzzy_find_project_files
from .human import ask_human
from .list_directory import list_directory_tree
from .memory import (
delete_key_facts,
delete_key_snippets,
delete_tasks,
deregister_related_files,
emit_key_facts,
emit_key_snippets,
emit_plan,
emit_related_files,
emit_research_notes,
emit_task,
get_memory_value,
plan_implementation_completed,
request_implementation,
swap_task_order,
task_completed,
)
from .programmer import run_programming_task
from .read_file import read_file_tool
from .research import existing_project_detected, monorepo_detected, ui_detected
from .ripgrep import ripgrep_search
from .shell import run_shell_command from .shell import run_shell_command
from .web_search_tavily import web_search_tavily from .web_search_tavily import web_search_tavily
from .research import monorepo_detected, existing_project_detected, ui_detected
from .human import ask_human
from .programmer import run_programming_task
from .expert import ask_expert, emit_expert_context
from .read_file import read_file_tool
from .file_str_replace import file_str_replace
from .write_file import write_file_tool from .write_file import write_file_tool
from .fuzzy_find import fuzzy_find_project_files
from .list_directory import list_directory_tree
from .ripgrep import ripgrep_search
from .memory import (
delete_tasks, emit_research_notes, emit_plan, emit_task, get_memory_value, emit_key_facts,
request_implementation, delete_key_facts,
emit_key_snippets, delete_key_snippets, emit_related_files, swap_task_order, task_completed,
plan_implementation_completed, deregister_related_files
)
__all__ = [ __all__ = [
'ask_expert', "ask_expert",
'delete_key_facts', "delete_key_facts",
'delete_key_snippets', "delete_key_snippets",
'web_search_tavily', "web_search_tavily",
'deregister_related_files', "deregister_related_files",
'emit_expert_context', "emit_expert_context",
'emit_key_facts', "emit_key_facts",
'emit_key_snippets', "emit_key_snippets",
'emit_plan', "emit_plan",
'emit_related_files', "emit_related_files",
'emit_research_notes', "emit_research_notes",
'emit_task', "emit_task",
'fuzzy_find_project_files', "fuzzy_find_project_files",
'get_memory_value', "get_memory_value",
'list_directory_tree', "list_directory_tree",
'read_file_tool', "read_file_tool",
'request_implementation', "request_implementation",
'run_programming_task', "run_programming_task",
'run_shell_command', "run_shell_command",
'write_file_tool', "write_file_tool",
'ripgrep_search', "ripgrep_search",
'file_str_replace', "file_str_replace",
'delete_tasks', "delete_tasks",
'swap_task_order', "swap_task_order",
'monorepo_detected', "monorepo_detected",
'existing_project_detected', "existing_project_detected",
'ui_detected', "ui_detected",
'ask_human', "ask_human",
'task_completed', "task_completed",
'plan_implementation_completed' "plan_implementation_completed",
] ]

View File

@ -1,18 +1,20 @@
"""Tools for spawning and managing sub-agents.""" """Tools for spawning and managing sub-agents."""
from typing import Any, Dict, List, Union
from langchain_core.tools import tool from langchain_core.tools import tool
from typing import Dict, Any, Union, List
from typing_extensions import TypeAlias
from ..agent_utils import AgentInterrupt
from ra_aid.exceptions import AgentInterrupt
ResearchResult = Dict[str, Union[str, bool, Dict[int, Any], List[Any], None]]
from rich.console import Console from rich.console import Console
from ra_aid.tools.memory import _global_memory
from ra_aid.console.formatting import print_error from ra_aid.console.formatting import print_error
from .memory import get_memory_value, get_related_files, get_work_log from ra_aid.exceptions import AgentInterrupt
from .human import ask_human from ra_aid.tools.memory import _global_memory
from ..llm import initialize_llm
from ..console import print_task_header from ..console import print_task_header
from ..llm import initialize_llm
from .human import ask_human
from .memory import get_memory_value, get_related_files, get_work_log
ResearchResult = Dict[str, Union[str, bool, Dict[int, Any], List[Any], None]]
CANCELLED_BY_USER_REASON = "The operation was explicitly cancelled by the user. This typically is an indication that the action requested was not aligned with the user request." CANCELLED_BY_USER_REASON = "The operation was explicitly cancelled by the user. This typically is an indication that the action requested was not aligned with the user request."
@ -20,6 +22,7 @@ RESEARCH_AGENT_RECURSION_LIMIT = 3
console = Console() console = Console()
@tool("request_research") @tool("request_research")
def request_research(query: str) -> ResearchResult: def request_research(query: str) -> ResearchResult:
"""Spawn a research-only agent to investigate the given query. """Spawn a research-only agent to investigate the given query.
@ -31,11 +34,14 @@ def request_research(query: str) -> ResearchResult:
query: The research question or project description query: The research question or project description
""" """
# Initialize model from config # Initialize model from config
config = _global_memory.get('config', {}) config = _global_memory.get("config", {})
model = initialize_llm(config.get('provider', 'anthropic'), config.get('model', 'claude-3-5-sonnet-20241022')) model = initialize_llm(
config.get("provider", "anthropic"),
config.get("model", "claude-3-5-sonnet-20241022"),
)
# Check recursion depth # Check recursion depth
current_depth = _global_memory.get('agent_depth', 0) current_depth = _global_memory.get("agent_depth", 0)
if current_depth >= RESEARCH_AGENT_RECURSION_LIMIT: if current_depth >= RESEARCH_AGENT_RECURSION_LIMIT:
print_error("Maximum research recursion depth reached") print_error("Maximum research recursion depth reached")
return { return {
@ -45,7 +51,7 @@ def request_research(query: str) -> ResearchResult:
"research_notes": get_memory_value("research_notes"), "research_notes": get_memory_value("research_notes"),
"key_snippets": get_memory_value("key_snippets"), "key_snippets": get_memory_value("key_snippets"),
"success": False, "success": False,
"reason": "max_depth_exceeded" "reason": "max_depth_exceeded",
} }
success = True success = True
@ -54,14 +60,15 @@ def request_research(query: str) -> ResearchResult:
try: try:
# Run research agent # Run research agent
from ..agent_utils import run_research_agent from ..agent_utils import run_research_agent
result = run_research_agent(
_result = run_research_agent(
query, query,
model, model,
expert_enabled=True, expert_enabled=True,
research_only=True, research_only=True,
hil=config.get('hil', False), hil=config.get("hil", False),
console_message=query, console_message=query,
config=config config=config,
) )
except AgentInterrupt: except AgentInterrupt:
print() print()
@ -76,13 +83,16 @@ def request_research(query: str) -> ResearchResult:
reason = f"error: {str(e)}" reason = f"error: {str(e)}"
finally: finally:
# Get completion message if available # Get completion message if available
completion_message = _global_memory.get('completion_message', 'Task was completed successfully.' if success else None) completion_message = _global_memory.get(
"completion_message",
"Task was completed successfully." if success else None,
)
work_log = get_work_log() work_log = get_work_log()
# Clear completion state from global memory # Clear completion state from global memory
_global_memory['completion_message'] = '' _global_memory["completion_message"] = ""
_global_memory['task_completed'] = False _global_memory["task_completed"] = False
response_data = { response_data = {
"completion_message": completion_message, "completion_message": completion_message,
@ -91,12 +101,13 @@ def request_research(query: str) -> ResearchResult:
"research_notes": get_memory_value("research_notes"), "research_notes": get_memory_value("research_notes"),
"key_snippets": get_memory_value("key_snippets"), "key_snippets": get_memory_value("key_snippets"),
"success": success, "success": success,
"reason": reason "reason": reason,
} }
if work_log is not None: if work_log is not None:
response_data["work_log"] = work_log response_data["work_log"] = work_log
return response_data return response_data
@tool("request_web_research") @tool("request_web_research")
def request_web_research(query: str) -> ResearchResult: def request_web_research(query: str) -> ResearchResult:
"""Spawn a web research agent to investigate the given query using web search. """Spawn a web research agent to investigate the given query using web search.
@ -105,8 +116,11 @@ def request_web_research(query: str) -> ResearchResult:
query: The research question or project description query: The research question or project description
""" """
# Initialize model from config # Initialize model from config
config = _global_memory.get('config', {}) config = _global_memory.get("config", {})
model = initialize_llm(config.get('provider', 'anthropic'), config.get('model', 'claude-3-5-sonnet-20241022')) model = initialize_llm(
config.get("provider", "anthropic"),
config.get("model", "claude-3-5-sonnet-20241022"),
)
success = True success = True
reason = None reason = None
@ -114,13 +128,14 @@ def request_web_research(query: str) -> ResearchResult:
try: try:
# Run web research agent # Run web research agent
from ..agent_utils import run_web_research_agent from ..agent_utils import run_web_research_agent
result = run_web_research_agent(
_result = run_web_research_agent(
query, query,
model, model,
expert_enabled=True, expert_enabled=True,
hil=config.get('hil', False), hil=config.get("hil", False),
console_message=query, console_message=query,
config=config config=config,
) )
except AgentInterrupt: except AgentInterrupt:
print() print()
@ -135,25 +150,29 @@ def request_web_research(query: str) -> ResearchResult:
reason = f"error: {str(e)}" reason = f"error: {str(e)}"
finally: finally:
# Get completion message if available # Get completion message if available
completion_message = _global_memory.get('completion_message', 'Task was completed successfully.' if success else None) completion_message = _global_memory.get(
"completion_message",
"Task was completed successfully." if success else None,
)
work_log = get_work_log() work_log = get_work_log()
# Clear completion state from global memory # Clear completion state from global memory
_global_memory['completion_message'] = '' _global_memory["completion_message"] = ""
_global_memory['task_completed'] = False _global_memory["task_completed"] = False
response_data = { response_data = {
"completion_message": completion_message, "completion_message": completion_message,
"key_snippets": get_memory_value("key_snippets"), "key_snippets": get_memory_value("key_snippets"),
"research_notes": get_memory_value("research_notes"), "research_notes": get_memory_value("research_notes"),
"success": success, "success": success,
"reason": reason "reason": reason,
} }
if work_log is not None: if work_log is not None:
response_data["work_log"] = work_log response_data["work_log"] = work_log
return response_data return response_data
@tool("request_research_and_implementation") @tool("request_research_and_implementation")
def request_research_and_implementation(query: str) -> Dict[str, Any]: def request_research_and_implementation(query: str) -> Dict[str, Any]:
"""Spawn a research agent to investigate and implement the given query. """Spawn a research agent to investigate and implement the given query.
@ -165,20 +184,24 @@ def request_research_and_implementation(query: str) -> Dict[str, Any]:
query: The research question or project description query: The research question or project description
""" """
# Initialize model from config # Initialize model from config
config = _global_memory.get('config', {}) config = _global_memory.get("config", {})
model = initialize_llm(config.get('provider', 'anthropic'), config.get('model', 'claude-3-5-sonnet-20241022')) model = initialize_llm(
config.get("provider", "anthropic"),
config.get("model", "claude-3-5-sonnet-20241022"),
)
try: try:
# Run research agent # Run research agent
from ..agent_utils import run_research_agent from ..agent_utils import run_research_agent
result = run_research_agent(
_result = run_research_agent(
query, query,
model, model,
expert_enabled=True, expert_enabled=True,
research_only=False, research_only=False,
hil=config.get('hil', False), hil=config.get("hil", False),
console_message=query, console_message=query,
config=config config=config,
) )
success = True success = True
@ -196,14 +219,16 @@ def request_research_and_implementation(query: str) -> Dict[str, Any]:
reason = f"error: {str(e)}" reason = f"error: {str(e)}"
# Get completion message if available # Get completion message if available
completion_message = _global_memory.get('completion_message', 'Task was completed successfully.' if success else None) completion_message = _global_memory.get(
"completion_message", "Task was completed successfully." if success else None
)
work_log = get_work_log() work_log = get_work_log()
# Clear completion state from global memory # Clear completion state from global memory
_global_memory['completion_message'] = '' _global_memory["completion_message"] = ""
_global_memory['task_completed'] = False _global_memory["task_completed"] = False
_global_memory['plan_completed'] = False _global_memory["plan_completed"] = False
response_data = { response_data = {
"completion_message": completion_message, "completion_message": completion_message,
@ -212,12 +237,13 @@ def request_research_and_implementation(query: str) -> Dict[str, Any]:
"research_notes": get_memory_value("research_notes"), "research_notes": get_memory_value("research_notes"),
"key_snippets": get_memory_value("key_snippets"), "key_snippets": get_memory_value("key_snippets"),
"success": success, "success": success,
"reason": reason "reason": reason,
} }
if work_log is not None: if work_log is not None:
response_data["work_log"] = work_log response_data["work_log"] = work_log
return response_data return response_data
@tool("request_task_implementation") @tool("request_task_implementation")
def request_task_implementation(task_spec: str) -> Dict[str, Any]: def request_task_implementation(task_spec: str) -> Dict[str, Any]:
"""Spawn an implementation agent to execute the given task. """Spawn an implementation agent to execute the given task.
@ -226,27 +252,33 @@ def request_task_implementation(task_spec: str) -> Dict[str, Any]:
task_spec: REQUIRED The full task specification (markdown format, typically one part of the overall plan) task_spec: REQUIRED The full task specification (markdown format, typically one part of the overall plan)
""" """
# Initialize model from config # Initialize model from config
config = _global_memory.get('config', {}) config = _global_memory.get("config", {})
model = initialize_llm(config.get('provider', 'anthropic'), config.get('model', 'claude-3-5-sonnet-20241022')) model = initialize_llm(
config.get("provider", "anthropic"),
config.get("model", "claude-3-5-sonnet-20241022"),
)
# Get required parameters # Get required parameters
tasks = [_global_memory['tasks'][task_id] for task_id in sorted(_global_memory['tasks'])] tasks = [
plan = _global_memory.get('plan', '') _global_memory["tasks"][task_id] for task_id in sorted(_global_memory["tasks"])
related_files = list(_global_memory['related_files'].values()) ]
plan = _global_memory.get("plan", "")
related_files = list(_global_memory["related_files"].values())
try: try:
print_task_header(task_spec) print_task_header(task_spec)
# Run implementation agent # Run implementation agent
from ..agent_utils import run_task_implementation_agent from ..agent_utils import run_task_implementation_agent
result = run_task_implementation_agent(
base_task=_global_memory.get('base_task', ''), _result = run_task_implementation_agent(
base_task=_global_memory.get("base_task", ""),
tasks=tasks, tasks=tasks,
task=task_spec, task=task_spec,
plan=plan, plan=plan,
related_files=related_files, related_files=related_files,
model=model, model=model,
expert_enabled=True, expert_enabled=True,
config=config config=config,
) )
success = True success = True
@ -264,14 +296,16 @@ def request_task_implementation(task_spec: str) -> Dict[str, Any]:
reason = f"error: {str(e)}" reason = f"error: {str(e)}"
# Get completion message if available # Get completion message if available
completion_message = _global_memory.get('completion_message', 'Task was completed successfully.' if success else None) completion_message = _global_memory.get(
"completion_message", "Task was completed successfully." if success else None
)
# Get and reset work log if at root depth # Get and reset work log if at root depth
work_log = get_work_log() work_log = get_work_log()
# Clear completion state from global memory # Clear completion state from global memory
_global_memory['completion_message'] = '' _global_memory["completion_message"] = ""
_global_memory['task_completed'] = False _global_memory["task_completed"] = False
response_data = { response_data = {
"key_facts": get_memory_value("key_facts"), "key_facts": get_memory_value("key_facts"),
@ -279,12 +313,13 @@ def request_task_implementation(task_spec: str) -> Dict[str, Any]:
"key_snippets": get_memory_value("key_snippets"), "key_snippets": get_memory_value("key_snippets"),
"completion_message": completion_message, "completion_message": completion_message,
"success": success, "success": success,
"reason": reason "reason": reason,
} }
if work_log is not None: if work_log is not None:
response_data["work_log"] = work_log response_data["work_log"] = work_log
return response_data return response_data
@tool("request_implementation") @tool("request_implementation")
def request_implementation(task_spec: str) -> Dict[str, Any]: def request_implementation(task_spec: str) -> Dict[str, Any]:
"""Spawn a planning agent to create an implementation plan for the given task. """Spawn a planning agent to create an implementation plan for the given task.
@ -293,18 +328,22 @@ def request_implementation(task_spec: str) -> Dict[str, Any]:
task_spec: The task specification to plan implementation for task_spec: The task specification to plan implementation for
""" """
# Initialize model from config # Initialize model from config
config = _global_memory.get('config', {}) config = _global_memory.get("config", {})
model = initialize_llm(config.get('provider', 'anthropic'), config.get('model', 'claude-3-5-sonnet-20241022')) model = initialize_llm(
config.get("provider", "anthropic"),
config.get("model", "claude-3-5-sonnet-20241022"),
)
try: try:
# Run planning agent # Run planning agent
from ..agent_utils import run_planning_agent from ..agent_utils import run_planning_agent
result = run_planning_agent(
_result = run_planning_agent(
task_spec, task_spec,
model, model,
config=config, config=config,
expert_enabled=True, expert_enabled=True,
hil=config.get('hil', False) hil=config.get("hil", False),
) )
success = True success = True
@ -322,15 +361,17 @@ def request_implementation(task_spec: str) -> Dict[str, Any]:
reason = f"error: {str(e)}" reason = f"error: {str(e)}"
# Get completion message if available # Get completion message if available
completion_message = _global_memory.get('completion_message', 'Task was completed successfully.' if success else None) completion_message = _global_memory.get(
"completion_message", "Task was completed successfully." if success else None
)
# Get and reset work log if at root depth # Get and reset work log if at root depth
work_log = get_work_log() work_log = get_work_log()
# Clear completion state from global memory # Clear completion state from global memory
_global_memory['completion_message'] = '' _global_memory["completion_message"] = ""
_global_memory['task_completed'] = False _global_memory["task_completed"] = False
_global_memory['plan_completed'] = False _global_memory["plan_completed"] = False
response_data = { response_data = {
"completion_message": completion_message, "completion_message": completion_message,
@ -338,7 +379,7 @@ def request_implementation(task_spec: str) -> Dict[str, Any]:
"related_files": get_related_files(), "related_files": get_related_files(),
"key_snippets": get_memory_value("key_snippets"), "key_snippets": get_memory_value("key_snippets"),
"success": success, "success": success,
"reason": reason "reason": reason,
} }
if work_log is not None: if work_log is not None:
response_data["work_log"] = work_log response_data["work_log"] = work_log

View File

@ -1,34 +1,45 @@
from typing import List
import os import os
from typing import List
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
from ..llm import initialize_expert_llm from ..llm import initialize_expert_llm
from .memory import get_memory_value, _global_memory from .memory import _global_memory, get_memory_value
console = Console() console = Console()
_model = None _model = None
def get_model(): def get_model():
global _model global _model
try: try:
if _model is None: if _model is None:
provider = _global_memory['config']['expert_provider'] or 'openai' provider = _global_memory["config"]["expert_provider"] or "openai"
model = _global_memory['config']['expert_model'] or 'o1' model = _global_memory["config"]["expert_model"] or "o1"
_model = initialize_expert_llm(provider, model) _model = initialize_expert_llm(provider, model)
except Exception as e: except Exception as e:
_model = None _model = None
console.print(Panel(f"Failed to initialize expert model: {e}", title="Error", border_style="red")) console.print(
Panel(
f"Failed to initialize expert model: {e}",
title="Error",
border_style="red",
)
)
raise raise
return _model return _model
# Keep track of context globally # Keep track of context globally
expert_context = { expert_context = {
'text': [], # Additional textual context "text": [], # Additional textual context
'files': [] # File paths to include "files": [], # File paths to include
} }
@tool("emit_expert_context") @tool("emit_expert_context")
def emit_expert_context(context: str) -> str: def emit_expert_context(context: str) -> str:
"""Add context for the next expert question. """Add context for the next expert question.
@ -46,13 +57,14 @@ def emit_expert_context(context: str) -> str:
Args: Args:
context: The context to add context: The context to add
""" """
expert_context['text'].append(context) expert_context["text"].append(context)
# Create and display status panel # Create and display status panel
panel_content = f"Added expert context ({len(context)} characters)" panel_content = f"Added expert context ({len(context)} characters)"
console.print(Panel(panel_content, title="Expert Context", border_style="blue")) console.print(Panel(panel_content, title="Expert Context", border_style="blue"))
return f"Context added." return "Context added."
def read_files_with_limit(file_paths: List[str], max_lines: int = 10000) -> str: def read_files_with_limit(file_paths: List[str], max_lines: int = 10000) -> str:
"""Read multiple files and concatenate contents, stopping at line limit. """Read multiple files and concatenate contents, stopping at line limit.
@ -75,24 +87,27 @@ def read_files_with_limit(file_paths: List[str], max_lines: int = 10000) -> str:
console.print(f"Warning: File not found: {path}", style="yellow") console.print(f"Warning: File not found: {path}", style="yellow")
continue continue
with open(path, 'r', encoding='utf-8') as f: with open(path, "r", encoding="utf-8") as f:
file_content = [] file_content = []
for i, line in enumerate(f): for i, line in enumerate(f):
if total_lines + i >= max_lines: if total_lines + i >= max_lines:
file_content.append(f"\n... truncated after {max_lines} lines ...") file_content.append(
f"\n... truncated after {max_lines} lines ..."
)
break break
file_content.append(line) file_content.append(line)
if file_content: if file_content:
contents.append(f'\n## File: {path}\n') contents.append(f"\n## File: {path}\n")
contents.append(''.join(file_content)) contents.append("".join(file_content))
total_lines += len(file_content) total_lines += len(file_content)
except Exception as e: except Exception as e:
console.print(f"Error reading file {path}: {str(e)}", style="red") console.print(f"Error reading file {path}: {str(e)}", style="red")
continue continue
return ''.join(contents) return "".join(contents)
def read_related_files(file_paths: List[str]) -> str: def read_related_files(file_paths: List[str]) -> str:
"""Read the provided files and return their contents. """Read the provided files and return their contents.
@ -104,10 +119,11 @@ def read_related_files(file_paths: List[str]) -> str:
String containing concatenated file contents, or empty string if no paths String containing concatenated file contents, or empty string if no paths
""" """
if not file_paths: if not file_paths:
return '' return ""
return read_files_with_limit(file_paths, max_lines=10000) return read_files_with_limit(file_paths, max_lines=10000)
@tool("ask_expert") @tool("ask_expert")
def ask_expert(question: str) -> str: def ask_expert(question: str) -> str:
"""Ask a question to an expert AI model. """Ask a question to an expert AI model.
@ -128,58 +144,58 @@ def ask_expert(question: str) -> str:
global expert_context global expert_context
# Get all content first # Get all content first
file_paths = list(_global_memory['related_files'].values()) file_paths = list(_global_memory["related_files"].values())
related_contents = read_related_files(file_paths) related_contents = read_related_files(file_paths)
key_snippets = get_memory_value('key_snippets') key_snippets = get_memory_value("key_snippets")
key_facts = get_memory_value('key_facts') key_facts = get_memory_value("key_facts")
research_notes = get_memory_value('research_notes') research_notes = get_memory_value("research_notes")
# Build display query (just question) # Build display query (just question)
display_query = "# Question\n" + question display_query = "# Question\n" + question
# Show only question in panel # Show only question in panel
console.print(Panel( console.print(
Markdown(display_query), Panel(Markdown(display_query), title="🤔 Expert Query", border_style="yellow")
title="🤔 Expert Query", )
border_style="yellow"
))
# Clear context after panel display # Clear context after panel display
expert_context['text'].clear() expert_context["text"].clear()
expert_context['files'].clear() expert_context["files"].clear()
# Build full query in specified order # Build full query in specified order
query_parts = [] query_parts = []
if related_contents: if related_contents:
query_parts.extend(['# Related Files', related_contents]) query_parts.extend(["# Related Files", related_contents])
if related_contents: if related_contents:
query_parts.extend(['# Research Notes', research_notes]) query_parts.extend(["# Research Notes", research_notes])
if key_snippets and len(key_snippets) > 0: if key_snippets and len(key_snippets) > 0:
query_parts.extend(['# Key Snippets', key_snippets]) query_parts.extend(["# Key Snippets", key_snippets])
if key_facts and len(key_facts) > 0: if key_facts and len(key_facts) > 0:
query_parts.extend(['# Key Facts About This Project', key_facts]) query_parts.extend(["# Key Facts About This Project", key_facts])
if expert_context['text']: if expert_context["text"]:
query_parts.extend(['\n# Additional Context', '\n'.join(expert_context['text'])]) query_parts.extend(
["\n# Additional Context", "\n".join(expert_context["text"])]
)
query_parts.extend(['# Question', question]) query_parts.extend(["# Question", question])
query_parts.extend(['\n # Addidional Requirements', "Do not expand the scope unnecessarily."]) query_parts.extend(
["\n # Addidional Requirements", "Do not expand the scope unnecessarily."]
)
# Join all parts # Join all parts
full_query = '\n'.join(query_parts) full_query = "\n".join(query_parts)
# Get response using full query # Get response using full query
response = get_model().invoke(full_query) response = get_model().invoke(full_query)
# Format and display response # Format and display response
console.print(Panel( console.print(
Markdown(response.content), Panel(Markdown(response.content), title="Expert Response", border_style="blue")
title="Expert Response", )
border_style="blue"
))
return response.content return response.content

View File

@ -1,10 +1,13 @@
from langchain_core.tools import tool
from typing import Dict
from pathlib import Path from pathlib import Path
from typing import Dict
from langchain_core.tools import tool
from rich.panel import Panel from rich.panel import Panel
from ra_aid.console import console from ra_aid.console import console
from ra_aid.console.formatting import print_error from ra_aid.console.formatting import print_error
def truncate_display_str(s: str, max_length: int = 30) -> str: def truncate_display_str(s: str, max_length: int = 30) -> str:
"""Truncate a string for display purposes if it exceeds max length. """Truncate a string for display purposes if it exceeds max length.
@ -19,6 +22,7 @@ def truncate_display_str(s: str, max_length: int = 30) -> str:
return s return s
return s[:max_length] + "..." return s[:max_length] + "..."
def format_string_for_display(s: str, threshold: int = 30) -> str: def format_string_for_display(s: str, threshold: int = 30) -> str:
"""Format a string for display, showing either quoted string or length. """Format a string for display, showing either quoted string or length.
@ -31,14 +35,11 @@ def format_string_for_display(s: str, threshold: int = 30) -> str:
""" """
if len(s) <= threshold: if len(s) <= threshold:
return f"'{s}'" return f"'{s}'"
return f'[{len(s)} characters]' return f"[{len(s)} characters]"
@tool @tool
def file_str_replace( def file_str_replace(filepath: str, old_str: str, new_str: str) -> Dict[str, any]:
filepath: str,
old_str: str,
new_str: str
) -> Dict[str, any]:
"""Replace an exact string match in a file with a new string. """Replace an exact string match in a file with a new string.
Only performs replacement if the old string appears exactly once. Only performs replacement if the old string appears exactly once.
@ -74,14 +75,16 @@ def file_str_replace(
new_content = content.replace(old_str, new_str) new_content = content.replace(old_str, new_str)
path.write_text(new_content) path.write_text(new_content)
console.print(Panel( console.print(
f"Replaced in {filepath}:\n{format_string_for_display(old_str)}{format_string_for_display(new_str)}", Panel(
title="✓ String Replaced", f"Replaced in {filepath}:\n{format_string_for_display(old_str)}{format_string_for_display(new_str)}",
border_style="bright_blue" title="✓ String Replaced",
)) border_style="bright_blue",
)
)
return { return {
"success": True, "success": True,
"message": f"Successfully replaced '{old_str}' with '{new_str}' in {filepath}" "message": f"Successfully replaced '{old_str}' with '{new_str}' in {filepath}",
} }
except Exception as e: except Exception as e:

View File

@ -1,23 +1,25 @@
from typing import List, Tuple
import fnmatch import fnmatch
from git import Repo from typing import List, Tuple
from fuzzywuzzy import process from fuzzywuzzy import process
from git import Repo
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
console = Console() console = Console()
DEFAULT_EXCLUDE_PATTERNS = [ DEFAULT_EXCLUDE_PATTERNS = [
'*.pyc', "*.pyc",
'__pycache__/*', "__pycache__/*",
'.git/*', ".git/*",
'*.so', "*.so",
'*.o', "*.o",
'*.class' "*.class",
] ]
@tool @tool
def fuzzy_find_project_files( def fuzzy_find_project_files(
search_term: str, search_term: str,
@ -26,7 +28,7 @@ def fuzzy_find_project_files(
threshold: int = 60, threshold: int = 60,
max_results: int = 10, max_results: int = 10,
include_paths: List[str] = None, include_paths: List[str] = None,
exclude_patterns: List[str] = None exclude_patterns: List[str] = None,
) -> List[Tuple[str, int]]: ) -> List[Tuple[str, int]]:
"""Fuzzy find files in a git repository matching the search term. """Fuzzy find files in a git repository matching the search term.
@ -73,33 +75,19 @@ def fuzzy_find_project_files(
if include_paths: if include_paths:
filtered_files = [] filtered_files = []
for pattern in include_paths: for pattern in include_paths:
filtered_files.extend( filtered_files.extend(f for f in all_files if fnmatch.fnmatch(f, pattern))
f for f in all_files
if fnmatch.fnmatch(f, pattern)
)
all_files = filtered_files all_files = filtered_files
# Apply exclude patterns # Apply exclude patterns
patterns = DEFAULT_EXCLUDE_PATTERNS + (exclude_patterns or []) patterns = DEFAULT_EXCLUDE_PATTERNS + (exclude_patterns or [])
for pattern in patterns: for pattern in patterns:
all_files = [ all_files = [f for f in all_files if not fnmatch.fnmatch(f, pattern)]
f for f in all_files
if not fnmatch.fnmatch(f, pattern)
]
# Perform fuzzy matching # Perform fuzzy matching
matches = process.extract( matches = process.extract(search_term, all_files, limit=max_results)
search_term,
all_files,
limit=max_results
)
# Filter by threshold # Filter by threshold
filtered_matches = [ filtered_matches = [(path, score) for path, score in matches if score >= threshold]
(path, score)
for path, score in matches
if score >= threshold
]
# Build info panel content # Build info panel content
info_sections = [] info_sections = []
@ -110,7 +98,7 @@ def fuzzy_find_project_files(
f"**Search Term**: `{search_term}`", f"**Search Term**: `{search_term}`",
f"**Repository**: `{repo_path}`", f"**Repository**: `{repo_path}`",
f"**Threshold**: {threshold}", f"**Threshold**: {threshold}",
f"**Max Results**: {max_results}" f"**Max Results**: {max_results}",
] ]
if include_paths: if include_paths:
params_section.append("\n**Include Patterns**:") params_section.append("\n**Include Patterns**:")
@ -126,7 +114,7 @@ def fuzzy_find_project_files(
stats_section = [ stats_section = [
"## Results Statistics", "## Results Statistics",
f"**Total Files Scanned**: {len(all_files)}", f"**Total Files Scanned**: {len(all_files)}",
f"**Matches Found**: {len(filtered_matches)}" f"**Matches Found**: {len(filtered_matches)}",
] ]
info_sections.append("\n".join(stats_section)) info_sections.append("\n".join(stats_section))
@ -140,10 +128,12 @@ def fuzzy_find_project_files(
info_sections.append("## Results\n*No matches found*") info_sections.append("## Results\n*No matches found*")
# Display the panel # Display the panel
console.print(Panel( console.print(
Markdown("\n\n".join(info_sections)), Panel(
title="🔍 Fuzzy Find Results", Markdown("\n\n".join(info_sections)),
border_style="bright_blue" title="🔍 Fuzzy Find Results",
)) border_style="bright_blue",
)
)
return filtered_matches return filtered_matches

View File

@ -1,30 +1,41 @@
"""Utilities for executing and managing user-defined test commands.""" """Utilities for executing and managing user-defined test commands."""
from typing import Dict, Any, Tuple
import subprocess import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Dict, Tuple
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
from ra_aid.logging_config import get_logger
from ra_aid.tools.human import ask_human from ra_aid.tools.human import ask_human
from ra_aid.tools.shell import run_shell_command from ra_aid.tools.shell import run_shell_command
from ra_aid.logging_config import get_logger
console = Console() console = Console()
logger = get_logger(__name__) logger = get_logger(__name__)
@dataclass @dataclass
class TestState: class TestState:
"""State for test execution.""" """State for test execution."""
prompt: str prompt: str
test_attempts: int test_attempts: int
auto_test: bool auto_test: bool
should_break: bool = False should_break: bool = False
class TestCommandExecutor: class TestCommandExecutor:
"""Class for executing and managing test commands.""" """Class for executing and managing test commands."""
def __init__(self, config: Dict[str, Any], original_prompt: str, test_attempts: int = 0, auto_test: bool = False): def __init__(
self,
config: Dict[str, Any],
original_prompt: str,
test_attempts: int = 0,
auto_test: bool = False,
):
"""Initialize the test command executor. """Initialize the test command executor.
Args: Args:
@ -38,7 +49,7 @@ class TestCommandExecutor:
prompt=original_prompt, prompt=original_prompt,
test_attempts=test_attempts, test_attempts=test_attempts,
auto_test=auto_test, auto_test=auto_test,
should_break=False should_break=False,
) )
self.max_retries = config.get("max_test_cmd_retries", 5) self.max_retries = config.get("max_test_cmd_retries", 5)
@ -46,13 +57,17 @@ class TestCommandExecutor:
"""Display test failure message.""" """Display test failure message."""
console.print( console.print(
Panel( Panel(
Markdown(f"Test failed. Attempt number {self.state.test_attempts} of {self.max_retries}. Retrying and informing of failure output"), Markdown(
f"Test failed. Attempt number {self.state.test_attempts} of {self.max_retries}. Retrying and informing of failure output"
),
title="🔎 User Defined Test", title="🔎 User Defined Test",
border_style="red bold" border_style="red bold",
) )
) )
def handle_test_failure(self, original_prompt: str, test_result: Dict[str, Any]) -> None: def handle_test_failure(
self, original_prompt: str, test_result: Dict[str, Any]
) -> None:
"""Handle test command failure. """Handle test command failure.
Args: Args:
@ -70,7 +85,7 @@ class TestCommandExecutor:
cmd: Test command to execute cmd: Test command to execute
original_prompt: Original prompt text original_prompt: Original prompt text
""" """
timeout = self.config.get('timeout', 30) timeout = self.config.get("timeout", 30)
try: try:
logger.info(f"Executing test command: {cmd} with timeout {timeout}s") logger.info(f"Executing test command: {cmd} with timeout {timeout}s")
test_result = run_shell_command(cmd, timeout=timeout) test_result = run_shell_command(cmd, timeout=timeout)
@ -83,14 +98,18 @@ class TestCommandExecutor:
self.state.should_break = True self.state.should_break = True
logger.info("Test command executed successfully") logger.info("Test command executed successfully")
except subprocess.TimeoutExpired as e: except subprocess.TimeoutExpired:
logger.warning(f"Test command timed out after {timeout}s: {cmd}") logger.warning(f"Test command timed out after {timeout}s: {cmd}")
self.state.test_attempts += 1 self.state.test_attempts += 1
self.state.prompt = f"{original_prompt}. Previous attempt timed out after {timeout} seconds" self.state.prompt = (
f"{original_prompt}. Previous attempt timed out after {timeout} seconds"
)
self.display_test_failure() self.display_test_failure()
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
logger.error(f"Test command failed with exit code {e.returncode}: {cmd}\nOutput: {e.output}") logger.error(
f"Test command failed with exit code {e.returncode}: {cmd}\nOutput: {e.output}"
)
self.state.test_attempts += 1 self.state.test_attempts += 1
self.state.prompt = f"{original_prompt}. Previous attempt failed with exit code {e.returncode}: {e.output}" self.state.prompt = f"{original_prompt}. Previous attempt failed with exit code {e.returncode}: {e.output}"
self.display_test_failure() self.display_test_failure()
@ -100,7 +119,9 @@ class TestCommandExecutor:
self.state.test_attempts += 1 self.state.test_attempts += 1
self.state.should_break = True self.state.should_break = True
def handle_user_response(self, response: str, cmd: str, original_prompt: str) -> None: def handle_user_response(
self, response: str, cmd: str, original_prompt: str
) -> None:
"""Handle user's response to test prompt. """Handle user's response to test prompt.
Args: Args:
response: User's response (y/n/a) response: User's response (y/n/a)
@ -144,23 +165,46 @@ class TestCommandExecutor:
""" """
if not self.config.get("test_cmd"): if not self.config.get("test_cmd"):
self.state.should_break = True self.state.should_break = True
return self.state.should_break, self.state.prompt, self.state.auto_test, self.state.test_attempts return (
self.state.should_break,
self.state.prompt,
self.state.auto_test,
self.state.test_attempts,
)
cmd = self.config["test_cmd"] cmd = self.config["test_cmd"]
if not self.state.auto_test: if not self.state.auto_test:
print() print()
response = ask_human.invoke({"question": "Would you like to run the test command? (y=yes, n=no, a=enable auto-test)"}) response = ask_human.invoke(
{
"question": "Would you like to run the test command? (y=yes, n=no, a=enable auto-test)"
}
)
self.handle_user_response(response, cmd, self.state.prompt) self.handle_user_response(response, cmd, self.state.prompt)
else: else:
if self.check_max_retries(): if self.check_max_retries():
logger.error(f"Maximum number of test retries ({self.max_retries}) reached. Stopping test execution.") logger.error(
console.print(Panel(f"Maximum retries ({self.max_retries}) reached. Test execution stopped.", title="⚠️ Test Execution", border_style="yellow bold")) f"Maximum number of test retries ({self.max_retries}) reached. Stopping test execution."
)
console.print(
Panel(
f"Maximum retries ({self.max_retries}) reached. Test execution stopped.",
title="⚠️ Test Execution",
border_style="yellow bold",
)
)
self.state.should_break = True self.state.should_break = True
else: else:
self.run_test_command(cmd, self.state.prompt) self.run_test_command(cmd, self.state.prompt)
return self.state.should_break, self.state.prompt, self.state.auto_test, self.state.test_attempts return (
self.state.should_break,
self.state.prompt,
self.state.auto_test,
self.state.test_attempts,
)
def execute_test_command( def execute_test_command(
config: Dict[str, Any], config: Dict[str, Any],

View File

@ -2,22 +2,24 @@ from langchain_core.tools import tool
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
console = Console() console = Console()
def create_keybindings(): def create_keybindings():
"""Create custom key bindings for Ctrl+D submission.""" """Create custom key bindings for Ctrl+D submission."""
bindings = KeyBindings() bindings = KeyBindings()
@bindings.add('c-d') @bindings.add("c-d")
def submit(event): def submit(event):
"""Trigger submission when Ctrl+D is pressed.""" """Trigger submission when Ctrl+D is pressed."""
event.current_buffer.validate_and_handle() event.current_buffer.validate_and_handle()
return bindings return bindings
@tool @tool
def ask_human(question: str) -> str: def ask_human(question: str) -> str:
"""Ask the human user a question with a nicely formatted display. """Ask the human user a question with a nicely formatted display.
@ -28,24 +30,26 @@ def ask_human(question: str) -> str:
Returns: Returns:
The user's response as a string The user's response as a string
""" """
console.print(Panel( console.print(
Markdown(question + "\n\n*Multiline input is supported; use Ctrl+D to submit. Use Ctrl+C to exit the program.*"), Panel(
title="💭 Question for Human", Markdown(
border_style="yellow bold" question
)) + "\n\n*Multiline input is supported; use Ctrl+D to submit. Use Ctrl+C to exit the program.*"
),
title="💭 Question for Human",
border_style="yellow bold",
)
)
session = PromptSession( session = PromptSession(
multiline=True, multiline=True,
key_bindings=create_keybindings(), key_bindings=create_keybindings(),
prompt_continuation='. ', prompt_continuation=". ",
) )
print() print()
response = session.prompt( response = session.prompt("> ", wrap_lines=True)
"> ",
wrap_lines=True
)
print() print()
return response return response

View File

@ -1,39 +1,45 @@
import datetime
import fnmatch
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List, Optional
import datetime
from dataclasses import dataclass
import pathspec import pathspec
from rich.tree import Tree
from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from langchain_core.tools import tool from langchain_core.tools import tool
import fnmatch from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from rich.tree import Tree
console = Console() console = Console()
@dataclass @dataclass
class DirScanConfig: class DirScanConfig:
"""Configuration for directory scanning""" """Configuration for directory scanning"""
max_depth: int max_depth: int
follow_links: bool follow_links: bool
show_size: bool show_size: bool
show_modified: bool show_modified: bool
exclude_patterns: List[str] exclude_patterns: List[str]
def format_size(size_bytes: int) -> str: def format_size(size_bytes: int) -> str:
"""Format file size in human readable format""" """Format file size in human readable format"""
for unit in ['B', 'KB', 'MB', 'GB']: for unit in ["B", "KB", "MB", "GB"]:
if size_bytes < 1024: if size_bytes < 1024:
return f"{size_bytes:.1f}{unit}" return f"{size_bytes:.1f}{unit}"
size_bytes /= 1024 size_bytes /= 1024
return f"{size_bytes:.1f}TB" return f"{size_bytes:.1f}TB"
def format_time(timestamp: float) -> str: def format_time(timestamp: float) -> str:
"""Format timestamp as readable date""" """Format timestamp as readable date"""
dt = datetime.datetime.fromtimestamp(timestamp) dt = datetime.datetime.fromtimestamp(timestamp)
return dt.strftime("%Y-%m-%d %H:%M") return dt.strftime("%Y-%m-%d %H:%M")
# Default patterns to exclude # Default patterns to exclude
DEFAULT_EXCLUDE_PATTERNS = [ DEFAULT_EXCLUDE_PATTERNS = [
".*", # Hidden files ".*", # Hidden files
@ -54,6 +60,7 @@ DEFAULT_EXCLUDE_PATTERNS = [
"*.cache", # Cache files "*.cache", # Cache files
] ]
def load_gitignore_patterns(path: Path) -> pathspec.PathSpec: def load_gitignore_patterns(path: Path) -> pathspec.PathSpec:
"""Load gitignore patterns from .gitignore file or use defaults. """Load gitignore patterns from .gitignore file or use defaults.
@ -63,7 +70,7 @@ def load_gitignore_patterns(path: Path) -> pathspec.PathSpec:
Returns: Returns:
PathSpec object configured with the loaded patterns PathSpec object configured with the loaded patterns
""" """
gitignore_path = path / '.gitignore' gitignore_path = path / ".gitignore"
patterns = [] patterns = []
def modify_path(p: str) -> str: def modify_path(p: str) -> str:
@ -99,20 +106,23 @@ def load_gitignore_patterns(path: Path) -> pathspec.PathSpec:
return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, patterns) return pathspec.PathSpec.from_lines(pathspec.patterns.GitWildMatchPattern, patterns)
def should_ignore(path: str, spec: pathspec.PathSpec) -> bool: def should_ignore(path: str, spec: pathspec.PathSpec) -> bool:
"""Check if a path should be ignored based on gitignore patterns""" """Check if a path should be ignored based on gitignore patterns"""
return spec.match_file(path) return spec.match_file(path)
def should_exclude(name: str, patterns: List[str]) -> bool: def should_exclude(name: str, patterns: List[str]) -> bool:
"""Check if a file/directory name matches any exclude patterns""" """Check if a file/directory name matches any exclude patterns"""
return any(fnmatch.fnmatch(name, pattern) for pattern in patterns) return any(fnmatch.fnmatch(name, pattern) for pattern in patterns)
def build_tree( def build_tree(
path: Path, path: Path,
tree: Tree, tree: Tree,
config: DirScanConfig, config: DirScanConfig,
current_depth: int = 0, current_depth: int = 0,
spec: Optional[pathspec.PathSpec] = None spec: Optional[pathspec.PathSpec] = None,
) -> None: ) -> None:
"""Recursively build a Rich tree representation of the directory""" """Recursively build a Rich tree representation of the directory"""
if current_depth >= config.max_depth: if current_depth >= config.max_depth:
@ -139,9 +149,7 @@ def build_tree(
try: try:
if entry.is_dir(): if entry.is_dir():
# Add directory node # Add directory node
branch = tree.add( branch = tree.add(f"📁 {entry.name}/")
f"📁 {entry.name}/"
)
# Recursively process subdirectory # Recursively process subdirectory
build_tree(entry, branch, config, current_depth + 1, spec) build_tree(entry, branch, config, current_depth + 1, spec)
@ -165,6 +173,7 @@ def build_tree(
except PermissionError: except PermissionError:
tree.add("🔒 (Permission denied)") tree.add("🔒 (Permission denied)")
@tool @tool
def list_directory_tree( def list_directory_tree(
path: str = ".", path: str = ".",
@ -173,7 +182,7 @@ def list_directory_tree(
follow_links: bool = False, follow_links: bool = False,
show_size: bool = False, # Default to not showing size show_size: bool = False, # Default to not showing size
show_modified: bool = False, # Default to not showing modified time show_modified: bool = False, # Default to not showing modified time
exclude_patterns: List[str] = None exclude_patterns: List[str] = None,
) -> str: ) -> str:
"""List directory contents in a tree format with optional metadata. """List directory contents in a tree format with optional metadata.
@ -204,7 +213,7 @@ def list_directory_tree(
follow_links=follow_links, follow_links=follow_links,
show_size=show_size, show_size=show_size,
show_modified=show_modified, show_modified=show_modified,
exclude_patterns=DEFAULT_EXCLUDE_PATTERNS + (exclude_patterns or []) exclude_patterns=DEFAULT_EXCLUDE_PATTERNS + (exclude_patterns or []),
) )
# Build tree # Build tree
@ -216,10 +225,12 @@ def list_directory_tree(
tree_str = capture.get() tree_str = capture.get()
# Display panel # Display panel
console.print(Panel( console.print(
Markdown(f"```\n{tree_str}\n```"), Panel(
title="📂 Directory Tree", Markdown(f"```\n{tree_str}\n```"),
border_style="bright_blue" title="📂 Directory Tree",
)) border_style="bright_blue",
)
)
return tree_str return tree_str

View File

@ -1,10 +1,11 @@
import os import os
from typing import Dict, List, Any, Union, Optional, Set from typing import Any, Dict, List, Optional, Set, Union
from typing_extensions import TypedDict
from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel from rich.panel import Panel
from langchain_core.tools import tool from typing_extensions import TypedDict
class WorkLogEntry(TypedDict): class WorkLogEntry(TypedDict):
@ -249,7 +250,7 @@ def emit_key_snippets(snippets: List[SnippetInfo]) -> str:
# Format display text as markdown # Format display text as markdown
display_text = [ display_text = [
f"**Source Location**:", "**Source Location**:",
f"- File: `{snippet_info['filepath']}`", f"- File: `{snippet_info['filepath']}`",
f"- Line: `{snippet_info['line_number']}`", f"- Line: `{snippet_info['line_number']}`",
"", # Empty line before code block "", # Empty line before code block

View File

@ -1,42 +1,51 @@
import os import os
from typing import List, Dict, Union from typing import Dict, List, Union
from ra_aid.logging_config import get_logger
from ra_aid.tools.memory import _global_memory
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
from rich.text import Text from rich.text import Text
from ra_aid.logging_config import get_logger
from ra_aid.proc.interactive import run_interactive_command from ra_aid.proc.interactive import run_interactive_command
from ra_aid.text.processing import truncate_output from ra_aid.text.processing import truncate_output
from ra_aid.tools.memory import _global_memory
console = Console() console = Console()
logger = get_logger(__name__) logger = get_logger(__name__)
@tool @tool
def run_programming_task(instructions: str, files: List[str] = []) -> Dict[str, Union[str, int, bool]]: def run_programming_task(
instructions: str, files: List[str] = []
) -> Dict[str, Union[str, int, bool]]:
"""Assign a programming task to a human programmer. Use this instead of trying to write code to files yourself. """Assign a programming task to a human programmer. Use this instead of trying to write code to files yourself.
Before using this tool, ensure all related files have been emitted with emit_related_files. Before using this tool, ensure all related files have been emitted with emit_related_files.
The programmer sees only what you provide, no conversation history. The programmer sees only what you provide, no conversation history.
Give detailed instructions but do not write their code. Give detailed instructions but do not write their code.
They are intelligent and can edit multiple files. They are intelligent and can edit multiple files.
If new files are created, emit them after finishing. If new files are created, emit them after finishing.
They can add/modify files, but not remove. Use run_shell_command to remove files. If referencing files youll delete, remove them after they finish. They can add/modify files, but not remove. Use run_shell_command to remove files. If referencing files youll delete, remove them after they finish.
Args: Args:
instructions: REQUIRED Programming task instructions (markdown format, use newlines and as many tokens as needed) instructions: REQUIRED Programming task instructions (markdown format, use newlines and as many tokens as needed)
files: Optional; if not provided, uses related_files files: Optional; if not provided, uses related_files
Returns: { "output": stdout+stderr, "return_code": 0 if success, "success": True/False } Returns: { "output": stdout+stderr, "return_code": 0 if success, "success": True/False }
""" """
# Get related files if no specific files provided # Get related files if no specific files provided
file_paths = list(_global_memory['related_files'].values()) if 'related_files' in _global_memory else [] file_paths = (
list(_global_memory["related_files"].values())
if "related_files" in _global_memory
else []
)
# Build command # Build command
command = [ command = [
@ -50,14 +59,14 @@ Returns: { "output": stdout+stderr, "return_code": 0 if success, "success": True
] ]
# Add config file if specified # Add config file if specified
if 'config' in _global_memory and _global_memory['config'].get('aider_config'): if "config" in _global_memory and _global_memory["config"].get("aider_config"):
command.extend(['--config', _global_memory['config']['aider_config']]) command.extend(["--config", _global_memory["config"]["aider_config"]])
# if environment variable AIDER_FLAGS exists then parse # if environment variable AIDER_FLAGS exists then parse
if 'AIDER_FLAGS' in os.environ: if "AIDER_FLAGS" in os.environ:
# wrap in try catch in case of any error and log the error # wrap in try catch in case of any error and log the error
try: try:
command.extend(parse_aider_flags(os.environ['AIDER_FLAGS'])) command.extend(parse_aider_flags(os.environ["AIDER_FLAGS"]))
except Exception as e: except Exception as e:
print(f"Error parsing AIDER_FLAGS: {e}") print(f"Error parsing AIDER_FLAGS: {e}")
@ -72,19 +81,21 @@ Returns: { "output": stdout+stderr, "return_code": 0 if success, "success": True
command.extend(files_to_use) command.extend(files_to_use)
# Create a pretty display of what we're doing # Create a pretty display of what we're doing
task_display = [ task_display = ["## Instructions\n", f"{instructions}\n"]
"## Instructions\n",
f"{instructions}\n"
]
if files_to_use: if files_to_use:
task_display.extend([ task_display.extend(
"\n## Files\n", ["\n## Files\n", *[f"- `{file}`\n" for file in files_to_use]]
*[f"- `{file}`\n" for file in files_to_use] )
])
markdown_content = "".join(task_display) markdown_content = "".join(task_display)
console.print(Panel(Markdown(markdown_content), title="🤖 Aider Task", border_style="bright_blue")) console.print(
Panel(
Markdown(markdown_content),
title="🤖 Aider Task",
border_style="bright_blue",
)
)
logger.debug(f"command: {command}") logger.debug(f"command: {command}")
try: try:
@ -97,7 +108,7 @@ Returns: { "output": stdout+stderr, "return_code": 0 if success, "success": True
return { return {
"output": truncate_output(output.decode() if output else ""), "output": truncate_output(output.decode() if output else ""),
"return_code": return_code, "return_code": return_code,
"success": return_code == 0 "success": return_code == 0,
} }
except Exception as e: except Exception as e:
@ -107,11 +118,8 @@ Returns: { "output": stdout+stderr, "return_code": 0 if success, "success": True
error_text.append(str(e), style="red") error_text.append(str(e), style="red")
console.print(error_text) console.print(error_text)
return { return {"output": str(e), "return_code": 1, "success": False}
"output": str(e),
"return_code": 1,
"success": False
}
def parse_aider_flags(aider_flags: str) -> List[str]: def parse_aider_flags(aider_flags: str) -> List[str]:
"""Parse a string of aider flags into a list of flags. """Parse a string of aider flags into a list of flags.
@ -140,5 +148,6 @@ def parse_aider_flags(aider_flags: str) -> List[str]:
# Add '--' prefix if not present and filter out empty flags # Add '--' prefix if not present and filter out empty flags
return [f"--{flag.lstrip('-')}" for flag in flags if flag.strip()] return [f"--{flag.lstrip('-')}" for flag in flags if flag.strip()]
# Export the functions # Export the functions
__all__ = ['run_programming_task'] __all__ = ["run_programming_task"]

View File

@ -1,10 +1,12 @@
import os.path
import logging import logging
import os.path
import time import time
from typing import Dict from typing import Dict
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from ra_aid.text.processing import truncate_output from ra_aid.text.processing import truncate_output
console = Console() console = Console()
@ -12,11 +14,9 @@ console = Console()
# Standard buffer size for file reading # Standard buffer size for file reading
CHUNK_SIZE = 8192 CHUNK_SIZE = 8192
@tool @tool
def read_file_tool( def read_file_tool(filepath: str, encoding: str = "utf-8") -> Dict[str, str]:
filepath: str,
encoding: str = 'utf-8'
) -> Dict[str, str]:
"""Read and return the contents of a text file. """Read and return the contents of a text file.
Args: Args:
@ -33,7 +33,7 @@ def read_file_tool(
line_count = 0 line_count = 0
total_bytes = 0 total_bytes = 0
with open(filepath, 'r', encoding=encoding) as f: with open(filepath, "r", encoding=encoding) as f:
while True: while True:
chunk = f.read(CHUNK_SIZE) chunk = f.read(CHUNK_SIZE)
if not chunk: if not chunk:
@ -41,27 +41,31 @@ def read_file_tool(
content.append(chunk) content.append(chunk)
total_bytes += len(chunk) total_bytes += len(chunk)
line_count += chunk.count('\n') line_count += chunk.count("\n")
logging.debug(f"Read chunk: {len(chunk)} bytes, running total: {total_bytes} bytes") logging.debug(
f"Read chunk: {len(chunk)} bytes, running total: {total_bytes} bytes"
)
full_content = ''.join(content) full_content = "".join(content)
elapsed = time.time() - start_time elapsed = time.time() - start_time
logging.debug(f"File read complete: {total_bytes} bytes in {elapsed:.2f}s") logging.debug(f"File read complete: {total_bytes} bytes in {elapsed:.2f}s")
logging.debug(f"Pre-truncation stats: {total_bytes} bytes, {line_count} lines") logging.debug(f"Pre-truncation stats: {total_bytes} bytes, {line_count} lines")
console.print(Panel( console.print(
f"Read {line_count} lines ({total_bytes} bytes) from {filepath} in {elapsed:.2f}s", Panel(
title="📄 File Read", f"Read {line_count} lines ({total_bytes} bytes) from {filepath} in {elapsed:.2f}s",
border_style="bright_blue" title="📄 File Read",
)) border_style="bright_blue",
)
)
# Truncate if needed # Truncate if needed
truncated = truncate_output(full_content) if full_content else "" truncated = truncate_output(full_content) if full_content else ""
return {"content": truncated} return {"content": truncated}
except Exception as e: except Exception:
elapsed = time.time() - start_time elapsed = time.time() - start_time
raise raise

View File

@ -7,7 +7,7 @@ This module provides utilities for:
import inspect import inspect
__all__ = ['get_function_info'] __all__ = ["get_function_info"]
def get_function_info(func): def get_function_info(func):
@ -32,5 +32,3 @@ def get_function_info(func):
{docstring} {docstring}
\"\"\"""" \"\"\""""
return info return info

View File

@ -4,6 +4,7 @@ from rich.panel import Panel
console = Console() console = Console()
@tool("existing_project_detected") @tool("existing_project_detected")
def existing_project_detected() -> dict: def existing_project_detected() -> dict:
""" """
@ -11,7 +12,7 @@ def existing_project_detected() -> dict:
""" """
console.print(Panel("📁 Existing Project Detected", style="bright_blue", padding=0)) console.print(Panel("📁 Existing Project Detected", style="bright_blue", padding=0))
return { return {
'hint': ( "hint": (
"You are working within an existing codebase that already has established patterns and standards. " "You are working within an existing codebase that already has established patterns and standards. "
"Integrate any new functionality by adhering to the project's conventions:\n\n" "Integrate any new functionality by adhering to the project's conventions:\n\n"
"- Carefully discover existing folder structure, naming conventions, and architecture.\n" "- Carefully discover existing folder structure, naming conventions, and architecture.\n"
@ -23,6 +24,7 @@ def existing_project_detected() -> dict:
) )
} }
@tool("monorepo_detected") @tool("monorepo_detected")
def monorepo_detected() -> dict: def monorepo_detected() -> dict:
""" """
@ -30,7 +32,7 @@ def monorepo_detected() -> dict:
""" """
console.print(Panel("📦 Monorepo Detected", style="bright_blue", padding=0)) console.print(Panel("📦 Monorepo Detected", style="bright_blue", padding=0))
return { return {
'hint': ( "hint": (
"You are researching in a monorepo environment that manages multiple packages or services under one roof. " "You are researching in a monorepo environment that manages multiple packages or services under one roof. "
"Ensure new work fits cohesively within the broader structure:\n\n" "Ensure new work fits cohesively within the broader structure:\n\n"
"- Search all packages for shared libraries, utilities, and patterns, and reuse them to avoid redundancy.\n" "- Search all packages for shared libraries, utilities, and patterns, and reuse them to avoid redundancy.\n"
@ -45,6 +47,7 @@ def monorepo_detected() -> dict:
) )
} }
@tool("ui_detected") @tool("ui_detected")
def ui_detected() -> dict: def ui_detected() -> dict:
""" """
@ -52,7 +55,7 @@ def ui_detected() -> dict:
""" """
console.print(Panel("🎯 UI Detected", style="bright_blue", padding=0)) console.print(Panel("🎯 UI Detected", style="bright_blue", padding=0))
return { return {
'hint': ( "hint": (
"You are working with a user interface component where established UI conventions, styles, and frameworks are likely in place. " "You are working with a user interface component where established UI conventions, styles, and frameworks are likely in place. "
"Any modifications or additions should blend seamlessly with the existing design and user experience:\n\n" "Any modifications or additions should blend seamlessly with the existing design and user experience:\n\n"
"- Locate and note existing UI design conventions, including layout, typography, color schemes, and interaction patterns.\n" "- Locate and note existing UI design conventions, including layout, typography, color schemes, and interaction patterns.\n"

View File

@ -1,27 +1,29 @@
from typing import Dict, Union, List from typing import Dict, List, Union
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
from ra_aid.proc.interactive import run_interactive_command from ra_aid.proc.interactive import run_interactive_command
from ra_aid.text.processing import truncate_output from ra_aid.text.processing import truncate_output
console = Console() console = Console()
DEFAULT_EXCLUDE_DIRS = [ DEFAULT_EXCLUDE_DIRS = [
'.git', ".git",
'node_modules', "node_modules",
'vendor', "vendor",
'.venv', ".venv",
'__pycache__', "__pycache__",
'.cache', ".cache",
'dist', "dist",
'build', "build",
'env', "env",
'.env', ".env",
'venv', "venv",
'.idea', ".idea",
'.vscode' ".vscode",
] ]
@ -64,6 +66,7 @@ FILE_TYPE_MAP = {
"psql": "postgres", "psql": "postgres",
} }
@tool @tool
def ripgrep_search( def ripgrep_search(
pattern: str, pattern: str,
@ -72,7 +75,7 @@ def ripgrep_search(
case_sensitive: bool = True, case_sensitive: bool = True,
include_hidden: bool = False, include_hidden: bool = False,
follow_links: bool = False, follow_links: bool = False,
exclude_dirs: List[str] = None exclude_dirs: List[str] = None,
) -> Dict[str, Union[str, int, bool]]: ) -> Dict[str, Union[str, int, bool]]:
"""Execute a ripgrep (rg) search with formatting and common options. """Execute a ripgrep (rg) search with formatting and common options.
@ -91,16 +94,16 @@ def ripgrep_search(
- success: Boolean indicating if search succeeded - success: Boolean indicating if search succeeded
""" """
# Build rg command with options # Build rg command with options
cmd = ['rg', '--color', 'always'] cmd = ["rg", "--color", "always"]
if not case_sensitive: if not case_sensitive:
cmd.append('-i') cmd.append("-i")
if include_hidden: if include_hidden:
cmd.append('--hidden') cmd.append("--hidden")
if follow_links: if follow_links:
cmd.append('--follow') cmd.append("--follow")
if file_type: if file_type:
if FILE_TYPE_MAP.get(file_type): if FILE_TYPE_MAP.get(file_type):
@ -110,7 +113,7 @@ def ripgrep_search(
# Add exclusions # Add exclusions
exclusions = DEFAULT_EXCLUDE_DIRS + (exclude_dirs or []) exclusions = DEFAULT_EXCLUDE_DIRS + (exclude_dirs or [])
for dir in exclusions: for dir in exclusions:
cmd.extend(['--glob', f'!{dir}']) cmd.extend(["--glob", f"!{dir}"])
# Add the search pattern # Add the search pattern
cmd.append(pattern) cmd.append(pattern)
@ -123,7 +126,7 @@ def ripgrep_search(
"## Search Parameters", "## Search Parameters",
f"**Pattern**: `{pattern}`", f"**Pattern**: `{pattern}`",
f"**Case Sensitive**: {case_sensitive}", f"**Case Sensitive**: {case_sensitive}",
f"**File Type**: {file_type or 'all'}" f"**File Type**: {file_type or 'all'}",
] ]
if include_hidden: if include_hidden:
params.append("**Including Hidden Files**: yes") params.append("**Including Hidden Files**: yes")
@ -136,7 +139,13 @@ def ripgrep_search(
info_sections.append("\n".join(params)) info_sections.append("\n".join(params))
# Execute command # Execute command
console.print(Panel(Markdown(f"Searching for: **{pattern}**"), title="🔎 Ripgrep Search", border_style="bright_blue")) console.print(
Panel(
Markdown(f"Searching for: **{pattern}**"),
title="🔎 Ripgrep Search",
border_style="bright_blue",
)
)
try: try:
print() print()
output, return_code = run_interactive_command(cmd) output, return_code = run_interactive_command(cmd)
@ -146,14 +155,10 @@ def ripgrep_search(
return { return {
"output": truncate_output(decoded_output), "output": truncate_output(decoded_output),
"return_code": return_code, "return_code": return_code,
"success": return_code == 0 "success": return_code == 0,
} }
except Exception as e: except Exception as e:
error_msg = str(e) error_msg = str(e)
console.print(Panel(error_msg, title="❌ Error", border_style="red")) console.print(Panel(error_msg, title="❌ Error", border_style="red"))
return { return {"output": error_msg, "return_code": 1, "success": False}
"output": error_msg,
"return_code": 1,
"success": False
}

View File

@ -1,15 +1,18 @@
from typing import Dict, Union from typing import Dict, Union
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
from rich.prompt import Prompt from rich.prompt import Prompt
from ra_aid.tools.memory import _global_memory
from ra_aid.console.cowboy_messages import get_cowboy_message
from ra_aid.proc.interactive import run_interactive_command from ra_aid.proc.interactive import run_interactive_command
from ra_aid.text.processing import truncate_output from ra_aid.text.processing import truncate_output
from ra_aid.console.cowboy_messages import get_cowboy_message from ra_aid.tools.memory import _global_memory
console = Console() console = Console()
@tool @tool
def run_shell_command(command: str) -> Dict[str, Union[str, int, bool]]: def run_shell_command(command: str) -> Dict[str, Union[str, int, bool]]:
"""Execute a shell command and return its output. """Execute a shell command and return its output.
@ -28,7 +31,7 @@ def run_shell_command(command: str) -> Dict[str, Union[str, int, bool]]:
4. Add flags e.g. git --no-pager in order to reduce interaction required by the human. 4. Add flags e.g. git --no-pager in order to reduce interaction required by the human.
""" """
# Check if we need approval # Check if we need approval
cowboy_mode = _global_memory.get('config', {}).get('cowboy_mode', False) cowboy_mode = _global_memory.get("config", {}).get("cowboy_mode", False)
if cowboy_mode: if cowboy_mode:
console.print("") console.print("")
@ -45,7 +48,7 @@ def run_shell_command(command: str) -> Dict[str, Union[str, int, bool]]:
choices=choices, choices=choices,
default="y", default="y",
show_choices=True, show_choices=True,
show_default=True show_default=True,
) )
if response == "n": if response == "n":
@ -53,28 +56,24 @@ def run_shell_command(command: str) -> Dict[str, Union[str, int, bool]]:
return { return {
"output": "Command execution cancelled by user", "output": "Command execution cancelled by user",
"return_code": 1, "return_code": 1,
"success": False "success": False,
} }
elif response == "c": elif response == "c":
_global_memory['config']['cowboy_mode'] = True _global_memory["config"]["cowboy_mode"] = True
console.print("") console.print("")
console.print(" " + get_cowboy_message()) console.print(" " + get_cowboy_message())
console.print("") console.print("")
try: try:
print() print()
output, return_code = run_interactive_command(['/bin/bash', '-c', command]) output, return_code = run_interactive_command(["/bin/bash", "-c", command])
print() print()
return { return {
"output": truncate_output(output.decode()) if output else "", "output": truncate_output(output.decode()) if output else "",
"return_code": return_code, "return_code": return_code,
"success": return_code == 0 "success": return_code == 0,
} }
except Exception as e: except Exception as e:
print() print()
console.print(Panel(str(e), title="❌ Error", border_style="red")) console.print(Panel(str(e), title="❌ Error", border_style="red"))
return { return {"output": str(e), "return_code": 1, "success": False}
"output": str(e),
"return_code": 1,
"success": False
}

View File

@ -1,13 +1,15 @@
import os import os
from typing import Dict from typing import Dict
from tavily import TavilyClient
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from rich.panel import Panel
from tavily import TavilyClient
console = Console() console = Console()
@tool @tool
def web_search_tavily(query: str) -> Dict: def web_search_tavily(query: str) -> Dict:
""" """
@ -20,6 +22,8 @@ def web_search_tavily(query: str) -> Dict:
Dict containing search results from Tavily Dict containing search results from Tavily
""" """
client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"]) client = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
console.print(Panel(Markdown(query), title="🔍 Searching Tavily", border_style="bright_blue")) console.print(
Panel(Markdown(query), title="🔍 Searching Tavily", border_style="bright_blue")
)
search_result = client.search(query=query) search_result = client.search(query=query)
return search_result return search_result

View File

@ -1,19 +1,18 @@
import os
import logging import logging
import os
import time import time
from typing import Dict from typing import Dict
from langchain_core.tools import tool from langchain_core.tools import tool
from rich.console import Console from rich.console import Console
from rich.panel import Panel from rich.panel import Panel
console = Console() console = Console()
@tool @tool
def write_file_tool( def write_file_tool(
filepath: str, filepath: str, content: str, encoding: str = "utf-8", verbose: bool = True
content: str,
encoding: str = 'utf-8',
verbose: bool = True
) -> Dict[str, any]: ) -> Dict[str, any]:
"""Write content to a text file. """Write content to a text file.
@ -40,7 +39,7 @@ def write_file_tool(
"elapsed_time": 0, "elapsed_time": 0,
"error": None, "error": None,
"filepath": None, "filepath": None,
"message": None "message": None,
} }
try: try:
@ -51,7 +50,7 @@ def write_file_tool(
logging.debug(f"Starting to write file: {filepath}") logging.debug(f"Starting to write file: {filepath}")
with open(filepath, 'w', encoding=encoding) as f: with open(filepath, "w", encoding=encoding) as f:
f.write(content) f.write(content)
result["bytes_written"] = len(content.encode(encoding)) result["bytes_written"] = len(content.encode(encoding))
@ -61,14 +60,18 @@ def write_file_tool(
result["filepath"] = filepath result["filepath"] = filepath
result["message"] = "Operation completed successfully" result["message"] = "Operation completed successfully"
logging.debug(f"File write complete: {result['bytes_written']} bytes in {elapsed:.2f}s") logging.debug(
f"File write complete: {result['bytes_written']} bytes in {elapsed:.2f}s"
)
if verbose: if verbose:
console.print(Panel( console.print(
f"Wrote {result['bytes_written']} bytes to {filepath} in {elapsed:.2f}s", Panel(
title="💾 File Write", f"Wrote {result['bytes_written']} bytes to {filepath} in {elapsed:.2f}s",
border_style="bright_green" title="💾 File Write",
)) border_style="bright_green",
)
)
except Exception as e: except Exception as e:
elapsed = time.time() - start_time elapsed = time.time() - start_time
@ -82,10 +85,12 @@ def write_file_tool(
result["message"] = error_msg result["message"] = error_msg
if verbose: if verbose:
console.print(Panel( console.print(
f"Failed to write {filepath}\nError: {error_msg}", Panel(
title="❌ File Write Error", f"Failed to write {filepath}\nError: {error_msg}",
border_style="red" title="❌ File Write Error",
)) border_style="red",
)
)
return result return result

View File

@ -6,8 +6,8 @@ Usage:
python extract_changelog.py VERSION python extract_changelog.py VERSION
""" """
import sys
import re import re
import sys
from pathlib import Path from pathlib import Path

View File

@ -22,7 +22,7 @@ import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple, Dict, Any, List from typing import Any, Dict, List, Optional, Tuple
from git import Repo from git import Repo
from rich.logging import RichHandler from rich.logging import RichHandler
@ -44,15 +44,13 @@ def setup_logging(log_dir: Path, verbose: bool = False) -> None:
file_handler = logging.FileHandler(log_file) file_handler = logging.FileHandler(log_file)
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(logging.DEBUG)
file_formatter = logging.Formatter( file_formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s' "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
) )
file_handler.setFormatter(file_formatter) file_handler.setFormatter(file_formatter)
root_logger.addHandler(file_handler) root_logger.addHandler(file_handler)
console_handler = RichHandler( console_handler = RichHandler(
rich_tracebacks=True, rich_tracebacks=True, show_time=False, show_path=False
show_time=False,
show_path=False
) )
console_handler.setLevel(logging.DEBUG if verbose else logging.INFO) console_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
root_logger.addHandler(console_handler) root_logger.addHandler(console_handler)
@ -62,6 +60,7 @@ def load_dataset_safely() -> Optional[Any]:
"""Load SWE-bench Lite dataset with error handling.""" """Load SWE-bench Lite dataset with error handling."""
try: try:
from datasets import load_dataset from datasets import load_dataset
dataset = load_dataset("princeton-nlp/SWE-bench_Lite") dataset = load_dataset("princeton-nlp/SWE-bench_Lite")
return dataset return dataset
except Exception as e: except Exception as e:
@ -122,18 +121,14 @@ def uv_run_raaid(repo_dir: Path, prompt: str) -> Optional[str]:
streaming output directly to the console (capture_output=False). streaming output directly to the console (capture_output=False).
Returns the patch if successful, else None. Returns the patch if successful, else None.
""" """
cmd = [ cmd = ["uv", "run", "ra-aid", "--cowboy-mode", "-m", prompt]
"uv", "run", "ra-aid",
"--cowboy-mode",
"-m", prompt
]
# We are NOT capturing output, so it streams live: # We are NOT capturing output, so it streams live:
try: try:
result = subprocess.run( result = subprocess.run(
cmd, cmd,
cwd=repo_dir, cwd=repo_dir,
text=True, text=True,
check=False, # We manually handle exit code check=False, # We manually handle exit code
) )
if result.returncode != 0: if result.returncode != 0:
logging.error("ra-aid returned non-zero exit code.") logging.error("ra-aid returned non-zero exit code.")
@ -160,7 +155,7 @@ def get_git_patch(repo_dir: Path) -> Optional[str]:
patch_text = repo.git.diff(unified=3) patch_text = repo.git.diff(unified=3)
if not patch_text.strip(): if not patch_text.strip():
return None return None
if not any(line.startswith('+') for line in patch_text.splitlines()): if not any(line.startswith("+") for line in patch_text.splitlines()):
return None return None
return patch_text return patch_text
except Exception as e: except Exception as e:
@ -214,7 +209,9 @@ def setup_venv_and_deps(repo_dir: Path, repo_name: str, force_venv: bool) -> Non
uv_pip_install(repo_dir, ["-e", "."]) uv_pip_install(repo_dir, ["-e", "."])
def build_prompt(problem_statement: str, fail_tests: List[str], pass_tests: List[str]) -> str: def build_prompt(
problem_statement: str, fail_tests: List[str], pass_tests: List[str]
) -> str:
""" """
Construct the prompt text from problem_statement, FAIL_TO_PASS, PASS_TO_PASS. Construct the prompt text from problem_statement, FAIL_TO_PASS, PASS_TO_PASS.
""" """
@ -232,10 +229,7 @@ def build_prompt(problem_statement: str, fail_tests: List[str], pass_tests: List
def process_instance( def process_instance(
instance: Dict[str, Any], instance: Dict[str, Any], projects_dir: Path, reuse_repo: bool, force_venv: bool
projects_dir: Path,
reuse_repo: bool,
force_venv: bool
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Process a single dataset instance without a progress bar/spinner. Process a single dataset instance without a progress bar/spinner.
@ -291,7 +285,7 @@ def process_instance(
return { return {
"instance_id": inst_id, "instance_id": inst_id,
"model_patch": patch if patch else "", "model_patch": patch if patch else "",
"model_name_or_path": "ra-aid" "model_name_or_path": "ra-aid",
} }
except Exception as e: except Exception as e:
@ -299,7 +293,7 @@ def process_instance(
return { return {
"instance_id": inst_id, "instance_id": inst_id,
"model_patch": "", "model_patch": "",
"model_name_or_path": "ra-aid" "model_name_or_path": "ra-aid",
} }
@ -308,41 +302,33 @@ def main() -> None:
description="Generate predictions for SWE-bench Lite using uv + ra-aid (no progress bar)." description="Generate predictions for SWE-bench Lite using uv + ra-aid (no progress bar)."
) )
parser.add_argument( parser.add_argument(
"output_dir", "output_dir", type=Path, help="Directory to store prediction file"
type=Path,
help="Directory to store prediction file"
) )
parser.add_argument( parser.add_argument(
"--projects-dir", "--projects-dir",
type=Path, type=Path,
required=True, required=True,
help="Directory where projects will be cloned." help="Directory where projects will be cloned.",
) )
parser.add_argument( parser.add_argument(
"--num-instances", "--num-instances",
type=int, type=int,
default=None, default=None,
help="Number of instances to process (default: all)" help="Number of instances to process (default: all)",
) )
parser.add_argument( parser.add_argument(
"--reuse-repo", "--reuse-repo",
action="store_true", action="store_true",
help="If set, do not delete an existing repo directory. We'll reuse it." help="If set, do not delete an existing repo directory. We'll reuse it.",
) )
parser.add_argument( parser.add_argument(
"--force-venv", "--force-venv",
action="store_true", action="store_true",
help="If set, recreate the .venv even if it exists." help="If set, recreate the .venv even if it exists.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose logging"
) )
parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
args = parser.parse_args() args = parser.parse_args()
from datasets import load_dataset
# Create base/log dirs and set up logging # Create base/log dirs and set up logging
base_dir, log_dir = create_output_dirs() base_dir, log_dir = create_output_dirs()
setup_logging(log_dir, args.verbose) setup_logging(log_dir, args.verbose)
@ -373,7 +359,9 @@ def main() -> None:
break break
logging.info(f"=== Instance {i+1}/{limit}, ID={inst.get('instance_id')} ===") logging.info(f"=== Instance {i+1}/{limit}, ID={inst.get('instance_id')} ===")
pred = process_instance(inst, args.projects_dir, args.reuse_repo, args.force_venv) pred = process_instance(
inst, args.projects_dir, args.reuse_repo, args.force_venv
)
predictions.append(pred) predictions.append(pred)
# Save predictions # Save predictions
@ -389,6 +377,6 @@ if __name__ == "__main__":
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nOperation cancelled by user.") print("\nOperation cancelled by user.")
sys.exit(1) sys.exit(1)
except Exception as e: except Exception:
logging.exception("Unhandled error occurred.") logging.exception("Unhandled error occurred.")
sys.exit(1) sys.exit(1)

View File

@ -1,8 +1,10 @@
from unittest.mock import Mock
import pytest import pytest
from unittest.mock import Mock, patch from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.messages import HumanMessage, AIMessage
from ra_aid.agents.ciayn_agent import CiaynAgent from ra_aid.agents.ciayn_agent import CiaynAgent, validate_function_call_pattern
from ra_aid.agents.ciayn_agent import validate_function_call_pattern
@pytest.fixture @pytest.fixture
def mock_model(): def mock_model():
@ -11,23 +13,25 @@ def mock_model():
model.invoke = Mock() model.invoke = Mock()
return model return model
@pytest.fixture @pytest.fixture
def agent(mock_model): def agent(mock_model):
"""Create a CiaynAgent instance with mock model.""" """Create a CiaynAgent instance with mock model."""
tools = [] # Empty tools list for testing trimming functionality tools = [] # Empty tools list for testing trimming functionality
return CiaynAgent(mock_model, tools, max_history_messages=3) return CiaynAgent(mock_model, tools, max_history_messages=3)
def test_trim_chat_history_preserves_initial_messages(agent): def test_trim_chat_history_preserves_initial_messages(agent):
"""Test that initial messages are preserved during trimming.""" """Test that initial messages are preserved during trimming."""
initial_messages = [ initial_messages = [
HumanMessage(content="Initial 1"), HumanMessage(content="Initial 1"),
AIMessage(content="Initial 2") AIMessage(content="Initial 2"),
] ]
chat_history = [ chat_history = [
HumanMessage(content="Chat 1"), HumanMessage(content="Chat 1"),
AIMessage(content="Chat 2"), AIMessage(content="Chat 2"),
HumanMessage(content="Chat 3"), HumanMessage(content="Chat 3"),
AIMessage(content="Chat 4") AIMessage(content="Chat 4"),
] ]
result = agent._trim_chat_history(initial_messages, chat_history) result = agent._trim_chat_history(initial_messages, chat_history)
@ -38,13 +42,11 @@ def test_trim_chat_history_preserves_initial_messages(agent):
assert len(result[2:]) == 3 assert len(result[2:]) == 3
assert result[2:] == chat_history[-3:] assert result[2:] == chat_history[-3:]
def test_trim_chat_history_under_limit(agent): def test_trim_chat_history_under_limit(agent):
"""Test trimming when chat history is under the maximum limit.""" """Test trimming when chat history is under the maximum limit."""
initial_messages = [HumanMessage(content="Initial")] initial_messages = [HumanMessage(content="Initial")]
chat_history = [ chat_history = [HumanMessage(content="Chat 1"), AIMessage(content="Chat 2")]
HumanMessage(content="Chat 1"),
AIMessage(content="Chat 2")
]
result = agent._trim_chat_history(initial_messages, chat_history) result = agent._trim_chat_history(initial_messages, chat_history)
@ -52,6 +54,7 @@ def test_trim_chat_history_under_limit(agent):
assert len(result) == 3 assert len(result) == 3
assert result == initial_messages + chat_history assert result == initial_messages + chat_history
def test_trim_chat_history_over_limit(agent): def test_trim_chat_history_over_limit(agent):
"""Test trimming when chat history exceeds the maximum limit.""" """Test trimming when chat history exceeds the maximum limit."""
initial_messages = [HumanMessage(content="Initial")] initial_messages = [HumanMessage(content="Initial")]
@ -60,7 +63,7 @@ def test_trim_chat_history_over_limit(agent):
AIMessage(content="Chat 2"), AIMessage(content="Chat 2"),
HumanMessage(content="Chat 3"), HumanMessage(content="Chat 3"),
AIMessage(content="Chat 4"), AIMessage(content="Chat 4"),
HumanMessage(content="Chat 5") HumanMessage(content="Chat 5"),
] ]
result = agent._trim_chat_history(initial_messages, chat_history) result = agent._trim_chat_history(initial_messages, chat_history)
@ -70,6 +73,7 @@ def test_trim_chat_history_over_limit(agent):
assert result[0] == initial_messages[0] # Initial message preserved assert result[0] == initial_messages[0] # Initial message preserved
assert result[1:] == chat_history[-3:] # Last 3 chat messages kept assert result[1:] == chat_history[-3:] # Last 3 chat messages kept
def test_trim_chat_history_empty_initial(agent): def test_trim_chat_history_empty_initial(agent):
"""Test trimming with empty initial messages.""" """Test trimming with empty initial messages."""
initial_messages = [] initial_messages = []
@ -77,7 +81,7 @@ def test_trim_chat_history_empty_initial(agent):
HumanMessage(content="Chat 1"), HumanMessage(content="Chat 1"),
AIMessage(content="Chat 2"), AIMessage(content="Chat 2"),
HumanMessage(content="Chat 3"), HumanMessage(content="Chat 3"),
AIMessage(content="Chat 4") AIMessage(content="Chat 4"),
] ]
result = agent._trim_chat_history(initial_messages, chat_history) result = agent._trim_chat_history(initial_messages, chat_history)
@ -86,11 +90,12 @@ def test_trim_chat_history_empty_initial(agent):
assert len(result) == 3 assert len(result) == 3
assert result == chat_history[-3:] assert result == chat_history[-3:]
def test_trim_chat_history_empty_chat(agent): def test_trim_chat_history_empty_chat(agent):
"""Test trimming with empty chat history.""" """Test trimming with empty chat history."""
initial_messages = [ initial_messages = [
HumanMessage(content="Initial 1"), HumanMessage(content="Initial 1"),
AIMessage(content="Initial 2") AIMessage(content="Initial 2"),
] ]
chat_history = [] chat_history = []
@ -100,15 +105,16 @@ def test_trim_chat_history_empty_chat(agent):
assert result == initial_messages assert result == initial_messages
assert len(result) == 2 assert len(result) == 2
def test_trim_chat_history_token_limit(): def test_trim_chat_history_token_limit():
"""Test trimming based on token limit.""" """Test trimming based on token limit."""
agent = CiaynAgent(Mock(), [], max_history_messages=10, max_tokens=20) agent = CiaynAgent(Mock(), [], max_history_messages=10, max_tokens=20)
initial_messages = [HumanMessage(content="Initial")] # ~2 tokens initial_messages = [HumanMessage(content="Initial")] # ~2 tokens
chat_history = [ chat_history = [
HumanMessage(content="A" * 40), # ~10 tokens HumanMessage(content="A" * 40), # ~10 tokens
AIMessage(content="B" * 40), # ~10 tokens AIMessage(content="B" * 40), # ~10 tokens
HumanMessage(content="C" * 40) # ~10 tokens HumanMessage(content="C" * 40), # ~10 tokens
] ]
result = agent._trim_chat_history(initial_messages, chat_history) result = agent._trim_chat_history(initial_messages, chat_history)
@ -118,6 +124,7 @@ def test_trim_chat_history_token_limit():
assert result[0] == initial_messages[0] assert result[0] == initial_messages[0]
assert result[1] == chat_history[-1] assert result[1] == chat_history[-1]
def test_trim_chat_history_no_token_limit(): def test_trim_chat_history_no_token_limit():
"""Test trimming with no token limit set.""" """Test trimming with no token limit set."""
agent = CiaynAgent(Mock(), [], max_history_messages=2, max_tokens=None) agent = CiaynAgent(Mock(), [], max_history_messages=2, max_tokens=None)
@ -126,7 +133,7 @@ def test_trim_chat_history_no_token_limit():
chat_history = [ chat_history = [
HumanMessage(content="A" * 1000), HumanMessage(content="A" * 1000),
AIMessage(content="B" * 1000), AIMessage(content="B" * 1000),
HumanMessage(content="C" * 1000) HumanMessage(content="C" * 1000),
] ]
result = agent._trim_chat_history(initial_messages, chat_history) result = agent._trim_chat_history(initial_messages, chat_history)
@ -136,16 +143,17 @@ def test_trim_chat_history_no_token_limit():
assert result[0] == initial_messages[0] assert result[0] == initial_messages[0]
assert result[1:] == chat_history[-2:] assert result[1:] == chat_history[-2:]
def test_trim_chat_history_both_limits(): def test_trim_chat_history_both_limits():
"""Test trimming with both message count and token limits.""" """Test trimming with both message count and token limits."""
agent = CiaynAgent(Mock(), [], max_history_messages=3, max_tokens=15) agent = CiaynAgent(Mock(), [], max_history_messages=3, max_tokens=15)
initial_messages = [HumanMessage(content="Init")] # ~1 token initial_messages = [HumanMessage(content="Init")] # ~1 token
chat_history = [ chat_history = [
HumanMessage(content="A" * 40), # ~10 tokens HumanMessage(content="A" * 40), # ~10 tokens
AIMessage(content="B" * 40), # ~10 tokens AIMessage(content="B" * 40), # ~10 tokens
HumanMessage(content="C" * 40), # ~10 tokens HumanMessage(content="C" * 40), # ~10 tokens
AIMessage(content="D" * 40) # ~10 tokens AIMessage(content="D" * 40), # ~10 tokens
] ]
result = agent._trim_chat_history(initial_messages, chat_history) result = agent._trim_chat_history(initial_messages, chat_history)
@ -158,46 +166,58 @@ def test_trim_chat_history_both_limits():
class TestFunctionCallValidation: class TestFunctionCallValidation:
@pytest.mark.parametrize("test_input", [ @pytest.mark.parametrize(
"basic_func()", "test_input",
"func_with_arg(\"test\")", [
"complex_func(1, \"two\", three)", "basic_func()",
"nested_parens(func(\"test\"))", 'func_with_arg("test")',
"under_score()", 'complex_func(1, "two", three)',
"with-dash()" 'nested_parens(func("test"))',
]) "under_score()",
"with-dash()",
],
)
def test_valid_function_calls(self, test_input): def test_valid_function_calls(self, test_input):
"""Test function call patterns that should pass validation.""" """Test function call patterns that should pass validation."""
assert not validate_function_call_pattern(test_input) assert not validate_function_call_pattern(test_input)
@pytest.mark.parametrize("test_input", [ @pytest.mark.parametrize(
"", "test_input",
"Invalid!function()", [
"missing_parens", "",
"unmatched(parens))", "Invalid!function()",
"multiple()calls()", "missing_parens",
"no spaces()()" "unmatched(parens))",
]) "multiple()calls()",
"no spaces()()",
],
)
def test_invalid_function_calls(self, test_input): def test_invalid_function_calls(self, test_input):
"""Test function call patterns that should fail validation.""" """Test function call patterns that should fail validation."""
assert validate_function_call_pattern(test_input) assert validate_function_call_pattern(test_input)
@pytest.mark.parametrize("test_input", [ @pytest.mark.parametrize(
" leading_space()", "test_input",
"trailing_space() ", [
"func (arg)", " leading_space()",
"func( spaced args )" "trailing_space() ",
]) "func (arg)",
"func( spaced args )",
],
)
def test_whitespace_handling(self, test_input): def test_whitespace_handling(self, test_input):
"""Test whitespace variations in function calls.""" """Test whitespace variations in function calls."""
assert not validate_function_call_pattern(test_input) assert not validate_function_call_pattern(test_input)
@pytest.mark.parametrize("test_input", [ @pytest.mark.parametrize(
"""multiline( "test_input",
[
"""multiline(
arg arg
)""", )""",
"func(\n arg1,\n arg2\n)" "func(\n arg1,\n arg2\n)",
]) ],
)
def test_multiline_responses(self, test_input): def test_multiline_responses(self, test_input):
"""Test function calls spanning multiple lines.""" """Test function calls spanning multiple lines."""
assert not validate_function_call_pattern(test_input) assert not validate_function_call_pattern(test_input)

View File

@ -1,5 +1,5 @@
import pytest from ra_aid.console.cowboy_messages import COWBOY_MESSAGES, get_cowboy_message
from ra_aid.console.cowboy_messages import get_cowboy_message, COWBOY_MESSAGES
def test_get_cowboy_message_returns_string(): def test_get_cowboy_message_returns_string():
"""Test that get_cowboy_message returns a non-empty string""" """Test that get_cowboy_message returns a non-empty string"""
@ -7,12 +7,14 @@ def test_get_cowboy_message_returns_string():
assert isinstance(message, str) assert isinstance(message, str)
assert len(message) > 0 assert len(message) > 0
def test_cowboy_message_contains_emoji(): def test_cowboy_message_contains_emoji():
"""Test that returned message contains at least one of the expected emojis""" """Test that returned message contains at least one of the expected emojis"""
message = get_cowboy_message() message = get_cowboy_message()
expected_emojis = ['🤠', '👶', '😏'] expected_emojis = ["🤠", "👶", "😏"]
assert any(emoji in message for emoji in expected_emojis) assert any(emoji in message for emoji in expected_emojis)
def test_message_from_predefined_list(): def test_message_from_predefined_list():
"""Test that returned message is from our predefined list""" """Test that returned message is from our predefined list"""
message = get_cowboy_message() message = get_cowboy_message()

View File

@ -1,9 +1,10 @@
"""Tests for the interactive subprocess module.""" """Tests for the interactive subprocess module."""
import os import os
import sys
import pytest
import tempfile import tempfile
import pytest
from ra_aid.proc.interactive import run_interactive_command from ra_aid.proc.interactive import run_interactive_command
@ -16,7 +17,9 @@ def test_basic_command():
def test_shell_pipeline(): def test_shell_pipeline():
"""Test running a shell pipeline command.""" """Test running a shell pipeline command."""
output, retcode = run_interactive_command(["/bin/bash", "-c", "echo 'hello world' | grep 'world'"]) output, retcode = run_interactive_command(
["/bin/bash", "-c", "echo 'hello world' | grep 'world'"]
)
assert b"world" in output assert b"world" in output
assert retcode == 0 assert retcode == 0
@ -24,7 +27,9 @@ def test_shell_pipeline():
def test_stderr_capture(): def test_stderr_capture():
"""Test that stderr is properly captured in combined output.""" """Test that stderr is properly captured in combined output."""
# Use a command that definitely writes to stderr # Use a command that definitely writes to stderr
output, retcode = run_interactive_command(["/bin/bash", "-c", "ls /nonexistent/path"]) output, retcode = run_interactive_command(
["/bin/bash", "-c", "ls /nonexistent/path"]
)
assert b"No such file or directory" in output assert b"No such file or directory" in output
assert retcode != 0 # ls returns 0 upon success assert retcode != 0 # ls returns 0 upon success
@ -46,22 +51,29 @@ def test_interactive_command():
This test verifies that output appears in real-time using process substitution. This test verifies that output appears in real-time using process substitution.
We use a command that prints to both stdout and stderr to verify capture.""" We use a command that prints to both stdout and stderr to verify capture."""
output, retcode = run_interactive_command(["/bin/bash", "-c", "echo stdout; echo stderr >&2"]) output, retcode = run_interactive_command(
["/bin/bash", "-c", "echo stdout; echo stderr >&2"]
)
assert b"stdout" in output assert b"stdout" in output
assert b"stderr" in output assert b"stderr" in output
assert retcode == 0 assert retcode == 0
def test_large_output(): def test_large_output():
"""Test handling of commands that produce large output.""" """Test handling of commands that produce large output."""
# Generate a large output with predictable content # Generate a large output with predictable content
cmd = "for i in {1..10000}; do echo \"Line $i of test output\"; done" cmd = 'for i in {1..10000}; do echo "Line $i of test output"; done'
output, retcode = run_interactive_command(["/bin/bash", "-c", cmd]) output, retcode = run_interactive_command(["/bin/bash", "-c", cmd])
# Clean up specific artifacts (e.g., ^D) # Clean up specific artifacts (e.g., ^D)
output_cleaned = output.lstrip(b'^D') # Remove the leading ^D if present output_cleaned = output.lstrip(b"^D") # Remove the leading ^D if present
# Split and filter lines # Split and filter lines
lines = [line.strip() for line in output_cleaned.splitlines() if b"Script" not in line and line.strip()] lines = [
line.strip()
for line in output_cleaned.splitlines()
if b"Script" not in line and line.strip()
]
# Verify we got all 10000 lines # Verify we got all 10000 lines
assert len(lines) == 10000, f"Expected 10000 lines, but got {len(lines)}" assert len(lines) == 10000, f"Expected 10000 lines, but got {len(lines)}"
@ -78,14 +90,18 @@ def test_large_output():
def test_unicode_handling(): def test_unicode_handling():
"""Test handling of unicode characters.""" """Test handling of unicode characters."""
test_string = "Hello " test_string = "Hello "
output, retcode = run_interactive_command(["/bin/bash", "-c", f"echo '{test_string}'"]) output, retcode = run_interactive_command(
["/bin/bash", "-c", f"echo '{test_string}'"]
)
assert test_string.encode() in output assert test_string.encode() in output
assert retcode == 0 assert retcode == 0
def test_multiple_commands(): def test_multiple_commands():
"""Test running multiple commands in sequence.""" """Test running multiple commands in sequence."""
output, retcode = run_interactive_command(["/bin/bash", "-c", "echo 'first'; echo 'second'"]) output, retcode = run_interactive_command(
["/bin/bash", "-c", "echo 'first'; echo 'second'"]
)
assert b"first" in output assert b"first" in output
assert b"second" in output assert b"second" in output
assert retcode == 0 assert retcode == 0
@ -94,15 +110,21 @@ def test_multiple_commands():
def test_cat_medium_file(): def test_cat_medium_file():
"""Test that cat command properly captures output for medium-length files.""" """Test that cat command properly captures output for medium-length files."""
# Create a temporary file with known content # Create a temporary file with known content
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f: with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
for i in range(500): for i in range(500):
f.write(f"This is test line {i}\n") f.write(f"This is test line {i}\n")
temp_path = f.name temp_path = f.name
try: try:
output, retcode = run_interactive_command(["/bin/bash", "-c", f"cat {temp_path}"]) output, retcode = run_interactive_command(
["/bin/bash", "-c", f"cat {temp_path}"]
)
# Split by newlines and filter out script header/footer lines # Split by newlines and filter out script header/footer lines
lines = [line for line in output.splitlines() if b"Script" not in line and line.strip()] lines = [
line
for line in output.splitlines()
if b"Script" not in line and line.strip()
]
assert len(lines) == 500 assert len(lines) == 500
assert retcode == 0 assert retcode == 0
@ -120,24 +142,29 @@ def test_realtime_output():
output, retcode = run_interactive_command(["/bin/bash", "-c", cmd]) output, retcode = run_interactive_command(["/bin/bash", "-c", cmd])
# Filter out script header/footer lines # Filter out script header/footer lines
lines = [line for line in output.splitlines() if b"Script" not in line and line.strip()] lines = [
line for line in output.splitlines() if b"Script" not in line and line.strip()
]
assert b"first" in lines[0] assert b"first" in lines[0]
assert b"second" in lines[1] assert b"second" in lines[1]
assert b"third" in lines[2] assert b"third" in lines[2]
assert retcode == 0 assert retcode == 0
def test_tty_available(): def test_tty_available():
"""Test that commands have access to a TTY.""" """Test that commands have access to a TTY."""
# Run the tty command # Run the tty command
output, retcode = run_interactive_command(["/bin/bash", "-c", "tty"]) output, retcode = run_interactive_command(["/bin/bash", "-c", "tty"])
# Clean up specific artifacts (e.g., ^D) # Clean up specific artifacts (e.g., ^D)
output_cleaned = output.lstrip(b'^D') # Remove leading ^D if present output_cleaned = output.lstrip(b"^D") # Remove leading ^D if present
# Debug: Print cleaned output # Debug: Print cleaned output
print(f"Cleaned TTY Output: {output_cleaned}") print(f"Cleaned TTY Output: {output_cleaned}")
# Check if the output contains a valid TTY path # Check if the output contains a valid TTY path
assert b"/dev/pts/" in output_cleaned or b"/dev/ttys" in output_cleaned, f"Unexpected TTY output: {output_cleaned}" assert (
b"/dev/pts/" in output_cleaned or b"/dev/ttys" in output_cleaned
), f"Unexpected TTY output: {output_cleaned}"
assert retcode == 0 assert retcode == 0

View File

@ -1,16 +1,19 @@
"""Unit tests for agent_utils.py.""" """Unit tests for agent_utils.py."""
import pytest
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from langchain_core.language_models import BaseChatModel
import litellm import litellm
import pytest
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from ra_aid.models_tokens import DEFAULT_TOKEN_LIMIT from ra_aid.agent_utils import (
from ra_aid.agent_utils import state_modifier, AgentState AgentState,
create_agent,
from ra_aid.agent_utils import create_agent, get_model_token_limit get_model_token_limit,
from ra_aid.models_tokens import models_tokens state_modifier,
)
from ra_aid.models_tokens import DEFAULT_TOKEN_LIMIT, models_tokens
@pytest.fixture @pytest.fixture
@ -60,7 +63,6 @@ def test_get_model_token_limit_missing_config(mock_memory):
assert token_limit is None assert token_limit is None
def test_get_model_token_limit_litellm_success(): def test_get_model_token_limit_litellm_success():
"""Test get_model_token_limit successfully getting limit from litellm.""" """Test get_model_token_limit successfully getting limit from litellm."""
config = {"provider": "anthropic", "model": "claude-2"} config = {"provider": "anthropic", "model": "claude-2"}

View File

@ -1,17 +1,19 @@
"""Tests for default provider and model configuration.""" """Tests for default provider and model configuration."""
import os
import pytest
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
from ra_aid.env import validate_environment import pytest
from ra_aid.__main__ import parse_arguments from ra_aid.__main__ import parse_arguments
from ra_aid.env import validate_environment
@dataclass @dataclass
class MockArgs: class MockArgs:
"""Mock arguments for testing.""" """Mock arguments for testing."""
provider: str
provider: Optional[str] = None
expert_provider: Optional[str] = None expert_provider: Optional[str] = None
model: Optional[str] = None model: Optional[str] = None
expert_model: Optional[str] = None expert_model: Optional[str] = None
@ -19,6 +21,7 @@ class MockArgs:
research_only: bool = False research_only: bool = False
chat: bool = False chat: bool = False
@pytest.fixture @pytest.fixture
def clean_env(monkeypatch): def clean_env(monkeypatch):
"""Remove all provider-related environment variables.""" """Remove all provider-related environment variables."""
@ -37,6 +40,7 @@ def clean_env(monkeypatch):
monkeypatch.delenv(var, raising=False) monkeypatch.delenv(var, raising=False)
yield yield
def test_default_anthropic_provider(clean_env, monkeypatch): def test_default_anthropic_provider(clean_env, monkeypatch):
"""Test that Anthropic is the default provider when no environment variables are set.""" """Test that Anthropic is the default provider when no environment variables are set."""
args = parse_arguments(["-m", "test message"]) args = parse_arguments(["-m", "test message"])
@ -44,62 +48,41 @@ def test_default_anthropic_provider(clean_env, monkeypatch):
assert args.model == "claude-3-5-sonnet-20241022" assert args.model == "claude-3-5-sonnet-20241022"
"""Unit tests for provider and model validation in research-only mode."""
import pytest
from dataclasses import dataclass
from argparse import Namespace
from ra_aid.env import validate_environment
@dataclass
class MockArgs:
"""Mock command line arguments."""
research_only: bool = False
provider: str = None
model: str = None
expert_provider: str = None
TEST_CASES = [ TEST_CASES = [
pytest.param( pytest.param(
"research_only_no_provider", "research_only_no_provider",
MockArgs(research_only=True), MockArgs(research_only=True),
{}, {},
"No provider specified", "No provider specified",
id="research_only_no_provider" id="research_only_no_provider",
), ),
pytest.param( pytest.param(
"research_only_anthropic", "research_only_anthropic",
MockArgs(research_only=True, provider="anthropic"), MockArgs(research_only=True, provider="anthropic"),
{}, {},
None, None,
id="research_only_anthropic" id="research_only_anthropic",
), ),
pytest.param( pytest.param(
"research_only_non_anthropic_no_model", "research_only_non_anthropic_no_model",
MockArgs(research_only=True, provider="openai"), MockArgs(research_only=True, provider="openai"),
{}, {},
"Model is required for non-Anthropic providers", "Model is required for non-Anthropic providers",
id="research_only_non_anthropic_no_model" id="research_only_non_anthropic_no_model",
), ),
pytest.param( pytest.param(
"research_only_non_anthropic_with_model", "research_only_non_anthropic_with_model",
MockArgs(research_only=True, provider="openai", model="gpt-4"), MockArgs(research_only=True, provider="openai", model="gpt-4"),
{}, {},
None, None,
id="research_only_non_anthropic_with_model" id="research_only_non_anthropic_with_model",
) ),
] ]
@pytest.mark.parametrize("test_name,args,env_vars,expected_error", TEST_CASES) @pytest.mark.parametrize("test_name,args,env_vars,expected_error", TEST_CASES)
def test_research_only_provider_validation( def test_research_only_provider_validation(
test_name: str, test_name: str, args: MockArgs, env_vars: dict, expected_error: str, monkeypatch
args: MockArgs,
env_vars: dict,
expected_error: str,
monkeypatch
): ):
"""Test provider and model validation in research-only mode.""" """Test provider and model validation in research-only mode."""
# Set test environment variables # Set test environment variables

View File

@ -1,10 +1,12 @@
import os import os
import pytest
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
import pytest
from ra_aid.env import validate_environment from ra_aid.env import validate_environment
@dataclass @dataclass
class MockArgs: class MockArgs:
provider: str provider: str
@ -16,30 +18,44 @@ class MockArgs:
planner_provider: Optional[str] = None planner_provider: Optional[str] = None
planner_model: Optional[str] = None planner_model: Optional[str] = None
@pytest.fixture @pytest.fixture
def clean_env(monkeypatch): def clean_env(monkeypatch):
"""Remove relevant environment variables before each test""" """Remove relevant environment variables before each test"""
env_vars = [ env_vars = [
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY', "ANTHROPIC_API_KEY",
'OPENAI_API_BASE', 'EXPERT_ANTHROPIC_API_KEY', 'EXPERT_OPENAI_API_KEY', "OPENAI_API_KEY",
'EXPERT_OPENROUTER_API_KEY', 'EXPERT_OPENAI_API_BASE', 'TAVILY_API_KEY', 'ANTHROPIC_MODEL' "OPENROUTER_API_KEY",
"OPENAI_API_BASE",
"EXPERT_ANTHROPIC_API_KEY",
"EXPERT_OPENAI_API_KEY",
"EXPERT_OPENROUTER_API_KEY",
"EXPERT_OPENAI_API_BASE",
"TAVILY_API_KEY",
"ANTHROPIC_MODEL",
] ]
for var in env_vars: for var in env_vars:
monkeypatch.delenv(var, raising=False) monkeypatch.delenv(var, raising=False)
def test_anthropic_validation(clean_env, monkeypatch): def test_anthropic_validation(clean_env, monkeypatch):
args = MockArgs(provider="anthropic", expert_provider="openai", model="claude-3-haiku-20240307") args = MockArgs(
provider="anthropic", expert_provider="openai", model="claude-3-haiku-20240307"
)
# Should fail without API key # Should fail without API key
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
validate_environment(args) validate_environment(args)
# Should pass with API key and model # Should pass with API key and model
monkeypatch.setenv('ANTHROPIC_API_KEY', 'test-key') monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert not expert_enabled assert not expert_enabled
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
def test_openai_validation(clean_env, monkeypatch): def test_openai_validation(clean_env, monkeypatch):
args = MockArgs(provider="openai", expert_provider="openai") args = MockArgs(provider="openai", expert_provider="openai")
@ -49,13 +65,16 @@ def test_openai_validation(clean_env, monkeypatch):
validate_environment(args) validate_environment(args)
# Should pass with API key and enable expert mode with fallback # Should pass with API key and enable expert mode with fallback
monkeypatch.setenv('OPENAI_API_KEY', 'test-key') monkeypatch.setenv("OPENAI_API_KEY", "test-key")
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'test-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "test-key"
def test_openai_compatible_validation(clean_env, monkeypatch): def test_openai_compatible_validation(clean_env, monkeypatch):
args = MockArgs(provider="openai-compatible", expert_provider="openai-compatible") args = MockArgs(provider="openai-compatible", expert_provider="openai-compatible")
@ -65,126 +84,158 @@ def test_openai_compatible_validation(clean_env, monkeypatch):
validate_environment(args) validate_environment(args)
# Should fail with only API key # Should fail with only API key
monkeypatch.setenv('OPENAI_API_KEY', 'test-key') monkeypatch.setenv("OPENAI_API_KEY", "test-key")
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
validate_environment(args) validate_environment(args)
# Should pass with both API key and base URL # Should pass with both API key and base URL
monkeypatch.setenv('OPENAI_API_BASE', 'http://test') monkeypatch.setenv("OPENAI_API_BASE", "http://test")
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'test-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "test-key"
assert os.environ.get('EXPERT_OPENAI_API_BASE') == 'http://test' assert os.environ.get("EXPERT_OPENAI_API_BASE") == "http://test"
def test_expert_fallback(clean_env, monkeypatch): def test_expert_fallback(clean_env, monkeypatch):
args = MockArgs(provider="openai", expert_provider="openai") args = MockArgs(provider="openai", expert_provider="openai")
# Set only base API key # Set only base API key
monkeypatch.setenv('OPENAI_API_KEY', 'test-key') monkeypatch.setenv("OPENAI_API_KEY", "test-key")
# Should enable expert mode with fallback # Should enable expert mode with fallback
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'test-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "test-key"
# Should use explicit expert key if available # Should use explicit expert key if available
monkeypatch.setenv('EXPERT_OPENAI_API_KEY', 'expert-key') monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "expert-key")
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'expert-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "expert-key"
def test_cross_provider_fallback(clean_env, monkeypatch): def test_cross_provider_fallback(clean_env, monkeypatch):
"""Test that fallback works even when providers differ""" """Test that fallback works even when providers differ"""
args = MockArgs(provider="openai", expert_provider="anthropic", expert_model="claude-3-haiku-20240307") args = MockArgs(
provider="openai",
expert_provider="anthropic",
expert_model="claude-3-haiku-20240307",
)
# Set base API key for main provider and expert provider # Set base API key for main provider and expert provider
monkeypatch.setenv('OPENAI_API_KEY', 'openai-key') monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.setenv('ANTHROPIC_API_KEY', 'anthropic-key') monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-key")
monkeypatch.setenv('ANTHROPIC_MODEL', 'claude-3-haiku-20240307') monkeypatch.setenv("ANTHROPIC_MODEL", "claude-3-haiku-20240307")
# Should enable expert mode with fallback to ANTHROPIC base key # Should enable expert mode with fallback to ANTHROPIC base key
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
# Try with openai-compatible expert provider # Try with openai-compatible expert provider
args = MockArgs(provider="anthropic", expert_provider="openai-compatible") args = MockArgs(provider="anthropic", expert_provider="openai-compatible")
monkeypatch.setenv('OPENAI_API_KEY', 'openai-key') monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.setenv('OPENAI_API_BASE', 'http://test') monkeypatch.setenv("OPENAI_API_BASE", "http://test")
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'openai-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "openai-key"
assert os.environ.get('EXPERT_OPENAI_API_BASE') == 'http://test' assert os.environ.get("EXPERT_OPENAI_API_BASE") == "http://test"
def test_no_warning_on_fallback(clean_env, monkeypatch): def test_no_warning_on_fallback(clean_env, monkeypatch):
"""Test that no warning is issued when fallback succeeds""" """Test that no warning is issued when fallback succeeds"""
args = MockArgs(provider="openai", expert_provider="openai") args = MockArgs(provider="openai", expert_provider="openai")
# Set only base API key # Set only base API key
monkeypatch.setenv('OPENAI_API_KEY', 'test-key') monkeypatch.setenv("OPENAI_API_KEY", "test-key")
# Should enable expert mode with fallback and no warnings # Should enable expert mode with fallback and no warnings
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'test-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "test-key"
# Should use explicit expert key if available # Should use explicit expert key if available
monkeypatch.setenv('EXPERT_OPENAI_API_KEY', 'expert-key') monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "expert-key")
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'expert-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "expert-key"
def test_different_providers_no_expert_key(clean_env, monkeypatch): def test_different_providers_no_expert_key(clean_env, monkeypatch):
"""Test behavior when providers differ and only base keys are available""" """Test behavior when providers differ and only base keys are available"""
args = MockArgs(provider="anthropic", expert_provider="openai", model="claude-3-haiku-20240307") args = MockArgs(
provider="anthropic", expert_provider="openai", model="claude-3-haiku-20240307"
)
# Set only base keys # Set only base keys
monkeypatch.setenv('ANTHROPIC_API_KEY', 'anthropic-key') monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-key")
monkeypatch.setenv('OPENAI_API_KEY', 'openai-key') monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
# Should enable expert mode and use base OPENAI key # Should enable expert mode and use base OPENAI key
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
def test_mixed_provider_openai_compatible(clean_env, monkeypatch): def test_mixed_provider_openai_compatible(clean_env, monkeypatch):
"""Test behavior with openai-compatible expert and different main provider""" """Test behavior with openai-compatible expert and different main provider"""
args = MockArgs(provider="anthropic", expert_provider="openai-compatible", model="claude-3-haiku-20240307") args = MockArgs(
provider="anthropic",
expert_provider="openai-compatible",
model="claude-3-haiku-20240307",
)
# Set all required keys and URLs # Set all required keys and URLs
monkeypatch.setenv('ANTHROPIC_API_KEY', 'anthropic-key') monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-key")
monkeypatch.setenv('OPENAI_API_KEY', 'openai-key') monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
monkeypatch.setenv('OPENAI_API_BASE', 'http://test') monkeypatch.setenv("OPENAI_API_BASE", "http://test")
# Should enable expert mode and use base openai key and URL # Should enable expert mode and use base openai key and URL
expert_enabled, expert_missing, web_research_enabled, web_research_missing = validate_environment(args) expert_enabled, expert_missing, web_research_enabled, web_research_missing = (
validate_environment(args)
)
assert expert_enabled assert expert_enabled
assert not expert_missing assert not expert_missing
assert not web_research_enabled assert not web_research_enabled
assert 'TAVILY_API_KEY environment variable is not set' in web_research_missing assert "TAVILY_API_KEY environment variable is not set" in web_research_missing
assert os.environ.get('EXPERT_OPENAI_API_KEY') == 'openai-key' assert os.environ.get("EXPERT_OPENAI_API_KEY") == "openai-key"
assert os.environ.get('EXPERT_OPENAI_API_BASE') == 'http://test' assert os.environ.get("EXPERT_OPENAI_API_BASE") == "http://test"

View File

@ -1,178 +1,184 @@
import os import os
import pytest
from unittest.mock import patch, Mock
from langchain_openai.chat_models import ChatOpenAI
from langchain_anthropic.chat_models import ChatAnthropic
from langchain_google_genai.chat_models import ChatGoogleGenerativeAI
from langchain_core.messages import HumanMessage
from dataclasses import dataclass from dataclasses import dataclass
from ra_aid.agents.ciayn_agent import CiaynAgent from unittest.mock import Mock, patch
import pytest
from langchain_anthropic.chat_models import ChatAnthropic
from langchain_core.messages import HumanMessage
from langchain_google_genai.chat_models import ChatGoogleGenerativeAI
from langchain_openai.chat_models import ChatOpenAI
from ra_aid.agents.ciayn_agent import CiaynAgent
from ra_aid.env import validate_environment from ra_aid.env import validate_environment
from ra_aid.llm import ( from ra_aid.llm import (
initialize_llm, create_llm_client,
initialize_expert_llm,
get_env_var, get_env_var,
get_provider_config, get_provider_config,
create_llm_client initialize_expert_llm,
initialize_llm,
) )
@pytest.fixture @pytest.fixture
def clean_env(monkeypatch): def clean_env(monkeypatch):
"""Remove relevant environment variables before each test""" """Remove relevant environment variables before each test"""
env_vars = [ env_vars = [
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY', "ANTHROPIC_API_KEY",
'OPENAI_API_BASE', 'EXPERT_ANTHROPIC_API_KEY', 'EXPERT_OPENAI_API_KEY', "OPENAI_API_KEY",
'EXPERT_OPENROUTER_API_KEY', 'EXPERT_OPENAI_API_BASE', 'GEMINI_API_KEY', 'EXPERT_GEMINI_API_KEY' "OPENROUTER_API_KEY",
"OPENAI_API_BASE",
"EXPERT_ANTHROPIC_API_KEY",
"EXPERT_OPENAI_API_KEY",
"EXPERT_OPENROUTER_API_KEY",
"EXPERT_OPENAI_API_BASE",
"GEMINI_API_KEY",
"EXPERT_GEMINI_API_KEY",
] ]
for var in env_vars: for var in env_vars:
monkeypatch.delenv(var, raising=False) monkeypatch.delenv(var, raising=False)
@pytest.fixture @pytest.fixture
def mock_openai(): def mock_openai():
""" """
Mock ChatOpenAI class for testing OpenAI provider initialization. Mock ChatOpenAI class for testing OpenAI provider initialization.
Prevents actual API calls during testing. Prevents actual API calls during testing.
""" """
with patch('ra_aid.llm.ChatOpenAI') as mock: with patch("ra_aid.llm.ChatOpenAI") as mock:
mock.return_value = Mock(spec=ChatOpenAI) mock.return_value = Mock(spec=ChatOpenAI)
yield mock yield mock
def test_initialize_expert_defaults(clean_env, mock_openai, monkeypatch): def test_initialize_expert_defaults(clean_env, mock_openai, monkeypatch):
"""Test expert LLM initialization with default parameters.""" """Test expert LLM initialization with default parameters."""
monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "test-key") monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "test-key")
llm = initialize_expert_llm() _llm = initialize_expert_llm()
mock_openai.assert_called_once_with(api_key="test-key", model="o1", temperature=0)
mock_openai.assert_called_once_with(
api_key="test-key",
model="o1",
temperature=0
)
def test_initialize_expert_openai_custom(clean_env, mock_openai, monkeypatch): def test_initialize_expert_openai_custom(clean_env, mock_openai, monkeypatch):
"""Test expert OpenAI initialization with custom parameters.""" """Test expert OpenAI initialization with custom parameters."""
monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "test-key") monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "test-key")
llm = initialize_expert_llm("openai", "gpt-4-preview") _llm = initialize_expert_llm("openai", "gpt-4-preview")
mock_openai.assert_called_once_with( mock_openai.assert_called_once_with(
api_key="test-key", api_key="test-key", model="gpt-4-preview", temperature=0
model="gpt-4-preview",
temperature=0
) )
def test_initialize_expert_gemini(clean_env, mock_gemini, monkeypatch): def test_initialize_expert_gemini(clean_env, mock_gemini, monkeypatch):
"""Test expert Gemini initialization.""" """Test expert Gemini initialization."""
monkeypatch.setenv("EXPERT_GEMINI_API_KEY", "test-key") monkeypatch.setenv("EXPERT_GEMINI_API_KEY", "test-key")
llm = initialize_expert_llm("gemini", "gemini-2.0-flash-thinking-exp-1219") _llm = initialize_expert_llm("gemini", "gemini-2.0-flash-thinking-exp-1219")
mock_gemini.assert_called_once_with( mock_gemini.assert_called_once_with(
api_key="test-key", api_key="test-key", model="gemini-2.0-flash-thinking-exp-1219", temperature=0
model="gemini-2.0-flash-thinking-exp-1219",
temperature=0
) )
def test_initialize_expert_anthropic(clean_env, mock_anthropic, monkeypatch): def test_initialize_expert_anthropic(clean_env, mock_anthropic, monkeypatch):
"""Test expert Anthropic initialization.""" """Test expert Anthropic initialization."""
monkeypatch.setenv("EXPERT_ANTHROPIC_API_KEY", "test-key") monkeypatch.setenv("EXPERT_ANTHROPIC_API_KEY", "test-key")
llm = initialize_expert_llm("anthropic", "claude-3") _llm = initialize_expert_llm("anthropic", "claude-3")
mock_anthropic.assert_called_once_with( mock_anthropic.assert_called_once_with(
api_key="test-key", api_key="test-key", model_name="claude-3", temperature=0
model_name="claude-3",
temperature=0
) )
def test_initialize_expert_openrouter(clean_env, mock_openai, monkeypatch): def test_initialize_expert_openrouter(clean_env, mock_openai, monkeypatch):
"""Test expert OpenRouter initialization.""" """Test expert OpenRouter initialization."""
monkeypatch.setenv("EXPERT_OPENROUTER_API_KEY", "test-key") monkeypatch.setenv("EXPERT_OPENROUTER_API_KEY", "test-key")
llm = initialize_expert_llm("openrouter", "models/mistral-large") _llm = initialize_expert_llm("openrouter", "models/mistral-large")
mock_openai.assert_called_once_with( mock_openai.assert_called_once_with(
api_key="test-key", api_key="test-key",
base_url="https://openrouter.ai/api/v1", base_url="https://openrouter.ai/api/v1",
model="models/mistral-large", model="models/mistral-large",
temperature=0 temperature=0,
) )
def test_initialize_expert_openai_compatible(clean_env, mock_openai, monkeypatch): def test_initialize_expert_openai_compatible(clean_env, mock_openai, monkeypatch):
"""Test expert OpenAI-compatible initialization.""" """Test expert OpenAI-compatible initialization."""
monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "test-key") monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "test-key")
monkeypatch.setenv("EXPERT_OPENAI_API_BASE", "http://test-url") monkeypatch.setenv("EXPERT_OPENAI_API_BASE", "http://test-url")
llm = initialize_expert_llm("openai-compatible", "local-model") _llm = initialize_expert_llm("openai-compatible", "local-model")
mock_openai.assert_called_once_with( mock_openai.assert_called_once_with(
api_key="test-key", api_key="test-key",
base_url="http://test-url", base_url="http://test-url",
model="local-model", model="local-model",
temperature=0 temperature=0,
) )
def test_initialize_expert_unsupported_provider(clean_env): def test_initialize_expert_unsupported_provider(clean_env):
"""Test error handling for unsupported provider in expert mode.""" """Test error handling for unsupported provider in expert mode."""
with pytest.raises(ValueError, match=r"Unsupported provider: unknown"): with pytest.raises(ValueError, match=r"Unsupported provider: unknown"):
initialize_expert_llm("unknown", "model") initialize_expert_llm("unknown", "model")
def test_estimate_tokens(): def test_estimate_tokens():
"""Test token estimation functionality.""" """Test token estimation functionality."""
# Test empty/None cases # Test empty/None cases
assert CiaynAgent._estimate_tokens(None) == 0 assert CiaynAgent._estimate_tokens(None) == 0
assert CiaynAgent._estimate_tokens('') == 0 assert CiaynAgent._estimate_tokens("") == 0
# Test string content # Test string content
assert CiaynAgent._estimate_tokens('test') == 1 # 4 bytes assert CiaynAgent._estimate_tokens("test") == 1 # 4 bytes
assert CiaynAgent._estimate_tokens('hello world') == 2 # 11 bytes assert CiaynAgent._estimate_tokens("hello world") == 2 # 11 bytes
assert CiaynAgent._estimate_tokens('🚀') == 1 # 4 bytes assert CiaynAgent._estimate_tokens("🚀") == 1 # 4 bytes
# Test message content # Test message content
msg = HumanMessage(content='test message') msg = HumanMessage(content="test message")
assert CiaynAgent._estimate_tokens(msg) == 3 # 11 bytes assert CiaynAgent._estimate_tokens(msg) == 3 # 11 bytes
def test_initialize_openai(clean_env, mock_openai): def test_initialize_openai(clean_env, mock_openai):
"""Test OpenAI provider initialization""" """Test OpenAI provider initialization"""
os.environ["OPENAI_API_KEY"] = "test-key" os.environ["OPENAI_API_KEY"] = "test-key"
model = initialize_llm("openai", "gpt-4") _model = initialize_llm("openai", "gpt-4")
mock_openai.assert_called_once_with(api_key="test-key", model="gpt-4")
mock_openai.assert_called_once_with(
api_key="test-key",
model="gpt-4"
)
def test_initialize_gemini(clean_env, mock_gemini): def test_initialize_gemini(clean_env, mock_gemini):
"""Test Gemini provider initialization""" """Test Gemini provider initialization"""
os.environ["GEMINI_API_KEY"] = "test-key" os.environ["GEMINI_API_KEY"] = "test-key"
model = initialize_llm("gemini", "gemini-2.0-flash-thinking-exp-1219") _model = initialize_llm("gemini", "gemini-2.0-flash-thinking-exp-1219")
mock_gemini.assert_called_once_with( mock_gemini.assert_called_once_with(
api_key="test-key", api_key="test-key", model="gemini-2.0-flash-thinking-exp-1219"
model="gemini-2.0-flash-thinking-exp-1219"
) )
def test_initialize_anthropic(clean_env, mock_anthropic): def test_initialize_anthropic(clean_env, mock_anthropic):
"""Test Anthropic provider initialization""" """Test Anthropic provider initialization"""
os.environ["ANTHROPIC_API_KEY"] = "test-key" os.environ["ANTHROPIC_API_KEY"] = "test-key"
model = initialize_llm("anthropic", "claude-3") _model = initialize_llm("anthropic", "claude-3")
mock_anthropic.assert_called_once_with(api_key="test-key", model_name="claude-3")
mock_anthropic.assert_called_once_with(
api_key="test-key",
model_name="claude-3"
)
def test_initialize_openrouter(clean_env, mock_openai): def test_initialize_openrouter(clean_env, mock_openai):
"""Test OpenRouter provider initialization""" """Test OpenRouter provider initialization"""
os.environ["OPENROUTER_API_KEY"] = "test-key" os.environ["OPENROUTER_API_KEY"] = "test-key"
model = initialize_llm("openrouter", "mistral-large") _model = initialize_llm("openrouter", "mistral-large")
mock_openai.assert_called_once_with( mock_openai.assert_called_once_with(
api_key="test-key", api_key="test-key",
base_url="https://openrouter.ai/api/v1", base_url="https://openrouter.ai/api/v1",
model="mistral-large" model="mistral-large",
) )
def test_initialize_openai_compatible(clean_env, mock_openai): def test_initialize_openai_compatible(clean_env, mock_openai):
"""Test OpenAI-compatible provider initialization""" """Test OpenAI-compatible provider initialization"""
os.environ["OPENAI_API_KEY"] = "test-key" os.environ["OPENAI_API_KEY"] = "test-key"
os.environ["OPENAI_API_BASE"] = "https://custom-endpoint/v1" os.environ["OPENAI_API_BASE"] = "https://custom-endpoint/v1"
model = initialize_llm("openai-compatible", "local-model") _model = initialize_llm("openai-compatible", "local-model")
mock_openai.assert_called_once_with( mock_openai.assert_called_once_with(
api_key="test-key", api_key="test-key",
@ -181,12 +187,14 @@ def test_initialize_openai_compatible(clean_env, mock_openai):
temperature=0.3, temperature=0.3,
) )
def test_initialize_unsupported_provider(clean_env): def test_initialize_unsupported_provider(clean_env):
"""Test initialization with unsupported provider raises ValueError""" """Test initialization with unsupported provider raises ValueError"""
with pytest.raises(ValueError) as exc_info: with pytest.raises(ValueError) as exc_info:
initialize_llm("unsupported", "model") initialize_llm("unsupported", "model")
assert str(exc_info.value) == "Unsupported provider: unsupported" assert str(exc_info.value) == "Unsupported provider: unsupported"
def test_temperature_defaults(clean_env, mock_openai, mock_anthropic, mock_gemini): def test_temperature_defaults(clean_env, mock_openai, mock_anthropic, mock_gemini):
"""Test default temperature behavior for different providers.""" """Test default temperature behavior for different providers."""
os.environ["OPENAI_API_KEY"] = "test-key" os.environ["OPENAI_API_KEY"] = "test-key"
@ -199,27 +207,19 @@ def test_temperature_defaults(clean_env, mock_openai, mock_anthropic, mock_gemin
api_key="test-key", api_key="test-key",
base_url="http://test-url", base_url="http://test-url",
model="test-model", model="test-model",
temperature=0.3 temperature=0.3,
) )
# Test other providers don't set temperature by default # Test other providers don't set temperature by default
initialize_llm("openai", "test-model") initialize_llm("openai", "test-model")
mock_openai.assert_called_with( mock_openai.assert_called_with(api_key="test-key", model="test-model")
api_key="test-key",
model="test-model"
)
initialize_llm("anthropic", "test-model") initialize_llm("anthropic", "test-model")
mock_anthropic.assert_called_with( mock_anthropic.assert_called_with(api_key="test-key", model_name="test-model")
api_key="test-key",
model_name="test-model"
)
initialize_llm("gemini", "test-model") initialize_llm("gemini", "test-model")
mock_gemini.assert_called_with( mock_gemini.assert_called_with(api_key="test-key", model="test-model")
api_key="test-key",
model="test-model"
)
def test_explicit_temperature(clean_env, mock_openai, mock_anthropic, mock_gemini): def test_explicit_temperature(clean_env, mock_openai, mock_anthropic, mock_gemini):
"""Test explicit temperature setting for each provider.""" """Test explicit temperature setting for each provider."""
@ -233,25 +233,19 @@ def test_explicit_temperature(clean_env, mock_openai, mock_anthropic, mock_gemin
# Test OpenAI # Test OpenAI
initialize_llm("openai", "test-model", temperature=test_temp) initialize_llm("openai", "test-model", temperature=test_temp)
mock_openai.assert_called_with( mock_openai.assert_called_with(
api_key="test-key", api_key="test-key", model="test-model", temperature=test_temp
model="test-model",
temperature=test_temp
) )
# Test Gemini # Test Gemini
initialize_llm("gemini", "test-model", temperature=test_temp) initialize_llm("gemini", "test-model", temperature=test_temp)
mock_gemini.assert_called_with( mock_gemini.assert_called_with(
api_key="test-key", api_key="test-key", model="test-model", temperature=test_temp
model="test-model",
temperature=test_temp
) )
# Test Anthropic # Test Anthropic
initialize_llm("anthropic", "test-model", temperature=test_temp) initialize_llm("anthropic", "test-model", temperature=test_temp)
mock_anthropic.assert_called_with( mock_anthropic.assert_called_with(
api_key="test-key", api_key="test-key", model_name="test-model", temperature=test_temp
model_name="test-model",
temperature=test_temp
) )
# Test OpenRouter # Test OpenRouter
@ -260,77 +254,80 @@ def test_explicit_temperature(clean_env, mock_openai, mock_anthropic, mock_gemin
api_key="test-key", api_key="test-key",
base_url="https://openrouter.ai/api/v1", base_url="https://openrouter.ai/api/v1",
model="test-model", model="test-model",
temperature=test_temp temperature=test_temp,
) )
def test_temperature_validation(clean_env, mock_openai): def test_temperature_validation(clean_env, mock_openai):
"""Test temperature validation in command line arguments.""" """Test temperature validation in command line arguments."""
from ra_aid.__main__ import parse_arguments from ra_aid.__main__ import parse_arguments
# Test temperature below minimum # Test temperature below minimum
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
parse_arguments(['--message', 'test', '--temperature', '-0.1']) parse_arguments(["--message", "test", "--temperature", "-0.1"])
# Test temperature above maximum # Test temperature above maximum
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
parse_arguments(['--message', 'test', '--temperature', '2.1']) parse_arguments(["--message", "test", "--temperature", "2.1"])
# Test valid temperature # Test valid temperature
args = parse_arguments(['--message', 'test', '--temperature', '0.7']) args = parse_arguments(["--message", "test", "--temperature", "0.7"])
assert args.temperature == 0.7 assert args.temperature == 0.7
def test_provider_name_validation(): def test_provider_name_validation():
"""Test provider name validation and normalization.""" """Test provider name validation and normalization."""
# Test all supported providers # Test all supported providers
providers = ["openai", "anthropic", "openrouter", "openai-compatible", "gemini"] providers = ["openai", "anthropic", "openrouter", "openai-compatible", "gemini"]
for provider in providers: for provider in providers:
try: try:
with patch(f'ra_aid.llm.ChatOpenAI'), patch('ra_aid.llm.ChatAnthropic'): with patch("ra_aid.llm.ChatOpenAI"), patch("ra_aid.llm.ChatAnthropic"):
initialize_llm(provider, "test-model") initialize_llm(provider, "test-model")
except ValueError: except ValueError:
pytest.fail(f"Valid provider {provider} raised ValueError") pytest.fail(f"Valid provider {provider} raised ValueError")
# Test case sensitivity # Test case sensitivity
with patch('ra_aid.llm.ChatOpenAI'): with patch("ra_aid.llm.ChatOpenAI"):
with pytest.raises(ValueError): with pytest.raises(ValueError):
initialize_llm("OpenAI", "test-model") initialize_llm("OpenAI", "test-model")
def test_initialize_llm_cross_provider(clean_env, mock_openai, mock_anthropic, mock_gemini, monkeypatch):
def test_initialize_llm_cross_provider(
clean_env, mock_openai, mock_anthropic, mock_gemini, monkeypatch
):
"""Test initializing different providers in sequence.""" """Test initializing different providers in sequence."""
# Initialize OpenAI # Initialize OpenAI
monkeypatch.setenv("OPENAI_API_KEY", "openai-key") monkeypatch.setenv("OPENAI_API_KEY", "openai-key")
llm1 = initialize_llm("openai", "gpt-4") _llm1 = initialize_llm("openai", "gpt-4")
# Initialize Anthropic # Initialize Anthropic
monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-key") monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-key")
llm2 = initialize_llm("anthropic", "claude-3") _llm2 = initialize_llm("anthropic", "claude-3")
# Initialize Gemini # Initialize Gemini
monkeypatch.setenv("GEMINI_API_KEY", "gemini-key") monkeypatch.setenv("GEMINI_API_KEY", "gemini-key")
llm3 = initialize_llm("gemini", "gemini-2.0-flash-thinking-exp-1219") _llm3 = initialize_llm("gemini", "gemini-2.0-flash-thinking-exp-1219")
# Verify both were initialized correctly # Verify both were initialized correctly
mock_openai.assert_called_once_with( mock_openai.assert_called_once_with(api_key="openai-key", model="gpt-4")
api_key="openai-key",
model="gpt-4"
)
mock_anthropic.assert_called_once_with( mock_anthropic.assert_called_once_with(
api_key="anthropic-key", api_key="anthropic-key", model_name="claude-3"
model_name="claude-3"
) )
mock_gemini.assert_called_once_with( mock_gemini.assert_called_once_with(
api_key="gemini-key", api_key="gemini-key", model="gemini-2.0-flash-thinking-exp-1219"
model="gemini-2.0-flash-thinking-exp-1219"
) )
@dataclass @dataclass
class Args: class Args:
"""Test arguments class.""" """Test arguments class."""
provider: str provider: str
expert_provider: str expert_provider: str
model: str = None model: str = None
expert_model: str = None expert_model: str = None
def test_environment_variable_precedence(clean_env, mock_openai, monkeypatch): def test_environment_variable_precedence(clean_env, mock_openai, monkeypatch):
"""Test environment variable precedence and fallback.""" """Test environment variable precedence and fallback."""
# Test get_env_var helper with fallback # Test get_env_var helper with fallback
@ -350,12 +347,8 @@ def test_environment_variable_precedence(clean_env, mock_openai, monkeypatch):
assert config["api_key"] == "expert-key" assert config["api_key"] == "expert-key"
# Test LLM client creation with expert mode # Test LLM client creation with expert mode
llm = create_llm_client("openai", "o1", is_expert=True) _llm = create_llm_client("openai", "o1", is_expert=True)
mock_openai.assert_called_with( mock_openai.assert_called_with(api_key="expert-key", model="o1", temperature=0)
api_key="expert-key",
model="o1",
temperature=0
)
# Test environment validation # Test environment validation
monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "") monkeypatch.setenv("EXPERT_OPENAI_API_KEY", "")
@ -366,123 +359,141 @@ def test_environment_variable_precedence(clean_env, mock_openai, monkeypatch):
monkeypatch.setenv("ANTHROPIC_MODEL", "claude-3-haiku-20240307") monkeypatch.setenv("ANTHROPIC_MODEL", "claude-3-haiku-20240307")
args = Args(provider="anthropic", expert_provider="openai") args = Args(provider="anthropic", expert_provider="openai")
expert_enabled, expert_missing, web_enabled, web_missing = validate_environment(args) expert_enabled, expert_missing, web_enabled, web_missing = validate_environment(
args
)
assert not expert_enabled assert not expert_enabled
assert expert_missing assert expert_missing
assert not web_enabled assert not web_enabled
assert web_missing assert web_missing
@pytest.fixture @pytest.fixture
def mock_anthropic(): def mock_anthropic():
""" """
Mock ChatAnthropic class for testing Anthropic provider initialization. Mock ChatAnthropic class for testing Anthropic provider initialization.
Prevents actual API calls during testing. Prevents actual API calls during testing.
""" """
with patch('ra_aid.llm.ChatAnthropic') as mock: with patch("ra_aid.llm.ChatAnthropic") as mock:
mock.return_value = Mock(spec=ChatAnthropic) mock.return_value = Mock(spec=ChatAnthropic)
yield mock yield mock
@pytest.fixture @pytest.fixture
def mock_gemini(): def mock_gemini():
"""Mock ChatGoogleGenerativeAI class for testing Gemini provider initialization.""" """Mock ChatGoogleGenerativeAI class for testing Gemini provider initialization."""
with patch('ra_aid.llm.ChatGoogleGenerativeAI') as mock: with patch("ra_aid.llm.ChatGoogleGenerativeAI") as mock:
mock.return_value = Mock(spec=ChatGoogleGenerativeAI) mock.return_value = Mock(spec=ChatGoogleGenerativeAI)
yield mock yield mock
@pytest.fixture @pytest.fixture
def mock_deepseek_reasoner(): def mock_deepseek_reasoner():
"""Mock ChatDeepseekReasoner for testing DeepSeek provider initialization.""" """Mock ChatDeepseekReasoner for testing DeepSeek provider initialization."""
with patch('ra_aid.llm.ChatDeepseekReasoner') as mock: with patch("ra_aid.llm.ChatDeepseekReasoner") as mock:
mock.return_value = Mock() mock.return_value = Mock()
yield mock yield mock
def test_initialize_deepseek(clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch):
def test_initialize_deepseek(
clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch
):
"""Test DeepSeek provider initialization with different models.""" """Test DeepSeek provider initialization with different models."""
monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key") monkeypatch.setenv("DEEPSEEK_API_KEY", "test-key")
# Test with reasoner model # Test with reasoner model
model = initialize_llm("deepseek", "deepseek-reasoner") _model = initialize_llm("deepseek", "deepseek-reasoner")
mock_deepseek_reasoner.assert_called_with( mock_deepseek_reasoner.assert_called_with(
api_key="test-key", api_key="test-key",
base_url="https://api.deepseek.com", base_url="https://api.deepseek.com",
temperature=1, temperature=1,
model="deepseek-reasoner" model="deepseek-reasoner",
) )
# Test with non-reasoner model # Test with non-reasoner model
model = initialize_llm("deepseek", "deepseek-chat") _model = initialize_llm("deepseek", "deepseek-chat")
mock_openai.assert_called_with( mock_openai.assert_called_with(
api_key="test-key", api_key="test-key",
base_url="https://api.deepseek.com", base_url="https://api.deepseek.com",
temperature=1, temperature=1,
model="deepseek-chat" model="deepseek-chat",
) )
def test_initialize_expert_deepseek(clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch):
def test_initialize_expert_deepseek(
clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch
):
"""Test expert DeepSeek provider initialization.""" """Test expert DeepSeek provider initialization."""
monkeypatch.setenv("EXPERT_DEEPSEEK_API_KEY", "test-key") monkeypatch.setenv("EXPERT_DEEPSEEK_API_KEY", "test-key")
# Test with reasoner model # Test with reasoner model
model = initialize_expert_llm("deepseek", "deepseek-reasoner") _model = initialize_expert_llm("deepseek", "deepseek-reasoner")
mock_deepseek_reasoner.assert_called_with( mock_deepseek_reasoner.assert_called_with(
api_key="test-key", api_key="test-key",
base_url="https://api.deepseek.com", base_url="https://api.deepseek.com",
temperature=0, temperature=0,
model="deepseek-reasoner" model="deepseek-reasoner",
) )
# Test with non-reasoner model # Test with non-reasoner model
model = initialize_expert_llm("deepseek", "deepseek-chat") _model = initialize_expert_llm("deepseek", "deepseek-chat")
mock_openai.assert_called_with( mock_openai.assert_called_with(
api_key="test-key", api_key="test-key",
base_url="https://api.deepseek.com", base_url="https://api.deepseek.com",
temperature=0, temperature=0,
model="deepseek-chat" model="deepseek-chat",
) )
def test_initialize_openrouter_deepseek(clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch):
def test_initialize_openrouter_deepseek(
clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch
):
"""Test OpenRouter DeepSeek model initialization.""" """Test OpenRouter DeepSeek model initialization."""
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
# Test with DeepSeek R1 model # Test with DeepSeek R1 model
model = initialize_llm("openrouter", "deepseek/deepseek-r1") _model = initialize_llm("openrouter", "deepseek/deepseek-r1")
mock_deepseek_reasoner.assert_called_with( mock_deepseek_reasoner.assert_called_with(
api_key="test-key", api_key="test-key",
base_url="https://openrouter.ai/api/v1", base_url="https://openrouter.ai/api/v1",
temperature=1, temperature=1,
model="deepseek/deepseek-r1" model="deepseek/deepseek-r1",
) )
# Test with non-DeepSeek model # Test with non-DeepSeek model
model = initialize_llm("openrouter", "mistral/mistral-large") _model = initialize_llm("openrouter", "mistral/mistral-large")
mock_openai.assert_called_with(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="mistral/mistral-large"
)
def test_initialize_expert_openrouter_deepseek(clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch):
"""Test expert OpenRouter DeepSeek model initialization."""
monkeypatch.setenv("EXPERT_OPENROUTER_API_KEY", "test-key")
# Test with DeepSeek R1 model via create_llm_client
model = create_llm_client("openrouter", "deepseek/deepseek-r1", is_expert=True)
mock_deepseek_reasoner.assert_called_with(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
temperature=0,
model="deepseek/deepseek-r1"
)
# Test with non-DeepSeek model
model = create_llm_client("openrouter", "mistral/mistral-large", is_expert=True)
mock_openai.assert_called_with( mock_openai.assert_called_with(
api_key="test-key", api_key="test-key",
base_url="https://openrouter.ai/api/v1", base_url="https://openrouter.ai/api/v1",
model="mistral/mistral-large", model="mistral/mistral-large",
temperature=0
) )
def test_initialize_expert_openrouter_deepseek(
clean_env, mock_openai, mock_deepseek_reasoner, monkeypatch
):
"""Test expert OpenRouter DeepSeek model initialization."""
monkeypatch.setenv("EXPERT_OPENROUTER_API_KEY", "test-key")
# Test with DeepSeek R1 model via create_llm_client
_model = create_llm_client("openrouter", "deepseek/deepseek-r1", is_expert=True)
mock_deepseek_reasoner.assert_called_with(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
temperature=0,
model="deepseek/deepseek-r1",
)
# Test with non-DeepSeek model
_model = create_llm_client("openrouter", "mistral/mistral-large", is_expert=True)
mock_openai.assert_called_with(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
model="mistral/mistral-large",
temperature=0,
)
def test_deepseek_environment_fallback(clean_env, mock_deepseek_reasoner, monkeypatch): def test_deepseek_environment_fallback(clean_env, mock_deepseek_reasoner, monkeypatch):
"""Test DeepSeek environment variable fallback behavior.""" """Test DeepSeek environment variable fallback behavior."""
# Test environment variable helper with fallback # Test environment variable helper with fallback
@ -500,10 +511,10 @@ def test_deepseek_environment_fallback(clean_env, mock_deepseek_reasoner, monkey
assert config["api_key"] == "expert-key" assert config["api_key"] == "expert-key"
# Test client creation with expert key # Test client creation with expert key
model = create_llm_client("deepseek", "deepseek-reasoner", is_expert=True) _model = create_llm_client("deepseek", "deepseek-reasoner", is_expert=True)
mock_deepseek_reasoner.assert_called_with( mock_deepseek_reasoner.assert_called_with(
api_key="expert-key", api_key="expert-key",
base_url="https://api.deepseek.com", base_url="https://api.deepseek.com",
temperature=0, temperature=0,
model="deepseek-reasoner" model="deepseek-reasoner",
) )

View File

@ -1,18 +1,20 @@
"""Unit tests for __main__.py argument parsing.""" """Unit tests for __main__.py argument parsing."""
import pytest import pytest
from ra_aid.__main__ import parse_arguments from ra_aid.__main__ import parse_arguments
from ra_aid.tools.memory import _global_memory
from ra_aid.config import DEFAULT_RECURSION_LIMIT from ra_aid.config import DEFAULT_RECURSION_LIMIT
from ra_aid.tools.memory import _global_memory
@pytest.fixture @pytest.fixture
def mock_dependencies(monkeypatch): def mock_dependencies(monkeypatch):
"""Mock all dependencies needed for main().""" """Mock all dependencies needed for main()."""
monkeypatch.setattr('ra_aid.__main__.check_dependencies', lambda: None) monkeypatch.setattr("ra_aid.__main__.check_dependencies", lambda: None)
monkeypatch.setattr('ra_aid.__main__.validate_environment', monkeypatch.setattr(
lambda args: (True, [], True, [])) "ra_aid.__main__.validate_environment", lambda args: (True, [], True, [])
)
def mock_config_update(*args, **kwargs): def mock_config_update(*args, **kwargs):
config = _global_memory.get("config", {}) config = _global_memory.get("config", {})
@ -21,27 +23,31 @@ def mock_dependencies(monkeypatch):
_global_memory["config"] = config _global_memory["config"] = config
return None return None
monkeypatch.setattr('ra_aid.__main__.initialize_llm', monkeypatch.setattr("ra_aid.__main__.initialize_llm", mock_config_update)
mock_config_update)
monkeypatch.setattr(
"ra_aid.__main__.run_research_agent", lambda *args, **kwargs: None
)
monkeypatch.setattr('ra_aid.__main__.run_research_agent',
lambda *args, **kwargs: None)
def test_recursion_limit_in_global_config(mock_dependencies): def test_recursion_limit_in_global_config(mock_dependencies):
"""Test that recursion limit is correctly set in global config.""" """Test that recursion limit is correctly set in global config."""
from ra_aid.__main__ import main
import sys import sys
from unittest.mock import patch from unittest.mock import patch
from ra_aid.__main__ import main
_global_memory.clear() _global_memory.clear()
with patch.object(sys, 'argv', ['ra-aid', '-m', 'test message']): with patch.object(sys, "argv", ["ra-aid", "-m", "test message"]):
main() main()
assert _global_memory["config"]["recursion_limit"] == DEFAULT_RECURSION_LIMIT assert _global_memory["config"]["recursion_limit"] == DEFAULT_RECURSION_LIMIT
_global_memory.clear() _global_memory.clear()
with patch.object(sys, 'argv', ['ra-aid', '-m', 'test message', '--recursion-limit', '50']): with patch.object(
sys, "argv", ["ra-aid", "-m", "test message", "--recursion-limit", "50"]
):
main() main()
assert _global_memory["config"]["recursion_limit"] == 50 assert _global_memory["config"]["recursion_limit"] == 50
@ -60,23 +66,35 @@ def test_zero_recursion_limit():
def test_config_settings(mock_dependencies): def test_config_settings(mock_dependencies):
"""Test that various settings are correctly applied in global config.""" """Test that various settings are correctly applied in global config."""
from ra_aid.__main__ import main
import sys import sys
from unittest.mock import patch from unittest.mock import patch
from ra_aid.__main__ import main
_global_memory.clear() _global_memory.clear()
with patch.object(sys, 'argv', [ with patch.object(
'ra-aid', '-m', 'test message', sys,
'--cowboy-mode', "argv",
'--research-only', [
'--provider', 'anthropic', "ra-aid",
'--model', 'claude-3-5-sonnet-20241022', "-m",
'--expert-provider', 'openai', "test message",
'--expert-model', 'gpt-4', "--cowboy-mode",
'--temperature', '0.7', "--research-only",
'--disable-limit-tokens' "--provider",
]): "anthropic",
"--model",
"claude-3-5-sonnet-20241022",
"--expert-provider",
"openai",
"--expert-model",
"gpt-4",
"--temperature",
"0.7",
"--disable-limit-tokens",
],
):
main() main()
config = _global_memory["config"] config = _global_memory["config"]
assert config["cowboy_mode"] is True assert config["cowboy_mode"] is True
@ -90,20 +108,25 @@ def test_config_settings(mock_dependencies):
def test_temperature_validation(mock_dependencies): def test_temperature_validation(mock_dependencies):
"""Test that temperature argument is correctly passed to initialize_llm.""" """Test that temperature argument is correctly passed to initialize_llm."""
from ra_aid.__main__ import main, initialize_llm
import sys import sys
from unittest.mock import patch from unittest.mock import patch
from ra_aid.__main__ import main
_global_memory.clear() _global_memory.clear()
with patch('ra_aid.__main__.initialize_llm') as mock_init_llm: with patch("ra_aid.__main__.initialize_llm") as mock_init_llm:
with patch.object(sys, 'argv', ['ra-aid', '-m', 'test', '--temperature', '0.7']): with patch.object(
sys, "argv", ["ra-aid", "-m", "test", "--temperature", "0.7"]
):
main() main()
mock_init_llm.assert_called_once() mock_init_llm.assert_called_once()
assert mock_init_llm.call_args.kwargs['temperature'] == 0.7 assert mock_init_llm.call_args.kwargs["temperature"] == 0.7
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
with patch.object(sys, 'argv', ['ra-aid', '-m', 'test', '--temperature', '2.1']): with patch.object(
sys, "argv", ["ra-aid", "-m", "test", "--temperature", "2.1"]
):
main() main()
@ -125,19 +148,30 @@ def test_missing_message():
def test_research_model_provider_args(mock_dependencies): def test_research_model_provider_args(mock_dependencies):
"""Test that research-specific model/provider args are correctly stored in config.""" """Test that research-specific model/provider args are correctly stored in config."""
from ra_aid.__main__ import main
import sys import sys
from unittest.mock import patch from unittest.mock import patch
from ra_aid.__main__ import main
_global_memory.clear() _global_memory.clear()
with patch.object(sys, 'argv', [ with patch.object(
'ra-aid', '-m', 'test message', sys,
'--research-provider', 'anthropic', "argv",
'--research-model', 'claude-3-haiku-20240307', [
'--planner-provider', 'openai', "ra-aid",
'--planner-model', 'gpt-4' "-m",
]): "test message",
"--research-provider",
"anthropic",
"--research-model",
"claude-3-haiku-20240307",
"--planner-provider",
"openai",
"--planner-model",
"gpt-4",
],
):
main() main()
config = _global_memory["config"] config = _global_memory["config"]
assert config["research_provider"] == "anthropic" assert config["research_provider"] == "anthropic"
@ -148,17 +182,18 @@ def test_research_model_provider_args(mock_dependencies):
def test_planner_model_provider_args(mock_dependencies): def test_planner_model_provider_args(mock_dependencies):
"""Test that planner provider/model args fall back to main config when not specified.""" """Test that planner provider/model args fall back to main config when not specified."""
from ra_aid.__main__ import main
import sys import sys
from unittest.mock import patch from unittest.mock import patch
from ra_aid.__main__ import main
_global_memory.clear() _global_memory.clear()
with patch.object(sys, 'argv', [ with patch.object(
'ra-aid', '-m', 'test message', sys,
'--provider', 'openai', "argv",
'--model', 'gpt-4' ["ra-aid", "-m", "test message", "--provider", "openai", "--model", "gpt-4"],
]): ):
main() main()
config = _global_memory["config"] config = _global_memory["config"]
assert config["planner_provider"] == "openai" assert config["planner_provider"] == "openai"

View File

@ -1,4 +1,5 @@
import pytest import pytest
from ra_aid.tools.programmer import parse_aider_flags, run_programming_task from ra_aid.tools.programmer import parse_aider_flags, run_programming_task
# Test cases for parse_aider_flags function # Test cases for parse_aider_flags function
@ -7,65 +8,57 @@ test_cases = [
( (
"yes-always,dark-mode", "yes-always,dark-mode",
["--yes-always", "--dark-mode"], ["--yes-always", "--dark-mode"],
"basic comma separated flags without dashes" "basic comma separated flags without dashes",
), ),
( (
"--yes-always,--dark-mode", "--yes-always,--dark-mode",
["--yes-always", "--dark-mode"], ["--yes-always", "--dark-mode"],
"comma separated flags with dashes" "comma separated flags with dashes",
), ),
( (
"yes-always, dark-mode", "yes-always, dark-mode",
["--yes-always", "--dark-mode"], ["--yes-always", "--dark-mode"],
"comma separated flags with space" "comma separated flags with space",
), ),
( (
"--yes-always, --dark-mode", "--yes-always, --dark-mode",
["--yes-always", "--dark-mode"], ["--yes-always", "--dark-mode"],
"comma separated flags with dashes and space" "comma separated flags with dashes and space",
),
(
"",
[],
"empty string"
), ),
("", [], "empty string"),
( (
" yes-always , dark-mode ", " yes-always , dark-mode ",
["--yes-always", "--dark-mode"], ["--yes-always", "--dark-mode"],
"flags with extra whitespace" "flags with extra whitespace",
), ),
( ("--yes-always", ["--yes-always"], "single flag with dashes"),
"--yes-always", ("yes-always", ["--yes-always"], "single flag without dashes"),
["--yes-always"],
"single flag with dashes"
),
(
"yes-always",
["--yes-always"],
"single flag without dashes"
)
] ]
@pytest.mark.parametrize("input_flags,expected,description", test_cases) @pytest.mark.parametrize("input_flags,expected,description", test_cases)
def test_parse_aider_flags(input_flags, expected, description): def test_parse_aider_flags(input_flags, expected, description):
"""Table-driven test for parse_aider_flags function.""" """Table-driven test for parse_aider_flags function."""
result = parse_aider_flags(input_flags) result = parse_aider_flags(input_flags)
assert result == expected, f"Failed test case: {description}" assert result == expected, f"Failed test case: {description}"
def test_aider_config_flag(mocker): def test_aider_config_flag(mocker):
"""Test that aider config flag is properly included in the command when specified.""" """Test that aider config flag is properly included in the command when specified."""
mock_memory = { mock_memory = {
'config': {'aider_config': '/path/to/config.yml'}, "config": {"aider_config": "/path/to/config.yml"},
'related_files': {} "related_files": {},
} }
mocker.patch('ra_aid.tools.programmer._global_memory', mock_memory) mocker.patch("ra_aid.tools.programmer._global_memory", mock_memory)
# Mock the run_interactive_command to capture the command that would be run # Mock the run_interactive_command to capture the command that would be run
mock_run = mocker.patch('ra_aid.tools.programmer.run_interactive_command', return_value=(b'', 0)) mock_run = mocker.patch(
"ra_aid.tools.programmer.run_interactive_command", return_value=(b"", 0)
)
run_programming_task("test instruction") run_programming_task("test instruction")
args = mock_run.call_args[0][0] # Get the first positional arg (command list) args = mock_run.call_args[0][0] # Get the first positional arg (command list)
assert '--config' in args assert "--config" in args
config_index = args.index('--config') config_index = args.index("--config")
assert args[config_index + 1] == '/path/to/config.yml' assert args[config_index + 1] == "/path/to/config.yml"

View File

@ -1,19 +1,19 @@
"""Integration tests for provider validation and environment handling.""" """Integration tests for provider validation and environment handling."""
import os import os
import pytest
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
import pytest
from ra_aid.env import validate_environment from ra_aid.env import validate_environment
from ra_aid.provider_strategy import ( from ra_aid.provider_strategy import (
ProviderFactory,
ValidationResult,
AnthropicStrategy, AnthropicStrategy,
OpenAIStrategy,
OpenAICompatibleStrategy,
OpenRouterStrategy,
GeminiStrategy, GeminiStrategy,
OpenAICompatibleStrategy,
OpenAIStrategy,
OpenRouterStrategy,
ProviderFactory,
) )

View File

@ -1,12 +1,12 @@
import pytest
from ra_aid.tool_configs import ( from ra_aid.tool_configs import (
get_implementation_tools,
get_planning_tools,
get_read_only_tools, get_read_only_tools,
get_research_tools, get_research_tools,
get_planning_tools, get_web_research_tools,
get_implementation_tools,
get_web_research_tools
) )
def test_get_read_only_tools(): def test_get_read_only_tools():
# Test without human interaction # Test without human interaction
tools = get_read_only_tools(human_interaction=False) tools = get_read_only_tools(human_interaction=False)
@ -17,6 +17,7 @@ def test_get_read_only_tools():
tools_with_human = get_read_only_tools(human_interaction=True) tools_with_human = get_read_only_tools(human_interaction=True)
assert len(tools_with_human) == len(tools) + 1 assert len(tools_with_human) == len(tools) + 1
def test_get_research_tools(): def test_get_research_tools():
# Test basic research tools # Test basic research tools
tools = get_research_tools() tools = get_research_tools()
@ -31,6 +32,7 @@ def test_get_research_tools():
tools_research_only = get_research_tools(research_only=True) tools_research_only = get_research_tools(research_only=True)
assert len(tools_research_only) < len(tools) assert len(tools_research_only) < len(tools)
def test_get_planning_tools(): def test_get_planning_tools():
# Test with expert enabled # Test with expert enabled
tools = get_planning_tools(expert_enabled=True) tools = get_planning_tools(expert_enabled=True)
@ -41,6 +43,7 @@ def test_get_planning_tools():
tools_no_expert = get_planning_tools(expert_enabled=False) tools_no_expert = get_planning_tools(expert_enabled=False)
assert len(tools_no_expert) < len(tools) assert len(tools_no_expert) < len(tools)
def test_get_implementation_tools(): def test_get_implementation_tools():
# Test with expert enabled # Test with expert enabled
tools = get_implementation_tools(expert_enabled=True) tools = get_implementation_tools(expert_enabled=True)
@ -51,6 +54,7 @@ def test_get_implementation_tools():
tools_no_expert = get_implementation_tools(expert_enabled=False) tools_no_expert = get_implementation_tools(expert_enabled=False)
assert len(tools_no_expert) < len(tools) assert len(tools_no_expert) < len(tools)
def test_get_web_research_tools(): def test_get_web_research_tools():
# Test with expert enabled # Test with expert enabled
tools = get_web_research_tools(expert_enabled=True) tools = get_web_research_tools(expert_enabled=True)
@ -59,7 +63,13 @@ def test_get_web_research_tools():
# Get tool names and verify exact matches # Get tool names and verify exact matches
tool_names = [tool.name for tool in tools] tool_names = [tool.name for tool in tools]
expected_names = ['emit_expert_context', 'ask_expert', 'web_search_tavily', 'emit_research_notes', 'task_completed'] expected_names = [
"emit_expert_context",
"ask_expert",
"web_search_tavily",
"emit_research_notes",
"task_completed",
]
assert sorted(tool_names) == sorted(expected_names) assert sorted(tool_names) == sorted(expected_names)
# Test without expert enabled # Test without expert enabled
@ -69,4 +79,6 @@ def test_get_web_research_tools():
# Verify exact tool names when expert is disabled # Verify exact tool names when expert is disabled
tool_names_no_expert = [tool.name for tool in tools_no_expert] tool_names_no_expert = [tool.name for tool in tools_no_expert]
assert sorted(tool_names_no_expert) == sorted(['web_search_tavily', 'emit_research_notes', 'task_completed']) assert sorted(tool_names_no_expert) == sorted(
["web_search_tavily", "emit_research_notes", "task_completed"]
)

View File

@ -1,6 +1,5 @@
"""Tests for utility functions.""" """Tests for utility functions."""
import pytest
from ra_aid.text.processing import truncate_output from ra_aid.text.processing import truncate_output
@ -71,7 +70,7 @@ def test_ansi_sequences():
"\033[31mRed Line 1\033[0m\n", "\033[31mRed Line 1\033[0m\n",
"\033[32mGreen Line 2\033[0m\n", "\033[32mGreen Line 2\033[0m\n",
"\033[34mBlue Line 3\033[0m\n", "\033[34mBlue Line 3\033[0m\n",
"\033[33mYellow Line 4\033[0m\n" "\033[33mYellow Line 4\033[0m\n",
] ]
input_text = "".join(input_lines) input_text = "".join(input_lines)

View File

@ -1,14 +1,15 @@
"""Tests for test execution utilities.""" """Tests for test execution utilities."""
from unittest.mock import patch
import pytest import pytest
from unittest.mock import Mock, patch
from ra_aid.tools.handle_user_defined_test_cmd_execution import execute_test_command from ra_aid.tools.handle_user_defined_test_cmd_execution import execute_test_command
# Test cases for execute_test_command # Test cases for execute_test_command
test_cases = [ test_cases = [
# Format: (name, config, original_prompt, test_attempts, auto_test, # Format: (name, config, original_prompt, test_attempts, auto_test,
# mock_responses, expected_result) # mock_responses, expected_result)
# Case 1: No test command configured # Case 1: No test command configured
( (
"no_test_command", "no_test_command",
@ -17,9 +18,8 @@ test_cases = [
0, 0,
False, False,
{}, {},
(True, "original prompt", False, 0) (True, "original prompt", False, 0),
), ),
# Case 2: User declines to run test # Case 2: User declines to run test
( (
"user_declines_test", "user_declines_test",
@ -28,9 +28,8 @@ test_cases = [
0, 0,
False, False,
{"ask_human_response": "n"}, {"ask_human_response": "n"},
(True, "original prompt", False, 0) (True, "original prompt", False, 0),
), ),
# Case 3: User enables auto-test # Case 3: User enables auto-test
( (
"user_enables_auto_test", "user_enables_auto_test",
@ -40,11 +39,10 @@ test_cases = [
False, False,
{ {
"ask_human_response": "a", "ask_human_response": "a",
"shell_cmd_result": {"success": True, "output": "All tests passed"} "shell_cmd_result": {"success": True, "output": "All tests passed"},
}, },
(True, "original prompt", True, 1) (True, "original prompt", True, 1),
), ),
# Case 4: Auto-test success # Case 4: Auto-test success
( (
"auto_test_success", "auto_test_success",
@ -53,9 +51,8 @@ test_cases = [
0, 0,
True, True,
{"shell_cmd_result": {"success": True, "output": "All tests passed"}}, {"shell_cmd_result": {"success": True, "output": "All tests passed"}},
(True, "original prompt", True, 1) (True, "original prompt", True, 1),
), ),
# Case 5: Auto-test failure with retry # Case 5: Auto-test failure with retry
( (
"auto_test_failure_retry", "auto_test_failure_retry",
@ -64,9 +61,13 @@ test_cases = [
0, 0,
True, True,
{"shell_cmd_result": {"success": False, "output": "Test failed"}}, {"shell_cmd_result": {"success": False, "output": "Test failed"}},
(False, "original prompt. Previous attempt failed with: <test_cmd_stdout>Test failed</test_cmd_stdout>", True, 1) (
False,
"original prompt. Previous attempt failed with: <test_cmd_stdout>Test failed</test_cmd_stdout>",
True,
1,
),
), ),
# Case 6: Max retries reached # Case 6: Max retries reached
( (
"max_retries_reached", "max_retries_reached",
@ -75,9 +76,8 @@ test_cases = [
3, 3,
True, True,
{}, {},
(True, "original prompt", True, 3) (True, "original prompt", True, 3),
), ),
# Case 7: User runs test manually # Case 7: User runs test manually
( (
"manual_test_success", "manual_test_success",
@ -87,11 +87,10 @@ test_cases = [
False, False,
{ {
"ask_human_response": "y", "ask_human_response": "y",
"shell_cmd_result": {"success": True, "output": "All tests passed"} "shell_cmd_result": {"success": True, "output": "All tests passed"},
}, },
(True, "original prompt", False, 1) (True, "original prompt", False, 1),
), ),
# Case 8: Manual test failure # Case 8: Manual test failure
( (
"manual_test_failure", "manual_test_failure",
@ -101,11 +100,15 @@ test_cases = [
False, False,
{ {
"ask_human_response": "y", "ask_human_response": "y",
"shell_cmd_result": {"success": False, "output": "Test failed"} "shell_cmd_result": {"success": False, "output": "Test failed"},
}, },
(False, "original prompt. Previous attempt failed with: <test_cmd_stdout>Test failed</test_cmd_stdout>", False, 1) (
False,
"original prompt. Previous attempt failed with: <test_cmd_stdout>Test failed</test_cmd_stdout>",
False,
1,
),
), ),
# Case 9: Manual test error # Case 9: Manual test error
( (
"manual_test_error", "manual_test_error",
@ -115,11 +118,10 @@ test_cases = [
False, False,
{ {
"ask_human_response": "y", "ask_human_response": "y",
"shell_cmd_result_error": Exception("Command failed") "shell_cmd_result_error": Exception("Command failed"),
}, },
(True, "original prompt", False, 1) (True, "original prompt", False, 1),
), ),
# Case 10: Auto-test error # Case 10: Auto-test error
( (
"auto_test_error", "auto_test_error",
@ -127,17 +129,16 @@ test_cases = [
"original prompt", "original prompt",
0, 0,
True, True,
{ {"shell_cmd_result_error": Exception("Command failed")},
"shell_cmd_result_error": Exception("Command failed") (True, "original prompt", True, 1),
},
(True, "original prompt", True, 1)
), ),
] ]
@pytest.mark.parametrize( @pytest.mark.parametrize(
"name,config,original_prompt,test_attempts,auto_test,mock_responses,expected", "name,config,original_prompt,test_attempts,auto_test,mock_responses,expected",
test_cases, test_cases,
ids=[case[0] for case in test_cases] ids=[case[0] for case in test_cases],
) )
def test_execute_test_command( def test_execute_test_command(
name: str, name: str,
@ -159,11 +160,20 @@ def test_execute_test_command(
mock_responses: Mock response data mock_responses: Mock response data
expected: Expected result tuple expected: Expected result tuple
""" """
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.ask_human") as mock_ask_human, \ with (
patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run_cmd, \ patch(
patch("ra_aid.tools.handle_user_defined_test_cmd_execution.console") as mock_console, \ "ra_aid.tools.handle_user_defined_test_cmd_execution.ask_human"
patch("ra_aid.tools.handle_user_defined_test_cmd_execution.logger") as mock_logger: ) as mock_ask_human,
patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command"
) as mock_run_cmd,
patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.console"
) as _mock_console,
patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.logger"
) as mock_logger,
):
# Configure mocks based on mock_responses # Configure mocks based on mock_responses
if "ask_human_response" in mock_responses: if "ask_human_response" in mock_responses:
mock_ask_human.invoke.return_value = mock_responses["ask_human_response"] mock_ask_human.invoke.return_value = mock_responses["ask_human_response"]
@ -174,12 +184,7 @@ def test_execute_test_command(
mock_run_cmd.return_value = mock_responses["shell_cmd_result"] mock_run_cmd.return_value = mock_responses["shell_cmd_result"]
# Execute test command # Execute test command
result = execute_test_command( result = execute_test_command(config, original_prompt, test_attempts, auto_test)
config,
original_prompt,
test_attempts,
auto_test
)
# Verify result matches expected # Verify result matches expected
assert result == expected, f"Test case '{name}' failed" assert result == expected, f"Test case '{name}' failed"
@ -191,28 +196,31 @@ def test_execute_test_command(
if auto_test and test_attempts < config.get("max_test_cmd_retries", 5): if auto_test and test_attempts < config.get("max_test_cmd_retries", 5):
if config.get("test_cmd"): if config.get("test_cmd"):
# Verify run_shell_command called with command and default timeout # Verify run_shell_command called with command and default timeout
mock_run_cmd.assert_called_once_with(config["test_cmd"], timeout=config.get('timeout', 30)) mock_run_cmd.assert_called_once_with(
config["test_cmd"], timeout=config.get("timeout", 30)
)
# Verify logging for max retries # Verify logging for max retries
if test_attempts >= config.get("max_test_cmd_retries", 5): if test_attempts >= config.get("max_test_cmd_retries", 5):
mock_logger.warning.assert_called_once_with("Max test retries reached") mock_logger.warning.assert_called_once_with("Max test retries reached")
def test_execute_test_command_error_handling() -> None: def test_execute_test_command_error_handling() -> None:
"""Test error handling in execute_test_command.""" """Test error handling in execute_test_command."""
config = {"test_cmd": "pytest"} config = {"test_cmd": "pytest"}
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run_cmd, \ with (
patch("ra_aid.tools.handle_user_defined_test_cmd_execution.logger") as mock_logger: patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command"
) as mock_run_cmd,
patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.logger"
) as mock_logger,
):
# Simulate run_shell_command raising an exception # Simulate run_shell_command raising an exception
mock_run_cmd.side_effect = Exception("Command failed") mock_run_cmd.side_effect = Exception("Command failed")
result = execute_test_command( result = execute_test_command(config, "original prompt", 0, True)
config,
"original prompt",
0,
True
)
# Should handle error and continue # Should handle error and continue
assert result == (True, "original prompt", True, 1) assert result == (True, "original prompt", True, 1)

View File

@ -1,8 +1,13 @@
import os from unittest.mock import patch
import pytest import pytest
from pathlib import Path
from unittest.mock import patch, mock_open from ra_aid.tools.expert import (
from ra_aid.tools.expert import read_files_with_limit, emit_expert_context, expert_context emit_expert_context,
expert_context,
read_files_with_limit,
)
@pytest.fixture @pytest.fixture
def temp_test_files(tmp_path): def temp_test_files(tmp_path):
@ -17,6 +22,7 @@ def temp_test_files(tmp_path):
return tmp_path, [file1, file2, file3] return tmp_path, [file1, file2, file3]
def test_read_files_with_limit_basic(temp_test_files): def test_read_files_with_limit_basic(temp_test_files):
"""Test basic successful reading of multiple files.""" """Test basic successful reading of multiple files."""
tmp_path, files = temp_test_files tmp_path, files = temp_test_files
@ -28,12 +34,14 @@ def test_read_files_with_limit_basic(temp_test_files):
assert str(files[0]) in result assert str(files[0]) in result
assert str(files[1]) in result assert str(files[1]) in result
def test_read_files_with_limit_empty_file(temp_test_files): def test_read_files_with_limit_empty_file(temp_test_files):
"""Test handling of empty files.""" """Test handling of empty files."""
tmp_path, files = temp_test_files tmp_path, files = temp_test_files
result = read_files_with_limit([str(files[2])]) # Empty file result = read_files_with_limit([str(files[2])]) # Empty file
assert result == "" # Empty files should be excluded from output assert result == "" # Empty files should be excluded from output
def test_read_files_with_limit_nonexistent_file(temp_test_files): def test_read_files_with_limit_nonexistent_file(temp_test_files):
"""Test handling of nonexistent files.""" """Test handling of nonexistent files."""
tmp_path, files = temp_test_files tmp_path, files = temp_test_files
@ -43,6 +51,7 @@ def test_read_files_with_limit_nonexistent_file(temp_test_files):
assert "Line 1" in result # Should contain content from existing file assert "Line 1" in result # Should contain content from existing file
assert "nonexistent.txt" not in result # Shouldn't include non-existent file assert "nonexistent.txt" not in result # Shouldn't include non-existent file
def test_read_files_with_limit_line_limit(temp_test_files): def test_read_files_with_limit_line_limit(temp_test_files):
"""Test enforcement of line limit.""" """Test enforcement of line limit."""
tmp_path, files = temp_test_files tmp_path, files = temp_test_files
@ -53,7 +62,8 @@ def test_read_files_with_limit_line_limit(temp_test_files):
assert "Line 2" in result assert "Line 2" in result
assert "File 2 Line 1" not in result # Should be truncated before reaching file 2 assert "File 2 Line 1" not in result # Should be truncated before reaching file 2
@patch('builtins.open')
@patch("builtins.open")
def test_read_files_with_limit_permission_error(mock_open_func, temp_test_files): def test_read_files_with_limit_permission_error(mock_open_func, temp_test_files):
"""Test handling of permission errors.""" """Test handling of permission errors."""
mock_open_func.side_effect = PermissionError("Permission denied") mock_open_func.side_effect = PermissionError("Permission denied")
@ -62,7 +72,8 @@ def test_read_files_with_limit_permission_error(mock_open_func, temp_test_files)
result = read_files_with_limit([str(files[0])]) result = read_files_with_limit([str(files[0])])
assert result == "" # Should return empty string on permission error assert result == "" # Should return empty string on permission error
@patch('builtins.open')
@patch("builtins.open")
def test_read_files_with_limit_io_error(mock_open_func, temp_test_files): def test_read_files_with_limit_io_error(mock_open_func, temp_test_files):
"""Test handling of IO errors.""" """Test handling of IO errors."""
mock_open_func.side_effect = IOError("IO Error") mock_open_func.side_effect = IOError("IO Error")
@ -71,35 +82,39 @@ def test_read_files_with_limit_io_error(mock_open_func, temp_test_files):
result = read_files_with_limit([str(files[0])]) result = read_files_with_limit([str(files[0])])
assert result == "" # Should return empty string on IO error assert result == "" # Should return empty string on IO error
def test_read_files_with_limit_encoding_error(temp_test_files): def test_read_files_with_limit_encoding_error(temp_test_files):
"""Test handling of encoding errors.""" """Test handling of encoding errors."""
tmp_path, files = temp_test_files tmp_path, files = temp_test_files
# Create a file with invalid UTF-8 # Create a file with invalid UTF-8
invalid_file = tmp_path / "invalid.txt" invalid_file = tmp_path / "invalid.txt"
with open(invalid_file, 'wb') as f: with open(invalid_file, "wb") as f:
f.write(b'\xFF\xFE\x00\x00') # Invalid UTF-8 f.write(b"\xff\xfe\x00\x00") # Invalid UTF-8
result = read_files_with_limit([str(invalid_file)]) result = read_files_with_limit([str(invalid_file)])
assert result == "" # Should return empty string on encoding error assert result == "" # Should return empty string on encoding error
def test_expert_context_management(): def test_expert_context_management():
"""Test expert context global state management.""" """Test expert context global state management."""
# Clear any existing context # Clear any existing context
expert_context['text'].clear() expert_context["text"].clear()
expert_context['files'].clear() expert_context["files"].clear()
# Test adding context # Test adding context
result1 = emit_expert_context.invoke("Test context 1") result1 = emit_expert_context.invoke("Test context 1")
assert "Context added" in result1 assert "Context added" in result1
assert len(expert_context['text']) == 1 assert len(expert_context["text"]) == 1
assert expert_context['text'][0] == "Test context 1" assert expert_context["text"][0] == "Test context 1"
# Test adding multiple contexts # Test adding multiple contexts
result2 = emit_expert_context.invoke("Test context 2") result2 = emit_expert_context.invoke("Test context 2")
assert "Context added" in result2 assert "Context added" in result2
assert len(expert_context['text']) == 2 assert len(expert_context["text"]) == 2
assert expert_context['text'][1] == "Test context 2" assert expert_context["text"][1] == "Test context 2"
# Test context accumulation # Test context accumulation
assert all(ctx in expert_context['text'] for ctx in ["Test context 1", "Test context 2"]) assert all(
ctx in expert_context["text"] for ctx in ["Test context 1", "Test context 2"]
)

View File

@ -1,9 +1,11 @@
import os import os
from unittest.mock import patch
import pytest import pytest
from pathlib import Path
from unittest.mock import patch, mock_open
from ra_aid.tools.file_str_replace import file_str_replace from ra_aid.tools.file_str_replace import file_str_replace
@pytest.fixture @pytest.fixture
def temp_test_dir(tmp_path): def temp_test_dir(tmp_path):
"""Create a temporary test directory.""" """Create a temporary test directory."""
@ -11,101 +13,98 @@ def temp_test_dir(tmp_path):
test_dir.mkdir(exist_ok=True) test_dir.mkdir(exist_ok=True)
return test_dir return test_dir
def test_basic_replacement(temp_test_dir): def test_basic_replacement(temp_test_dir):
"""Test basic string replacement functionality.""" """Test basic string replacement functionality."""
test_file = temp_test_dir / "test.txt" test_file = temp_test_dir / "test.txt"
initial_content = "Hello world! This is a test." initial_content = "Hello world! This is a test."
test_file.write_text(initial_content) test_file.write_text(initial_content)
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "world", "new_str": "universe"}
"old_str": "world", )
"new_str": "universe"
})
assert result["success"] is True assert result["success"] is True
assert test_file.read_text() == "Hello universe! This is a test." assert test_file.read_text() == "Hello universe! This is a test."
assert "Successfully replaced" in result["message"] assert "Successfully replaced" in result["message"]
def test_file_not_found(): def test_file_not_found():
"""Test handling of non-existent file.""" """Test handling of non-existent file."""
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": "nonexistent.txt", {"filepath": "nonexistent.txt", "old_str": "test", "new_str": "replacement"}
"old_str": "test", )
"new_str": "replacement"
})
assert result["success"] is False assert result["success"] is False
assert "File not found" in result["message"] assert "File not found" in result["message"]
def test_string_not_found(temp_test_dir): def test_string_not_found(temp_test_dir):
"""Test handling of string not present in file.""" """Test handling of string not present in file."""
test_file = temp_test_dir / "test.txt" test_file = temp_test_dir / "test.txt"
test_file.write_text("Hello world!") test_file.write_text("Hello world!")
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "nonexistent", "new_str": "replacement"}
"old_str": "nonexistent", )
"new_str": "replacement"
})
assert result["success"] is False assert result["success"] is False
assert "String not found" in result["message"] assert "String not found" in result["message"]
def test_multiple_occurrences(temp_test_dir): def test_multiple_occurrences(temp_test_dir):
"""Test handling of multiple string occurrences.""" """Test handling of multiple string occurrences."""
test_file = temp_test_dir / "test.txt" test_file = temp_test_dir / "test.txt"
test_file.write_text("test test test") test_file.write_text("test test test")
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "test", "new_str": "replacement"}
"old_str": "test", )
"new_str": "replacement"
})
assert result["success"] is False assert result["success"] is False
assert "appears" in result["message"] assert "appears" in result["message"]
assert "must be unique" in result["message"] assert "must be unique" in result["message"]
def test_empty_strings(temp_test_dir): def test_empty_strings(temp_test_dir):
"""Test handling of empty strings.""" """Test handling of empty strings."""
test_file = temp_test_dir / "test.txt" test_file = temp_test_dir / "test.txt"
test_file.write_text("Hello world!") test_file.write_text("Hello world!")
# Test empty old string # Test empty old string
result1 = file_str_replace.invoke({ result1 = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "", "new_str": "replacement"}
"old_str": "", )
"new_str": "replacement"
})
assert result1["success"] is False assert result1["success"] is False
# Test empty new string # Test empty new string
result2 = file_str_replace.invoke({ result2 = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "world", "new_str": ""}
"old_str": "world", )
"new_str": ""
})
assert result2["success"] is True assert result2["success"] is True
assert test_file.read_text() == "Hello !" assert test_file.read_text() == "Hello !"
def test_special_characters(temp_test_dir): def test_special_characters(temp_test_dir):
"""Test handling of special characters.""" """Test handling of special characters."""
test_file = temp_test_dir / "test.txt" test_file = temp_test_dir / "test.txt"
initial_content = "Hello\nworld!\t\r\nSpecial chars: $@#%" initial_content = "Hello\nworld!\t\r\nSpecial chars: $@#%"
test_file.write_text(initial_content) test_file.write_text(initial_content)
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {
"old_str": "Special chars: $@#%", "filepath": str(test_file),
"new_str": "Replaced!" "old_str": "Special chars: $@#%",
}) "new_str": "Replaced!",
}
)
assert result["success"] is True assert result["success"] is True
assert "Special chars: $@#%" not in test_file.read_text() assert "Special chars: $@#%" not in test_file.read_text()
assert "Replaced!" in test_file.read_text() assert "Replaced!" in test_file.read_text()
@patch('pathlib.Path.read_text')
@patch("pathlib.Path.read_text")
def test_io_error(mock_read_text, temp_test_dir): def test_io_error(mock_read_text, temp_test_dir):
"""Test handling of IO errors during read.""" """Test handling of IO errors during read."""
# Create and write to file first # Create and write to file first
@ -115,15 +114,14 @@ def test_io_error(mock_read_text, temp_test_dir):
# Then mock read_text to raise error # Then mock read_text to raise error
mock_read_text.side_effect = IOError("Failed to read file") mock_read_text.side_effect = IOError("Failed to read file")
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "test", "new_str": "replacement"}
"old_str": "test", )
"new_str": "replacement"
})
assert result["success"] is False assert result["success"] is False
assert "Failed to read file" in result["message"] assert "Failed to read file" in result["message"]
def test_permission_error(temp_test_dir): def test_permission_error(temp_test_dir):
"""Test handling of permission errors.""" """Test handling of permission errors."""
test_file = temp_test_dir / "readonly.txt" test_file = temp_test_dir / "readonly.txt"
@ -131,44 +129,40 @@ def test_permission_error(temp_test_dir):
os.chmod(test_file, 0o444) # Make file read-only os.chmod(test_file, 0o444) # Make file read-only
try: try:
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "test", "new_str": "replacement"}
"old_str": "test", )
"new_str": "replacement"
})
assert result["success"] is False assert result["success"] is False
assert "Permission" in result["message"] or "Error" in result["message"] assert "Permission" in result["message"] or "Error" in result["message"]
finally: finally:
os.chmod(test_file, 0o644) # Restore permissions for cleanup os.chmod(test_file, 0o644) # Restore permissions for cleanup
def test_unicode_strings(temp_test_dir): def test_unicode_strings(temp_test_dir):
"""Test handling of Unicode strings.""" """Test handling of Unicode strings."""
test_file = temp_test_dir / "unicode.txt" test_file = temp_test_dir / "unicode.txt"
initial_content = "Hello 世界! Unicode テスト" initial_content = "Hello 世界! Unicode テスト"
test_file.write_text(initial_content) test_file.write_text(initial_content)
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": "世界", "new_str": "ワールド"}
"old_str": "世界", )
"new_str": "ワールド"
})
assert result["success"] is True assert result["success"] is True
assert "世界" not in test_file.read_text() assert "世界" not in test_file.read_text()
assert "ワールド" in test_file.read_text() assert "ワールド" in test_file.read_text()
def test_long_string_truncation(temp_test_dir): def test_long_string_truncation(temp_test_dir):
"""Test handling and truncation of very long strings.""" """Test handling and truncation of very long strings."""
test_file = temp_test_dir / "test.txt" test_file = temp_test_dir / "test.txt"
long_string = "x" * 100 long_string = "x" * 100
test_file.write_text(f"prefix {long_string} suffix") test_file.write_text(f"prefix {long_string} suffix")
result = file_str_replace.invoke({ result = file_str_replace.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "old_str": long_string, "new_str": "replaced"}
"old_str": long_string, )
"new_str": "replaced"
})
assert result["success"] is True assert result["success"] is True
assert test_file.read_text() == "prefix replaced suffix" assert test_file.read_text() == "prefix replaced suffix"

View File

@ -1,9 +1,10 @@
import pytest import pytest
from pytest import mark
from git import Repo from git import Repo
from git.exc import InvalidGitRepositoryError from git.exc import InvalidGitRepositoryError
from ra_aid.tools import fuzzy_find_project_files from ra_aid.tools import fuzzy_find_project_files
@pytest.fixture @pytest.fixture
def git_repo(tmp_path): def git_repo(tmp_path):
"""Create a temporary git repository with some test files""" """Create a temporary git repository with some test files"""
@ -27,108 +28,106 @@ def git_repo(tmp_path):
return tmp_path return tmp_path
def test_basic_fuzzy_search(git_repo): def test_basic_fuzzy_search(git_repo):
"""Test basic fuzzy matching functionality""" """Test basic fuzzy matching functionality"""
results = fuzzy_find_project_files.invoke({"search_term": "utils", "repo_path": str(git_repo)}) results = fuzzy_find_project_files.invoke(
{"search_term": "utils", "repo_path": str(git_repo)}
)
assert len(results) >= 1 assert len(results) >= 1
assert any("lib/utils.py" in match[0] for match in results) assert any("lib/utils.py" in match[0] for match in results)
assert all(isinstance(match[1], int) for match in results) assert all(isinstance(match[1], int) for match in results)
def test_threshold_filtering(git_repo): def test_threshold_filtering(git_repo):
"""Test threshold parameter behavior""" """Test threshold parameter behavior"""
# Should match with high threshold # Should match with high threshold
results_high = fuzzy_find_project_files.invoke({ results_high = fuzzy_find_project_files.invoke(
"search_term": "main", {"search_term": "main", "threshold": 80, "repo_path": str(git_repo)}
"threshold": 80, )
"repo_path": str(git_repo)
})
assert len(results_high) >= 1 assert len(results_high) >= 1
assert any("main.py" in match[0] for match in results_high) assert any("main.py" in match[0] for match in results_high)
# Should not match with very high threshold # Should not match with very high threshold
results_very_high = fuzzy_find_project_files.invoke({ results_very_high = fuzzy_find_project_files.invoke(
"search_term": "mian", {"search_term": "mian", "threshold": 99, "repo_path": str(git_repo)}
"threshold": 99, )
"repo_path": str(git_repo)
})
assert len(results_very_high) == 0 assert len(results_very_high) == 0
def test_max_results_limit(git_repo): def test_max_results_limit(git_repo):
"""Test max_results parameter""" """Test max_results parameter"""
max_results = 1 max_results = 1
results = fuzzy_find_project_files.invoke({ results = fuzzy_find_project_files.invoke(
"search_term": "py", {"search_term": "py", "max_results": max_results, "repo_path": str(git_repo)}
"max_results": max_results, )
"repo_path": str(git_repo)
})
assert len(results) <= max_results assert len(results) <= max_results
def test_include_paths_filter(git_repo): def test_include_paths_filter(git_repo):
"""Test include_paths filtering""" """Test include_paths filtering"""
results = fuzzy_find_project_files.invoke({ results = fuzzy_find_project_files.invoke(
"search_term": "py", {"search_term": "py", "include_paths": ["lib/*"], "repo_path": str(git_repo)}
"include_paths": ["lib/*"], )
"repo_path": str(git_repo)
})
assert all("lib/" in match[0] for match in results) assert all("lib/" in match[0] for match in results)
def test_exclude_patterns_filter(git_repo): def test_exclude_patterns_filter(git_repo):
"""Test exclude_patterns filtering""" """Test exclude_patterns filtering"""
results = fuzzy_find_project_files.invoke({ results = fuzzy_find_project_files.invoke(
"search_term": "py", {
"exclude_patterns": ["*test*"], "search_term": "py",
"repo_path": str(git_repo) "exclude_patterns": ["*test*"],
}) "repo_path": str(git_repo),
}
)
assert not any("test" in match[0] for match in results) assert not any("test" in match[0] for match in results)
def test_invalid_threshold(): def test_invalid_threshold():
"""Test error handling for invalid threshold""" """Test error handling for invalid threshold"""
with pytest.raises(ValueError): with pytest.raises(ValueError):
fuzzy_find_project_files.invoke({ fuzzy_find_project_files.invoke({"search_term": "test", "threshold": 101})
"search_term": "test",
"threshold": 101
})
def test_non_git_repo(tmp_path): def test_non_git_repo(tmp_path):
"""Test error handling outside git repo""" """Test error handling outside git repo"""
with pytest.raises(InvalidGitRepositoryError): with pytest.raises(InvalidGitRepositoryError):
fuzzy_find_project_files.invoke({ fuzzy_find_project_files.invoke(
"search_term": "test", {"search_term": "test", "repo_path": str(tmp_path)}
"repo_path": str(tmp_path) )
})
def test_exact_match(git_repo): def test_exact_match(git_repo):
"""Test exact matching returns 100% score""" """Test exact matching returns 100% score"""
results = fuzzy_find_project_files.invoke({ results = fuzzy_find_project_files.invoke(
"search_term": "main.py", {"search_term": "main.py", "repo_path": str(git_repo)}
"repo_path": str(git_repo) )
})
assert len(results) >= 1 assert len(results) >= 1
assert any(match[1] == 100 for match in results) assert any(match[1] == 100 for match in results)
def test_empty_search_term(git_repo): def test_empty_search_term(git_repo):
"""Test behavior with empty search term""" """Test behavior with empty search term"""
results = fuzzy_find_project_files.invoke({ results = fuzzy_find_project_files.invoke(
"search_term": "", {"search_term": "", "repo_path": str(git_repo)}
"repo_path": str(git_repo) )
})
assert len(results) == 0 assert len(results) == 0
def test_untracked_files(git_repo): def test_untracked_files(git_repo):
"""Test that untracked files are included in search results""" """Test that untracked files are included in search results"""
results = fuzzy_find_project_files.invoke({ results = fuzzy_find_project_files.invoke(
"search_term": "untracked", {"search_term": "untracked", "repo_path": str(git_repo)}
"repo_path": str(git_repo) )
})
assert len(results) >= 1 assert len(results) >= 1
assert any("untracked.txt" in match[0] for match in results) assert any("untracked.txt" in match[0] for match in results)
def test_no_matches(git_repo): def test_no_matches(git_repo):
"""Test behavior when no files match the search term""" """Test behavior when no files match the search term"""
results = fuzzy_find_project_files.invoke({ results = fuzzy_find_project_files.invoke(
"search_term": "nonexistentfile", {"search_term": "nonexistentfile", "threshold": 80, "repo_path": str(git_repo)}
"threshold": 80, )
"repo_path": str(git_repo)
})
assert len(results) == 0 assert len(results) == 0

View File

@ -1,30 +1,32 @@
"""Tests for user-defined test command execution utilities.""" """Tests for user-defined test command execution utilities."""
import pytest
from unittest.mock import patch, Mock
import subprocess import subprocess
from unittest.mock import Mock, patch
import pytest
from ra_aid.tools.handle_user_defined_test_cmd_execution import ( from ra_aid.tools.handle_user_defined_test_cmd_execution import (
TestState,
TestCommandExecutor, TestCommandExecutor,
execute_test_command TestState,
execute_test_command,
) )
@pytest.fixture @pytest.fixture
def test_state(): def test_state():
"""Create a test state fixture.""" """Create a test state fixture."""
return TestState( return TestState(
prompt="test prompt", prompt="test prompt", test_attempts=0, auto_test=False, should_break=False
test_attempts=0,
auto_test=False,
should_break=False
) )
@pytest.fixture @pytest.fixture
def test_executor(): def test_executor():
"""Create a test executor fixture.""" """Create a test executor fixture."""
config = {"test_cmd": "test", "max_test_cmd_retries": 3} config = {"test_cmd": "test", "max_test_cmd_retries": 3}
return TestCommandExecutor(config, "test prompt") return TestCommandExecutor(config, "test prompt")
def test_check_max_retries(test_executor): def test_check_max_retries(test_executor):
"""Test max retries check.""" """Test max retries check."""
test_executor.state.test_attempts = 2 test_executor.state.test_attempts = 2
@ -36,6 +38,7 @@ def test_check_max_retries(test_executor):
test_executor.state.test_attempts = 4 test_executor.state.test_attempts = 4
assert test_executor.check_max_retries() assert test_executor.check_max_retries()
def test_handle_test_failure(test_executor): def test_handle_test_failure(test_executor):
"""Test handling of test failures.""" """Test handling of test failures."""
test_result = {"output": "error message"} test_result = {"output": "error message"}
@ -44,36 +47,51 @@ def test_handle_test_failure(test_executor):
assert not test_executor.state.should_break assert not test_executor.state.should_break
assert "error message" in test_executor.state.prompt assert "error message" in test_executor.state.prompt
def test_run_test_command_success(test_executor): def test_run_test_command_success(test_executor):
"""Test successful test command execution.""" """Test successful test command execution."""
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run: with patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command"
) as mock_run:
mock_run.return_value = {"success": True, "output": ""} mock_run.return_value = {"success": True, "output": ""}
test_executor.run_test_command("test", "original") test_executor.run_test_command("test", "original")
assert test_executor.state.should_break assert test_executor.state.should_break
assert test_executor.state.test_attempts == 1 assert test_executor.state.test_attempts == 1
def test_run_test_command_failure(test_executor): def test_run_test_command_failure(test_executor):
"""Test failed test command execution.""" """Test failed test command execution."""
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run: with patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command"
) as mock_run:
mock_run.return_value = {"success": False, "output": "error"} mock_run.return_value = {"success": False, "output": "error"}
test_executor.run_test_command("test", "original") test_executor.run_test_command("test", "original")
assert not test_executor.state.should_break assert not test_executor.state.should_break
assert test_executor.state.test_attempts == 1 assert test_executor.state.test_attempts == 1
assert "error" in test_executor.state.prompt assert "error" in test_executor.state.prompt
def test_run_test_command_error(test_executor): def test_run_test_command_error(test_executor):
"""Test test command execution error.""" """Test test command execution error."""
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run: with patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command"
) as mock_run:
mock_run.side_effect = Exception("Generic error") mock_run.side_effect = Exception("Generic error")
test_executor.run_test_command("test", "original") test_executor.run_test_command("test", "original")
assert test_executor.state.should_break assert test_executor.state.should_break
assert test_executor.state.test_attempts == 1 assert test_executor.state.test_attempts == 1
def test_run_test_command_timeout(test_executor): def test_run_test_command_timeout(test_executor):
"""Test test command timeout handling.""" """Test test command timeout handling."""
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run,\ with (
patch("ra_aid.tools.handle_user_defined_test_cmd_execution.logger.warning") as mock_logger: patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command"
) as mock_run,
patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.logger.warning"
) as mock_logger,
):
# Create a TimeoutExpired exception # Create a TimeoutExpired exception
timeout_exc = subprocess.TimeoutExpired(cmd="test", timeout=30) timeout_exc = subprocess.TimeoutExpired(cmd="test", timeout=30)
mock_run.side_effect = timeout_exc mock_run.side_effect = timeout_exc
@ -88,16 +106,20 @@ def test_run_test_command_timeout(test_executor):
# Verify logging # Verify logging
mock_logger.assert_called_once() mock_logger.assert_called_once()
def test_run_test_command_called_process_error(test_executor): def test_run_test_command_called_process_error(test_executor):
"""Test handling of CalledProcessError exception.""" """Test handling of CalledProcessError exception."""
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command") as mock_run,\ with (
patch("ra_aid.tools.handle_user_defined_test_cmd_execution.logger.error") as mock_logger: patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.run_shell_command"
) as mock_run,
patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.logger.error"
) as mock_logger,
):
# Create a CalledProcessError exception # Create a CalledProcessError exception
process_error = subprocess.CalledProcessError( process_error = subprocess.CalledProcessError(
returncode=1, returncode=1, cmd="test", output="Command failed output"
cmd="test",
output="Command failed output"
) )
mock_run.side_effect = process_error mock_run.side_effect = process_error
@ -111,12 +133,14 @@ def test_run_test_command_called_process_error(test_executor):
# Verify logging # Verify logging
mock_logger.assert_called_once() mock_logger.assert_called_once()
def test_handle_user_response_no(test_executor): def test_handle_user_response_no(test_executor):
"""Test handling of 'no' response.""" """Test handling of 'no' response."""
test_executor.handle_user_response("n", "test", "original") test_executor.handle_user_response("n", "test", "original")
assert test_executor.state.should_break assert test_executor.state.should_break
assert not test_executor.state.auto_test assert not test_executor.state.auto_test
def test_handle_user_response_auto(test_executor): def test_handle_user_response_auto(test_executor):
"""Test handling of 'auto' response.""" """Test handling of 'auto' response."""
with patch.object(test_executor, "run_test_command") as mock_run: with patch.object(test_executor, "run_test_command") as mock_run:
@ -124,6 +148,7 @@ def test_handle_user_response_auto(test_executor):
assert test_executor.state.auto_test assert test_executor.state.auto_test
mock_run.assert_called_once_with("test", "original") mock_run.assert_called_once_with("test", "original")
def test_handle_user_response_yes(test_executor): def test_handle_user_response_yes(test_executor):
"""Test handling of 'yes' response.""" """Test handling of 'yes' response."""
with patch.object(test_executor, "run_test_command") as mock_run: with patch.object(test_executor, "run_test_command") as mock_run:
@ -131,12 +156,14 @@ def test_handle_user_response_yes(test_executor):
assert not test_executor.state.auto_test assert not test_executor.state.auto_test
mock_run.assert_called_once_with("test", "original") mock_run.assert_called_once_with("test", "original")
def test_execute_no_cmd(): def test_execute_no_cmd():
"""Test execution with no test command.""" """Test execution with no test command."""
executor = TestCommandExecutor({}, "prompt") executor = TestCommandExecutor({}, "prompt")
result = executor.execute() result = executor.execute()
assert result == (True, "prompt", False, 0) assert result == (True, "prompt", False, 0)
def test_execute_manual(): def test_execute_manual():
"""Test manual test execution.""" """Test manual test execution."""
config = {"test_cmd": "test"} config = {"test_cmd": "test"}
@ -148,14 +175,21 @@ def test_execute_manual():
executor.state.test_attempts = 1 executor.state.test_attempts = 1
executor.state.prompt = "new prompt" executor.state.prompt = "new prompt"
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.ask_human") as mock_ask, \ with (
patch.object(executor, "handle_user_response", side_effect=mock_handle_response) as mock_handle: patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.ask_human"
) as mock_ask,
patch.object(
executor, "handle_user_response", side_effect=mock_handle_response
) as mock_handle,
):
mock_ask.invoke.return_value = "y" mock_ask.invoke.return_value = "y"
result = executor.execute() result = executor.execute()
mock_handle.assert_called_once_with("y", "test", "prompt") mock_handle.assert_called_once_with("y", "test", "prompt")
assert result == (True, "new prompt", False, 1) assert result == (True, "new prompt", False, 1)
def test_execute_auto(): def test_execute_auto():
"""Test auto test execution.""" """Test auto test execution."""
config = {"test_cmd": "test", "max_test_cmd_retries": 3} config = {"test_cmd": "test", "max_test_cmd_retries": 3}
@ -170,10 +204,13 @@ def test_execute_auto():
assert result == (True, "prompt", True, 1) assert result == (True, "prompt", True, 1)
mock_run.assert_called_once_with("test", "prompt") mock_run.assert_called_once_with("test", "prompt")
def test_execute_test_command_function(): def test_execute_test_command_function():
"""Test the execute_test_command function.""" """Test the execute_test_command function."""
config = {"test_cmd": "test"} config = {"test_cmd": "test"}
with patch("ra_aid.tools.handle_user_defined_test_cmd_execution.TestCommandExecutor") as mock_executor_class: with patch(
"ra_aid.tools.handle_user_defined_test_cmd_execution.TestCommandExecutor"
) as mock_executor_class:
mock_executor = Mock() mock_executor = Mock()
mock_executor.execute.return_value = (True, "new prompt", True, 1) mock_executor.execute.return_value = (True, "new prompt", True, 1)
mock_executor_class.return_value = mock_executor mock_executor_class.return_value = mock_executor

View File

@ -1,19 +1,22 @@
import os
import pytest
import tempfile import tempfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
import pytest
from ra_aid.tools import list_directory_tree from ra_aid.tools import list_directory_tree
from ra_aid.tools.list_directory import load_gitignore_patterns, should_ignore from ra_aid.tools.list_directory import load_gitignore_patterns, should_ignore
EXPECTED_YEAR = str(datetime.now().year) EXPECTED_YEAR = str(datetime.now().year)
@pytest.fixture @pytest.fixture
def temp_dir(): def temp_dir():
"""Create a temporary directory for testing""" """Create a temporary directory for testing"""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir) yield Path(tmpdir)
def create_test_directory_structure(path: Path): def create_test_directory_structure(path: Path):
"""Create a test directory structure""" """Create a test directory structure"""
# Create files # Create files
@ -32,15 +35,14 @@ def create_test_directory_structure(path: Path):
(subdir2 / ".git").mkdir() (subdir2 / ".git").mkdir()
(subdir2 / "__pycache__").mkdir() (subdir2 / "__pycache__").mkdir()
def test_list_directory_basic(temp_dir): def test_list_directory_basic(temp_dir):
"""Test basic directory listing functionality""" """Test basic directory listing functionality"""
create_test_directory_structure(temp_dir) create_test_directory_structure(temp_dir)
result = list_directory_tree.invoke({ result = list_directory_tree.invoke(
"path": str(temp_dir), {"path": str(temp_dir), "max_depth": 2, "follow_links": False}
"max_depth": 2, )
"follow_links": False
})
# Check basic structure # Check basic structure
assert isinstance(result, str) assert isinstance(result, str)
@ -58,50 +60,56 @@ def test_list_directory_basic(temp_dir):
assert "bytes" not in result.lower() assert "bytes" not in result.lower()
assert "2024-" not in result assert "2024-" not in result
def test_list_directory_with_details(temp_dir): def test_list_directory_with_details(temp_dir):
"""Test directory listing with file details""" """Test directory listing with file details"""
create_test_directory_structure(temp_dir) create_test_directory_structure(temp_dir)
result = list_directory_tree.invoke({ result = list_directory_tree.invoke(
"path": str(temp_dir), {
"max_depth": 2, "path": str(temp_dir),
"show_size": True, "max_depth": 2,
"show_modified": True "show_size": True,
}) "show_modified": True,
}
)
# File details should be present # File details should be present
assert "bytes" in result.lower() or "kb" in result.lower() or "b" in result.lower() assert "bytes" in result.lower() or "kb" in result.lower() or "b" in result.lower()
assert f"{EXPECTED_YEAR}-" in result assert f"{EXPECTED_YEAR}-" in result
def test_list_directory_depth_limit(temp_dir): def test_list_directory_depth_limit(temp_dir):
"""Test max_depth parameter""" """Test max_depth parameter"""
create_test_directory_structure(temp_dir) create_test_directory_structure(temp_dir)
# Test with depth 1 (default) # Test with depth 1 (default)
result = list_directory_tree.invoke({ result = list_directory_tree.invoke(
"path": str(temp_dir) # Use defaults {
}) "path": str(temp_dir) # Use defaults
}
)
assert isinstance(result, str) assert isinstance(result, str)
assert "subdir1" in result # Directory name should be visible assert "subdir1" in result # Directory name should be visible
assert "subfile1.txt" not in result # But not its contents assert "subfile1.txt" not in result # But not its contents
assert "subfile2.py" not in result assert "subfile2.py" not in result
def test_list_directory_ignore_patterns(temp_dir): def test_list_directory_ignore_patterns(temp_dir):
"""Test exclude patterns""" """Test exclude patterns"""
create_test_directory_structure(temp_dir) create_test_directory_structure(temp_dir)
result = list_directory_tree.invoke({ result = list_directory_tree.invoke(
"path": str(temp_dir), {"path": str(temp_dir), "max_depth": 2, "exclude_patterns": ["*.py"]}
"max_depth": 2, )
"exclude_patterns": ["*.py"]
})
assert isinstance(result, str) assert isinstance(result, str)
assert "file1.txt" in result assert "file1.txt" in result
assert "file2.py" not in result assert "file2.py" not in result
assert "subfile2.py" not in result assert "subfile2.py" not in result
def test_gitignore_patterns(): def test_gitignore_patterns():
"""Test gitignore pattern loading and matching""" """Test gitignore pattern loading and matching"""
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
@ -117,10 +125,13 @@ def test_gitignore_patterns():
assert should_ignore("test.txt", spec) is False assert should_ignore("test.txt", spec) is False
assert should_ignore("dir/test.log", spec) is True assert should_ignore("dir/test.log", spec) is True
def test_invalid_path(): def test_invalid_path():
"""Test error handling for invalid paths""" """Test error handling for invalid paths"""
with pytest.raises(ValueError, match="Path does not exist"): with pytest.raises(ValueError, match="Path does not exist"):
list_directory_tree.invoke({"path": "/nonexistent/path"}) list_directory_tree.invoke({"path": "/nonexistent/path"})
with pytest.raises(ValueError, match="Path is not a directory"): with pytest.raises(ValueError, match="Path is not a directory"):
list_directory_tree.invoke({"path": __file__}) # Try to list the test file itself list_directory_tree.invoke(
{"path": __file__}
) # Try to list the test file itself

View File

@ -1,57 +1,61 @@
import pytest import pytest
from ra_aid.tools.memory import ( from ra_aid.tools.memory import (
_global_memory, _global_memory,
get_memory_value,
emit_key_facts,
delete_key_facts, delete_key_facts,
emit_key_snippets,
delete_key_snippets, delete_key_snippets,
emit_related_files,
get_related_files,
deregister_related_files,
emit_task,
delete_tasks, delete_tasks,
swap_task_order, deregister_related_files,
emit_key_facts,
emit_key_snippets,
emit_related_files,
emit_task,
get_memory_value,
get_related_files,
get_work_log,
log_work_event, log_work_event,
reset_work_log, reset_work_log,
get_work_log swap_task_order,
) )
@pytest.fixture @pytest.fixture
def reset_memory(): def reset_memory():
"""Reset global memory before each test""" """Reset global memory before each test"""
_global_memory['key_facts'] = {} _global_memory["key_facts"] = {}
_global_memory['key_fact_id_counter'] = 0 _global_memory["key_fact_id_counter"] = 0
_global_memory['key_snippets'] = {} _global_memory["key_snippets"] = {}
_global_memory['key_snippet_id_counter'] = 0 _global_memory["key_snippet_id_counter"] = 0
_global_memory['research_notes'] = [] _global_memory["research_notes"] = []
_global_memory['plans'] = [] _global_memory["plans"] = []
_global_memory['tasks'] = {} _global_memory["tasks"] = {}
_global_memory['task_id_counter'] = 0 _global_memory["task_id_counter"] = 0
_global_memory['related_files'] = {} _global_memory["related_files"] = {}
_global_memory['related_file_id_counter'] = 0 _global_memory["related_file_id_counter"] = 0
_global_memory['work_log'] = [] _global_memory["work_log"] = []
yield yield
# Clean up after test # Clean up after test
_global_memory['key_facts'] = {} _global_memory["key_facts"] = {}
_global_memory['key_fact_id_counter'] = 0 _global_memory["key_fact_id_counter"] = 0
_global_memory['key_snippets'] = {} _global_memory["key_snippets"] = {}
_global_memory['key_snippet_id_counter'] = 0 _global_memory["key_snippet_id_counter"] = 0
_global_memory['research_notes'] = [] _global_memory["research_notes"] = []
_global_memory['plans'] = [] _global_memory["plans"] = []
_global_memory['tasks'] = {} _global_memory["tasks"] = {}
_global_memory['task_id_counter'] = 0 _global_memory["task_id_counter"] = 0
_global_memory['related_files'] = {} _global_memory["related_files"] = {}
_global_memory['related_file_id_counter'] = 0 _global_memory["related_file_id_counter"] = 0
_global_memory['work_log'] = [] _global_memory["work_log"] = []
def test_emit_key_facts_single_fact(reset_memory): def test_emit_key_facts_single_fact(reset_memory):
"""Test emitting a single key fact using emit_key_facts""" """Test emitting a single key fact using emit_key_facts"""
# Test with single fact # Test with single fact
result = emit_key_facts.invoke({"facts": ["First fact"]}) result = emit_key_facts.invoke({"facts": ["First fact"]})
assert result == "Facts stored." assert result == "Facts stored."
assert _global_memory['key_facts'][0] == "First fact" assert _global_memory["key_facts"][0] == "First fact"
assert _global_memory['key_fact_id_counter'] == 1 assert _global_memory["key_fact_id_counter"] == 1
def test_delete_key_facts_single_fact(reset_memory): def test_delete_key_facts_single_fact(reset_memory):
"""Test deleting a single key fact using delete_key_facts""" """Test deleting a single key fact using delete_key_facts"""
@ -61,7 +65,8 @@ def test_delete_key_facts_single_fact(reset_memory):
# Delete the fact # Delete the fact
result = delete_key_facts.invoke({"fact_ids": [0]}) result = delete_key_facts.invoke({"fact_ids": [0]})
assert result == "Facts deleted." assert result == "Facts deleted."
assert 0 not in _global_memory['key_facts'] assert 0 not in _global_memory["key_facts"]
def test_delete_key_facts_invalid(reset_memory): def test_delete_key_facts_invalid(reset_memory):
"""Test deleting non-existent facts returns empty list""" """Test deleting non-existent facts returns empty list"""
@ -75,31 +80,34 @@ def test_delete_key_facts_invalid(reset_memory):
result = delete_key_facts.invoke({"fact_ids": [0]}) result = delete_key_facts.invoke({"fact_ids": [0]})
assert result == "Facts deleted." assert result == "Facts deleted."
def test_get_memory_value_key_facts(reset_memory): def test_get_memory_value_key_facts(reset_memory):
"""Test get_memory_value with key facts dictionary""" """Test get_memory_value with key facts dictionary"""
# Empty key facts should return empty string # Empty key facts should return empty string
assert get_memory_value('key_facts') == "" assert get_memory_value("key_facts") == ""
# Add some facts # Add some facts
emit_key_facts.invoke({"facts": ["First fact", "Second fact"]}) emit_key_facts.invoke({"facts": ["First fact", "Second fact"]})
# Should return markdown formatted list # Should return markdown formatted list
expected = "## 🔑 Key Fact #0\n\nFirst fact\n\n## 🔑 Key Fact #1\n\nSecond fact" expected = "## 🔑 Key Fact #0\n\nFirst fact\n\n## 🔑 Key Fact #1\n\nSecond fact"
assert get_memory_value('key_facts') == expected assert get_memory_value("key_facts") == expected
def test_get_memory_value_other_types(reset_memory): def test_get_memory_value_other_types(reset_memory):
"""Test get_memory_value remains compatible with other memory types""" """Test get_memory_value remains compatible with other memory types"""
# Add some research notes # Add some research notes
_global_memory['research_notes'].append("Note 1") _global_memory["research_notes"].append("Note 1")
_global_memory['research_notes'].append("Note 2") _global_memory["research_notes"].append("Note 2")
assert get_memory_value('research_notes') == "Note 1\nNote 2" assert get_memory_value("research_notes") == "Note 1\nNote 2"
# Test with empty list # Test with empty list
assert get_memory_value('plans') == "" assert get_memory_value("plans") == ""
# Test with non-existent key # Test with non-existent key
assert get_memory_value('nonexistent') == "" assert get_memory_value("nonexistent") == ""
def test_log_work_event(reset_memory): def test_log_work_event(reset_memory):
"""Test logging work events with timestamps""" """Test logging work events with timestamps"""
@ -109,16 +117,17 @@ def test_log_work_event(reset_memory):
log_work_event("Completed task") log_work_event("Completed task")
# Verify events are stored # Verify events are stored
assert len(_global_memory['work_log']) == 3 assert len(_global_memory["work_log"]) == 3
# Check event structure # Check event structure
event = _global_memory['work_log'][0] event = _global_memory["work_log"][0]
assert isinstance(event['timestamp'], str) assert isinstance(event["timestamp"], str)
assert event['event'] == "Started task" assert event["event"] == "Started task"
# Verify order # Verify order
assert _global_memory['work_log'][1]['event'] == "Made progress" assert _global_memory["work_log"][1]["event"] == "Made progress"
assert _global_memory['work_log'][2]['event'] == "Completed task" assert _global_memory["work_log"][2]["event"] == "Completed task"
def test_get_work_log(reset_memory): def test_get_work_log(reset_memory):
"""Test work log formatting with heading-based markdown""" """Test work log formatting with heading-based markdown"""
@ -135,23 +144,26 @@ def test_get_work_log(reset_memory):
assert "First event" in log assert "First event" in log
assert "Second event" in log assert "Second event" in log
def test_reset_work_log(reset_memory): def test_reset_work_log(reset_memory):
"""Test resetting the work log""" """Test resetting the work log"""
# Add some events # Add some events
log_work_event("Test event") log_work_event("Test event")
assert len(_global_memory['work_log']) == 1 assert len(_global_memory["work_log"]) == 1
# Reset log # Reset log
reset_work_log() reset_work_log()
# Verify log is empty # Verify log is empty
assert len(_global_memory['work_log']) == 0 assert len(_global_memory["work_log"]) == 0
assert get_memory_value('work_log') == "" assert get_memory_value("work_log") == ""
def test_empty_work_log(reset_memory): def test_empty_work_log(reset_memory):
"""Test empty work log behavior""" """Test empty work log behavior"""
# Fresh work log should return empty string # Fresh work log should return empty string
assert get_memory_value('work_log') == "" assert get_memory_value("work_log") == ""
def test_emit_key_facts(reset_memory): def test_emit_key_facts(reset_memory):
"""Test emitting multiple key facts at once""" """Test emitting multiple key facts at once"""
@ -163,12 +175,13 @@ def test_emit_key_facts(reset_memory):
assert result == "Facts stored." assert result == "Facts stored."
# Verify facts stored in memory with correct IDs # Verify facts stored in memory with correct IDs
assert _global_memory['key_facts'][0] == "First fact" assert _global_memory["key_facts"][0] == "First fact"
assert _global_memory['key_facts'][1] == "Second fact" assert _global_memory["key_facts"][1] == "Second fact"
assert _global_memory['key_facts'][2] == "Third fact" assert _global_memory["key_facts"][2] == "Third fact"
# Verify counter incremented correctly # Verify counter incremented correctly
assert _global_memory['key_fact_id_counter'] == 3 assert _global_memory["key_fact_id_counter"] == 3
def test_delete_key_facts(reset_memory): def test_delete_key_facts(reset_memory):
"""Test deleting multiple key facts""" """Test deleting multiple key facts"""
@ -182,10 +195,11 @@ def test_delete_key_facts(reset_memory):
assert result == "Facts deleted." assert result == "Facts deleted."
# Verify correct facts removed from memory # Verify correct facts removed from memory
assert 0 not in _global_memory['key_facts'] assert 0 not in _global_memory["key_facts"]
assert 1 not in _global_memory['key_facts'] assert 1 not in _global_memory["key_facts"]
assert 2 in _global_memory['key_facts'] # ID 2 should remain assert 2 in _global_memory["key_facts"] # ID 2 should remain
assert _global_memory['key_facts'][2] == "Third fact" assert _global_memory["key_facts"][2] == "Third fact"
def test_emit_key_snippets(reset_memory): def test_emit_key_snippets(reset_memory):
"""Test emitting multiple code snippets at once""" """Test emitting multiple code snippets at once"""
@ -195,14 +209,14 @@ def test_emit_key_snippets(reset_memory):
"filepath": "test.py", "filepath": "test.py",
"line_number": 10, "line_number": 10,
"snippet": "def test():\n pass", "snippet": "def test():\n pass",
"description": "Test function" "description": "Test function",
}, },
{ {
"filepath": "main.py", "filepath": "main.py",
"line_number": 20, "line_number": 20,
"snippet": "print('hello')", "snippet": "print('hello')",
"description": None "description": None,
} },
] ]
# Emit snippets # Emit snippets
@ -212,11 +226,12 @@ def test_emit_key_snippets(reset_memory):
assert result == "Snippets stored." assert result == "Snippets stored."
# Verify snippets stored correctly # Verify snippets stored correctly
assert _global_memory['key_snippets'][0] == snippets[0] assert _global_memory["key_snippets"][0] == snippets[0]
assert _global_memory['key_snippets'][1] == snippets[1] assert _global_memory["key_snippets"][1] == snippets[1]
# Verify counter incremented correctly # Verify counter incremented correctly
assert _global_memory['key_snippet_id_counter'] == 2 assert _global_memory["key_snippet_id_counter"] == 2
def test_delete_key_snippets(reset_memory): def test_delete_key_snippets(reset_memory):
"""Test deleting multiple code snippets""" """Test deleting multiple code snippets"""
@ -226,20 +241,20 @@ def test_delete_key_snippets(reset_memory):
"filepath": "test1.py", "filepath": "test1.py",
"line_number": 1, "line_number": 1,
"snippet": "code1", "snippet": "code1",
"description": None "description": None,
}, },
{ {
"filepath": "test2.py", "filepath": "test2.py",
"line_number": 2, "line_number": 2,
"snippet": "code2", "snippet": "code2",
"description": None "description": None,
}, },
{ {
"filepath": "test3.py", "filepath": "test3.py",
"line_number": 3, "line_number": 3,
"snippet": "code3", "snippet": "code3",
"description": None "description": None,
} },
] ]
emit_key_snippets.invoke({"snippets": snippets}) emit_key_snippets.invoke({"snippets": snippets})
@ -250,10 +265,11 @@ def test_delete_key_snippets(reset_memory):
assert result == "Snippets deleted." assert result == "Snippets deleted."
# Verify correct snippets removed # Verify correct snippets removed
assert 0 not in _global_memory['key_snippets'] assert 0 not in _global_memory["key_snippets"]
assert 1 not in _global_memory['key_snippets'] assert 1 not in _global_memory["key_snippets"]
assert 2 in _global_memory['key_snippets'] assert 2 in _global_memory["key_snippets"]
assert _global_memory['key_snippets'][2]['filepath'] == "test3.py" assert _global_memory["key_snippets"][2]["filepath"] == "test3.py"
def test_delete_key_snippets_empty(reset_memory): def test_delete_key_snippets_empty(reset_memory):
"""Test deleting snippets with empty ID list""" """Test deleting snippets with empty ID list"""
@ -262,7 +278,7 @@ def test_delete_key_snippets_empty(reset_memory):
"filepath": "test.py", "filepath": "test.py",
"line_number": 1, "line_number": 1,
"snippet": "code", "snippet": "code",
"description": None "description": None,
} }
emit_key_snippets.invoke({"snippets": [snippet]}) emit_key_snippets.invoke({"snippets": [snippet]})
@ -271,7 +287,8 @@ def test_delete_key_snippets_empty(reset_memory):
assert result == "Snippets deleted." assert result == "Snippets deleted."
# Verify snippet still exists # Verify snippet still exists
assert 0 in _global_memory['key_snippets'] assert 0 in _global_memory["key_snippets"]
def test_emit_related_files_basic(reset_memory, tmp_path): def test_emit_related_files_basic(reset_memory, tmp_path):
"""Test basic adding of files with ID tracking""" """Test basic adding of files with ID tracking"""
@ -286,20 +303,22 @@ def test_emit_related_files_basic(reset_memory, tmp_path):
# Test adding single file # Test adding single file
result = emit_related_files.invoke({"files": [str(test_file)]}) result = emit_related_files.invoke({"files": [str(test_file)]})
assert result == f"File ID #0: {test_file}" assert result == f"File ID #0: {test_file}"
assert _global_memory['related_files'][0] == str(test_file) assert _global_memory["related_files"][0] == str(test_file)
# Test adding multiple files # Test adding multiple files
result = emit_related_files.invoke({"files": [str(main_file), str(utils_file)]}) result = emit_related_files.invoke({"files": [str(main_file), str(utils_file)]})
assert result == f"File ID #1: {main_file}\nFile ID #2: {utils_file}" assert result == f"File ID #1: {main_file}\nFile ID #2: {utils_file}"
# Verify both files exist in related_files # Verify both files exist in related_files
values = list(_global_memory['related_files'].values()) values = list(_global_memory["related_files"].values())
assert str(main_file) in values assert str(main_file) in values
assert str(utils_file) in values assert str(utils_file) in values
def test_get_related_files_empty(reset_memory): def test_get_related_files_empty(reset_memory):
"""Test getting related files when none added""" """Test getting related files when none added"""
assert get_related_files() == [] assert get_related_files() == []
def test_emit_related_files_duplicates(reset_memory, tmp_path): def test_emit_related_files_duplicates(reset_memory, tmp_path):
"""Test that duplicate files return existing IDs with proper formatting""" """Test that duplicate files return existing IDs with proper formatting"""
# Create test files # Create test files
@ -313,17 +332,18 @@ def test_emit_related_files_duplicates(reset_memory, tmp_path):
# Add initial files # Add initial files
result = emit_related_files.invoke({"files": [str(test_file), str(main_file)]}) result = emit_related_files.invoke({"files": [str(test_file), str(main_file)]})
assert result == f"File ID #0: {test_file}\nFile ID #1: {main_file}" assert result == f"File ID #0: {test_file}\nFile ID #1: {main_file}"
first_id = 0 # ID of test.py _first_id = 0 # ID of test.py
# Try adding duplicates # Try adding duplicates
result = emit_related_files.invoke({"files": [str(test_file)]}) result = emit_related_files.invoke({"files": [str(test_file)]})
assert result == f"File ID #0: {test_file}" # Should return same ID assert result == f"File ID #0: {test_file}" # Should return same ID
assert len(_global_memory['related_files']) == 2 # Count should not increase assert len(_global_memory["related_files"]) == 2 # Count should not increase
# Try mix of new and duplicate files # Try mix of new and duplicate files
result = emit_related_files.invoke({"files": [str(test_file), str(new_file)]}) result = emit_related_files.invoke({"files": [str(test_file), str(new_file)]})
assert result == f"File ID #0: {test_file}\nFile ID #2: {new_file}" assert result == f"File ID #0: {test_file}\nFile ID #2: {new_file}"
assert len(_global_memory['related_files']) == 3 assert len(_global_memory["related_files"]) == 3
def test_related_files_id_tracking(reset_memory, tmp_path): def test_related_files_id_tracking(reset_memory, tmp_path):
"""Test ID assignment and counter functionality for related files""" """Test ID assignment and counter functionality for related files"""
@ -336,16 +356,17 @@ def test_related_files_id_tracking(reset_memory, tmp_path):
# Add first file # Add first file
result = emit_related_files.invoke({"files": [str(file1)]}) result = emit_related_files.invoke({"files": [str(file1)]})
assert result == f"File ID #0: {file1}" assert result == f"File ID #0: {file1}"
assert _global_memory['related_file_id_counter'] == 1 assert _global_memory["related_file_id_counter"] == 1
# Add second file # Add second file
result = emit_related_files.invoke({"files": [str(file2)]}) result = emit_related_files.invoke({"files": [str(file2)]})
assert result == f"File ID #1: {file2}" assert result == f"File ID #1: {file2}"
assert _global_memory['related_file_id_counter'] == 2 assert _global_memory["related_file_id_counter"] == 2
# Verify all files stored correctly # Verify all files stored correctly
assert _global_memory['related_files'][0] == str(file1) assert _global_memory["related_files"][0] == str(file1)
assert _global_memory['related_files'][1] == str(file2) assert _global_memory["related_files"][1] == str(file2)
def test_deregister_related_files(reset_memory, tmp_path): def test_deregister_related_files(reset_memory, tmp_path):
"""Test deleting related files""" """Test deleting related files"""
@ -363,16 +384,17 @@ def test_deregister_related_files(reset_memory, tmp_path):
# Delete middle file # Delete middle file
result = deregister_related_files.invoke({"file_ids": [1]}) result = deregister_related_files.invoke({"file_ids": [1]})
assert result == "File references removed." assert result == "File references removed."
assert 1 not in _global_memory['related_files'] assert 1 not in _global_memory["related_files"]
assert len(_global_memory['related_files']) == 2 assert len(_global_memory["related_files"]) == 2
# Delete multiple files including non-existent ID # Delete multiple files including non-existent ID
result = deregister_related_files.invoke({"file_ids": [0, 2, 999]}) result = deregister_related_files.invoke({"file_ids": [0, 2, 999]})
assert result == "File references removed." assert result == "File references removed."
assert len(_global_memory['related_files']) == 0 assert len(_global_memory["related_files"]) == 0
# Counter should remain unchanged after deletions # Counter should remain unchanged after deletions
assert _global_memory['related_file_id_counter'] == 3 assert _global_memory["related_file_id_counter"] == 3
def test_related_files_duplicates(reset_memory, tmp_path): def test_related_files_duplicates(reset_memory, tmp_path):
"""Test duplicate file handling returns same ID""" """Test duplicate file handling returns same ID"""
@ -389,8 +411,9 @@ def test_related_files_duplicates(reset_memory, tmp_path):
assert result2 == f"File ID #0: {test_file}" assert result2 == f"File ID #0: {test_file}"
# Verify only one entry exists # Verify only one entry exists
assert len(_global_memory['related_files']) == 1 assert len(_global_memory["related_files"]) == 1
assert _global_memory['related_file_id_counter'] == 1 assert _global_memory["related_file_id_counter"] == 1
def test_emit_related_files_with_directory(reset_memory, tmp_path): def test_emit_related_files_with_directory(reset_memory, tmp_path):
"""Test that directories and non-existent paths are rejected while valid files are added""" """Test that directories and non-existent paths are rejected while valid files are added"""
@ -402,22 +425,23 @@ def test_emit_related_files_with_directory(reset_memory, tmp_path):
nonexistent = tmp_path / "does_not_exist.txt" nonexistent = tmp_path / "does_not_exist.txt"
# Try to emit directory, nonexistent path, and valid file # Try to emit directory, nonexistent path, and valid file
result = emit_related_files.invoke({ result = emit_related_files.invoke(
"files": [str(test_dir), str(nonexistent), str(test_file)] {"files": [str(test_dir), str(nonexistent), str(test_file)]}
}) )
# Verify specific error messages for directory and nonexistent path # Verify specific error messages for directory and nonexistent path
assert f"Error: Path '{test_dir}' is a directory, not a file" in result assert f"Error: Path '{test_dir}' is a directory, not a file" in result
assert f"Error: Path '{nonexistent}' does not exist" in result assert f"Error: Path '{nonexistent}' does not exist" in result
# Verify directory and nonexistent not added but valid file was # Verify directory and nonexistent not added but valid file was
assert len(_global_memory['related_files']) == 1 assert len(_global_memory["related_files"]) == 1
values = list(_global_memory['related_files'].values()) values = list(_global_memory["related_files"].values())
assert str(test_file) in values assert str(test_file) in values
assert str(test_dir) not in values assert str(test_dir) not in values
assert str(nonexistent) not in values assert str(nonexistent) not in values
assert str(nonexistent) not in values assert str(nonexistent) not in values
def test_related_files_formatting(reset_memory, tmp_path): def test_related_files_formatting(reset_memory, tmp_path):
"""Test related files output string formatting""" """Test related files output string formatting"""
# Create test files # Create test files
@ -430,14 +454,15 @@ def test_related_files_formatting(reset_memory, tmp_path):
emit_related_files.invoke({"files": [str(file1), str(file2)]}) emit_related_files.invoke({"files": [str(file1), str(file2)]})
# Get formatted output # Get formatted output
output = get_memory_value('related_files') output = get_memory_value("related_files")
# Expect just the IDs on separate lines # Expect just the IDs on separate lines
expected = "0\n1" expected = "0\n1"
assert output == expected assert output == expected
# Test empty case # Test empty case
_global_memory['related_files'] = {} _global_memory["related_files"] = {}
assert get_memory_value('related_files') == "" assert get_memory_value("related_files") == ""
def test_key_snippets_integration(reset_memory, tmp_path): def test_key_snippets_integration(reset_memory, tmp_path):
"""Integration test for key snippets functionality""" """Integration test for key snippets functionality"""
@ -455,51 +480,51 @@ def test_key_snippets_integration(reset_memory, tmp_path):
"filepath": str(file1), "filepath": str(file1),
"line_number": 10, "line_number": 10,
"snippet": "def func1():\n pass", "snippet": "def func1():\n pass",
"description": "First function" "description": "First function",
}, },
{ {
"filepath": str(file2), "filepath": str(file2),
"line_number": 20, "line_number": 20,
"snippet": "def func2():\n return True", "snippet": "def func2():\n return True",
"description": "Second function" "description": "Second function",
}, },
{ {
"filepath": str(file3), "filepath": str(file3),
"line_number": 30, "line_number": 30,
"snippet": "class TestClass:\n pass", "snippet": "class TestClass:\n pass",
"description": "Test class" "description": "Test class",
} },
] ]
# Add all snippets # Add all snippets
result = emit_key_snippets.invoke({"snippets": snippets}) result = emit_key_snippets.invoke({"snippets": snippets})
assert result == "Snippets stored." assert result == "Snippets stored."
assert _global_memory['key_snippet_id_counter'] == 3 assert _global_memory["key_snippet_id_counter"] == 3
# Verify related files were tracked with IDs # Verify related files were tracked with IDs
assert len(_global_memory['related_files']) == 3 assert len(_global_memory["related_files"]) == 3
# Check files are stored with proper IDs # Check files are stored with proper IDs
file_values = _global_memory['related_files'].values() file_values = _global_memory["related_files"].values()
assert str(file1) in file_values assert str(file1) in file_values
assert str(file2) in file_values assert str(file2) in file_values
assert str(file3) in file_values assert str(file3) in file_values
# Verify all snippets were stored correctly # Verify all snippets were stored correctly
assert len(_global_memory['key_snippets']) == 3 assert len(_global_memory["key_snippets"]) == 3
assert _global_memory['key_snippets'][0] == snippets[0] assert _global_memory["key_snippets"][0] == snippets[0]
assert _global_memory['key_snippets'][1] == snippets[1] assert _global_memory["key_snippets"][1] == snippets[1]
assert _global_memory['key_snippets'][2] == snippets[2] assert _global_memory["key_snippets"][2] == snippets[2]
# Delete some but not all snippets (0 and 2) # Delete some but not all snippets (0 and 2)
result = delete_key_snippets.invoke({"snippet_ids": [0, 2]}) result = delete_key_snippets.invoke({"snippet_ids": [0, 2]})
assert result == "Snippets deleted." assert result == "Snippets deleted."
# Verify remaining snippet is intact # Verify remaining snippet is intact
assert len(_global_memory['key_snippets']) == 1 assert len(_global_memory["key_snippets"]) == 1
assert 1 in _global_memory['key_snippets'] assert 1 in _global_memory["key_snippets"]
assert _global_memory['key_snippets'][1] == snippets[1] assert _global_memory["key_snippets"][1] == snippets[1]
# Counter should remain unchanged after deletions # Counter should remain unchanged after deletions
assert _global_memory['key_snippet_id_counter'] == 3 assert _global_memory["key_snippet_id_counter"] == 3
# Add new snippet to verify counter continues correctly # Add new snippet to verify counter continues correctly
file4 = tmp_path / "file4.py" file4 = tmp_path / "file4.py"
@ -508,25 +533,26 @@ def test_key_snippets_integration(reset_memory, tmp_path):
"filepath": str(file4), "filepath": str(file4),
"line_number": 40, "line_number": 40,
"snippet": "def func4():\n return False", "snippet": "def func4():\n return False",
"description": "Fourth function" "description": "Fourth function",
} }
result = emit_key_snippets.invoke({"snippets": [new_snippet]}) result = emit_key_snippets.invoke({"snippets": [new_snippet]})
assert result == "Snippets stored." assert result == "Snippets stored."
assert _global_memory['key_snippet_id_counter'] == 4 assert _global_memory["key_snippet_id_counter"] == 4
# Verify new file was added to related files # Verify new file was added to related files
file_values = _global_memory['related_files'].values() file_values = _global_memory["related_files"].values()
assert str(file4) in file_values assert str(file4) in file_values
assert len(_global_memory['related_files']) == 4 assert len(_global_memory["related_files"]) == 4
# Delete remaining snippets # Delete remaining snippets
result = delete_key_snippets.invoke({"snippet_ids": [1, 3]}) result = delete_key_snippets.invoke({"snippet_ids": [1, 3]})
assert result == "Snippets deleted." assert result == "Snippets deleted."
# Verify all snippets are gone # Verify all snippets are gone
assert len(_global_memory['key_snippets']) == 0 assert len(_global_memory["key_snippets"]) == 0
# Counter should still maintain its value # Counter should still maintain its value
assert _global_memory['key_snippet_id_counter'] == 4 assert _global_memory["key_snippet_id_counter"] == 4
def test_emit_task_with_id(reset_memory): def test_emit_task_with_id(reset_memory):
"""Test emitting tasks with ID tracking""" """Test emitting tasks with ID tracking"""
@ -538,17 +564,18 @@ def test_emit_task_with_id(reset_memory):
assert result == "Task #0 stored." assert result == "Task #0 stored."
# Verify task stored correctly with ID # Verify task stored correctly with ID
assert _global_memory['tasks'][0] == task assert _global_memory["tasks"][0] == task
# Verify counter incremented # Verify counter incremented
assert _global_memory['task_id_counter'] == 1 assert _global_memory["task_id_counter"] == 1
# Add another task to verify counter continues correctly # Add another task to verify counter continues correctly
task2 = "Fix bug" task2 = "Fix bug"
result = emit_task.invoke({"task": task2}) result = emit_task.invoke({"task": task2})
assert result == "Task #1 stored." assert result == "Task #1 stored."
assert _global_memory['tasks'][1] == task2 assert _global_memory["tasks"][1] == task2
assert _global_memory['task_id_counter'] == 2 assert _global_memory["task_id_counter"] == 2
def test_delete_tasks(reset_memory): def test_delete_tasks(reset_memory):
"""Test deleting tasks""" """Test deleting tasks"""
@ -560,20 +587,21 @@ def test_delete_tasks(reset_memory):
# Test deleting single task # Test deleting single task
result = delete_tasks.invoke({"task_ids": [1]}) result = delete_tasks.invoke({"task_ids": [1]})
assert result == "Tasks deleted." assert result == "Tasks deleted."
assert 1 not in _global_memory['tasks'] assert 1 not in _global_memory["tasks"]
assert len(_global_memory['tasks']) == 2 assert len(_global_memory["tasks"]) == 2
# Test deleting multiple tasks including non-existent ID # Test deleting multiple tasks including non-existent ID
result = delete_tasks.invoke({"task_ids": [0, 2, 999]}) result = delete_tasks.invoke({"task_ids": [0, 2, 999]})
assert result == "Tasks deleted." assert result == "Tasks deleted."
assert len(_global_memory['tasks']) == 0 assert len(_global_memory["tasks"]) == 0
# Test deleting from empty tasks dict # Test deleting from empty tasks dict
result = delete_tasks.invoke({"task_ids": [0]}) result = delete_tasks.invoke({"task_ids": [0]})
assert result == "Tasks deleted." assert result == "Tasks deleted."
# Counter should remain unchanged after deletions # Counter should remain unchanged after deletions
assert _global_memory['task_id_counter'] == 3 assert _global_memory["task_id_counter"] == 3
def test_swap_task_order_valid_ids(reset_memory): def test_swap_task_order_valid_ids(reset_memory):
"""Test basic task swapping functionality""" """Test basic task swapping functionality"""
@ -587,9 +615,10 @@ def test_swap_task_order_valid_ids(reset_memory):
assert result == "Tasks swapped." assert result == "Tasks swapped."
# Verify tasks were swapped # Verify tasks were swapped
assert _global_memory['tasks'][0] == "Task 3" assert _global_memory["tasks"][0] == "Task 3"
assert _global_memory['tasks'][2] == "Task 1" assert _global_memory["tasks"][2] == "Task 1"
assert _global_memory['tasks'][1] == "Task 2" # Unchanged assert _global_memory["tasks"][1] == "Task 2" # Unchanged
def test_swap_task_order_invalid_ids(reset_memory): def test_swap_task_order_invalid_ids(reset_memory):
"""Test error handling for invalid task IDs""" """Test error handling for invalid task IDs"""
@ -601,7 +630,8 @@ def test_swap_task_order_invalid_ids(reset_memory):
assert result == "Invalid task ID(s)" assert result == "Invalid task ID(s)"
# Verify original task unchanged # Verify original task unchanged
assert _global_memory['tasks'][0] == "Task 1" assert _global_memory["tasks"][0] == "Task 1"
def test_swap_task_order_same_id(reset_memory): def test_swap_task_order_same_id(reset_memory):
"""Test handling of attempt to swap a task with itself""" """Test handling of attempt to swap a task with itself"""
@ -613,13 +643,15 @@ def test_swap_task_order_same_id(reset_memory):
assert result == "Cannot swap task with itself" assert result == "Cannot swap task with itself"
# Verify task unchanged # Verify task unchanged
assert _global_memory['tasks'][0] == "Task 1" assert _global_memory["tasks"][0] == "Task 1"
def test_swap_task_order_empty_tasks(reset_memory): def test_swap_task_order_empty_tasks(reset_memory):
"""Test swapping behavior with empty tasks dictionary""" """Test swapping behavior with empty tasks dictionary"""
result = swap_task_order.invoke({"id1": 0, "id2": 1}) result = swap_task_order.invoke({"id1": 0, "id2": 1})
assert result == "Invalid task ID(s)" assert result == "Invalid task ID(s)"
def test_swap_task_order_after_delete(reset_memory): def test_swap_task_order_after_delete(reset_memory):
"""Test swapping after deleting a task""" """Test swapping after deleting a task"""
# Add test tasks # Add test tasks
@ -639,5 +671,5 @@ def test_swap_task_order_after_delete(reset_memory):
assert result == "Tasks swapped." assert result == "Tasks swapped."
# Verify swap worked # Verify swap worked
assert _global_memory['tasks'][0] == "Task 3" assert _global_memory["tasks"][0] == "Task 3"
assert _global_memory['tasks'][2] == "Task 1" assert _global_memory["tasks"][2] == "Task 1"

View File

@ -1,8 +1,8 @@
import pytest import pytest
from pytest import mark
from langchain.schema.runnable import Runnable
from ra_aid.tools import read_file_tool from ra_aid.tools import read_file_tool
def test_basic_file_reading(tmp_path): def test_basic_file_reading(tmp_path):
"""Test basic file reading functionality""" """Test basic file reading functionality"""
# Create a test file # Create a test file
@ -15,8 +15,9 @@ def test_basic_file_reading(tmp_path):
# Verify return format and content # Verify return format and content
assert isinstance(result, dict) assert isinstance(result, dict)
assert 'content' in result assert "content" in result
assert result['content'] == test_content assert result["content"] == test_content
def test_no_truncation(tmp_path): def test_no_truncation(tmp_path):
"""Test that files under max_lines are not truncated""" """Test that files under max_lines are not truncated"""
@ -31,8 +32,9 @@ def test_no_truncation(tmp_path):
# Verify no truncation occurred # Verify no truncation occurred
assert isinstance(result, dict) assert isinstance(result, dict)
assert '[lines of output truncated]' not in result['content'] assert "[lines of output truncated]" not in result["content"]
assert len(result['content'].splitlines()) == line_count assert len(result["content"].splitlines()) == line_count
def test_with_truncation(tmp_path): def test_with_truncation(tmp_path):
"""Test that files over max_lines are properly truncated""" """Test that files over max_lines are properly truncated"""
@ -47,14 +49,18 @@ def test_with_truncation(tmp_path):
# Verify truncation occurred correctly # Verify truncation occurred correctly
assert isinstance(result, dict) assert isinstance(result, dict)
assert '[1000 lines of output truncated]' in result['content'] assert "[1000 lines of output truncated]" in result["content"]
assert len(result['content'].splitlines()) == 5001 # 5000 content lines + 1 truncation message assert (
len(result["content"].splitlines()) == 5001
) # 5000 content lines + 1 truncation message
def test_nonexistent_file(): def test_nonexistent_file():
"""Test error handling for non-existent files""" """Test error handling for non-existent files"""
with pytest.raises(FileNotFoundError): with pytest.raises(FileNotFoundError):
read_file_tool.invoke({"filepath": "/nonexistent/file.txt"}) read_file_tool.invoke({"filepath": "/nonexistent/file.txt"})
def test_empty_file(tmp_path): def test_empty_file(tmp_path):
"""Test reading an empty file""" """Test reading an empty file"""
# Create an empty test file # Create an empty test file
@ -66,5 +72,5 @@ def test_empty_file(tmp_path):
# Verify return format and empty content # Verify return format and empty content
assert isinstance(result, dict) assert isinstance(result, dict)
assert 'content' in result assert "content" in result
assert result['content'] == "" assert result["content"] == ""

View File

@ -1,11 +1,12 @@
import pytest
from ra_aid.tools.reflection import get_function_info from ra_aid.tools.reflection import get_function_info
# Sample functions for testing get_function_info # Sample functions for testing get_function_info
def simple_func(): def simple_func():
"""A simple function with no parameters.""" """A simple function with no parameters."""
pass pass
def typed_func(a: int, b: str = "default") -> bool: def typed_func(a: int, b: str = "default") -> bool:
"""A function with type hints and default value. """A function with type hints and default value.
@ -18,13 +19,16 @@ def typed_func(a: int, b: str = "default") -> bool:
""" """
return True return True
def complex_func(pos1, pos2, *args, kw1="default", **kwargs): def complex_func(pos1, pos2, *args, kw1="default", **kwargs):
"""A function with complex signature.""" """A function with complex signature."""
pass pass
def no_docstring_func(x): def no_docstring_func(x):
pass pass
class TestGetFunctionInfo: class TestGetFunctionInfo:
def test_simple_function_info(self): def test_simple_function_info(self):
"""Test info extraction for simple function.""" """Test info extraction for simple function."""
@ -58,5 +62,3 @@ class TestGetFunctionInfo:
info = get_function_info(no_docstring_func) info = get_function_info(no_docstring_func)
assert "no_docstring_func" in info assert "no_docstring_func" in info
assert "No docstring provided" in info assert "No docstring provided" in info

View File

@ -1,93 +1,107 @@
from unittest.mock import patch
import pytest import pytest
from unittest.mock import patch, MagicMock
from ra_aid.tools.shell import run_shell_command
from ra_aid.tools.memory import _global_memory from ra_aid.tools.memory import _global_memory
from ra_aid.tools.shell import run_shell_command
@pytest.fixture @pytest.fixture
def mock_console(): def mock_console():
with patch('ra_aid.tools.shell.console') as mock: with patch("ra_aid.tools.shell.console") as mock:
yield mock yield mock
@pytest.fixture @pytest.fixture
def mock_prompt(): def mock_prompt():
with patch('ra_aid.tools.shell.Prompt') as mock: with patch("ra_aid.tools.shell.Prompt") as mock:
yield mock yield mock
@pytest.fixture @pytest.fixture
def mock_run_interactive(): def mock_run_interactive():
with patch('ra_aid.tools.shell.run_interactive_command') as mock: with patch("ra_aid.tools.shell.run_interactive_command") as mock:
mock.return_value = (b"test output", 0) mock.return_value = (b"test output", 0)
yield mock yield mock
def test_shell_command_cowboy_mode(mock_console, mock_prompt, mock_run_interactive): def test_shell_command_cowboy_mode(mock_console, mock_prompt, mock_run_interactive):
"""Test shell command execution in cowboy mode (no approval)""" """Test shell command execution in cowboy mode (no approval)"""
_global_memory['config'] = {'cowboy_mode': True} _global_memory["config"] = {"cowboy_mode": True}
result = run_shell_command.invoke({"command": "echo test"}) result = run_shell_command.invoke({"command": "echo test"})
assert result['success'] is True assert result["success"] is True
assert result['return_code'] == 0 assert result["return_code"] == 0
assert "test output" in result['output'] assert "test output" in result["output"]
mock_prompt.ask.assert_not_called() mock_prompt.ask.assert_not_called()
def test_shell_command_cowboy_message(mock_console, mock_prompt, mock_run_interactive): def test_shell_command_cowboy_message(mock_console, mock_prompt, mock_run_interactive):
"""Test that cowboy mode displays a properly formatted cowboy message with correct spacing""" """Test that cowboy mode displays a properly formatted cowboy message with correct spacing"""
_global_memory['config'] = {'cowboy_mode': True} _global_memory["config"] = {"cowboy_mode": True}
with patch('ra_aid.tools.shell.get_cowboy_message') as mock_get_message: with patch("ra_aid.tools.shell.get_cowboy_message") as mock_get_message:
mock_get_message.return_value = '🤠 Test cowboy message!' mock_get_message.return_value = "🤠 Test cowboy message!"
result = run_shell_command.invoke({"command": "echo test"}) result = run_shell_command.invoke({"command": "echo test"})
assert result['success'] is True assert result["success"] is True
mock_console.print.assert_any_call("") mock_console.print.assert_any_call("")
mock_console.print.assert_any_call(" 🤠 Test cowboy message!") mock_console.print.assert_any_call(" 🤠 Test cowboy message!")
mock_console.print.assert_any_call("") mock_console.print.assert_any_call("")
mock_get_message.assert_called_once() mock_get_message.assert_called_once()
def test_shell_command_interactive_approved(mock_console, mock_prompt, mock_run_interactive):
def test_shell_command_interactive_approved(
mock_console, mock_prompt, mock_run_interactive
):
"""Test shell command execution with interactive approval""" """Test shell command execution with interactive approval"""
_global_memory['config'] = {'cowboy_mode': False} _global_memory["config"] = {"cowboy_mode": False}
mock_prompt.ask.return_value = 'y' mock_prompt.ask.return_value = "y"
result = run_shell_command.invoke({"command": "echo test"}) result = run_shell_command.invoke({"command": "echo test"})
assert result['success'] is True assert result["success"] is True
assert result['return_code'] == 0 assert result["return_code"] == 0
assert "test output" in result['output'] assert "test output" in result["output"]
mock_prompt.ask.assert_called_once_with( mock_prompt.ask.assert_called_once_with(
"Execute this command? (y=yes, n=no, c=enable cowboy mode for session)", "Execute this command? (y=yes, n=no, c=enable cowboy mode for session)",
choices=["y", "n", "c"], choices=["y", "n", "c"],
default="y", default="y",
show_choices=True, show_choices=True,
show_default=True show_default=True,
) )
def test_shell_command_interactive_rejected(mock_console, mock_prompt, mock_run_interactive):
def test_shell_command_interactive_rejected(
mock_console, mock_prompt, mock_run_interactive
):
"""Test shell command rejection in interactive mode""" """Test shell command rejection in interactive mode"""
_global_memory['config'] = {'cowboy_mode': False} _global_memory["config"] = {"cowboy_mode": False}
mock_prompt.ask.return_value = 'n' mock_prompt.ask.return_value = "n"
result = run_shell_command.invoke({"command": "echo test"}) result = run_shell_command.invoke({"command": "echo test"})
assert result['success'] is False assert result["success"] is False
assert result['return_code'] == 1 assert result["return_code"] == 1
assert "cancelled by user" in result['output'] assert "cancelled by user" in result["output"]
mock_prompt.ask.assert_called_once_with( mock_prompt.ask.assert_called_once_with(
"Execute this command? (y=yes, n=no, c=enable cowboy mode for session)", "Execute this command? (y=yes, n=no, c=enable cowboy mode for session)",
choices=["y", "n", "c"], choices=["y", "n", "c"],
default="y", default="y",
show_choices=True, show_choices=True,
show_default=True show_default=True,
) )
mock_run_interactive.assert_not_called() mock_run_interactive.assert_not_called()
def test_shell_command_execution_error(mock_console, mock_prompt, mock_run_interactive): def test_shell_command_execution_error(mock_console, mock_prompt, mock_run_interactive):
"""Test handling of shell command execution errors""" """Test handling of shell command execution errors"""
_global_memory['config'] = {'cowboy_mode': True} _global_memory["config"] = {"cowboy_mode": True}
mock_run_interactive.side_effect = Exception("Command failed") mock_run_interactive.side_effect = Exception("Command failed")
result = run_shell_command.invoke({"command": "invalid command"}) result = run_shell_command.invoke({"command": "invalid command"})
assert result['success'] is False assert result["success"] is False
assert result['return_code'] == 1 assert result["return_code"] == 1
assert "Command failed" in result['output'] assert "Command failed" in result["output"]

View File

@ -1,9 +1,11 @@
import os import os
from unittest.mock import patch
import pytest import pytest
from pathlib import Path
from unittest.mock import patch, mock_open
from ra_aid.tools.write_file import write_file_tool from ra_aid.tools.write_file import write_file_tool
@pytest.fixture @pytest.fixture
def temp_test_dir(tmp_path): def temp_test_dir(tmp_path):
"""Create a temporary test directory.""" """Create a temporary test directory."""
@ -11,15 +13,13 @@ def temp_test_dir(tmp_path):
test_dir.mkdir(exist_ok=True) test_dir.mkdir(exist_ok=True)
return test_dir return test_dir
def test_basic_write_functionality(temp_test_dir): def test_basic_write_functionality(temp_test_dir):
"""Test basic successful file writing.""" """Test basic successful file writing."""
test_file = temp_test_dir / "test.txt" test_file = temp_test_dir / "test.txt"
content = "Hello, World!\nTest content" content = "Hello, World!\nTest content"
result = write_file_tool.invoke({ result = write_file_tool.invoke({"filepath": str(test_file), "content": content})
"filepath": str(test_file),
"content": content
})
# Verify file contents # Verify file contents
assert test_file.read_text() == content assert test_file.read_text() == content
@ -28,91 +28,85 @@ def test_basic_write_functionality(temp_test_dir):
assert isinstance(result, dict) assert isinstance(result, dict)
assert result["success"] is True assert result["success"] is True
assert result["filepath"] == str(test_file) assert result["filepath"] == str(test_file)
assert result["bytes_written"] == len(content.encode('utf-8')) assert result["bytes_written"] == len(content.encode("utf-8"))
assert "Operation completed" in result["message"] assert "Operation completed" in result["message"]
def test_directory_creation(temp_test_dir): def test_directory_creation(temp_test_dir):
"""Test writing to a file in a non-existent directory.""" """Test writing to a file in a non-existent directory."""
nested_dir = temp_test_dir / "nested" / "subdirs" nested_dir = temp_test_dir / "nested" / "subdirs"
test_file = nested_dir / "test.txt" test_file = nested_dir / "test.txt"
content = "Test content" content = "Test content"
result = write_file_tool.invoke({ result = write_file_tool.invoke({"filepath": str(test_file), "content": content})
"filepath": str(test_file),
"content": content
})
assert test_file.exists() assert test_file.exists()
assert test_file.read_text() == content assert test_file.read_text() == content
assert result["success"] is True assert result["success"] is True
def test_different_encodings(temp_test_dir): def test_different_encodings(temp_test_dir):
"""Test writing files with different encodings.""" """Test writing files with different encodings."""
test_file = temp_test_dir / "encoded.txt" test_file = temp_test_dir / "encoded.txt"
content = "Hello 世界" # Mixed ASCII and Unicode content = "Hello 世界" # Mixed ASCII and Unicode
# Test UTF-8 # Test UTF-8
result_utf8 = write_file_tool.invoke({ result_utf8 = write_file_tool.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "content": content, "encoding": "utf-8"}
"content": content, )
"encoding": 'utf-8'
})
assert result_utf8["success"] is True assert result_utf8["success"] is True
assert test_file.read_text(encoding='utf-8') == content assert test_file.read_text(encoding="utf-8") == content
# Test UTF-16 # Test UTF-16
result_utf16 = write_file_tool.invoke({ result_utf16 = write_file_tool.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "content": content, "encoding": "utf-16"}
"content": content, )
"encoding": 'utf-16'
})
assert result_utf16["success"] is True assert result_utf16["success"] is True
assert test_file.read_text(encoding='utf-16') == content assert test_file.read_text(encoding="utf-16") == content
@patch('builtins.open')
@patch("builtins.open")
def test_permission_error(mock_open_func, temp_test_dir): def test_permission_error(mock_open_func, temp_test_dir):
"""Test handling of permission errors.""" """Test handling of permission errors."""
mock_open_func.side_effect = PermissionError("Permission denied") mock_open_func.side_effect = PermissionError("Permission denied")
test_file = temp_test_dir / "noperm.txt" test_file = temp_test_dir / "noperm.txt"
result = write_file_tool.invoke({ result = write_file_tool.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "content": "test content"}
"content": "test content" )
})
assert result["success"] is False assert result["success"] is False
assert "Permission denied" in result["message"] assert "Permission denied" in result["message"]
assert result["error"] is not None assert result["error"] is not None
@patch('builtins.open')
@patch("builtins.open")
def test_io_error(mock_open_func, temp_test_dir): def test_io_error(mock_open_func, temp_test_dir):
"""Test handling of IO errors.""" """Test handling of IO errors."""
mock_open_func.side_effect = IOError("IO Error occurred") mock_open_func.side_effect = IOError("IO Error occurred")
test_file = temp_test_dir / "ioerror.txt" test_file = temp_test_dir / "ioerror.txt"
result = write_file_tool.invoke({ result = write_file_tool.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "content": "test content"}
"content": "test content" )
})
assert result["success"] is False assert result["success"] is False
assert "IO Error" in result["message"] assert "IO Error" in result["message"]
assert result["error"] is not None assert result["error"] is not None
def test_empty_content(temp_test_dir): def test_empty_content(temp_test_dir):
"""Test writing empty content to a file.""" """Test writing empty content to a file."""
test_file = temp_test_dir / "empty.txt" test_file = temp_test_dir / "empty.txt"
result = write_file_tool.invoke({ result = write_file_tool.invoke({"filepath": str(test_file), "content": ""})
"filepath": str(test_file),
"content": ""
})
assert test_file.exists() assert test_file.exists()
assert test_file.read_text() == "" assert test_file.read_text() == ""
assert result["success"] is True assert result["success"] is True
assert result["bytes_written"] == 0 assert result["bytes_written"] == 0
def test_overwrite_existing_file(temp_test_dir): def test_overwrite_existing_file(temp_test_dir):
"""Test overwriting an existing file.""" """Test overwriting an existing file."""
test_file = temp_test_dir / "overwrite.txt" test_file = temp_test_dir / "overwrite.txt"
@ -122,43 +116,41 @@ def test_overwrite_existing_file(temp_test_dir):
# Overwrite with new content # Overwrite with new content
new_content = "New content" new_content = "New content"
result = write_file_tool.invoke({ result = write_file_tool.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "content": new_content}
"content": new_content )
})
assert test_file.read_text() == new_content assert test_file.read_text() == new_content
assert result["success"] is True assert result["success"] is True
assert result["bytes_written"] == len(new_content.encode('utf-8')) assert result["bytes_written"] == len(new_content.encode("utf-8"))
def test_large_file_write(temp_test_dir): def test_large_file_write(temp_test_dir):
"""Test writing a large file and verify statistics.""" """Test writing a large file and verify statistics."""
test_file = temp_test_dir / "large.txt" test_file = temp_test_dir / "large.txt"
content = "Large content\n" * 1000 # Create substantial content content = "Large content\n" * 1000 # Create substantial content
result = write_file_tool.invoke({ result = write_file_tool.invoke({"filepath": str(test_file), "content": content})
"filepath": str(test_file),
"content": content
})
assert test_file.exists() assert test_file.exists()
assert test_file.read_text() == content assert test_file.read_text() == content
assert result["success"] is True assert result["success"] is True
assert result["bytes_written"] == len(content.encode('utf-8')) assert result["bytes_written"] == len(content.encode("utf-8"))
assert os.path.getsize(test_file) == len(content.encode('utf-8')) assert os.path.getsize(test_file) == len(content.encode("utf-8"))
def test_invalid_path_characters(temp_test_dir): def test_invalid_path_characters(temp_test_dir):
"""Test handling of invalid path characters.""" """Test handling of invalid path characters."""
invalid_path = temp_test_dir / "invalid\0file.txt" invalid_path = temp_test_dir / "invalid\0file.txt"
result = write_file_tool.invoke({ result = write_file_tool.invoke(
"filepath": str(invalid_path), {"filepath": str(invalid_path), "content": "test content"}
"content": "test content" )
})
assert result["success"] is False assert result["success"] is False
assert "Invalid file path" in result["message"] assert "Invalid file path" in result["message"]
def test_write_to_readonly_directory(temp_test_dir): def test_write_to_readonly_directory(temp_test_dir):
"""Test writing to a readonly directory.""" """Test writing to a readonly directory."""
readonly_dir = temp_test_dir / "readonly" readonly_dir = temp_test_dir / "readonly"
@ -169,10 +161,9 @@ def test_write_to_readonly_directory(temp_test_dir):
os.chmod(readonly_dir, 0o444) os.chmod(readonly_dir, 0o444)
try: try:
result = write_file_tool.invoke({ result = write_file_tool.invoke(
"filepath": str(test_file), {"filepath": str(test_file), "content": "test content"}
"content": "test content" )
})
assert result["success"] is False assert result["success"] is False
assert "Permission" in result["message"] assert "Permission" in result["message"]
finally: finally:

View File

@ -1,6 +1,8 @@
import pytest import pytest
from scripts.extract_changelog import extract_version_content from scripts.extract_changelog import extract_version_content
@pytest.fixture @pytest.fixture
def basic_changelog(): def basic_changelog():
return """## [1.2.0] return """## [1.2.0]
@ -14,6 +16,7 @@ def basic_changelog():
- Change Y - Change Y
""" """
@pytest.fixture @pytest.fixture
def complex_changelog(): def complex_changelog():
return """## [2.0.0] return """## [2.0.0]
@ -30,6 +33,7 @@ def complex_changelog():
Some content Some content
""" """
def test_basic_version_extraction(basic_changelog): def test_basic_version_extraction(basic_changelog):
"""Test extracting a simple version entry""" """Test extracting a simple version entry"""
result = extract_version_content(basic_changelog, "1.2.0") result = extract_version_content(basic_changelog, "1.2.0")
@ -39,6 +43,7 @@ def test_basic_version_extraction(basic_changelog):
- Feature B""" - Feature B"""
assert result == expected assert result == expected
def test_middle_version_extraction(complex_changelog): def test_middle_version_extraction(complex_changelog):
"""Test extracting a version from middle of changelog""" """Test extracting a version from middle of changelog"""
result = extract_version_content(complex_changelog, "1.9.0") result = extract_version_content(complex_changelog, "1.9.0")
@ -49,22 +54,26 @@ def test_middle_version_extraction(complex_changelog):
- Bug fix""" - Bug fix"""
assert result == expected assert result == expected
def test_version_not_found(): def test_version_not_found():
"""Test error handling when version doesn't exist""" """Test error handling when version doesn't exist"""
with pytest.raises(ValueError, match="Version 9.9.9 not found in changelog"): with pytest.raises(ValueError, match="Version 9.9.9 not found in changelog"):
extract_version_content("## [1.0.0]\nSome content", "9.9.9") extract_version_content("## [1.0.0]\nSome content", "9.9.9")
def test_empty_changelog(): def test_empty_changelog():
"""Test handling empty changelog""" """Test handling empty changelog"""
with pytest.raises(ValueError, match="Version 1.0.0 not found in changelog"): with pytest.raises(ValueError, match="Version 1.0.0 not found in changelog"):
extract_version_content("", "1.0.0") extract_version_content("", "1.0.0")
def test_malformed_changelog(): def test_malformed_changelog():
"""Test handling malformed changelog without proper version headers""" """Test handling malformed changelog without proper version headers"""
content = "Some content\nNo version headers here\n" content = "Some content\nNo version headers here\n"
with pytest.raises(ValueError, match="Version 1.0.0 not found in changelog"): with pytest.raises(ValueError, match="Version 1.0.0 not found in changelog"):
extract_version_content(content, "1.0.0") extract_version_content(content, "1.0.0")
def test_version_with_special_chars(): def test_version_with_special_chars():
"""Test handling versions with special regex characters""" """Test handling versions with special regex characters"""
content = """## [1.0.0-beta.1] content = """## [1.0.0-beta.1]

View File

@ -1,19 +1,21 @@
"""Tests for file listing functionality.""" """Tests for file listing functionality."""
import os import os
import pytest
from pathlib import Path
import subprocess import subprocess
from unittest.mock import patch, MagicMock from unittest.mock import MagicMock, patch
import pytest
from ra_aid.file_listing import ( from ra_aid.file_listing import (
DirectoryAccessError,
DirectoryNotFoundError,
FileListerError,
GitCommandError,
get_file_listing, get_file_listing,
is_git_repo, is_git_repo,
GitCommandError,
DirectoryNotFoundError,
DirectoryAccessError,
FileListerError,
) )
@pytest.fixture @pytest.fixture
def empty_git_repo(tmp_path): def empty_git_repo(tmp_path):
"""Create an empty git repository.""" """Create an empty git repository."""
@ -30,7 +32,7 @@ def sample_git_repo(empty_git_repo):
"src/main.py", "src/main.py",
"src/utils.py", "src/utils.py",
"tests/test_main.py", "tests/test_main.py",
"docs/index.html" "docs/index.html",
] ]
for file_path in files: for file_path in files:
@ -43,10 +45,12 @@ def sample_git_repo(empty_git_repo):
subprocess.run( subprocess.run(
["git", "commit", "-m", "Initial commit"], ["git", "commit", "-m", "Initial commit"],
cwd=empty_git_repo, cwd=empty_git_repo,
env={"GIT_AUTHOR_NAME": "Test", env={
"GIT_AUTHOR_EMAIL": "test@example.com", "GIT_AUTHOR_NAME": "Test",
"GIT_COMMITTER_NAME": "Test", "GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_EMAIL": "test@example.com"} "GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.com",
},
) )
return empty_git_repo return empty_git_repo
@ -154,7 +158,10 @@ FILE_LISTING_TEST_CASES = [
}, },
{ {
"name": "duplicate_files", "name": "duplicate_files",
"git_output": "\n".join([SINGLE_FILE_NAME, SINGLE_FILE_NAME] + MULTI_FILE_NAMES[1:]) + "\n", "git_output": "\n".join(
[SINGLE_FILE_NAME, SINGLE_FILE_NAME] + MULTI_FILE_NAMES[1:]
)
+ "\n",
"expected_files": [SINGLE_FILE_NAME] + MULTI_FILE_NAMES[1:], "expected_files": [SINGLE_FILE_NAME] + MULTI_FILE_NAMES[1:],
"expected_total": 3, # After deduplication "expected_total": 3, # After deduplication
"limit": None, "limit": None,
@ -217,6 +224,7 @@ FILE_LISTING_TEST_CASES = [
}, },
] ]
def create_mock_process(git_output: str) -> MagicMock: def create_mock_process(git_output: str) -> MagicMock:
"""Create a mock process with the given git output.""" """Create a mock process with the given git output."""
mock_process = MagicMock() mock_process = MagicMock()
@ -224,12 +232,14 @@ def create_mock_process(git_output: str) -> MagicMock:
mock_process.returncode = 0 mock_process.returncode = 0
return mock_process return mock_process
@pytest.fixture @pytest.fixture
def mock_subprocess(): def mock_subprocess():
"""Fixture to mock subprocess.run.""" """Fixture to mock subprocess.run."""
with patch("subprocess.run") as mock_run: with patch("subprocess.run") as mock_run:
yield mock_run yield mock_run
@pytest.fixture @pytest.fixture
def mock_is_git_repo(): def mock_is_git_repo():
"""Fixture to mock is_git_repo function.""" """Fixture to mock is_git_repo function."""
@ -237,6 +247,7 @@ def mock_is_git_repo():
mock.return_value = True mock.return_value = True
yield mock yield mock
@pytest.mark.parametrize("test_case", FILE_LISTING_TEST_CASES, ids=lambda x: x["name"]) @pytest.mark.parametrize("test_case", FILE_LISTING_TEST_CASES, ids=lambda x: x["name"])
def test_get_file_listing(test_case, mock_subprocess, mock_is_git_repo): def test_get_file_listing(test_case, mock_subprocess, mock_is_git_repo):
"""Test get_file_listing with various inputs.""" """Test get_file_listing with various inputs."""
@ -245,6 +256,7 @@ def test_get_file_listing(test_case, mock_subprocess, mock_is_git_repo):
assert files == test_case["expected_files"] assert files == test_case["expected_files"]
assert total == test_case["expected_total"] assert total == test_case["expected_total"]
def test_get_file_listing_non_git_repo(mock_is_git_repo): def test_get_file_listing_non_git_repo(mock_is_git_repo):
"""Test get_file_listing with non-git repository.""" """Test get_file_listing with non-git repository."""
mock_is_git_repo.return_value = False mock_is_git_repo.return_value = False
@ -252,21 +264,23 @@ def test_get_file_listing_non_git_repo(mock_is_git_repo):
assert files == EMPTY_FILE_LIST assert files == EMPTY_FILE_LIST
assert total == EMPTY_FILE_TOTAL assert total == EMPTY_FILE_TOTAL
def test_get_file_listing_git_error(mock_subprocess, mock_is_git_repo): def test_get_file_listing_git_error(mock_subprocess, mock_is_git_repo):
"""Test get_file_listing when git command fails.""" """Test get_file_listing when git command fails."""
mock_subprocess.side_effect = GitCommandError("Git command failed") mock_subprocess.side_effect = GitCommandError("Git command failed")
with pytest.raises(GitCommandError): with pytest.raises(GitCommandError):
get_file_listing(DUMMY_PATH) get_file_listing(DUMMY_PATH)
def test_get_file_listing_permission_error(mock_subprocess, mock_is_git_repo): def test_get_file_listing_permission_error(mock_subprocess, mock_is_git_repo):
"""Test get_file_listing with permission error.""" """Test get_file_listing with permission error."""
mock_subprocess.side_effect = PermissionError("Permission denied") mock_subprocess.side_effect = PermissionError("Permission denied")
with pytest.raises(DirectoryAccessError): with pytest.raises(DirectoryAccessError):
get_file_listing(DUMMY_PATH) get_file_listing(DUMMY_PATH)
def test_get_file_listing_unexpected_error(mock_subprocess, mock_is_git_repo): def test_get_file_listing_unexpected_error(mock_subprocess, mock_is_git_repo):
"""Test get_file_listing with unexpected error.""" """Test get_file_listing with unexpected error."""
mock_subprocess.side_effect = Exception("Unexpected error") mock_subprocess.side_effect = Exception("Unexpected error")
with pytest.raises(FileListerError): with pytest.raises(FileListerError):
get_file_listing(DUMMY_PATH) get_file_listing(DUMMY_PATH)

View File

@ -2,22 +2,18 @@
import os import os
import subprocess import subprocess
import pytest
from pathlib import Path
from ra_aid.project_info import ( import pytest
get_project_info,
ProjectInfo, from ra_aid.project_info import ProjectInfo, get_project_info
ProjectInfoError from ra_aid.project_state import DirectoryAccessError, DirectoryNotFoundError
)
from ra_aid.project_state import DirectoryNotFoundError, DirectoryAccessError
from ra_aid.file_listing import GitCommandError
@pytest.fixture @pytest.fixture
def empty_git_repo(tmp_path): def empty_git_repo(tmp_path):
"""Create an empty git repository.""" """Create an empty git repository."""
import subprocess import subprocess
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True) subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True)
return tmp_path return tmp_path
@ -31,7 +27,7 @@ def sample_git_repo(empty_git_repo):
"src/main.py", "src/main.py",
"src/utils.py", "src/utils.py",
"tests/test_main.py", "tests/test_main.py",
"docs/index.html" "docs/index.html",
] ]
for file_path in files: for file_path in files:
@ -44,10 +40,12 @@ def sample_git_repo(empty_git_repo):
subprocess.run( subprocess.run(
["git", "commit", "-m", "Initial commit"], ["git", "commit", "-m", "Initial commit"],
cwd=empty_git_repo, cwd=empty_git_repo,
env={"GIT_AUTHOR_NAME": "Test", env={
"GIT_AUTHOR_EMAIL": "test@example.com", "GIT_AUTHOR_NAME": "Test",
"GIT_COMMITTER_NAME": "Test", "GIT_AUTHOR_EMAIL": "test@example.com",
"GIT_COMMITTER_EMAIL": "test@example.com"} "GIT_COMMITTER_NAME": "Test",
"GIT_COMMITTER_EMAIL": "test@example.com",
},
) )
return empty_git_repo return empty_git_repo

View File

@ -1,14 +1,14 @@
"""Tests for project state detection functionality.""" """Tests for project state detection functionality."""
import os import os
import pytest import pytest
from pathlib import Path
from ra_aid.project_state import ( from ra_aid.project_state import (
is_new_project,
DirectoryNotFoundError,
DirectoryAccessError, DirectoryAccessError,
ProjectStateError DirectoryNotFoundError,
ProjectStateError,
is_new_project,
) )