blob: 27d522d4b04a46e323e737abbf0abc9c1ba764db [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/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
}