mnemo_cards/tools/agent/agent_orchestrator.py
2025-11-21 03:11:57 +03:00

505 lines
18 KiB
Python

"""
Main orchestrator for AI agent execution.
Executes a single task per run: read task -> execute -> test -> commit
"""
import sys
import subprocess
import time
from pathlib import Path
from datetime import datetime, timezone
from typing import Optional, List
from config import AgentConfig, get_test_command, get_lint_command
from task_manager import TaskManager, GlobalLock, Task
from cursor_cli_wrapper import CursorCLI, CursorResultStatus
from planning_agent import PlanningAgent
class AgentOrchestrator:
"""Orchestrates the agent development cycle."""
def __init__(self, config: AgentConfig):
self.config = config
self.task_manager = TaskManager(
config.task_list_path,
config.agent_state_path
)
self.global_lock = GlobalLock(config.global_lock_path)
self.cursor_cli = CursorCLI(
project_root=config.project_root,
api_key=config.cursor_api_key,
model=config.cursor_model,
verbose=True
)
def run(self) -> int:
"""
Execute a single task from the task list.
Returns: 0 on success, 1 on error
"""
print(f"🚀 Starting AI Agent for component: {self.config.component}")
print(f"📁 Project root: {self.config.project_root}")
print(f"🎯 Executing one task per run")
# Check if another agent is running
if self.task_manager.is_agent_running():
print(f"⚠️ Another agent is already running for {self.config.component}")
print("Exiting to avoid conflicts.")
return 0
try:
state = self.task_manager.load_state()
# Check iteration limit
if state.iteration_count >= self.config.max_iterations:
print(f"\n⏹️ Reached maximum iterations ({self.config.max_iterations})")
return 0
# Get next task
next_task = self.task_manager.get_next_task()
if not next_task:
print("\n✅ All tasks completed!")
self.task_manager.reset_state()
return 0
print(f"\n{'='*80}")
print(f"📋 Task: {next_task.id}")
print(f"📝 {next_task.title}")
print(f"⏱️ Estimated: {next_task.estimated_hours}h")
print(f"{'='*80}\n")
# Start task (only if not already in progress)
if next_task.status != "in_progress":
self.task_manager.start_task(next_task)
# Check if task needs global lock (modifies common package)
needs_global_lock = self._needs_global_lock(next_task)
if needs_global_lock:
if not self._acquire_global_lock():
print("⚠️ Cannot acquire global lock, skipping task")
self.task_manager.mark_task_skipped(
next_task.id,
"Global lock not available"
)
return 0
try:
# Retry loop: keep trying the same task until success or max retries
while True:
# Get current retry count
current_state = self.task_manager.load_state()
retry_count = current_state.retry_count
if retry_count > 0:
print(f"\n🔄 Retry attempt {retry_count}/{self.config.max_retries} for task {next_task.id}")
# Execute task
success = self._execute_task(next_task)
if success:
# Task completed successfully
print(f"\n✅ Task {next_task.id} completed successfully")
self.task_manager.mark_task_completed(next_task.id)
break
else:
# Task failed, increment retry count
retry_count = self.task_manager.increment_retry()
if retry_count >= self.config.max_retries:
print(f"\n❌ Task {next_task.id} failed after {retry_count} retries")
# Try to break down the task into subtasks
print(f"\n🔀 Attempting to break down task {next_task.id} into subtasks...")
planning_agent = PlanningAgent(self.config)
failure_reason = f"Failed after {retry_count} retry attempts. Task may be too complex."
breakdown_success = planning_agent.break_down_task(
next_task,
failure_reason
)
if breakdown_success:
print(f"\n✅ Task {next_task.id} successfully broken down into subtasks")
print(" Subtasks have been added to task_list.json")
# Mark original task as failed
self.task_manager.mark_task_failed(
next_task.id,
f"Failed after {retry_count} retries. Broken down into subtasks."
)
else:
print(f"\n⚠️ Failed to break down task {next_task.id}")
print(" Marking task as failed without subtasks")
self.task_manager.mark_task_failed(
next_task.id,
f"Failed after {retry_count} retries"
)
# Create GitHub issue
self._create_issue_for_failed_task(next_task)
break
else:
print(f"\n⚠️ Task {next_task.id} failed, retrying ({retry_count}/{self.config.max_retries})")
print("Waiting before retry...")
time.sleep(5) # Small delay before retry
finally:
# Release global lock if held
if needs_global_lock:
self.global_lock.release(self.config.component)
print("\n🎉 Agent execution completed (one task)")
return 0
except KeyboardInterrupt:
print("\n⚠️ Interrupted by user")
self.task_manager.reset_state()
return 1
except Exception as e:
print(f"\n❌ Fatal error: {e}")
import traceback
traceback.print_exc()
return 1
def _needs_global_lock(self, task: Task) -> bool:
"""Check if task needs global lock (modifies shared resources)."""
# Check if any files to modify are in mnemo_cards_common
for file_path in task.files_to_modify:
if "mnemo_cards_common" in file_path:
return True
return False
def _acquire_global_lock(self, timeout_minutes: int = 30) -> bool:
"""Try to acquire global lock with timeout."""
start_time = time.time()
while True:
if self.global_lock.acquire(
self.config.component,
f"Working on {self.config.component}"
):
print("🔒 Acquired global lock")
return True
elapsed_minutes = (time.time() - start_time) / 60
if elapsed_minutes >= timeout_minutes:
return False
print(f"⏳ Waiting for global lock... ({elapsed_minutes:.1f}/{timeout_minutes} min)")
time.sleep(30) # Check every 30 seconds
def _execute_task(self, task: Task) -> bool:
"""
Execute a single task.
Returns: True if successful, False otherwise
"""
# Load prompt template
prompt = self._build_task_prompt(task)
# Run cursor agent
print("🤖 Running Cursor Agent...")
result = self.cursor_cli.run_agent(
task_description=prompt,
force=True,
max_iterations=5
)
if result.status != CursorResultStatus.SUCCESS:
print(f"❌ Cursor agent failed: {result.error}")
return False
print(f"\n📊 Agent completed:")
print(f" - Files created: {len(result.files_created)}")
print(f" - Files modified: {len(result.files_modified)}")
print(f" - Files read: {len(result.files_read)}")
print(f" - Tool calls: {result.tool_calls}")
# Check if any files were actually changed
if len(result.files_created) == 0 and len(result.files_modified) == 0:
print("\n⚠️ WARNING: Agent completed but no files were created or modified!")
print(f" Agent read {len(result.files_read)} file(s) but made no changes.")
print(" This might indicate:")
print(" - Task was already completed")
print(" - Agent only analyzed files without making changes")
print(" - Agent encountered an issue but didn't report it")
# Check git status to see if there are any uncommitted changes
git_changes = self._check_git_changes()
if git_changes:
print(f"\n However, git shows {len(git_changes)} uncommitted change(s):")
for change in git_changes[:5]: # Show first 5
print(f" - {change}")
if len(git_changes) > 5:
print(f" ... and {len(git_changes) - 5} more")
else:
print("\n Git confirms: no changes detected in working directory.")
print(" Task may need to be retried or task description clarified.")
# Pull latest changes before committing
print("\n🔄 Pulling latest changes...")
self._git_pull()
# Run linter
print("🔍 Linter is disabled for now")
# print("\n🔍 Running linter...")
# if not self._run_lint():
# print("⚠️ Linter found issues")
# # Don't fail, cursor can fix in retry
# return False
# Run tests
print("Tests are disabled for now")
# print("\n🧪 Running tests...")
# if not self._run_tests():
# print("❌ Tests failed")
# return False
# Commit changes
print("\n💾 Committing changes...")
commit_message = self._build_commit_message(task, result)
if not self._git_commit(commit_message):
print("⚠️ No changes to commit")
# Push changes
print("\n📤 Pushing changes...")
self._git_push()
return True
def _build_task_prompt(self, task: Task) -> str:
"""Build comprehensive prompt for the agent."""
# Load development prompt template
prompt_file = self.config.prompts_dir / "development_prompt.md"
if prompt_file.exists():
with open(prompt_file, 'r') as f:
template = f.read()
else:
template = "You are an AI software engineer. Complete the following task:\n\n"
# Add task details
prompt = f"""{template}
## TASK: {task.id} - {task.title}
### Priority: {task.priority.upper()}
### Description:
{task.description}
### Acceptance Criteria:
"""
for i, criterion in enumerate(task.acceptance_criteria, 1):
prompt += f"{i}. {criterion}\n"
prompt += f"""
### Files to Modify:
"""
for file_path in task.files_to_modify:
prompt += f"- {file_path}\n"
prompt += f"""
### Component: {task.component}
### Working Directory: {self.config.component_root}
---
Complete this task following clean architecture principles and project conventions.
Write comprehensive unit tests for all new functionality.
Make sure all existing tests continue to pass.
"""
return prompt
def _build_commit_message(self, task: Task, result) -> str:
"""Build descriptive commit message."""
message = f"""feat({self.config.component}): {task.title}
Task ID: {task.id}
Priority: {task.priority}
Changes:
"""
if result.files_created:
message += f"- Created {len(result.files_created)} file(s)\n"
for file in result.files_created[:5]: # Limit to 5
message += f" - {file}\n"
if result.files_modified:
message += f"- Modified {len(result.files_modified)} file(s)\n"
for file in result.files_modified[:5]: # Limit to 5
message += f" - {file}\n"
message += f"\nCompleted by: AI Agent\n"
message += f"Duration: {result.duration_ms}ms\n"
return message
def _run_lint(self) -> bool:
"""Run linter for component."""
cmd = get_lint_command(self.config.component)
result = subprocess.run(
cmd,
shell=True,
cwd=self.config.project_root,
capture_output=True,
text=True
)
if result.returncode == 0:
print("✅ Linter passed")
return True
else:
print(f"⚠️ Linter output:\n{result.stdout}")
return False
def _run_tests(self) -> bool:
"""Run tests for component."""
cmd = get_test_command(self.config.component)
result = subprocess.run(
cmd,
shell=True,
cwd=self.config.project_root,
capture_output=True,
text=True
)
if result.returncode == 0:
print("✅ All tests passed")
return True
else:
print(f"❌ Test output:\n{result.stdout}")
return False
def _check_git_changes(self) -> List[str]:
"""Check for uncommitted changes in git."""
# Check status for modified, added, deleted files
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if result.returncode != 0:
return []
changes = []
for line in result.stdout.strip().split('\n'):
if line.strip():
# Format: "XY filename" where X is staging area, Y is working tree
# We care about files that are modified (M), added (A), deleted (D), or renamed (R)
status = line[:2]
filename = line[3:].strip()
if any(c in status for c in ['M', 'A', 'D', 'R']):
changes.append(filename)
return changes
def _git_pull(self) -> bool:
"""Pull latest changes from remote."""
result = subprocess.run(
["git", "pull", "--rebase"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
return result.returncode == 0
def _git_commit(self, message: str) -> bool:
"""Commit changes."""
# Add all changes
subprocess.run(
["git", "add", "-A"],
cwd=self.config.project_root
)
# Commit
result = subprocess.run(
["git", "commit", "-m", message],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if result.returncode == 0:
# Get commit hash
commit_hash = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=self.config.project_root,
capture_output=True,
text=True
).stdout.strip()
# Update state with commit hash
state = self.task_manager.load_state()
state.last_commit = commit_hash
self.task_manager.save_state(state)
print(f"✅ Committed: {commit_hash[:8]}")
return True
else:
# No changes or error
return False
def _git_push(self) -> bool:
"""Push changes to remote."""
result = subprocess.run(
["git", "push"],
cwd=self.config.project_root,
capture_output=True,
text=True
)
if result.returncode == 0:
print("✅ Pushed to remote")
return True
else:
print(f"⚠️ Push failed: {result.stderr}")
return False
def _create_issue_for_failed_task(self, task: Task) -> None:
"""Create GitHub/Forgejo issue for failed task."""
# TODO: Implement GitHub/Forgejo API integration
print(f"\n📋 TODO: Create issue for failed task {task.id}")
print(f" Title: Failed: {task.title}")
print(f" Description: Task failed after {self.config.max_retries} retries")
def main():
"""Main entry point."""
if len(sys.argv) < 2:
print("Usage: python agent_orchestrator.py <component>")
print("Components: web_v2, backend, common")
sys.exit(1)
component = sys.argv[1]
# Load configuration
config = AgentConfig.from_env(component)
try:
config.validate()
except ValueError as e:
print(f"❌ Configuration error: {e}")
sys.exit(1)
# Create orchestrator and run
orchestrator = AgentOrchestrator(config)
exit_code = orchestrator.run()
sys.exit(exit_code)
if __name__ == "__main__":
main()