blob: 27d522d4b04a46e323e737abbf0abc9c1ba764db [file] [log] [blame]
user5a7d60d2025-07-27 21:22:04 +04001package agent
2
3import (
4 "context"
5 "fmt"
6 "log"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "github.com/iomodo/staff/assignment"
14 "github.com/iomodo/staff/config"
15 "github.com/iomodo/staff/git"
16 "github.com/iomodo/staff/llm"
17 "github.com/iomodo/staff/tm"
18)
19
20// SimpleAgent represents a simplified AI agent for MVP
21type SimpleAgent struct {
22 Name string
23 Role string
24 Model string
25 SystemPrompt string
26 Provider llm.LLMProvider
27 MaxTokens *int
28 Temperature *float64
29}
30
31// SimpleAgentManager manages multiple AI agents with basic Git operations
32type SimpleAgentManager struct {
33 config *config.Config
34 agents map[string]*SimpleAgent
35 taskManager tm.TaskManager
36 autoAssigner *assignment.AutoAssigner
37 prProvider git.PullRequestProvider
38 cloneManager *git.CloneManager
39 isRunning map[string]bool
40 stopChannels map[string]chan struct{}
41}
42
43// NewSimpleAgentManager creates a simplified agent manager
44func NewSimpleAgentManager(cfg *config.Config, taskManager tm.TaskManager) (*SimpleAgentManager, error) {
45 // Create auto-assigner
46 autoAssigner := assignment.NewAutoAssigner(cfg.Agents)
47
48 // Create GitHub PR provider
49 githubConfig := git.GitHubConfig{
50 Token: cfg.GitHub.Token,
51 }
52 prProvider := git.NewGitHubPullRequestProvider(cfg.GitHub.Owner, cfg.GitHub.Repo, githubConfig)
53
54 // Create clone manager for per-agent Git repositories
55 repoURL := fmt.Sprintf("https://github.com/%s/%s.git", cfg.GitHub.Owner, cfg.GitHub.Repo)
56 workspacePath := filepath.Join(".", "workspace")
57 cloneManager := git.NewCloneManager(repoURL, workspacePath)
58
59 manager := &SimpleAgentManager{
60 config: cfg,
61 agents: make(map[string]*SimpleAgent),
62 taskManager: taskManager,
63 autoAssigner: autoAssigner,
64 prProvider: prProvider,
65 cloneManager: cloneManager,
66 isRunning: make(map[string]bool),
67 stopChannels: make(map[string]chan struct{}),
68 }
69
70 // Initialize agents
71 if err := manager.initializeAgents(); err != nil {
72 return nil, fmt.Errorf("failed to initialize agents: %w", err)
73 }
74
75 return manager, nil
76}
77
78// initializeAgents creates agent instances from configuration
79func (am *SimpleAgentManager) initializeAgents() error {
80 for _, agentConfig := range am.config.Agents {
81 agent, err := am.createAgent(agentConfig)
82 if err != nil {
83 return fmt.Errorf("failed to create agent %s: %w", agentConfig.Name, err)
84 }
85 am.agents[agentConfig.Name] = agent
86 }
87 return nil
88}
89
90// createAgent creates a single agent instance
91func (am *SimpleAgentManager) createAgent(agentConfig config.AgentConfig) (*SimpleAgent, error) {
92 // Load system prompt
93 systemPrompt, err := am.loadSystemPrompt(agentConfig.SystemPromptFile)
94 if err != nil {
95 return nil, fmt.Errorf("failed to load system prompt: %w", err)
96 }
97
98 // Create LLM provider
99 llmConfig := llm.Config{
100 Provider: llm.ProviderOpenAI,
101 APIKey: am.config.OpenAI.APIKey,
102 BaseURL: am.config.OpenAI.BaseURL,
103 Timeout: am.config.OpenAI.Timeout,
104 }
105
106 provider, err := llm.CreateProvider(llmConfig)
107 if err != nil {
108 return nil, fmt.Errorf("failed to create LLM provider: %w", err)
109 }
110
111 agent := &SimpleAgent{
112 Name: agentConfig.Name,
113 Role: agentConfig.Role,
114 Model: agentConfig.Model,
115 SystemPrompt: systemPrompt,
116 Provider: provider,
117 MaxTokens: agentConfig.MaxTokens,
118 Temperature: agentConfig.Temperature,
119 }
120
121 return agent, nil
122}
123
124// loadSystemPrompt loads the system prompt from file
125func (am *SimpleAgentManager) loadSystemPrompt(filePath string) (string, error) {
126 content, err := os.ReadFile(filePath)
127 if err != nil {
128 return "", fmt.Errorf("failed to read system prompt file %s: %w", filePath, err)
129 }
130 return string(content), nil
131}
132
133// StartAgent starts an agent to process tasks in a loop
134func (am *SimpleAgentManager) StartAgent(agentName string, loopInterval time.Duration) error {
135 agent, exists := am.agents[agentName]
136 if !exists {
137 return fmt.Errorf("agent %s not found", agentName)
138 }
139
140 if am.isRunning[agentName] {
141 return fmt.Errorf("agent %s is already running", agentName)
142 }
143
144 stopChan := make(chan struct{})
145 am.stopChannels[agentName] = stopChan
146 am.isRunning[agentName] = true
147
148 go am.runAgentLoop(agent, loopInterval, stopChan)
149
150 log.Printf("Started agent %s (%s) with %s model", agentName, agent.Role, agent.Model)
151 return nil
152}
153
154// StopAgent stops a running agent
155func (am *SimpleAgentManager) StopAgent(agentName string) error {
156 if !am.isRunning[agentName] {
157 return fmt.Errorf("agent %s is not running", agentName)
158 }
159
160 close(am.stopChannels[agentName])
161 delete(am.stopChannels, agentName)
162 am.isRunning[agentName] = false
163
164 log.Printf("Stopped agent %s", agentName)
165 return nil
166}
167
168// runAgentLoop runs the main processing loop for an agent
169func (am *SimpleAgentManager) runAgentLoop(agent *SimpleAgent, interval time.Duration, stopChan <-chan struct{}) {
170 ticker := time.NewTicker(interval)
171 defer ticker.Stop()
172
173 for {
174 select {
175 case <-stopChan:
176 log.Printf("Agent %s stopping", agent.Name)
177 return
178 case <-ticker.C:
179 if err := am.processAgentTasks(agent); err != nil {
180 log.Printf("Error processing tasks for agent %s: %v", agent.Name, err)
181 }
182 }
183 }
184}
185
186// processAgentTasks processes all assigned tasks for an agent
187func (am *SimpleAgentManager) processAgentTasks(agent *SimpleAgent) error {
188 // Get tasks assigned to this agent
189 tasks, err := am.taskManager.GetTasksByAssignee(agent.Name)
190 if err != nil {
191 return fmt.Errorf("failed to get tasks for agent %s: %w", agent.Name, err)
192 }
193
194 for _, task := range tasks {
195 if task.Status == tm.StatusPending || task.Status == tm.StatusInProgress {
196 if err := am.processTask(agent, task); err != nil {
197 log.Printf("Error processing task %s: %v", task.ID, err)
198 // Mark task as failed
199 task.Status = tm.StatusFailed
200 if err := am.taskManager.UpdateTask(task); err != nil {
201 log.Printf("Error updating failed task %s: %v", task.ID, err)
202 }
203 }
204 }
205 }
206
207 return nil
208}
209
210// processTask processes a single task with an agent
211func (am *SimpleAgentManager) processTask(agent *SimpleAgent, task *tm.Task) error {
212 ctx := context.Background()
213
214 log.Printf("Agent %s processing task %s: %s", agent.Name, task.ID, task.Title)
215
216 // Mark task as in progress
217 task.Status = tm.StatusInProgress
218 if err := am.taskManager.UpdateTask(task); err != nil {
219 return fmt.Errorf("failed to update task status: %w", err)
220 }
221
222 // Generate solution using LLM
223 solution, err := am.generateSolution(ctx, agent, task)
224 if err != nil {
225 return fmt.Errorf("failed to generate solution: %w", err)
226 }
227
228 // Create Git branch and commit solution
229 branchName := am.generateBranchName(task)
230 if err := am.createAndCommitSolution(branchName, task, solution, agent); err != nil {
231 return fmt.Errorf("failed to commit solution: %w", err)
232 }
233
234 // Create pull request
235 prURL, err := am.createPullRequest(ctx, task, solution, agent, branchName)
236 if err != nil {
237 return fmt.Errorf("failed to create pull request: %w", err)
238 }
239
240 // Update task as completed
241 task.Status = tm.StatusCompleted
242 task.Solution = solution
243 task.PullRequestURL = prURL
244 task.CompletedAt = &time.Time{}
245 *task.CompletedAt = time.Now()
246
247 if err := am.taskManager.UpdateTask(task); err != nil {
248 return fmt.Errorf("failed to update completed task: %w", err)
249 }
250
251 log.Printf("Task %s completed by agent %s. PR: %s", task.ID, agent.Name, prURL)
252 return nil
253}
254
255// generateSolution uses the agent's LLM to generate a solution
256func (am *SimpleAgentManager) generateSolution(ctx context.Context, agent *SimpleAgent, task *tm.Task) (string, error) {
257 prompt := am.buildTaskPrompt(task)
258
259 req := llm.ChatCompletionRequest{
260 Model: agent.Model,
261 Messages: []llm.Message{
262 {
263 Role: llm.RoleSystem,
264 Content: agent.SystemPrompt,
265 },
266 {
267 Role: llm.RoleUser,
268 Content: prompt,
269 },
270 },
271 MaxTokens: agent.MaxTokens,
272 Temperature: agent.Temperature,
273 }
274
275 resp, err := agent.Provider.ChatCompletion(ctx, req)
276 if err != nil {
277 return "", fmt.Errorf("LLM request failed: %w", err)
278 }
279
280 if len(resp.Choices) == 0 {
281 return "", fmt.Errorf("no response from LLM")
282 }
283
284 return resp.Choices[0].Message.Content, nil
285}
286
287// buildTaskPrompt creates a detailed prompt for the LLM
288func (am *SimpleAgentManager) buildTaskPrompt(task *tm.Task) string {
289 return fmt.Sprintf(`Task: %s
290
291Priority: %s
292Description: %s
293
294Please provide a complete solution for this task. Include:
2951. Detailed implementation plan
2962. Code changes needed (if applicable)
2973. Files to be created or modified
2984. Testing considerations
2995. Any dependencies or prerequisites
300
301Your response should be comprehensive and actionable.`,
302 task.Title,
303 task.Priority,
304 task.Description)
305}
306
307// generateBranchName creates a Git branch name for the task
308func (am *SimpleAgentManager) generateBranchName(task *tm.Task) string {
309 // Clean title for use in branch name
310 cleanTitle := strings.ToLower(task.Title)
311 cleanTitle = strings.ReplaceAll(cleanTitle, " ", "-")
312 cleanTitle = strings.ReplaceAll(cleanTitle, "/", "-")
313 // Remove special characters
314 var result strings.Builder
315 for _, r := range cleanTitle {
316 if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
317 result.WriteRune(r)
318 }
319 }
320 cleanTitle = result.String()
321
322 // Limit length
323 if len(cleanTitle) > 40 {
324 cleanTitle = cleanTitle[:40]
325 }
326
327 return fmt.Sprintf("%s%s-%s", am.config.Git.BranchPrefix, task.ID, cleanTitle)
328}
329
330// createAndCommitSolution creates a Git branch and commits the solution using per-agent clones
331// Each agent works in its own Git clone, eliminating concurrency issues
332func (am *SimpleAgentManager) createAndCommitSolution(branchName string, task *tm.Task, solution string, agent *SimpleAgent) error {
333 ctx := context.Background()
334
335 // Get agent's dedicated Git clone
336 clonePath, err := am.cloneManager.GetAgentClonePath(agent.Name)
337 if err != nil {
338 return fmt.Errorf("failed to get agent clone: %w", err)
339 }
340
341 log.Printf("Agent %s working in clone: %s", agent.Name, clonePath)
342
343 // Refresh the clone with latest changes
344 if err := am.cloneManager.RefreshAgentClone(agent.Name); err != nil {
345 log.Printf("Warning: Failed to refresh clone for agent %s: %v", agent.Name, err)
346 }
347
348 // All Git operations use the agent's clone directory
349 gitCmd := func(args ...string) *exec.Cmd {
350 return exec.CommandContext(ctx, "git", append([]string{"-C", clonePath}, args...)...)
351 }
352
353 // Ensure we're on main branch before creating new branch
354 cmd := gitCmd("checkout", "main")
355 if err := cmd.Run(); err != nil {
356 // Try master branch if main doesn't exist
357 cmd = gitCmd("checkout", "master")
358 if err := cmd.Run(); err != nil {
359 return fmt.Errorf("failed to checkout main/master branch: %w", err)
360 }
361 }
362
363 // Create branch
364 cmd = gitCmd("checkout", "-b", branchName)
365 if err := cmd.Run(); err != nil {
366 return fmt.Errorf("failed to create branch: %w", err)
367 }
368
369 // Create solution file in agent's clone
370 solutionDir := filepath.Join(clonePath, "tasks", "solutions")
371 if err := os.MkdirAll(solutionDir, 0755); err != nil {
372 return fmt.Errorf("failed to create solution directory: %w", err)
373 }
374
375 solutionFile := filepath.Join(solutionDir, fmt.Sprintf("%s-solution.md", task.ID))
376 solutionContent := fmt.Sprintf(`# Solution for Task: %s
377
378**Agent:** %s (%s)
379**Model:** %s
380**Completed:** %s
381
382## Task Description
383%s
384
385## Solution
386%s
387
388---
389*Generated by Staff AI Agent System*
390`, task.Title, agent.Name, agent.Role, agent.Model, time.Now().Format(time.RFC3339), task.Description, solution)
391
392 if err := os.WriteFile(solutionFile, []byte(solutionContent), 0644); err != nil {
393 return fmt.Errorf("failed to write solution file: %w", err)
394 }
395
396 // Stage files
397 relativeSolutionFile := filepath.Join("tasks", "solutions", fmt.Sprintf("%s-solution.md", task.ID))
398 cmd = gitCmd("add", relativeSolutionFile)
399 if err := cmd.Run(); err != nil {
400 return fmt.Errorf("failed to stage files: %w", err)
401 }
402
403 // Commit changes
404 commitMsg := am.buildCommitMessage(task, agent)
405 cmd = gitCmd("commit", "-m", commitMsg)
406 if err := cmd.Run(); err != nil {
407 return fmt.Errorf("failed to commit: %w", err)
408 }
409
410 // Push branch
411 cmd = gitCmd("push", "-u", "origin", branchName)
412 if err := cmd.Run(); err != nil {
413 return fmt.Errorf("failed to push branch: %w", err)
414 }
415
416 log.Printf("Agent %s successfully pushed branch %s", agent.Name, branchName)
417 return nil
418}
419
420// buildCommitMessage creates a commit message from template
421func (am *SimpleAgentManager) buildCommitMessage(task *tm.Task, agent *SimpleAgent) string {
422 template := am.config.Git.CommitMessageTemplate
423
424 replacements := map[string]string{
425 "{task_id}": task.ID,
426 "{task_title}": task.Title,
427 "{agent_name}": agent.Name,
428 "{solution}": "See solution file for details",
429 }
430
431 result := template
432 for placeholder, value := range replacements {
433 result = strings.ReplaceAll(result, placeholder, value)
434 }
435
436 return result
437}
438
439// createPullRequest creates a GitHub pull request
440func (am *SimpleAgentManager) createPullRequest(ctx context.Context, task *tm.Task, solution string, agent *SimpleAgent, branchName string) (string, error) {
441 title := fmt.Sprintf("Task %s: %s", task.ID, task.Title)
442
443 // Build PR description from template
444 description := am.buildPRDescription(task, solution, agent)
445
446 options := git.PullRequestOptions{
447 Title: title,
448 Description: description,
449 HeadBranch: branchName,
450 BaseBranch: "main",
451 Labels: []string{"ai-generated", "staff-agent", strings.ToLower(agent.Role)},
452 Draft: false,
453 }
454
455 pr, err := am.prProvider.CreatePullRequest(ctx, options)
456 if err != nil {
457 return "", fmt.Errorf("failed to create PR: %w", err)
458 }
459
460 return fmt.Sprintf("https://github.com/%s/%s/pull/%d", am.config.GitHub.Owner, am.config.GitHub.Repo, pr.Number), nil
461}
462
463// buildPRDescription creates PR description from template
464func (am *SimpleAgentManager) buildPRDescription(task *tm.Task, solution string, agent *SimpleAgent) string {
465 template := am.config.Git.PRTemplate
466
467 // Truncate solution for PR if too long
468 truncatedSolution := solution
469 if len(solution) > 1000 {
470 truncatedSolution = solution[:1000] + "...\n\n*See solution file for complete details*"
471 }
472
473 replacements := map[string]string{
474 "{task_id}": task.ID,
475 "{task_title}": task.Title,
476 "{task_description}": task.Description,
477 "{agent_name}": fmt.Sprintf("%s (%s)", agent.Name, agent.Role),
478 "{priority}": string(task.Priority),
479 "{solution}": truncatedSolution,
480 "{files_changed}": fmt.Sprintf("- `tasks/solutions/%s-solution.md`", task.ID),
481 }
482
483 result := template
484 for placeholder, value := range replacements {
485 result = strings.ReplaceAll(result, placeholder, value)
486 }
487
488 return result
489}
490
491// AutoAssignTask automatically assigns a task to the best matching agent
492func (am *SimpleAgentManager) AutoAssignTask(taskID string) error {
493 task, err := am.taskManager.GetTask(taskID)
494 if err != nil {
495 return fmt.Errorf("failed to get task: %w", err)
496 }
497
498 agentName, err := am.autoAssigner.AssignTask(task)
499 if err != nil {
500 return fmt.Errorf("failed to auto-assign task: %w", err)
501 }
502
503 task.Assignee = agentName
504 if err := am.taskManager.UpdateTask(task); err != nil {
505 return fmt.Errorf("failed to update task assignment: %w", err)
506 }
507
508 explanation := am.autoAssigner.GetRecommendationExplanation(task, agentName)
509 log.Printf("Auto-assigned task %s to %s: %s", taskID, agentName, explanation)
510
511 return nil
512}
513
514// GetAgentStatus returns the status of all agents
515func (am *SimpleAgentManager) GetAgentStatus() map[string]SimpleAgentStatus {
516 status := make(map[string]SimpleAgentStatus)
517
518 for name, agent := range am.agents {
519 status[name] = SimpleAgentStatus{
520 Name: agent.Name,
521 Role: agent.Role,
522 Model: agent.Model,
523 IsRunning: am.isRunning[name],
524 }
525 }
526
527 return status
528}
529
530// SimpleAgentStatus represents the status of an agent
531type SimpleAgentStatus struct {
532 Name string `json:"name"`
533 Role string `json:"role"`
534 Model string `json:"model"`
535 IsRunning bool `json:"is_running"`
536}
537
538// IsAgentRunning checks if an agent is currently running
539func (am *SimpleAgentManager) IsAgentRunning(agentName string) bool {
540 return am.isRunning[agentName]
541}
542
543// Close shuts down the agent manager
544func (am *SimpleAgentManager) Close() error {
545 // Stop all running agents
546 for agentName := range am.isRunning {
547 if am.isRunning[agentName] {
548 am.StopAgent(agentName)
549 }
550 }
551
552 // Close all LLM providers
553 for _, agent := range am.agents {
554 if err := agent.Provider.Close(); err != nil {
555 log.Printf("Error closing provider for agent %s: %v", agent.Name, err)
556 }
557 }
558
559 // Cleanup all agent Git clones
560 if err := am.cloneManager.CleanupAllClones(); err != nil {
561 log.Printf("Error cleaning up agent clones: %v", err)
562 }
563
564 return nil
565}