diff --git a/README.md b/README.md index eb6eeb6..cd482de 100644 --- a/README.md +++ b/README.md @@ -51,21 +51,68 @@ graph TD Gemma & Gemini & Claude --> Orchestrator[Multi-Agent Orchestrator] - subgraph Agents + subgraph Agents Layer Orchestrator --> Planner[Planner Agent] - Planner --> Coder[Coder Agent] - Coder --> Reviewer[Reviewer Agent] + Planner --> SpecDecider{Is SIMPLE task?} + + SpecDecider -->|Yes| SimpleCoder[Simple Coder Agent] + SpecDecider -->|No| SpecAgent[Spec Agent] + + SpecAgent --> UserReview[User Review & Approval] + UserReview -->|Modify/Approve| SpecCoder[Spec Coder Agent] + + SimpleCoder & SpecCoder --> Reviewer[Reviewer Agent] end - Reviewer --> Write[File System Writer] + Reviewer -->|Approved| Write[File System Writer] + Reviewer -->|Rejected| ReCoder[Nudge Coder to fix] + ReCoder --> SimpleCoder & SpecCoder Write --> Workspace[(Workspace)] ``` -> **Note on Complexity Classification**: The Task Router queries the local Gemma model (`gemma2:2b`) to classify prompt complexity. If Gemma is offline, it falls back to a regex-based keyword density and word count heuristic classifier. +### 1. How Task Routing Works + +MACA evaluates task complexity before selecting a model to execute the coding assignment: +- **Complexity Assessment**: + - The **Task Router** queries the local Gemma model (gemma2:2b via Ollama) to analyze the user prompt and classify it into one of four categories: SIMPLE, MEDIUM, COMPLEX, or VERY_COMPLEX. + - *Fallback Heuristic*: If Gemma/Ollama is offline, the router falls back to a regex-based keyword density and word count heuristic classifier to make a prediction. +- **Model Assignment & Fallbacks**: + - **SIMPLE Tasks**: Handled entirely locally by the **Local Gemma Client** to minimize latency and token usage. + - **MEDIUM Tasks**: Routed preferentially to **Gemini Client** (Flash/Pro) for rapid, intelligent processing. If Gemini is configured but offline, it falls back to **Claude Client** (or local Gemma if no remote APIs are online). + - **COMPLEX or VERY_COMPLEX Tasks**: Routed preferentially to **Claude Client** (Opus/Sonnet) for advanced reasoning. If Claude is configured but offline, it falls back to **Gemini Client** (or local Gemma if necessary). --- -## 🛠️ Setup Instructions +### 2. How Agents Collaborate + +Once a model client has been assigned, the **Multi-Agent Orchestrator** manages the coding pipeline using a suite of dedicated, specialized agents: + +1. **Planner Agent**: + - Analyzes the task description and list of files in the workspace. + - Generates a structured Markdown implementation plan specifying the files to create or modify. + - Saves the plan to .maca/plan-.md. + +2. **Complexity Routing**: + - **For SIMPLE Tasks**: Bypasses the specification phase entirely. The orchestrator routes the task directly to the **Simple Coder Agent**, which implements the modifications based solely on the plan. + - **For MEDIUM/COMPLEX Tasks**: The orchestrator routes the task to the **Spec Agent** first. + +3. **Spec Agent (Spec Flow)**: + - Generates a detailed **Technical Specification** document from the plan. + - Saves it to .maca/spec-.md. + - **Interactive User Review**: The CLI pauses execution, alerting the user that the specification is ready for review. The user can open .maca/spec-.md, modify it to add constraints or adjust design, and then hit Enter to approve and continue. If the user types /cancel, the task is cleanly aborted. + - Once approved, the orchestrator loads the specification and routes it to the **Spec Coder Agent**. + +4. **Coder Agents**: + - Implements code changes based on either the plan (SimpleCoderAgent) or the technical specification (SpecCoderAgent). Both coder agents strictly enforce **Clean Code Guidelines** (modularity, variable naming, error handling, and type annotations). + - Generates file content blocks parsed via [FILE: path]. + - *Verification Loop*: The orchestrator runs a QA check. If the code is incomplete compared to the specification or plan, it nudges the coder with feedback to continue implementation. + +5. **Reviewer Agent**: + - Audits the coder output against **Clean Code Auditing Criteria** (readability, modularity, single responsibility, type safety, and correctness) and the original plan or specification (if available). + - **Approval & Verification**: + - If the code meets quality standards, it outputs APPROVED, and the orchestrator writes the changes to the disk. + - If not, it rejects the code, outputs a detailed feedback report, and nudges the coder to apply corrections. The loop repeats until approved (up to 10 attempts). +\n\n## 🛠️ Setup Instructions ### 1. Install and run the CLI Use the local launcher scripts under [local/scripts](local/scripts): diff --git a/src/maca/agents/__init__.py b/src/maca/agents/__init__.py index e69de29..1301d9d 100644 --- a/src/maca/agents/__init__.py +++ b/src/maca/agents/__init__.py @@ -0,0 +1,15 @@ +from maca.agents.base import BaseAgent +from maca.agents.coder import BaseCoderAgent, SimpleCoderAgent, SpecCoderAgent +from maca.agents.planner import PlannerAgent +from maca.agents.reviewer import ReviewerAgent +from maca.agents.spec import SpecAgent + +__all__ = [ + "BaseAgent", + "PlannerAgent", + "SpecAgent", + "BaseCoderAgent", + "SimpleCoderAgent", + "SpecCoderAgent", + "ReviewerAgent", +] diff --git a/src/maca/agents/base.py b/src/maca/agents/base.py index 3fe1d50..609a0df 100644 --- a/src/maca/agents/base.py +++ b/src/maca/agents/base.py @@ -1,44 +1,82 @@ +import abc import os import re +from typing import Any, Dict, List, Optional -class BaseAgent: - def __init__(self, name, model_client): +class BaseAgent(abc.ABC): + def __init__(self, name: str, model_client: Any, repo_path: str = "."): self.name = name self.model_client = model_client + self.repo_path = os.path.abspath(repo_path) - def run(self, *args, **kwargs): - raise NotImplementedError("Subclasses must implement run()") + @abc.abstractmethod + def _build_system_instruction(self, *args: Any, **kwargs: Any) -> str: + """Subclasses must implement this to return the system instructions.""" + pass + + @abc.abstractmethod + def _build_prompt(self, *args: Any, **kwargs: Any) -> str: + """Subclasses must implement this to return the user prompt.""" + pass + + def run(self, *args: Any, **kwargs: Any) -> str: + """Consolidated run method implementing the template pattern. + Constructs instructions and prompt, then invokes model generation. + """ + system_instruction = self._build_system_instruction(*args, **kwargs) + prompt = self._build_prompt(*args, **kwargs) + try: + return str(self.model_client.generate(prompt, system_instruction)) + except Exception as e: + raise RuntimeError(f"Agent {self.name} failed during generation: {e}") from e + + def _is_path_safe(self, abs_path: str) -> bool: + """Validates that a resolved path resides inside the repository root.""" + try: + common = os.path.commonpath([self.repo_path, abs_path]) + return common == self.repo_path + except Exception: + return False + + def list_files(self, repo_path: Optional[str] = None) -> List[str]: + target_path = os.path.abspath(repo_path) if repo_path else self.repo_path + if not self._is_path_safe(target_path): + return [] - # Helper tools available to the orchestrator/agents - def list_files(self, repo_path): files_list = [] - for root, dirs, files in os.walk(repo_path): + for root, dirs, files in os.walk(target_path): # Ignore git and hidden directories dirs[:] = [d for d in dirs if not d.startswith(".") and d != "node_modules"] for file in files: if not file.startswith("."): - rel_path = os.path.relpath(os.path.join(root, file), repo_path) + rel_path = os.path.relpath(os.path.join(root, file), self.repo_path) files_list.append(rel_path) return files_list - def read_file(self, file_path): + def read_file(self, file_path: str) -> str: + abs_path = os.path.abspath(os.path.join(self.repo_path, file_path)) + if not self._is_path_safe(abs_path): + return f"Error: Path {file_path} escapes repository root {self.repo_path}" try: - with open(file_path, "r", encoding="utf-8") as f: + with open(abs_path, "r", encoding="utf-8") as f: return f.read() except Exception as e: return f"Error reading file {file_path}: {e}" - def write_file(self, file_path, content): + def write_file(self, file_path: str, content: str) -> str: + abs_path = os.path.abspath(os.path.join(self.repo_path, file_path)) + if not self._is_path_safe(abs_path): + return f"Error: Path {file_path} escapes repository root {self.repo_path}" try: - os.makedirs(os.path.dirname(file_path), exist_ok=True) - with open(file_path, "w", encoding="utf-8") as f: + os.makedirs(os.path.dirname(abs_path), exist_ok=True) + with open(abs_path, "w", encoding="utf-8") as f: f.write(content) return f"Successfully wrote to {file_path}" except Exception as e: return f"Error writing to {file_path}: {e}" - def clean_code_content(self, content): + def clean_code_content(self, content: str) -> str: content = content.strip() while True: cleaned = False @@ -56,7 +94,7 @@ def clean_code_content(self, content): break return content - def parse_files(self, response_text): + def parse_files(self, response_text: str) -> Dict[str, str]: pattern = r"\[FILE:\s*([^\s\]]+)\]\s*(?:\r?\n)*```\w*\s*\n(.*?)\n```" matches = re.findall(pattern, response_text, re.DOTALL) diff --git a/src/maca/agents/coder.py b/src/maca/agents/coder.py index e56fdbb..651f384 100644 --- a/src/maca/agents/coder.py +++ b/src/maca/agents/coder.py @@ -1,16 +1,33 @@ +from typing import Any, Dict, List, Optional + from maca.agents.base import BaseAgent -class CoderAgent(BaseAgent): - def __init__(self, name, model_client): - super().__init__(name, model_client) +class BaseCoderAgent(BaseAgent): + """Abstract base coder agent consolidating common file formatting methods.""" + + def __init__(self, name: str, model_client: Any, repo_path: str = "."): + super().__init__(name, model_client, repo_path=repo_path) + + def _format_history(self, history: Optional[List[str]]) -> str: + if not history: + return "" + return "\n\nPrevious Conversation History:\n" + "\n".join(history) + + def _format_files_context(self, repo_files_content: Optional[Dict[str, str]]) -> str: + if not repo_files_content: + return "" + formatted_files = "\n\nExisting File Contents:\n" + for filepath, content in repo_files_content.items(): + formatted_files += f"--- FILE: {filepath} ---\n{content}\n\n" + return formatted_files + - def run(self, task_description, plan, repo_files_content=None, history=None): - system_instruction = self._build_system_instruction() - prompt = self._build_prompt(task_description, plan, repo_files_content, history) - return self.model_client.generate(prompt, system_instruction) +class SimpleCoderAgent(BaseCoderAgent): + def __init__(self, name: str, model_client: Any, repo_path: str = "."): + super().__init__(name, model_client, repo_path=repo_path) - def _build_system_instruction(self): + def _build_system_instruction(self, *args: Any, **kwargs: Any) -> str: return ( "You are a Software Coder Agent. Your job is to implement the changes outlined in the plan " "while strictly adhering to clean code practices.\n\n" @@ -32,7 +49,13 @@ def _build_system_instruction(self): "Make sure to provide the entire, complete contents of the file. Do not use placeholders or ellipsis." ) - def _build_prompt(self, task_description, plan, repo_files_content, history): + def _build_prompt( + self, + task_description: str, + plan: str, + repo_files_content: Optional[Dict[str, str]] = None, + history: Optional[List[str]] = None, + ) -> str: history_context = self._format_history(history) files_context = self._format_files_context(repo_files_content) @@ -43,16 +66,46 @@ def _build_prompt(self, task_description, plan, repo_files_content, history): "Please implement the changes using clean code principles, and output the files using the requested [FILE: path] format." ) - def _format_history(self, history): - if not history: - return "" - return "\n\nPrevious Conversation History:\n" + "\n".join(history) - def _format_files_context(self, repo_files_content): - if not repo_files_content: - return "" +class SpecCoderAgent(BaseCoderAgent): + def __init__(self, name: str, model_client: Any, repo_path: str = "."): + super().__init__(name, model_client, repo_path=repo_path) - formatted_files = "\n\nExisting File Contents:\n" - for filepath, content in repo_files_content.items(): - formatted_files += f"--- FILE: {filepath} ---\n{content}\n\n" - return formatted_files + def _build_system_instruction(self, *args: Any, **kwargs: Any) -> str: + return ( + "You are a Software Coder Agent. Your job is to implement the changes outlined in the specification " + "while strictly adhering to clean code practices.\n\n" + "CLEAN CODE GUIDELINES:\n" + "- Modularity: Break code into small, single-purpose functions/classes.\n" + "- Readability: Use clear, descriptive, and consistent variable/function names.\n" + "- Documentation: Include concise docstrings and inline comments explaining complex logic.\n" + "- Type Safety: Use type hints for function parameters and return types where applicable.\n" + "- Error Handling: Handle potential exceptions gracefully (no bare except blocks).\n" + "- Simplicity: Avoid over-engineering, code duplication, or spaghetti code.\n\n" + "CRITICAL: You MUST write the file identifier line in the EXACT format: [FILE: path/to/file.ext]\n" + "Do NOT use markdown headers (like '## FILE: ...' or '# FILE: ...'), bullet points, or bold text. " + "The parser WILL fail if you do not use square brackets [FILE: ...].\n\n" + "Format:\n" + "[FILE: path/to/file.ext]\n" + "```language\n" + "code contents\n" + "```\n\n" + "Make sure to provide the entire, complete contents of the file. Do not use placeholders or ellipsis." + ) + + def _build_prompt( + self, + task_description: str, + spec: str, + repo_files_content: Optional[Dict[str, str]] = None, + history: Optional[List[str]] = None, + ) -> str: + history_context = self._format_history(history) + files_context = self._format_files_context(repo_files_content) + + return ( + f"User Task: {task_description}{history_context}\n\n" + f"Technical Specification:\n{spec}\n" + f"{files_context}\n" + "Please implement the changes using clean code principles, and output the files using the requested [FILE: path] format." + ) diff --git a/src/maca/agents/planner.py b/src/maca/agents/planner.py index 1ecafe7..7cf5889 100644 --- a/src/maca/agents/planner.py +++ b/src/maca/agents/planner.py @@ -1,29 +1,34 @@ +from typing import Any, List, Optional + from maca.agents.base import BaseAgent class PlannerAgent(BaseAgent): - def __init__(self, model_client): - super().__init__("Planner", model_client) - - def run(self, task_description, repo_files=None, history=None): - files_str = "\n".join(repo_files) if repo_files else "Empty repository" - - history_str = "" - if history: - history_str = "\n\nPrevious Conversation History:\n" + "\n".join(history) + def __init__(self, model_client: Any, repo_path: str = "."): + super().__init__("Planner", model_client, repo_path=repo_path) - system_instruction = ( + def _build_system_instruction(self, *args: Any, **kwargs: Any) -> str: + return ( "You are a technical Planner Agent. Your job is to analyze the user request " "and create a structured markdown implementation plan. " "Do NOT write any code implementation or scripts. Only write the steps and " "identify which files need to be created or modified." ) - prompt = ( + def _build_prompt( + self, + task_description: str, + repo_files: Optional[List[str]] = None, + history: Optional[List[str]] = None, + ) -> str: + files_str = "\n".join(repo_files) if repo_files else "Empty repository" + history_str = "" + if history: + history_str = "\n\nPrevious Conversation History:\n" + "\n".join(history) + + return ( f"User Task: {task_description}{history_str}\n\n" f"Current Files in Repository:\n{files_str}\n\n" "Please output a detailed implementation plan in markdown format. " "Clearly indicate the files to be created or modified using [NEW] and [MODIFY] tags." ) - - return self.model_client.generate(prompt, system_instruction) diff --git a/src/maca/agents/reviewer.py b/src/maca/agents/reviewer.py index 1098553..837bdfc 100644 --- a/src/maca/agents/reviewer.py +++ b/src/maca/agents/reviewer.py @@ -1,16 +1,13 @@ +from typing import Any, Dict, List, Optional + from maca.agents.base import BaseAgent class ReviewerAgent(BaseAgent): - def __init__(self, model_client): - super().__init__("Reviewer", model_client) - - def run(self, task_description, generated_files, history=None): - system_instruction = self._build_system_instruction() - prompt = self._build_prompt(task_description, generated_files, history) - return self.model_client.generate(prompt, system_instruction) + def __init__(self, model_client: Any, repo_path: str = "."): + super().__init__("Reviewer", model_client, repo_path=repo_path) - def _build_system_instruction(self): + def _build_system_instruction(self, *args: Any, **kwargs: Any) -> str: return ( "You are a Senior Reviewer Agent. Your job is to review the code generated for the task " "with a strict focus on clean code practices and technical correctness.\n\n" @@ -32,27 +29,39 @@ def _build_system_instruction(self): "If the code is perfect, output a summary and conclude with the word: APPROVED." ) - def _build_prompt(self, task_description, generated_files, history): + def _build_prompt( + self, + task_description: str, + generated_files: Dict[str, str], + history: Optional[List[str]] = None, + plan_or_spec: Optional[str] = None, + ) -> str: history_context = self._format_history(history) files_context = self._format_files_context(generated_files) + spec_context = "" + if plan_or_spec: + spec_context = f"\n\nImplementation Plan/Specification:\n{plan_or_spec}" return ( - f"User Task: {task_description}{history_context}\n\n" + f"User Task: {task_description}{history_context}{spec_context}\n\n" f"Generated Files to Review:\n{files_context}" "Please review the code for correctness, logical bugs, and clean code practices. " "Suggest improvements and output corrected files if needed." ) - def _format_history(self, history): + def _format_history(self, history: Optional[List[str]]) -> str: if not history: return "" return "\n\nPrevious Conversation History:\n" + "\n".join(history) - def _format_files_context(self, files): + def _format_files_context(self, files: Dict[str, str]) -> str: if not files: return "" - formatted_files = "" for filepath, content in files.items(): formatted_files += f"--- FILE: {filepath} ---\n{content}\n\n" return formatted_files + + def is_approved(self, response_text: str) -> bool: + """Parses the reviewer response and determines if the implementation is approved.""" + return "APPROVED" in response_text.upper() diff --git a/src/maca/agents/spec.py b/src/maca/agents/spec.py new file mode 100644 index 0000000..eb30dd2 --- /dev/null +++ b/src/maca/agents/spec.py @@ -0,0 +1,30 @@ +from typing import Any, List, Optional + +from maca.agents.base import BaseAgent + + +class SpecAgent(BaseAgent): + def __init__(self, model_client: Any, repo_path: str = "."): + super().__init__("SpecWriter", model_client, repo_path=repo_path) + + def _build_system_instruction(self, *args: Any, **kwargs: Any) -> str: + return ( + "You are a Technical Specification Agent. Your job is to analyze the user request " + "and the provided implementation plan, and generate a detailed technical specification. " + "The specification should outline exactly what needs to be implemented, the expected " + "behavior, and the specific file changes required. " + "Do NOT write the actual code. Focus on the requirements, constraints, and architecture." + ) + + def _build_prompt( + self, task_description: str, plan: str, history: Optional[List[str]] = None + ) -> str: + history_str = "" + if history: + history_str = "\n\nPrevious Conversation History:\n" + "\n".join(history) + + return ( + f"User Task: {task_description}{history_str}\n\n" + f"Implementation Plan:\n{plan}\n\n" + "Please output a detailed technical specification in markdown format based on this plan." + ) diff --git a/src/maca/orchestrator.py b/src/maca/orchestrator.py index 3f7be87..6760036 100644 --- a/src/maca/orchestrator.py +++ b/src/maca/orchestrator.py @@ -2,16 +2,17 @@ from typing import Any from maca import maca_config as config -from maca.agents.coder import CoderAgent +from maca.agents.coder import SimpleCoderAgent, SpecCoderAgent # Import agents from maca.agents.planner import PlannerAgent from maca.agents.reviewer import ReviewerAgent +from maca.agents.spec import SpecAgent from maca.evaluator import ComplexityEvaluator from maca.models.claude import ClaudeClient from maca.models.gemini import GeminiClient from maca.models.local_gemma import LocalGemmaClient -from maca.rich_compat import Console, Markdown, Panel, Table +from maca.rich_compat import Console, Markdown, Panel, Prompt, Table console = Console() @@ -22,7 +23,7 @@ def __init__(self, repo_path="."): self.evaluator = ComplexityEvaluator() self.conversation_history = [] - def _is_coder_done(self, client, task_description, plan, generated_files): + def _is_coder_done(self, client, task_description, spec, generated_files): """Ask the model if the coder has completed all steps in the plan.""" if config.MOCK_GEMMA_FALLBACK and not client.api_key: return True, "Mock Coder finished." @@ -32,16 +33,16 @@ def _is_coder_done(self, client, task_description, plan, generated_files): files_str += f"--- FILE: {filepath} ---\n{content}\n\n" system_instruction = ( - "You are a Quality Assurance validator. Compare the implementation plan " + "You are a Quality Assurance validator. Compare the technical specification " "with the generated files to see if all planned tasks/steps are fully completed. " "Respond with 'YES' if everything is completely implemented. " "Otherwise, respond with 'NO' followed by a detailed list of what is missing." ) prompt = ( f"Task: {task_description}\n\n" - f"Plan:\n{plan}\n\n" + f"Specification:\n{spec}\n\n" f"Generated Files:\n{files_str}\n\n" - "Are all steps in the plan completely implemented? (Start your response with YES or NO)" + "Are all steps in the specification completely implemented? (Start your response with YES or NO)" ) try: response = client.generate(prompt, system_instruction).strip() @@ -251,7 +252,7 @@ def run_task(self, task_description, model_override=None): ) # Get existing files in the repo - planner = PlannerAgent(client) + planner = PlannerAgent(client, repo_path=self.repo_path) repo_files = planner.list_files(self.repo_path) # 3. Step 1: Planning Agent @@ -274,6 +275,61 @@ def run_task(self, task_description, model_override=None): ) ) + # Write Plan to .maca/plan-.md + safe_task_name = ( + "".join(c if c.isalnum() else "-" for c in task_description[:30]).strip("-").lower() + ) + maca_dir = os.path.join(self.repo_path, ".maca") + os.makedirs(maca_dir, exist_ok=True) + plan_file = os.path.join(maca_dir, f"plan-{safe_task_name}.md") + planner.write_file(plan_file, plan) + console.print(f"[bold green]Plan saved to {plan_file}[/bold green]") + + # 3.5. Step 1.5: Spec Agent + if complexity == "SIMPLE": + console.print( + Panel( + "[bold yellow]Step 1.5: Spec Agent skipped for SIMPLE task...[/bold yellow]", + border_style="yellow", + ) + ) + spec = plan + else: + console.print( + Panel( + "[bold yellow]Step 1.5: Spec Agent starting...[/bold yellow]", + border_style="yellow", + ) + ) + spec_agent = SpecAgent(client, repo_path=self.repo_path) + with console.status( + "[bold yellow]Spec Agent is generating the technical specification...", + spinner="dots", + ): + spec = spec_agent.run(task_description, plan, history=self.conversation_history) + + console.print( + Panel( + Markdown(spec), + title="[bold green]Technical Specification[/bold green]", + border_style="green", + ) + ) + spec_file = os.path.join(maca_dir, f"spec-{safe_task_name}.md") + spec_agent.write_file(spec_file, spec) + console.print(f"[bold green]Spec saved to {spec_file}[/bold green]") + + # Interactive Pause + user_input = Prompt.ask( + f"\n[bold yellow]Spec is ready for review at .maca/spec-{safe_task_name}.md[/bold yellow]\n[cyan]Modify it if needed. Press Enter to approve and continue, or type '/cancel' to abort[/cyan]" + ) + if user_input.strip().lower() == "/cancel": + console.print("[bold red]Task cancelled by user.[/bold red]") + return + + # Re-read the spec in case the user modified it + spec = spec_agent.read_file(spec_file) + # 4. Step 2: Coder Agent console.print( Panel( @@ -281,22 +337,31 @@ def run_task(self, task_description, model_override=None): ) ) - # Read contents of files mentioned in plan to provide context to Coder if they exist + # Read contents of files mentioned in spec to provide context to Coder if they exist repo_files_content = {} for filepath in repo_files: - # Check if planner plan mentions the file - if filepath.lower() in plan.lower(): + if filepath.lower() in spec.lower() or filepath.lower() in plan.lower(): full_path = os.path.join(self.repo_path, filepath) if os.path.exists(full_path): repo_files_content[filepath] = planner.read_file(full_path) - coder = CoderAgent("Coder", client) - with console.status( - "[bold yellow]Coder Agent is implementing the changes...", spinner="dots" - ): - coder_response = coder.run( - task_description, plan, repo_files_content, history=self.conversation_history - ) + coder: Any + if complexity == "SIMPLE": + coder = SimpleCoderAgent("SimpleCoder", client, repo_path=self.repo_path) + with console.status( + "[bold yellow]Simple Coder Agent is implementing the plan...", spinner="dots" + ): + coder_response = coder.run( + task_description, plan, repo_files_content, history=self.conversation_history + ) + else: + coder = SpecCoderAgent("SpecCoder", client, repo_path=self.repo_path) + with console.status( + "[bold yellow]Spec Coder Agent is implementing the specification...", spinner="dots" + ): + coder_response = coder.run( + task_description, spec, repo_files_content, history=self.conversation_history + ) generated_files = coder.parse_files(coder_response) if not generated_files: @@ -316,7 +381,7 @@ def run_task(self, task_description, model_override=None): console.print( f"[bold yellow]Checking if Coder completed all planned steps (Attempt {attempt + 1})...[/bold yellow]" ) - is_done, feedback = self._is_coder_done(client, task_description, plan, generated_files) + is_done, feedback = self._is_coder_done(client, task_description, spec, generated_files) if is_done: console.print( "[bold green]Coder confirmed all planned tasks are complete![/bold green]" @@ -344,12 +409,20 @@ def run_task(self, task_description, model_override=None): with console.status( "[bold yellow]Coder Agent is continuing implementation...", spinner="dots" ): - coder_response = coder.run( - task_description=task_description + f"\n\nNudge: {nudge_prompt}", - plan=plan, - repo_files_content={**repo_files_content, **generated_files}, - history=self.conversation_history, - ) + if complexity == "SIMPLE": + coder_response = coder.run( + task_description=task_description + f"\n\nNudge: {nudge_prompt}", + plan=plan, + repo_files_content={**repo_files_content, **generated_files}, + history=self.conversation_history, + ) + else: + coder_response = coder.run( + task_description=task_description + f"\n\nNudge: {nudge_prompt}", + spec=spec, + repo_files_content={**repo_files_content, **generated_files}, + history=self.conversation_history, + ) updated_files = coder.parse_files(coder_response) if updated_files: for fp, content in updated_files.items(): @@ -362,7 +435,7 @@ def run_task(self, task_description, model_override=None): border_style="yellow", ) ) - reviewer = ReviewerAgent(client) + reviewer = ReviewerAgent(client, repo_path=self.repo_path) max_review_attempts = 10 for r_attempt in range(max_review_attempts): @@ -373,7 +446,10 @@ def run_task(self, task_description, model_override=None): "[bold yellow]Reviewer Agent is auditing the generated code...", spinner="dots" ): reviewer_response = reviewer.run( - task_description, generated_files, history=self.conversation_history + task_description, + generated_files, + history=self.conversation_history, + plan_or_spec=spec, ) reviewed_files = reviewer.parse_files(reviewer_response) @@ -385,7 +461,7 @@ def run_task(self, task_description, model_override=None): ) ) - is_approved = "APPROVED" in reviewer_response.upper() + is_approved = reviewer.is_approved(reviewer_response) if is_approved: if reviewed_files: @@ -416,12 +492,20 @@ def run_task(self, task_description, model_override=None): with console.status( "[bold yellow]Coder Agent is applying corrections...", spinner="dots" ): - coder_response = coder.run( - task_description=task_description + f"\n\nNudge: {nudge_prompt}", - plan=plan, - repo_files_content={**repo_files_content, **generated_files}, - history=self.conversation_history, - ) + if complexity == "SIMPLE": + coder_response = coder.run( + task_description=task_description + f"\n\nNudge: {nudge_prompt}", + plan=plan, + repo_files_content={**repo_files_content, **generated_files}, + history=self.conversation_history, + ) + else: + coder_response = coder.run( + task_description=task_description + f"\n\nNudge: {nudge_prompt}", + spec=spec, + repo_files_content={**repo_files_content, **generated_files}, + history=self.conversation_history, + ) updated_files = coder.parse_files(coder_response) if updated_files: for fp, content in updated_files.items(): diff --git a/tests/test_agents.py b/tests/test_agents.py index e6d8645..9ba2a3a 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -1,27 +1,51 @@ import unittest from unittest.mock import MagicMock -from maca.agents.coder import CoderAgent +from maca.agents.coder import SimpleCoderAgent, SpecCoderAgent from maca.agents.reviewer import ReviewerAgent +from maca.agents.spec import SpecAgent class TestAgentsPrompts(unittest.TestCase): def setUp(self): self.mock_client = MagicMock() - self.coder = CoderAgent("Coder", self.mock_client) + self.simple_coder = SimpleCoderAgent("SimpleCoder", self.mock_client) + self.spec_coder = SpecCoderAgent("SpecCoder", self.mock_client) + self.spec_agent = SpecAgent(self.mock_client) self.reviewer = ReviewerAgent(self.mock_client) - def test_coder_system_instruction_clean_code(self): - sys_inst = self.coder._build_system_instruction() + def test_simple_coder_system_instruction_clean_code(self): + sys_inst = self.simple_coder._build_system_instruction() self.assertIn("clean code practices", sys_inst) self.assertIn("CLEAN CODE GUIDELINES", sys_inst) self.assertIn("Modularity", sys_inst) self.assertIn("Type Safety", sys_inst) - def test_coder_prompt_clean_code(self): - prompt = self.coder._build_prompt("test task", "test plan", {}, []) + def test_simple_coder_prompt_clean_code(self): + prompt = self.simple_coder._build_prompt("test task", "test plan", {}, []) self.assertIn("clean code principles", prompt) + def test_spec_coder_system_instruction_clean_code(self): + sys_inst = self.spec_coder._build_system_instruction() + self.assertIn("clean code practices", sys_inst) + self.assertIn("CLEAN CODE GUIDELINES", sys_inst) + self.assertIn("Modularity", sys_inst) + self.assertIn("Type Safety", sys_inst) + + def test_spec_coder_prompt_clean_code(self): + prompt = self.spec_coder._build_prompt("test task", "test spec", {}, []) + self.assertIn("clean code principles", prompt) + + def test_spec_agent_system_instruction(self): + sys_inst = self.spec_agent._build_system_instruction() + self.assertIn("Technical Specification Agent", sys_inst) + self.assertIn("detailed technical specification", sys_inst) + + def test_spec_agent_prompt(self): + prompt = self.spec_agent._build_prompt("test task", "test plan", []) + self.assertIn("test task", prompt) + self.assertIn("Implementation Plan:", prompt) + def test_reviewer_system_instruction_clean_code(self): sys_inst = self.reviewer._build_system_instruction() self.assertIn("clean code practices", sys_inst) @@ -30,5 +54,17 @@ def test_reviewer_system_instruction_clean_code(self): self.assertIn("Single Responsibility", sys_inst) def test_reviewer_prompt_clean_code(self): - prompt = self.reviewer._build_prompt("test task", {}, []) - self.assertIn("clean code practices", prompt) + prompt_no_spec = self.reviewer._build_prompt("test task", {}, []) + self.assertIn("clean code practices", prompt_no_spec) + self.assertNotIn("Implementation Plan/Specification:", prompt_no_spec) + + prompt_with_spec = self.reviewer._build_prompt("test task", {}, [], "test plan content") + self.assertIn("clean code practices", prompt_with_spec) + self.assertIn("Implementation Plan/Specification:", prompt_with_spec) + self.assertIn("test plan content", prompt_with_spec) + + def test_reviewer_is_approved(self): + self.assertTrue(self.reviewer.is_approved("Looks perfect. APPROVED.")) + self.assertTrue(self.reviewer.is_approved("approved")) + self.assertFalse(self.reviewer.is_approved("Issues found: missing docstring. REJECTED.")) + self.assertFalse(self.reviewer.is_approved("No approval given.")) diff --git a/tests/test_behavior.py b/tests/test_behavior.py index 42f0699..86954d7 100644 --- a/tests/test_behavior.py +++ b/tests/test_behavior.py @@ -212,7 +212,9 @@ def test_coder_completion_verification_loop(self): mock.patch.object(config, "get_gemini_api_key", return_value="fake_key"), mock.patch.object(config, "SANDBOX_READ_ONLY", True), mock.patch("maca.orchestrator.PlannerAgent") as mock_planner_cls, - mock.patch("maca.orchestrator.CoderAgent") as mock_coder_cls, + mock.patch("maca.orchestrator.SpecAgent") as mock_spec_cls, + mock.patch("maca.orchestrator.Prompt.ask", return_value=""), + mock.patch("maca.orchestrator.SpecCoderAgent") as mock_coder_cls, mock.patch("maca.orchestrator.ReviewerAgent") as mock_reviewer_cls, mock.patch.object(orch, "_is_coder_done", is_done_mock), ): @@ -220,6 +222,10 @@ def test_coder_completion_verification_loop(self): planner_inst.list_files.return_value = [] planner_inst.run.return_value = mock_plan + spec_inst = mock_spec_cls.return_value + spec_inst.run.return_value = "Mock Spec" + spec_inst.read_file.return_value = "Mock Spec" + coder_inst = mock_coder_cls.return_value coder_inst.run = coder_run_mock coder_inst.parse_files.side_effect = [ @@ -230,6 +236,7 @@ def test_coder_completion_verification_loop(self): reviewer_inst = mock_reviewer_cls.return_value reviewer_inst.run = reviewer_run_mock reviewer_inst.parse_files.return_value = {} + reviewer_inst.is_approved.side_effect = lambda x: "APPROVED" in x.upper() orch.run_task("Implement task") @@ -265,7 +272,9 @@ def test_reviewer_rejection_nudge_loop(self): mock.patch.object(config, "get_gemini_api_key", return_value="fake_key"), mock.patch.object(config, "SANDBOX_READ_ONLY", True), mock.patch("maca.orchestrator.PlannerAgent") as mock_planner_cls, - mock.patch("maca.orchestrator.CoderAgent") as mock_coder_cls, + mock.patch("maca.orchestrator.SpecAgent") as mock_spec_cls, + mock.patch("maca.orchestrator.Prompt.ask", return_value=""), + mock.patch("maca.orchestrator.SpecCoderAgent") as mock_coder_cls, mock.patch("maca.orchestrator.ReviewerAgent") as mock_reviewer_cls, mock.patch.object(orch, "_is_coder_done", return_value=(True, "Done")), ): @@ -273,6 +282,10 @@ def test_reviewer_rejection_nudge_loop(self): planner_inst.list_files.return_value = [] planner_inst.run.return_value = mock_plan + spec_inst = mock_spec_cls.return_value + spec_inst.run.return_value = "Mock Spec" + spec_inst.read_file.return_value = "Mock Spec" + coder_inst = mock_coder_cls.return_value coder_inst.run = coder_run_mock coder_inst.parse_files.side_effect = [ @@ -283,6 +296,7 @@ def test_reviewer_rejection_nudge_loop(self): reviewer_inst = mock_reviewer_cls.return_value reviewer_inst.run = reviewer_run_mock reviewer_inst.parse_files.return_value = {} + reviewer_inst.is_approved.side_effect = lambda x: "APPROVED" in x.upper() orch.run_task("Implement task")