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