task: task-1753636924-a1d4c708 - created
Change-Id: Ic78528c47ae38114b9b7504f1c4a76f95e93eb13
diff --git a/server/agent/simple_manager.go b/server/agent/simple_manager.go
new file mode 100644
index 0000000..27d522d
--- /dev/null
+++ b/server/agent/simple_manager.go
@@ -0,0 +1,565 @@
+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/tm"
+)
+
+// SimpleAgent represents a simplified AI agent for MVP
+type SimpleAgent struct {
+ Name string
+ Role string
+ Model string
+ SystemPrompt string
+ Provider llm.LLMProvider
+ MaxTokens *int
+ Temperature *float64
+}
+
+// SimpleAgentManager manages multiple AI agents with basic Git operations
+type SimpleAgentManager struct {
+ config *config.Config
+ agents map[string]*SimpleAgent
+ taskManager tm.TaskManager
+ autoAssigner *assignment.AutoAssigner
+ prProvider git.PullRequestProvider
+ cloneManager *git.CloneManager
+ isRunning map[string]bool
+ stopChannels map[string]chan struct{}
+}
+
+// NewSimpleAgentManager creates a simplified agent manager
+func NewSimpleAgentManager(cfg *config.Config, taskManager tm.TaskManager) (*SimpleAgentManager, 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 := &SimpleAgentManager{
+ config: cfg,
+ agents: make(map[string]*SimpleAgent),
+ 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)
+ }
+
+ return manager, nil
+}
+
+// initializeAgents creates agent instances from configuration
+func (am *SimpleAgentManager) initializeAgents() error {
+ for _, agentConfig := range am.config.Agents {
+ agent, err := am.createAgent(agentConfig)
+ if err != nil {
+ return fmt.Errorf("failed to create agent %s: %w", agentConfig.Name, err)
+ }
+ am.agents[agentConfig.Name] = agent
+ }
+ return nil
+}
+
+// createAgent creates a single agent instance
+func (am *SimpleAgentManager) createAgent(agentConfig config.AgentConfig) (*SimpleAgent, error) {
+ // Load system prompt
+ systemPrompt, err := am.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.ProviderOpenAI,
+ APIKey: am.config.OpenAI.APIKey,
+ BaseURL: am.config.OpenAI.BaseURL,
+ Timeout: am.config.OpenAI.Timeout,
+ }
+
+ provider, err := llm.CreateProvider(llmConfig)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create LLM provider: %w", err)
+ }
+
+ agent := &SimpleAgent{
+ Name: agentConfig.Name,
+ Role: agentConfig.Role,
+ Model: agentConfig.Model,
+ SystemPrompt: systemPrompt,
+ Provider: provider,
+ MaxTokens: agentConfig.MaxTokens,
+ Temperature: agentConfig.Temperature,
+ }
+
+ return agent, nil
+}
+
+// loadSystemPrompt loads the system prompt from file
+func (am *SimpleAgentManager) 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 (am *SimpleAgentManager) StartAgent(agentName string, loopInterval time.Duration) error {
+ agent, exists := am.agents[agentName]
+ if !exists {
+ return fmt.Errorf("agent %s not found", agentName)
+ }
+
+ if am.isRunning[agentName] {
+ return fmt.Errorf("agent %s is already running", agentName)
+ }
+
+ stopChan := make(chan struct{})
+ am.stopChannels[agentName] = stopChan
+ am.isRunning[agentName] = true
+
+ go am.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 (am *SimpleAgentManager) StopAgent(agentName string) error {
+ if !am.isRunning[agentName] {
+ return fmt.Errorf("agent %s is not running", agentName)
+ }
+
+ close(am.stopChannels[agentName])
+ delete(am.stopChannels, agentName)
+ am.isRunning[agentName] = false
+
+ log.Printf("Stopped agent %s", agentName)
+ return nil
+}
+
+// runAgentLoop runs the main processing loop for an agent
+func (am *SimpleAgentManager) runAgentLoop(agent *SimpleAgent, 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 := am.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 (am *SimpleAgentManager) processAgentTasks(agent *SimpleAgent) error {
+ // Get tasks assigned to this agent
+ tasks, err := am.taskManager.GetTasksByAssignee(agent.Name)
+ if err != nil {
+ return fmt.Errorf("failed to get tasks for agent %s: %w", agent.Name, err)
+ }
+
+ for _, task := range tasks {
+ if task.Status == tm.StatusPending || task.Status == tm.StatusInProgress {
+ if err := am.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 := am.taskManager.UpdateTask(task); err != nil {
+ log.Printf("Error updating failed task %s: %v", task.ID, err)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// processTask processes a single task with an agent
+func (am *SimpleAgentManager) processTask(agent *SimpleAgent, task *tm.Task) error {
+ ctx := context.Background()
+
+ log.Printf("Agent %s processing task %s: %s", agent.Name, task.ID, task.Title)
+
+ // Mark task as in progress
+ task.Status = tm.StatusInProgress
+ if err := am.taskManager.UpdateTask(task); err != nil {
+ return fmt.Errorf("failed to update task status: %w", err)
+ }
+
+ // Generate solution using LLM
+ solution, err := am.generateSolution(ctx, agent, task)
+ if err != nil {
+ return fmt.Errorf("failed to generate solution: %w", err)
+ }
+
+ // Create Git branch and commit solution
+ branchName := am.generateBranchName(task)
+ if err := am.createAndCommitSolution(branchName, task, solution, agent); err != nil {
+ return fmt.Errorf("failed to commit solution: %w", err)
+ }
+
+ // Create pull request
+ prURL, err := am.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
+ task.CompletedAt = &time.Time{}
+ *task.CompletedAt = time.Now()
+
+ if err := am.taskManager.UpdateTask(task); err != nil {
+ return fmt.Errorf("failed to update completed task: %w", err)
+ }
+
+ log.Printf("Task %s completed by agent %s. PR: %s", task.ID, agent.Name, prURL)
+ return nil
+}
+
+// generateSolution uses the agent's LLM to generate a solution
+func (am *SimpleAgentManager) generateSolution(ctx context.Context, agent *SimpleAgent, task *tm.Task) (string, error) {
+ prompt := am.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 (am *SimpleAgentManager) 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 (am *SimpleAgentManager) 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", am.config.Git.BranchPrefix, task.ID, cleanTitle)
+}
+
+// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
+// Each agent works in its own Git clone, eliminating concurrency issues
+func (am *SimpleAgentManager) createAndCommitSolution(branchName string, task *tm.Task, solution string, agent *SimpleAgent) error {
+ ctx := context.Background()
+
+ // Get agent's dedicated Git clone
+ clonePath, err := am.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 := am.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 := am.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 (am *SimpleAgentManager) buildCommitMessage(task *tm.Task, agent *SimpleAgent) string {
+ template := am.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 (am *SimpleAgentManager) createPullRequest(ctx context.Context, task *tm.Task, solution string, agent *SimpleAgent, branchName string) (string, error) {
+ title := fmt.Sprintf("Task %s: %s", task.ID, task.Title)
+
+ // Build PR description from template
+ description := am.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 := am.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", am.config.GitHub.Owner, am.config.GitHub.Repo, pr.Number), nil
+}
+
+// buildPRDescription creates PR description from template
+func (am *SimpleAgentManager) buildPRDescription(task *tm.Task, solution string, agent *SimpleAgent) string {
+ template := am.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 (am *SimpleAgentManager) AutoAssignTask(taskID string) error {
+ task, err := am.taskManager.GetTask(taskID)
+ if err != nil {
+ return fmt.Errorf("failed to get task: %w", err)
+ }
+
+ agentName, err := am.autoAssigner.AssignTask(task)
+ if err != nil {
+ return fmt.Errorf("failed to auto-assign task: %w", err)
+ }
+
+ task.Assignee = agentName
+ if err := am.taskManager.UpdateTask(task); err != nil {
+ return fmt.Errorf("failed to update task assignment: %w", err)
+ }
+
+ explanation := am.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 (am *SimpleAgentManager) GetAgentStatus() map[string]SimpleAgentStatus {
+ status := make(map[string]SimpleAgentStatus)
+
+ for name, agent := range am.agents {
+ status[name] = SimpleAgentStatus{
+ Name: agent.Name,
+ Role: agent.Role,
+ Model: agent.Model,
+ IsRunning: am.isRunning[name],
+ }
+ }
+
+ return status
+}
+
+// SimpleAgentStatus represents the status of an agent
+type SimpleAgentStatus struct {
+ Name string `json:"name"`
+ Role string `json:"role"`
+ Model string `json:"model"`
+ IsRunning bool `json:"is_running"`
+}
+
+// IsAgentRunning checks if an agent is currently running
+func (am *SimpleAgentManager) IsAgentRunning(agentName string) bool {
+ return am.isRunning[agentName]
+}
+
+// Close shuts down the agent manager
+func (am *SimpleAgentManager) Close() error {
+ // Stop all running agents
+ for agentName := range am.isRunning {
+ if am.isRunning[agentName] {
+ am.StopAgent(agentName)
+ }
+ }
+
+ // Close all LLM providers
+ for _, agent := range am.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 := am.cloneManager.CleanupAllClones(); err != nil {
+ log.Printf("Error cleaning up agent clones: %v", err)
+ }
+
+ return nil
+}
\ No newline at end of file