blob: ed4706d6d46519c54611e9a967d3e514684fd5e5 [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)
267 if err != nil {
268 return fmt.Errorf("failed to get tasks: %w", err)
269 }
270
271 // Find a task that's ready to be worked on
272 var taskToProcess *tm.Task
273 for _, task := range taskList.Tasks {
274 if task.Status == tm.StatusToDo {
275 taskToProcess = task
276 break
277 }
278 }
279
280 if taskToProcess == nil {
281 // No tasks to process, wait a bit
282 time.Sleep(60 * time.Second)
283 return nil
284 }
285
iomodo0c203b12025-07-26 19:44:57 +0400286 a.logger.Info("Processing task", slog.String("id", taskToProcess.ID), slog.String("title", taskToProcess.Title))
iomodo76f9a2d2025-07-26 12:14:40 +0400287
288 // Start the task
289 startedTask, err := a.Config.TaskManager.StartTask(ctx, taskToProcess.ID)
290 if err != nil {
291 return fmt.Errorf("failed to start task: %w", err)
292 }
293
294 // Process the task with LLM
295 solution, err := a.processTaskWithLLM(startedTask)
296 if err != nil {
297 // Mark task as failed or retry
iomodo0c203b12025-07-26 19:44:57 +0400298 a.logger.Error("Failed to process task with LLM", slog.String("error", err.Error()))
iomodo76f9a2d2025-07-26 12:14:40 +0400299 return err
300 }
301
302 // Create PR with the solution
303 if err := a.createPullRequest(startedTask, solution); err != nil {
304 return fmt.Errorf("failed to create pull request: %w", err)
305 }
306
307 // Complete the task
308 if _, err := a.Config.TaskManager.CompleteTask(ctx, startedTask.ID); err != nil {
309 return fmt.Errorf("failed to complete task: %w", err)
310 }
311
iomodo0c203b12025-07-26 19:44:57 +0400312 a.logger.Info("Successfully completed task", slog.String("id", startedTask.ID))
iomodo76f9a2d2025-07-26 12:14:40 +0400313 return nil
314}
315
316// processTaskWithLLM sends the task to the LLM and gets a solution
317func (a *Agent) processTaskWithLLM(task *tm.Task) (string, error) {
318 ctx := context.Background()
319
320 // Prepare the prompt
321 prompt := a.buildTaskPrompt(task)
322
323 // Create chat completion request
324 req := llm.ChatCompletionRequest{
325 Model: a.Config.LLMModel,
326 Messages: []llm.Message{
327 {
328 Role: llm.RoleSystem,
329 Content: a.Config.SystemPrompt,
330 },
331 {
332 Role: llm.RoleUser,
333 Content: prompt,
334 },
335 },
336 MaxTokens: intPtr(4000),
337 Temperature: float64Ptr(0.7),
338 }
339
340 // Get response from LLM
341 resp, err := a.llmProvider.ChatCompletion(ctx, req)
342 if err != nil {
343 return "", fmt.Errorf("LLM chat completion failed: %w", err)
344 }
345
346 if len(resp.Choices) == 0 {
347 return "", fmt.Errorf("no response from LLM")
348 }
349
350 return resp.Choices[0].Message.Content, nil
351}
352
353// buildTaskPrompt builds the prompt for the LLM based on the task
354func (a *Agent) buildTaskPrompt(task *tm.Task) string {
355 var prompt strings.Builder
356
357 prompt.WriteString(fmt.Sprintf("Task ID: %s\n", task.ID))
358 prompt.WriteString(fmt.Sprintf("Title: %s\n", task.Title))
359 prompt.WriteString(fmt.Sprintf("Priority: %s\n", task.Priority))
360
361 if task.Description != "" {
362 prompt.WriteString(fmt.Sprintf("Description: %s\n", task.Description))
363 }
364
365 if task.DueDate != nil {
366 prompt.WriteString(fmt.Sprintf("Due Date: %s\n", task.DueDate.Format("2006-01-02")))
367 }
368
369 prompt.WriteString("\nPlease provide a detailed solution for this task. ")
370 prompt.WriteString("Include any code, documentation, or other deliverables as needed. ")
371 prompt.WriteString("Format your response appropriately for the type of task.")
372
373 return prompt.String()
374}
375
376// createPullRequest creates a pull request with the solution
377func (a *Agent) createPullRequest(task *tm.Task, solution string) error {
378 ctx := context.Background()
379
380 // Generate branch name
381 branchName := a.generateBranchName(task)
382
383 // Create and checkout to new branch
384 if err := a.gitInterface.CreateBranch(ctx, branchName, ""); err != nil {
385 return fmt.Errorf("failed to create branch: %w", err)
386 }
387
388 if err := a.gitInterface.Checkout(ctx, branchName); err != nil {
389 return fmt.Errorf("failed to checkout branch: %w", err)
390 }
391
392 // Create solution file
393 solutionPath := filepath.Join(a.Config.WorkingDir, fmt.Sprintf("task-%s-solution.md", task.ID))
394 solutionContent := a.formatSolution(task, solution)
395
396 if err := os.WriteFile(solutionPath, []byte(solutionContent), 0644); err != nil {
397 return fmt.Errorf("failed to write solution file: %w", err)
398 }
399
400 // Add and commit the solution
401 if err := a.gitInterface.Add(ctx, []string{solutionPath}); err != nil {
402 return fmt.Errorf("failed to add solution file: %w", err)
403 }
404
iomodo570d4262025-07-26 16:26:36 +0400405 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 +0400406 if err := a.gitInterface.Commit(ctx, commitMessage, git.CommitOptions{
407 Author: &git.Author{
408 Name: a.Config.GitUsername,
409 Email: a.Config.GitEmail,
410 Time: time.Now(),
411 },
412 }); err != nil {
iomodo76f9a2d2025-07-26 12:14:40 +0400413 return fmt.Errorf("failed to commit solution: %w", err)
414 }
415
iomodo570d4262025-07-26 16:26:36 +0400416 if a.Config.GerritEnabled {
417 // For Gerrit: Push to refs/for/BRANCH to create a change
418 gerritRef := fmt.Sprintf("refs/for/%s", a.Config.GitBranch)
419 if err := a.gitInterface.Push(ctx, "origin", gerritRef, git.PushOptions{}); err != nil {
420 return fmt.Errorf("failed to push to Gerrit: %w", err)
421 }
iomodo0c203b12025-07-26 19:44:57 +0400422 a.logger.Info("Created Gerrit change for task", slog.String("id", task.ID), slog.String("ref", gerritRef))
iomodo570d4262025-07-26 16:26:36 +0400423 } else {
424 // For GitHub: Push branch and create PR
425 if err := a.gitInterface.Push(ctx, "origin", branchName, git.PushOptions{SetUpstream: true}); err != nil {
426 return fmt.Errorf("failed to push branch: %w", err)
427 }
428
429 // Create pull request using the git interface
430 prOptions := git.PullRequestOptions{
431 Title: fmt.Sprintf("Complete task %s: %s", task.ID, task.Title),
432 Description: a.formatPullRequestDescription(task, solution),
433 BaseBranch: a.Config.GitBranch,
434 HeadBranch: branchName,
435 BaseRepo: a.Config.GerritConfig.Project,
436 HeadRepo: a.Config.GerritConfig.Project,
437 }
438
439 pr, err := a.gitInterface.CreatePullRequest(ctx, prOptions)
440 if err != nil {
441 return fmt.Errorf("failed to create pull request: %w", err)
442 }
443
iomodo0c203b12025-07-26 19:44:57 +0400444 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 +0400445 }
446
iomodo76f9a2d2025-07-26 12:14:40 +0400447 return nil
448}
449
450// generateBranchName generates a branch name for the task
451func (a *Agent) generateBranchName(task *tm.Task) string {
452 // Clean the task title for branch name
453 cleanTitle := strings.ReplaceAll(task.Title, " ", "-")
454 cleanTitle = strings.ToLower(cleanTitle)
455
456 // Remove special characters that are not allowed in git branch names
457 // Keep only alphanumeric characters and hyphens
458 var result strings.Builder
459 for _, char := range cleanTitle {
460 if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' {
461 result.WriteRune(char)
462 }
463 }
464 cleanTitle = result.String()
465
466 // Remove consecutive hyphens
467 for strings.Contains(cleanTitle, "--") {
468 cleanTitle = strings.ReplaceAll(cleanTitle, "--", "-")
469 }
470
471 // Remove leading and trailing hyphens
472 cleanTitle = strings.Trim(cleanTitle, "-")
473
474 // Limit length
475 if len(cleanTitle) > 50 {
476 cleanTitle = cleanTitle[:50]
477 // Ensure we don't end with a hyphen after truncation
478 cleanTitle = strings.TrimSuffix(cleanTitle, "-")
479 }
480
481 return fmt.Sprintf("task/%s-%s", task.ID, cleanTitle)
482}
483
484// formatSolution formats the solution for the pull request
485func (a *Agent) formatSolution(task *tm.Task, solution string) string {
486 var content strings.Builder
487
488 content.WriteString(fmt.Sprintf("# Task Solution: %s\n\n", task.Title))
489 content.WriteString(fmt.Sprintf("**Task ID:** %s\n", task.ID))
490 content.WriteString(fmt.Sprintf("**Agent:** %s (%s)\n", a.Config.Name, a.Config.Role))
491 content.WriteString(fmt.Sprintf("**Completed:** %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
492
493 content.WriteString("## Task Description\n\n")
494 content.WriteString(task.Description)
495 content.WriteString("\n\n")
496
497 content.WriteString("## Solution\n\n")
498 content.WriteString(solution)
499 content.WriteString("\n\n")
500
501 content.WriteString("---\n")
502 content.WriteString("*This solution was generated by AI Agent*\n")
503
504 return content.String()
505}
506
iomodo570d4262025-07-26 16:26:36 +0400507// formatPullRequestDescription formats the description for the pull request
508func (a *Agent) formatPullRequestDescription(task *tm.Task, solution string) string {
509 var content strings.Builder
510
511 content.WriteString(fmt.Sprintf("**Task ID:** %s\n", task.ID))
512 content.WriteString(fmt.Sprintf("**Title:** %s\n", task.Title))
513 content.WriteString(fmt.Sprintf("**Priority:** %s\n", task.Priority))
514
515 if task.Description != "" {
516 content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description))
517 }
518
519 if task.DueDate != nil {
520 content.WriteString(fmt.Sprintf("**Due Date:** %s\n", task.DueDate.Format("2006-01-02")))
521 }
522
523 content.WriteString("\n**Solution:**\n\n")
524 content.WriteString(solution)
525
526 return content.String()
527}
528
iomodo76f9a2d2025-07-26 12:14:40 +0400529// ptr helpers for cleaner code
530func intPtr(i int) *int {
531 return &i
532}
533
534func float64Ptr(f float64) *float64 {
535 return &f
iomodob67a3762025-07-25 20:27:04 +0400536}