blob: 6de4784d3ba1c68b32fff8a5f8f53abb2562ec71 [file] [log] [blame]
iomodob67a3762025-07-25 20:27:04 +04001package agent
2
iomodo76f9a2d2025-07-26 12:14:40 +04003import (
4 "context"
5 "fmt"
6 "log"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/iomodo/staff/git"
13 "github.com/iomodo/staff/llm"
14 "github.com/iomodo/staff/tm"
15)
16
17// AgentConfig contains configuration for the agent
iomodob67a3762025-07-25 20:27:04 +040018type AgentConfig struct {
19 Name string
20 Role string
21 GitUsername string
22 GitEmail string
23 WorkingDir string
iomodo76f9a2d2025-07-26 12:14:40 +040024
25 // LLM Configuration
26 LLMProvider llm.Provider
27 LLMModel string
28 LLMConfig llm.Config
29
30 // System prompt for the agent
31 SystemPrompt string
32
33 // Task Manager Configuration
34 TaskManager tm.TaskManager
35
36 // Git Configuration
37 GitRepoPath string
38 GitRemote string
39 GitBranch string
iomodo570d4262025-07-26 16:26:36 +040040
41 // Gerrit Configuration
42 GerritEnabled bool
43 GerritConfig GerritConfig
44}
45
46// GerritConfig holds configuration for Gerrit operations
47type GerritConfig struct {
48 Username string
49 Password string // Can be HTTP password or API token
50 BaseURL string
51 Project string
iomodob67a3762025-07-25 20:27:04 +040052}
53
iomodo76f9a2d2025-07-26 12:14:40 +040054// Agent represents an AI agent that can process tasks
iomodob67a3762025-07-25 20:27:04 +040055type Agent struct {
iomodo76f9a2d2025-07-26 12:14:40 +040056 Config AgentConfig
57 llmProvider llm.LLMProvider
58 gitInterface git.GitInterface
59 ctx context.Context
60 cancel context.CancelFunc
iomodob67a3762025-07-25 20:27:04 +040061}
62
iomodo76f9a2d2025-07-26 12:14:40 +040063// NewAgent creates a new agent instance
64func NewAgent(config AgentConfig) (*Agent, error) {
65 // Validate configuration
66 if err := validateConfig(config); err != nil {
67 return nil, fmt.Errorf("invalid config: %w", err)
68 }
69
70 // Create LLM provider
71 llmProvider, err := llm.CreateProvider(config.LLMConfig)
72 if err != nil {
73 return nil, fmt.Errorf("failed to create LLM provider: %w", err)
74 }
75
76 // Create git interface
iomodo570d4262025-07-26 16:26:36 +040077 var gitInterface git.GitInterface
78 if config.GerritEnabled {
79 // Create Gerrit pull request provider
80 gerritPRProvider := git.NewGerritPullRequestProvider(config.GerritConfig.Project, git.GerritConfig{
81 Username: config.GerritConfig.Username,
82 Password: config.GerritConfig.Password,
83 BaseURL: config.GerritConfig.BaseURL,
84 HTTPClient: nil, // Will use default client
85 })
86
87 // Create git interface with Gerrit pull request provider
88 gitConfig := git.GitConfig{
89 Timeout: 30 * time.Second,
90 PullRequestProvider: gerritPRProvider,
91 }
92 gitInterface = git.NewGitWithPullRequests(config.GitRepoPath, gitConfig, gerritPRProvider)
93 } else {
94 // Use default git interface (GitHub)
95 gitInterface = git.DefaultGit(config.GitRepoPath)
96 }
iomodo76f9a2d2025-07-26 12:14:40 +040097
98 // Create context with cancellation
99 ctx, cancel := context.WithCancel(context.Background())
100
101 agent := &Agent{
102 Config: config,
103 llmProvider: llmProvider,
104 gitInterface: gitInterface,
105 ctx: ctx,
106 cancel: cancel,
107 }
108
109 return agent, nil
110}
111
112// validateConfig validates the agent configuration
113func validateConfig(config AgentConfig) error {
114 if config.Name == "" {
115 return fmt.Errorf("agent name is required")
116 }
117 if config.Role == "" {
118 return fmt.Errorf("agent role is required")
119 }
120 if config.WorkingDir == "" {
121 return fmt.Errorf("working directory is required")
122 }
123 if config.SystemPrompt == "" {
124 return fmt.Errorf("system prompt is required")
125 }
126 if config.TaskManager == nil {
127 return fmt.Errorf("task manager is required")
128 }
129 if config.GitRepoPath == "" {
130 return fmt.Errorf("git repository path is required")
131 }
132 return nil
133}
134
135// Run starts the agent's main loop
136func (a *Agent) Run() error {
137 log.Printf("Starting agent %s (%s)", a.Config.Name, a.Config.Role)
138 defer log.Printf("Agent %s stopped", a.Config.Name)
139
140 // Initialize git repository if needed
141 if err := a.initializeGit(); err != nil {
142 return fmt.Errorf("failed to initialize git: %w", err)
143 }
144
145 // Main agent loop
146 for {
147 select {
148 case <-a.ctx.Done():
149 return a.ctx.Err()
150 default:
151 if err := a.processNextTask(); err != nil {
152 log.Printf("Error processing task: %v", err)
153 // Continue running even if there's an error
154 time.Sleep(30 * time.Second)
155 }
156 }
iomodob67a3762025-07-25 20:27:04 +0400157 }
158}
159
iomodo76f9a2d2025-07-26 12:14:40 +0400160// Stop stops the agent
161func (a *Agent) Stop() {
162 log.Printf("Stopping agent %s", a.Config.Name)
163 a.cancel()
164 if a.llmProvider != nil {
165 a.llmProvider.Close()
166 }
167}
iomodob67a3762025-07-25 20:27:04 +0400168
iomodo76f9a2d2025-07-26 12:14:40 +0400169// initializeGit initializes the git repository
170func (a *Agent) initializeGit() error {
171 ctx := context.Background()
172
173 // Check if repository exists
174 isRepo, err := a.gitInterface.IsRepository(ctx, a.Config.GitRepoPath)
175 if err != nil {
176 return fmt.Errorf("failed to check repository: %w", err)
177 }
178
179 if !isRepo {
180 // Initialize new repository
181 if err := a.gitInterface.Init(ctx, a.Config.GitRepoPath); err != nil {
182 return fmt.Errorf("failed to initialize repository: %w", err)
183 }
184 }
185
iomodo570d4262025-07-26 16:26:36 +0400186 // Check if remote origin exists, if not add it
187 remotes, err := a.gitInterface.ListRemotes(ctx)
188 if err != nil {
189 return fmt.Errorf("failed to list remotes: %w", err)
190 }
191
192 originExists := false
193 for _, remote := range remotes {
194 if remote.Name == "origin" {
195 originExists = true
196 break
197 }
198 }
199
200 if !originExists {
201 // Add remote origin - use Gerrit URL if enabled, otherwise use the configured remote
202 remoteURL := a.Config.GitRemote
203 if a.Config.GerritEnabled {
204 // For Gerrit, the remote URL should be the Gerrit SSH or HTTP URL
205 // Format: ssh://username@gerrit-host:29418/project-name.git
206 // or: https://gerrit-host/project-name.git
207 if strings.HasPrefix(a.Config.GerritConfig.BaseURL, "https://") {
208 remoteURL = fmt.Sprintf("%s/%s.git", a.Config.GerritConfig.BaseURL, a.Config.GerritConfig.Project)
209 } else {
210 // Assume SSH format
211 remoteURL = fmt.Sprintf("ssh://%s@%s:29418/%s.git",
212 a.Config.GerritConfig.Username,
213 strings.TrimPrefix(a.Config.GerritConfig.BaseURL, "https://"),
214 a.Config.GerritConfig.Project)
215 }
216 }
217
218 if err := a.gitInterface.AddRemote(ctx, "origin", remoteURL); err != nil {
219 return fmt.Errorf("failed to add remote origin: %w", err)
220 }
221 }
222
iomodo76f9a2d2025-07-26 12:14:40 +0400223 // Checkout to the specified branch
224 if a.Config.GitBranch != "" {
iomodo4dc799e2025-07-26 18:39:34 +0400225 // First, check if we're already on the target branch
226 currentBranch, err := a.gitInterface.GetCurrentBranch(ctx)
227 if err != nil {
228 return fmt.Errorf("failed to get current branch: %w", err)
229 }
230
231 // Only checkout if we're not already on the target branch
232 if currentBranch != a.Config.GitBranch {
233 if err := a.gitInterface.Checkout(ctx, a.Config.GitBranch); err != nil {
234 errMsg := err.Error()
235
236 // Only create the branch if the error indicates it doesn't exist
237 if strings.Contains(errMsg, "did not match any file(s) known to git") ||
238 strings.Contains(errMsg, "not found") ||
239 strings.Contains(errMsg, "unknown revision") ||
240 strings.Contains(errMsg, "reference is not a tree") ||
241 strings.Contains(errMsg, "pathspec") ||
242 strings.Contains(errMsg, "fatal: invalid reference") {
243 if err := a.gitInterface.CreateBranch(ctx, a.Config.GitBranch, ""); err != nil {
244 return fmt.Errorf("failed to create branch %s: %w", a.Config.GitBranch, err)
245 }
246 } else {
247 return fmt.Errorf("failed to checkout branch %s: %w", a.Config.GitBranch, err)
248 }
iomodo76f9a2d2025-07-26 12:14:40 +0400249 }
iomodo4dc799e2025-07-26 18:39:34 +0400250 } else {
251 log.Printf("Already on target branch: %s", a.Config.GitBranch)
iomodo76f9a2d2025-07-26 12:14:40 +0400252 }
253 }
254
255 return nil
256}
257
258// processNextTask processes the next available task
259func (a *Agent) processNextTask() error {
260 ctx := context.Background()
261
262 // Get tasks assigned to this agent
263 taskList, err := a.Config.TaskManager.GetTasksByOwner(ctx, a.Config.Name, 0, 10)
264 if err != nil {
265 return fmt.Errorf("failed to get tasks: %w", err)
266 }
267
268 // Find a task that's ready to be worked on
269 var taskToProcess *tm.Task
270 for _, task := range taskList.Tasks {
271 if task.Status == tm.StatusToDo {
272 taskToProcess = task
273 break
274 }
275 }
276
277 if taskToProcess == nil {
278 // No tasks to process, wait a bit
279 time.Sleep(60 * time.Second)
280 return nil
281 }
282
283 log.Printf("Processing task: %s - %s", taskToProcess.ID, taskToProcess.Title)
284
285 // Start the task
286 startedTask, err := a.Config.TaskManager.StartTask(ctx, taskToProcess.ID)
287 if err != nil {
288 return fmt.Errorf("failed to start task: %w", err)
289 }
290
291 // Process the task with LLM
292 solution, err := a.processTaskWithLLM(startedTask)
293 if err != nil {
294 // Mark task as failed or retry
295 log.Printf("Failed to process task with LLM: %v", err)
296 return err
297 }
298
299 // Create PR with the solution
300 if err := a.createPullRequest(startedTask, solution); err != nil {
301 return fmt.Errorf("failed to create pull request: %w", err)
302 }
303
304 // Complete the task
305 if _, err := a.Config.TaskManager.CompleteTask(ctx, startedTask.ID); err != nil {
306 return fmt.Errorf("failed to complete task: %w", err)
307 }
308
309 log.Printf("Successfully completed task: %s", startedTask.ID)
310 return nil
311}
312
313// processTaskWithLLM sends the task to the LLM and gets a solution
314func (a *Agent) processTaskWithLLM(task *tm.Task) (string, error) {
315 ctx := context.Background()
316
317 // Prepare the prompt
318 prompt := a.buildTaskPrompt(task)
319
320 // Create chat completion request
321 req := llm.ChatCompletionRequest{
322 Model: a.Config.LLMModel,
323 Messages: []llm.Message{
324 {
325 Role: llm.RoleSystem,
326 Content: a.Config.SystemPrompt,
327 },
328 {
329 Role: llm.RoleUser,
330 Content: prompt,
331 },
332 },
333 MaxTokens: intPtr(4000),
334 Temperature: float64Ptr(0.7),
335 }
336
337 // Get response from LLM
338 resp, err := a.llmProvider.ChatCompletion(ctx, req)
339 if err != nil {
340 return "", fmt.Errorf("LLM chat completion failed: %w", err)
341 }
342
343 if len(resp.Choices) == 0 {
344 return "", fmt.Errorf("no response from LLM")
345 }
346
347 return resp.Choices[0].Message.Content, nil
348}
349
350// buildTaskPrompt builds the prompt for the LLM based on the task
351func (a *Agent) buildTaskPrompt(task *tm.Task) string {
352 var prompt strings.Builder
353
354 prompt.WriteString(fmt.Sprintf("Task ID: %s\n", task.ID))
355 prompt.WriteString(fmt.Sprintf("Title: %s\n", task.Title))
356 prompt.WriteString(fmt.Sprintf("Priority: %s\n", task.Priority))
357
358 if task.Description != "" {
359 prompt.WriteString(fmt.Sprintf("Description: %s\n", task.Description))
360 }
361
362 if task.DueDate != nil {
363 prompt.WriteString(fmt.Sprintf("Due Date: %s\n", task.DueDate.Format("2006-01-02")))
364 }
365
366 prompt.WriteString("\nPlease provide a detailed solution for this task. ")
367 prompt.WriteString("Include any code, documentation, or other deliverables as needed. ")
368 prompt.WriteString("Format your response appropriately for the type of task.")
369
370 return prompt.String()
371}
372
373// createPullRequest creates a pull request with the solution
374func (a *Agent) createPullRequest(task *tm.Task, solution string) error {
375 ctx := context.Background()
376
377 // Generate branch name
378 branchName := a.generateBranchName(task)
379
380 // Create and checkout to new branch
381 if err := a.gitInterface.CreateBranch(ctx, branchName, ""); err != nil {
382 return fmt.Errorf("failed to create branch: %w", err)
383 }
384
385 if err := a.gitInterface.Checkout(ctx, branchName); err != nil {
386 return fmt.Errorf("failed to checkout branch: %w", err)
387 }
388
389 // Create solution file
390 solutionPath := filepath.Join(a.Config.WorkingDir, fmt.Sprintf("task-%s-solution.md", task.ID))
391 solutionContent := a.formatSolution(task, solution)
392
393 if err := os.WriteFile(solutionPath, []byte(solutionContent), 0644); err != nil {
394 return fmt.Errorf("failed to write solution file: %w", err)
395 }
396
397 // Add and commit the solution
398 if err := a.gitInterface.Add(ctx, []string{solutionPath}); err != nil {
399 return fmt.Errorf("failed to add solution file: %w", err)
400 }
401
iomodo570d4262025-07-26 16:26:36 +0400402 commitMessage := fmt.Sprintf("feat: Complete task %s - %s\n\n%s", task.ID, task.Title, a.formatPullRequestDescription(task, solution))
iomodo7d08e8e2025-07-26 15:24:42 +0400403 if err := a.gitInterface.Commit(ctx, commitMessage, git.CommitOptions{
404 Author: &git.Author{
405 Name: a.Config.GitUsername,
406 Email: a.Config.GitEmail,
407 Time: time.Now(),
408 },
409 }); err != nil {
iomodo76f9a2d2025-07-26 12:14:40 +0400410 return fmt.Errorf("failed to commit solution: %w", err)
411 }
412
iomodo570d4262025-07-26 16:26:36 +0400413 if a.Config.GerritEnabled {
414 // For Gerrit: Push to refs/for/BRANCH to create a change
415 gerritRef := fmt.Sprintf("refs/for/%s", a.Config.GitBranch)
416 if err := a.gitInterface.Push(ctx, "origin", gerritRef, git.PushOptions{}); err != nil {
417 return fmt.Errorf("failed to push to Gerrit: %w", err)
418 }
419 log.Printf("Created Gerrit change for task %s by pushing to %s", task.ID, gerritRef)
420 } else {
421 // For GitHub: Push branch and create PR
422 if err := a.gitInterface.Push(ctx, "origin", branchName, git.PushOptions{SetUpstream: true}); err != nil {
423 return fmt.Errorf("failed to push branch: %w", err)
424 }
425
426 // Create pull request using the git interface
427 prOptions := git.PullRequestOptions{
428 Title: fmt.Sprintf("Complete task %s: %s", task.ID, task.Title),
429 Description: a.formatPullRequestDescription(task, solution),
430 BaseBranch: a.Config.GitBranch,
431 HeadBranch: branchName,
432 BaseRepo: a.Config.GerritConfig.Project,
433 HeadRepo: a.Config.GerritConfig.Project,
434 }
435
436 pr, err := a.gitInterface.CreatePullRequest(ctx, prOptions)
437 if err != nil {
438 return fmt.Errorf("failed to create pull request: %w", err)
439 }
440
441 log.Printf("Created pull request for task %s: %s (ID: %s)", task.ID, pr.Title, pr.ID)
iomodo76f9a2d2025-07-26 12:14:40 +0400442 }
443
iomodo76f9a2d2025-07-26 12:14:40 +0400444 return nil
445}
446
447// generateBranchName generates a branch name for the task
448func (a *Agent) generateBranchName(task *tm.Task) string {
449 // Clean the task title for branch name
450 cleanTitle := strings.ReplaceAll(task.Title, " ", "-")
451 cleanTitle = strings.ToLower(cleanTitle)
452
453 // Remove special characters that are not allowed in git branch names
454 // Keep only alphanumeric characters and hyphens
455 var result strings.Builder
456 for _, char := range cleanTitle {
457 if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' {
458 result.WriteRune(char)
459 }
460 }
461 cleanTitle = result.String()
462
463 // Remove consecutive hyphens
464 for strings.Contains(cleanTitle, "--") {
465 cleanTitle = strings.ReplaceAll(cleanTitle, "--", "-")
466 }
467
468 // Remove leading and trailing hyphens
469 cleanTitle = strings.Trim(cleanTitle, "-")
470
471 // Limit length
472 if len(cleanTitle) > 50 {
473 cleanTitle = cleanTitle[:50]
474 // Ensure we don't end with a hyphen after truncation
475 cleanTitle = strings.TrimSuffix(cleanTitle, "-")
476 }
477
478 return fmt.Sprintf("task/%s-%s", task.ID, cleanTitle)
479}
480
481// formatSolution formats the solution for the pull request
482func (a *Agent) formatSolution(task *tm.Task, solution string) string {
483 var content strings.Builder
484
485 content.WriteString(fmt.Sprintf("# Task Solution: %s\n\n", task.Title))
486 content.WriteString(fmt.Sprintf("**Task ID:** %s\n", task.ID))
487 content.WriteString(fmt.Sprintf("**Agent:** %s (%s)\n", a.Config.Name, a.Config.Role))
488 content.WriteString(fmt.Sprintf("**Completed:** %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
489
490 content.WriteString("## Task Description\n\n")
491 content.WriteString(task.Description)
492 content.WriteString("\n\n")
493
494 content.WriteString("## Solution\n\n")
495 content.WriteString(solution)
496 content.WriteString("\n\n")
497
498 content.WriteString("---\n")
499 content.WriteString("*This solution was generated by AI Agent*\n")
500
501 return content.String()
502}
503
iomodo570d4262025-07-26 16:26:36 +0400504// formatPullRequestDescription formats the description for the pull request
505func (a *Agent) formatPullRequestDescription(task *tm.Task, solution string) string {
506 var content strings.Builder
507
508 content.WriteString(fmt.Sprintf("**Task ID:** %s\n", task.ID))
509 content.WriteString(fmt.Sprintf("**Title:** %s\n", task.Title))
510 content.WriteString(fmt.Sprintf("**Priority:** %s\n", task.Priority))
511
512 if task.Description != "" {
513 content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description))
514 }
515
516 if task.DueDate != nil {
517 content.WriteString(fmt.Sprintf("**Due Date:** %s\n", task.DueDate.Format("2006-01-02")))
518 }
519
520 content.WriteString("\n**Solution:**\n\n")
521 content.WriteString(solution)
522
523 return content.String()
524}
525
iomodo76f9a2d2025-07-26 12:14:40 +0400526// ptr helpers for cleaner code
527func intPtr(i int) *int {
528 return &i
529}
530
531func float64Ptr(f float64) *float64 {
532 return &f
iomodob67a3762025-07-25 20:27:04 +0400533}