blob: 45b0965281b5d5c8cf8e86998a31fd4b998dfacd [file] [log] [blame]
package agent
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/iomodo/staff/assignment"
"github.com/iomodo/staff/config"
"github.com/iomodo/staff/git"
"github.com/iomodo/staff/llm"
_ "github.com/iomodo/staff/llm/providers" // Auto-register all providers
"github.com/iomodo/staff/subtasks"
"github.com/iomodo/staff/tm"
)
// Manager manages multiple AI agents with Git operations and task processing
type Manager struct {
config *config.Config
agents map[string]*Agent
taskManager tm.TaskManager
autoAssigner *assignment.AutoAssigner
prProvider git.PullRequestProvider
cloneManager *git.CloneManager
subtaskService *subtasks.SubtaskService
isRunning map[string]bool
stopChannels map[string]chan struct{}
}
// NewManager creates a new agent manager
func NewManager(cfg *config.Config, taskManager tm.TaskManager) (*Manager, error) {
// Create auto-assigner
autoAssigner := assignment.NewAutoAssigner(cfg.Agents)
// Create GitHub PR provider
githubConfig := git.GitHubConfig{
Token: cfg.GitHub.Token,
}
prProvider := git.NewGitHubPullRequestProvider(cfg.GitHub.Owner, cfg.GitHub.Repo, githubConfig)
// Create clone manager for per-agent Git repositories
repoURL := fmt.Sprintf("https://github.com/%s/%s.git", cfg.GitHub.Owner, cfg.GitHub.Repo)
workspacePath := filepath.Join(".", "workspace")
cloneManager := git.NewCloneManager(repoURL, workspacePath)
manager := &Manager{
config: cfg,
agents: make(map[string]*Agent),
taskManager: taskManager,
autoAssigner: autoAssigner,
prProvider: prProvider,
cloneManager: cloneManager,
isRunning: make(map[string]bool),
stopChannels: make(map[string]chan struct{}),
}
// Initialize agents
if err := manager.initializeAgents(); err != nil {
return nil, fmt.Errorf("failed to initialize agents: %w", err)
}
// Initialize subtask service after agents are created
if err := manager.initializeSubtaskService(); err != nil {
return nil, fmt.Errorf("failed to initialize subtask service: %w", err)
}
return manager, nil
}
// initializeAgents creates agent instances from configuration
func (m *Manager) initializeAgents() error {
for _, agentConfig := range m.config.Agents {
agent, err := m.createAgent(agentConfig)
if err != nil {
return fmt.Errorf("failed to create agent %s: %w", agentConfig.Name, err)
}
m.agents[agentConfig.Name] = agent
}
return nil
}
// initializeSubtaskService creates the subtask service with available agent roles
func (m *Manager) initializeSubtaskService() error {
// Get agent roles from configuration
agentRoles := make([]string, 0, len(m.config.Agents))
for _, agentConfig := range m.config.Agents {
agentRoles = append(agentRoles, agentConfig.Name)
}
// Use the first agent's LLM provider for subtask analysis
if len(m.agents) == 0 {
return fmt.Errorf("no agents available for subtask service")
}
var firstAgent *Agent
for _, agent := range m.agents {
firstAgent = agent
break
}
m.subtaskService = subtasks.NewSubtaskService(
firstAgent.Provider,
m.taskManager,
agentRoles,
m.prProvider,
m.config.GitHub.Owner,
m.config.GitHub.Repo,
m.cloneManager,
)
return nil
}
// createAgent creates a single agent instance
func (m *Manager) createAgent(agentConfig config.AgentConfig) (*Agent, error) {
// Load system prompt
systemPrompt, err := m.loadSystemPrompt(agentConfig.SystemPromptFile)
if err != nil {
return nil, fmt.Errorf("failed to load system prompt: %w", err)
}
// Create LLM provider
llmConfig := llm.Config{
Provider: llm.ProviderFake, // Use fake provider for testing
APIKey: m.config.OpenAI.APIKey,
BaseURL: m.config.OpenAI.BaseURL,
Timeout: m.config.OpenAI.Timeout,
}
provider, err := llm.CreateProvider(llmConfig)
if err != nil {
return nil, fmt.Errorf("failed to create LLM provider: %w", err)
}
agent := &Agent{
Name: agentConfig.Name,
Role: agentConfig.Role,
Model: agentConfig.Model,
SystemPrompt: systemPrompt,
Provider: provider,
MaxTokens: agentConfig.MaxTokens,
Temperature: agentConfig.Temperature,
Stats: AgentStats{},
}
return agent, nil
}
// loadSystemPrompt loads the system prompt from file
func (m *Manager) loadSystemPrompt(filePath string) (string, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read system prompt file %s: %w", filePath, err)
}
return string(content), nil
}
// StartAgent starts an agent to process tasks in a loop
func (m *Manager) StartAgent(agentName string, loopInterval time.Duration) error {
agent, exists := m.agents[agentName]
if !exists {
return fmt.Errorf("agent %s not found", agentName)
}
if m.isRunning[agentName] {
return fmt.Errorf("agent %s is already running", agentName)
}
stopChan := make(chan struct{})
m.stopChannels[agentName] = stopChan
m.isRunning[agentName] = true
go m.runAgentLoop(agent, loopInterval, stopChan)
log.Printf("Started agent %s (%s) with %s model", agentName, agent.Role, agent.Model)
return nil
}
// StopAgent stops a running agent
func (m *Manager) StopAgent(agentName string) error {
if !m.isRunning[agentName] {
return fmt.Errorf("agent %s is not running", agentName)
}
close(m.stopChannels[agentName])
delete(m.stopChannels, agentName)
m.isRunning[agentName] = false
log.Printf("Stopped agent %s", agentName)
return nil
}
// runAgentLoop runs the main processing loop for an agent
func (m *Manager) runAgentLoop(agent *Agent, interval time.Duration, stopChan <-chan struct{}) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-stopChan:
log.Printf("Agent %s stopping", agent.Name)
return
case <-ticker.C:
if err := m.processAgentTasks(agent); err != nil {
log.Printf("Error processing tasks for agent %s: %v", agent.Name, err)
}
}
}
}
// processAgentTasks processes all assigned tasks for an agent
func (m *Manager) processAgentTasks(agent *Agent) error {
if agent.CurrentTask != nil {
return nil
}
// Get tasks assigned to this agent
tasks, err := m.taskManager.GetTasksByAssignee(agent.Name)
if err != nil {
return fmt.Errorf("failed to get tasks for agent %s: %w", agent.Name, err)
}
log.Printf("Processing %d tasks for agent %s", len(tasks), agent.Name)
for _, task := range tasks {
if task.Status == tm.StatusToDo || task.Status == tm.StatusPending {
if err := m.processTask(agent, task); err != nil {
log.Printf("Error processing task %s: %v", task.ID, err)
// Mark task as failed
task.Status = tm.StatusFailed
if err := m.taskManager.UpdateTask(task); err != nil {
log.Printf("Error updating failed task %s: %v", task.ID, err)
}
agent.Stats.TasksFailed++
} else {
agent.Stats.TasksCompleted++
}
// Update success rate
total := agent.Stats.TasksCompleted + agent.Stats.TasksFailed
if total > 0 {
agent.Stats.SuccessRate = float64(agent.Stats.TasksCompleted) / float64(total) * 100
}
}
}
return nil
}
// processTask processes a single task with an agent
func (m *Manager) processTask(agent *Agent, task *tm.Task) error {
ctx := context.Background()
startTime := time.Now()
log.Printf("Agent %s processing task %s: %s", agent.Name, task.ID, task.Title)
// Mark task as in progress
task.Status = tm.StatusInProgress
agent.CurrentTask = &task.ID
if err := m.taskManager.UpdateTask(task); err != nil {
return fmt.Errorf("failed to update task status: %w", err)
}
// Check if this task should generate subtasks (with LLM decision)
if m.shouldGenerateSubtasks(task) {
log.Printf("LLM determined task %s should generate subtasks", task.ID)
if err := m.generateSubtasksForTask(ctx, task); err != nil {
log.Printf("Warning: Failed to generate subtasks for task %s: %v", task.ID, err)
// Continue with normal processing if subtask generation fails
} else {
// Task has been converted to subtask management, mark as completed
task.Status = tm.StatusCompleted
task.Solution = "Task analyzed by LLM and broken down into subtasks with potential new agent creation. See subtasks PR for details."
completedAt := time.Now()
task.CompletedAt = &completedAt
agent.CurrentTask = nil
if err := m.taskManager.UpdateTask(task); err != nil {
return fmt.Errorf("failed to update task with subtasks: %w", err)
}
log.Printf("Task %s converted to subtasks by agent %s using LLM analysis", task.ID, agent.Name)
return nil
}
}
// Generate solution using LLM
solution, err := m.generateSolution(ctx, agent, task)
if err != nil {
return fmt.Errorf("failed to generate solution: %w", err)
}
// Create Git branch and commit solution
branchName := m.generateBranchName(task)
if err := m.createAndCommitSolution(branchName, task, solution, agent); err != nil {
return fmt.Errorf("failed to commit solution: %w", err)
}
// Create pull request
prURL, err := m.createPullRequest(ctx, task, solution, agent, branchName)
if err != nil {
return fmt.Errorf("failed to create pull request: %w", err)
}
// Update task as completed
task.Status = tm.StatusCompleted
task.Solution = solution
task.PullRequestURL = prURL
completedAt := time.Now()
task.CompletedAt = &completedAt
agent.CurrentTask = nil
if err := m.taskManager.UpdateTask(task); err != nil {
return fmt.Errorf("failed to update completed task: %w", err)
}
// Update agent stats
duration := time.Since(startTime)
if agent.Stats.AvgTime == 0 {
agent.Stats.AvgTime = duration.Milliseconds()
} else {
agent.Stats.AvgTime = (agent.Stats.AvgTime + duration.Milliseconds()) / 2
}
log.Printf("Task %s completed by agent %s in %v. PR: %s", task.ID, agent.Name, duration, prURL)
return nil
}
// generateSolution uses the agent's LLM to generate a solution
func (m *Manager) generateSolution(ctx context.Context, agent *Agent, task *tm.Task) (string, error) {
prompt := m.buildTaskPrompt(task)
req := llm.ChatCompletionRequest{
Model: agent.Model,
Messages: []llm.Message{
{
Role: llm.RoleSystem,
Content: agent.SystemPrompt,
},
{
Role: llm.RoleUser,
Content: prompt,
},
},
MaxTokens: agent.MaxTokens,
Temperature: agent.Temperature,
}
resp, err := agent.Provider.ChatCompletion(ctx, req)
if err != nil {
return "", fmt.Errorf("LLM request failed: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("no response from LLM")
}
return resp.Choices[0].Message.Content, nil
}
// buildTaskPrompt creates a detailed prompt for the LLM
func (m *Manager) buildTaskPrompt(task *tm.Task) string {
return fmt.Sprintf(`Task: %s
Priority: %s
Description: %s
Please provide a complete solution for this task. Include:
1. Detailed implementation plan
2. Code changes needed (if applicable)
3. Files to be created or modified
4. Testing considerations
5. Any dependencies or prerequisites
Your response should be comprehensive and actionable.`,
task.Title,
task.Priority,
task.Description)
}
// generateBranchName creates a Git branch name for the task
func (m *Manager) generateBranchName(task *tm.Task) string {
// Clean title for use in branch name
cleanTitle := strings.ToLower(task.Title)
cleanTitle = strings.ReplaceAll(cleanTitle, " ", "-")
cleanTitle = strings.ReplaceAll(cleanTitle, "/", "-")
// Remove special characters
var result strings.Builder
for _, r := range cleanTitle {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
result.WriteRune(r)
}
}
cleanTitle = result.String()
// Limit length
if len(cleanTitle) > 40 {
cleanTitle = cleanTitle[:40]
}
return fmt.Sprintf("%s%s-%s", m.config.Git.BranchPrefix, task.ID, cleanTitle)
}
// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
func (m *Manager) createAndCommitSolution(branchName string, task *tm.Task, solution string, agent *Agent) error {
ctx := context.Background()
// Get agent's dedicated Git clone
clonePath, err := m.cloneManager.GetAgentClonePath(agent.Name)
if err != nil {
return fmt.Errorf("failed to get agent clone: %w", err)
}
log.Printf("Agent %s working in clone: %s", agent.Name, clonePath)
// Refresh the clone with latest changes
if err := m.cloneManager.RefreshAgentClone(agent.Name); err != nil {
log.Printf("Warning: Failed to refresh clone for agent %s: %v", agent.Name, err)
}
// All Git operations use the agent's clone directory
gitCmd := func(args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
}
// Ensure we're on main branch before creating new branch
cmd := gitCmd("checkout", "main")
if err := cmd.Run(); err != nil {
// Try master branch if main doesn't exist
cmd = gitCmd("checkout", "master")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to checkout main/master branch: %w", err)
}
}
// Create branch
cmd = gitCmd("checkout", "-b", branchName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to create branch: %w", err)
}
// Create solution file in agent's clone
solutionDir := filepath.Join(clonePath, "tasks", "solutions")
if err := os.MkdirAll(solutionDir, 0755); err != nil {
return fmt.Errorf("failed to create solution directory: %w", err)
}
solutionFile := filepath.Join(solutionDir, fmt.Sprintf("%s-solution.md", task.ID))
solutionContent := fmt.Sprintf(`# Solution for Task: %s
**Agent:** %s (%s)
**Model:** %s
**Completed:** %s
## Task Description
%s
## Solution
%s
---
*Generated by Staff AI Agent System*
`, task.Title, agent.Name, agent.Role, agent.Model, time.Now().Format(time.RFC3339), task.Description, solution)
if err := os.WriteFile(solutionFile, []byte(solutionContent), 0644); err != nil {
return fmt.Errorf("failed to write solution file: %w", err)
}
// Stage files
relativeSolutionFile := filepath.Join("tasks", "solutions", fmt.Sprintf("%s-solution.md", task.ID))
cmd = gitCmd("add", relativeSolutionFile)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to stage files: %w", err)
}
// Commit changes
commitMsg := m.buildCommitMessage(task, agent)
cmd = gitCmd("commit", "-m", commitMsg)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to commit: %w", err)
}
// Push branch
cmd = gitCmd("push", "-u", "origin", branchName)
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to push branch: %w", err)
}
log.Printf("Agent %s successfully pushed branch %s", agent.Name, branchName)
return nil
}
// buildCommitMessage creates a commit message from template
func (m *Manager) buildCommitMessage(task *tm.Task, agent *Agent) string {
template := m.config.Git.CommitMessageTemplate
replacements := map[string]string{
"{task_id}": task.ID,
"{task_title}": task.Title,
"{agent_name}": agent.Name,
"{solution}": "See solution file for details",
}
result := template
for placeholder, value := range replacements {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
// createPullRequest creates a GitHub pull request
func (m *Manager) createPullRequest(ctx context.Context, task *tm.Task, solution string, agent *Agent, branchName string) (string, error) {
title := fmt.Sprintf("Task %s: %s", task.ID, task.Title)
// Build PR description from template
description := m.buildPRDescription(task, solution, agent)
options := git.PullRequestOptions{
Title: title,
Description: description,
HeadBranch: branchName,
BaseBranch: "main",
Labels: []string{"ai-generated", "staff-agent", strings.ToLower(agent.Role)},
Draft: false,
}
pr, err := m.prProvider.CreatePullRequest(ctx, options)
if err != nil {
return "", fmt.Errorf("failed to create PR: %w", err)
}
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", m.config.GitHub.Owner, m.config.GitHub.Repo, pr.Number), nil
}
// buildPRDescription creates PR description from template
func (m *Manager) buildPRDescription(task *tm.Task, solution string, agent *Agent) string {
template := m.config.Git.PRTemplate
// Truncate solution for PR if too long
truncatedSolution := solution
if len(solution) > 1000 {
truncatedSolution = solution[:1000] + "...\n\n*See solution file for complete details*"
}
replacements := map[string]string{
"{task_id}": task.ID,
"{task_title}": task.Title,
"{task_description}": task.Description,
"{agent_name}": fmt.Sprintf("%s (%s)", agent.Name, agent.Role),
"{priority}": string(task.Priority),
"{solution}": truncatedSolution,
"{files_changed}": fmt.Sprintf("- `tasks/solutions/%s-solution.md`", task.ID),
}
result := template
for placeholder, value := range replacements {
result = strings.ReplaceAll(result, placeholder, value)
}
return result
}
// AutoAssignTask automatically assigns a task to the best matching agent
func (m *Manager) AutoAssignTask(taskID string) error {
task, err := m.taskManager.GetTask(taskID)
if err != nil {
return fmt.Errorf("failed to get task: %w", err)
}
agentName, err := m.autoAssigner.AssignTask(task)
if err != nil {
return fmt.Errorf("failed to auto-assign task: %w", err)
}
task.Assignee = agentName
if err := m.taskManager.UpdateTask(task); err != nil {
return fmt.Errorf("failed to update task assignment: %w", err)
}
explanation := m.autoAssigner.GetRecommendationExplanation(task, agentName)
log.Printf("Auto-assigned task %s to %s: %s", taskID, agentName, explanation)
return nil
}
// GetAgentStatus returns the status of all agents
func (m *Manager) GetAgentStatus() map[string]AgentInfo {
status := make(map[string]AgentInfo)
for name, agent := range m.agents {
agentStatus := StatusIdle
if m.isRunning[name] {
if agent.CurrentTask != nil {
agentStatus = StatusRunning
}
} else {
agentStatus = StatusStopped
}
status[name] = AgentInfo{
Name: agent.Name,
Role: agent.Role,
Model: agent.Model,
Status: agentStatus,
CurrentTask: agent.CurrentTask,
Stats: agent.Stats,
}
}
return status
}
// shouldGenerateSubtasks determines if a task should be broken down into subtasks using LLM
func (m *Manager) shouldGenerateSubtasks(task *tm.Task) bool {
// Don't generate subtasks for subtasks
if task.ParentTaskID != "" {
return false
}
// Don't generate if already evaluated
if task.SubtasksEvaluated {
return false
}
// Ask LLM to decide
ctx := context.Background()
decision, err := m.subtaskService.ShouldGenerateSubtasks(ctx, task)
if err != nil {
log.Printf("Warning: Failed to get LLM subtask decision for task %s: %v", task.ID, err)
// Fallback to simple heuristics
return task.Priority == tm.PriorityHigh || len(task.Description) > 200
}
// Update task to mark as evaluated
task.SubtasksEvaluated = true
if err := m.taskManager.UpdateTask(task); err != nil {
log.Printf("Warning: Failed to update task evaluation status: %v", err)
}
log.Printf("LLM subtask decision for task %s: needs_subtasks=%v, complexity=%d, reasoning=%s",
task.ID, decision.NeedsSubtasks, decision.ComplexityScore, decision.Reasoning)
return decision.NeedsSubtasks
}
// generateSubtasksForTask analyzes a task and creates a PR with proposed subtasks
func (m *Manager) generateSubtasksForTask(ctx context.Context, task *tm.Task) error {
if m.subtaskService == nil {
return fmt.Errorf("subtask service not initialized")
}
// Analyze the task for subtasks
analysis, err := m.subtaskService.AnalyzeTaskForSubtasks(ctx, task)
if err != nil {
return fmt.Errorf("failed to analyze task for subtasks: %w", err)
}
// Generate a PR with the subtask proposals
prURL, err := m.subtaskService.GenerateSubtaskPR(ctx, analysis)
if err != nil {
return fmt.Errorf("failed to generate subtask PR: %w", err)
}
// Update the task with subtask information
task.SubtasksPRURL = prURL
task.SubtasksGenerated = true
log.Printf("Generated subtask PR for task %s: %s", task.ID, prURL)
log.Printf("Proposed %d subtasks and %d new agents for task %s", len(analysis.Subtasks), len(analysis.AgentCreations), task.ID)
// Log proposed new agents if any
if len(analysis.AgentCreations) > 0 {
for _, agent := range analysis.AgentCreations {
log.Printf("Proposed new agent: %s with skills: %v", agent.Role, agent.Skills)
}
}
return nil
}
// IsAgentRunning checks if an agent is currently running
func (m *Manager) IsAgentRunning(agentName string) bool {
return m.isRunning[agentName]
}
// Close shuts down the agent manager
func (m *Manager) Close() error {
// Stop all running agents
for agentName := range m.isRunning {
if m.isRunning[agentName] {
m.StopAgent(agentName)
}
}
// Close all LLM providers
for _, agent := range m.agents {
if err := agent.Provider.Close(); err != nil {
log.Printf("Error closing provider for agent %s: %v", agent.Name, err)
}
}
// Cleanup all agent Git clones
if err := m.cloneManager.CleanupAllClones(); err != nil {
log.Printf("Error cleaning up agent clones: %v", err)
}
// Cleanup subtask service
if m.subtaskService != nil {
if err := m.subtaskService.Close(); err != nil {
log.Printf("Error closing subtask service: %v", err)
}
}
return nil
}