blob: 42a70246df6a7c901f99bea90455596fe0bec7f1 [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 // Set up git user configuration
187 userConfig := git.UserConfig{
188 Name: a.Config.GitUsername,
189 Email: a.Config.GitEmail,
190 }
191 if err := a.gitInterface.SetUserConfig(ctx, userConfig); err != nil {
192 return fmt.Errorf("failed to set git user config: %w", err)
193 }
194
195 // Check if remote origin exists, if not add it
196 remotes, err := a.gitInterface.ListRemotes(ctx)
197 if err != nil {
198 return fmt.Errorf("failed to list remotes: %w", err)
199 }
200
201 originExists := false
202 for _, remote := range remotes {
203 if remote.Name == "origin" {
204 originExists = true
205 break
206 }
207 }
208
209 if !originExists {
210 // Add remote origin - use Gerrit URL if enabled, otherwise use the configured remote
211 remoteURL := a.Config.GitRemote
212 if a.Config.GerritEnabled {
213 // For Gerrit, the remote URL should be the Gerrit SSH or HTTP URL
214 // Format: ssh://username@gerrit-host:29418/project-name.git
215 // or: https://gerrit-host/project-name.git
216 if strings.HasPrefix(a.Config.GerritConfig.BaseURL, "https://") {
217 remoteURL = fmt.Sprintf("%s/%s.git", a.Config.GerritConfig.BaseURL, a.Config.GerritConfig.Project)
218 } else {
219 // Assume SSH format
220 remoteURL = fmt.Sprintf("ssh://%s@%s:29418/%s.git",
221 a.Config.GerritConfig.Username,
222 strings.TrimPrefix(a.Config.GerritConfig.BaseURL, "https://"),
223 a.Config.GerritConfig.Project)
224 }
225 }
226
227 if err := a.gitInterface.AddRemote(ctx, "origin", remoteURL); err != nil {
228 return fmt.Errorf("failed to add remote origin: %w", err)
229 }
230 }
231
iomodo76f9a2d2025-07-26 12:14:40 +0400232 // Checkout to the specified branch
233 if a.Config.GitBranch != "" {
234 if err := a.gitInterface.Checkout(ctx, a.Config.GitBranch); err != nil {
235 // Try to create the branch if it doesn't exist
236 if err := a.gitInterface.CreateBranch(ctx, a.Config.GitBranch, ""); err != nil {
237 return fmt.Errorf("failed to create branch %s: %w", a.Config.GitBranch, err)
238 }
239 }
240 }
241
242 return nil
243}
244
245// processNextTask processes the next available task
246func (a *Agent) processNextTask() error {
247 ctx := context.Background()
248
249 // Get tasks assigned to this agent
250 taskList, err := a.Config.TaskManager.GetTasksByOwner(ctx, a.Config.Name, 0, 10)
251 if err != nil {
252 return fmt.Errorf("failed to get tasks: %w", err)
253 }
254
255 // Find a task that's ready to be worked on
256 var taskToProcess *tm.Task
257 for _, task := range taskList.Tasks {
258 if task.Status == tm.StatusToDo {
259 taskToProcess = task
260 break
261 }
262 }
263
264 if taskToProcess == nil {
265 // No tasks to process, wait a bit
266 time.Sleep(60 * time.Second)
267 return nil
268 }
269
270 log.Printf("Processing task: %s - %s", taskToProcess.ID, taskToProcess.Title)
271
272 // Start the task
273 startedTask, err := a.Config.TaskManager.StartTask(ctx, taskToProcess.ID)
274 if err != nil {
275 return fmt.Errorf("failed to start task: %w", err)
276 }
277
278 // Process the task with LLM
279 solution, err := a.processTaskWithLLM(startedTask)
280 if err != nil {
281 // Mark task as failed or retry
282 log.Printf("Failed to process task with LLM: %v", err)
283 return err
284 }
285
286 // Create PR with the solution
287 if err := a.createPullRequest(startedTask, solution); err != nil {
288 return fmt.Errorf("failed to create pull request: %w", err)
289 }
290
291 // Complete the task
292 if _, err := a.Config.TaskManager.CompleteTask(ctx, startedTask.ID); err != nil {
293 return fmt.Errorf("failed to complete task: %w", err)
294 }
295
296 log.Printf("Successfully completed task: %s", startedTask.ID)
297 return nil
298}
299
300// processTaskWithLLM sends the task to the LLM and gets a solution
301func (a *Agent) processTaskWithLLM(task *tm.Task) (string, error) {
302 ctx := context.Background()
303
304 // Prepare the prompt
305 prompt := a.buildTaskPrompt(task)
306
307 // Create chat completion request
308 req := llm.ChatCompletionRequest{
309 Model: a.Config.LLMModel,
310 Messages: []llm.Message{
311 {
312 Role: llm.RoleSystem,
313 Content: a.Config.SystemPrompt,
314 },
315 {
316 Role: llm.RoleUser,
317 Content: prompt,
318 },
319 },
320 MaxTokens: intPtr(4000),
321 Temperature: float64Ptr(0.7),
322 }
323
324 // Get response from LLM
325 resp, err := a.llmProvider.ChatCompletion(ctx, req)
326 if err != nil {
327 return "", fmt.Errorf("LLM chat completion failed: %w", err)
328 }
329
330 if len(resp.Choices) == 0 {
331 return "", fmt.Errorf("no response from LLM")
332 }
333
334 return resp.Choices[0].Message.Content, nil
335}
336
337// buildTaskPrompt builds the prompt for the LLM based on the task
338func (a *Agent) buildTaskPrompt(task *tm.Task) string {
339 var prompt strings.Builder
340
341 prompt.WriteString(fmt.Sprintf("Task ID: %s\n", task.ID))
342 prompt.WriteString(fmt.Sprintf("Title: %s\n", task.Title))
343 prompt.WriteString(fmt.Sprintf("Priority: %s\n", task.Priority))
344
345 if task.Description != "" {
346 prompt.WriteString(fmt.Sprintf("Description: %s\n", task.Description))
347 }
348
349 if task.DueDate != nil {
350 prompt.WriteString(fmt.Sprintf("Due Date: %s\n", task.DueDate.Format("2006-01-02")))
351 }
352
353 prompt.WriteString("\nPlease provide a detailed solution for this task. ")
354 prompt.WriteString("Include any code, documentation, or other deliverables as needed. ")
355 prompt.WriteString("Format your response appropriately for the type of task.")
356
357 return prompt.String()
358}
359
360// createPullRequest creates a pull request with the solution
361func (a *Agent) createPullRequest(task *tm.Task, solution string) error {
362 ctx := context.Background()
363
364 // Generate branch name
365 branchName := a.generateBranchName(task)
366
367 // Create and checkout to new branch
368 if err := a.gitInterface.CreateBranch(ctx, branchName, ""); err != nil {
369 return fmt.Errorf("failed to create branch: %w", err)
370 }
371
372 if err := a.gitInterface.Checkout(ctx, branchName); err != nil {
373 return fmt.Errorf("failed to checkout branch: %w", err)
374 }
375
376 // Create solution file
377 solutionPath := filepath.Join(a.Config.WorkingDir, fmt.Sprintf("task-%s-solution.md", task.ID))
378 solutionContent := a.formatSolution(task, solution)
379
380 if err := os.WriteFile(solutionPath, []byte(solutionContent), 0644); err != nil {
381 return fmt.Errorf("failed to write solution file: %w", err)
382 }
383
384 // Add and commit the solution
385 if err := a.gitInterface.Add(ctx, []string{solutionPath}); err != nil {
386 return fmt.Errorf("failed to add solution file: %w", err)
387 }
388
iomodo570d4262025-07-26 16:26:36 +0400389 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 +0400390 if err := a.gitInterface.Commit(ctx, commitMessage, git.CommitOptions{
391 Author: &git.Author{
392 Name: a.Config.GitUsername,
393 Email: a.Config.GitEmail,
394 Time: time.Now(),
395 },
396 }); err != nil {
iomodo76f9a2d2025-07-26 12:14:40 +0400397 return fmt.Errorf("failed to commit solution: %w", err)
398 }
399
iomodo570d4262025-07-26 16:26:36 +0400400 if a.Config.GerritEnabled {
401 // For Gerrit: Push to refs/for/BRANCH to create a change
402 gerritRef := fmt.Sprintf("refs/for/%s", a.Config.GitBranch)
403 if err := a.gitInterface.Push(ctx, "origin", gerritRef, git.PushOptions{}); err != nil {
404 return fmt.Errorf("failed to push to Gerrit: %w", err)
405 }
406 log.Printf("Created Gerrit change for task %s by pushing to %s", task.ID, gerritRef)
407 } else {
408 // For GitHub: Push branch and create PR
409 if err := a.gitInterface.Push(ctx, "origin", branchName, git.PushOptions{SetUpstream: true}); err != nil {
410 return fmt.Errorf("failed to push branch: %w", err)
411 }
412
413 // Create pull request using the git interface
414 prOptions := git.PullRequestOptions{
415 Title: fmt.Sprintf("Complete task %s: %s", task.ID, task.Title),
416 Description: a.formatPullRequestDescription(task, solution),
417 BaseBranch: a.Config.GitBranch,
418 HeadBranch: branchName,
419 BaseRepo: a.Config.GerritConfig.Project,
420 HeadRepo: a.Config.GerritConfig.Project,
421 }
422
423 pr, err := a.gitInterface.CreatePullRequest(ctx, prOptions)
424 if err != nil {
425 return fmt.Errorf("failed to create pull request: %w", err)
426 }
427
428 log.Printf("Created pull request for task %s: %s (ID: %s)", task.ID, pr.Title, pr.ID)
iomodo76f9a2d2025-07-26 12:14:40 +0400429 }
430
iomodo76f9a2d2025-07-26 12:14:40 +0400431 return nil
432}
433
434// generateBranchName generates a branch name for the task
435func (a *Agent) generateBranchName(task *tm.Task) string {
436 // Clean the task title for branch name
437 cleanTitle := strings.ReplaceAll(task.Title, " ", "-")
438 cleanTitle = strings.ToLower(cleanTitle)
439
440 // Remove special characters that are not allowed in git branch names
441 // Keep only alphanumeric characters and hyphens
442 var result strings.Builder
443 for _, char := range cleanTitle {
444 if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-' {
445 result.WriteRune(char)
446 }
447 }
448 cleanTitle = result.String()
449
450 // Remove consecutive hyphens
451 for strings.Contains(cleanTitle, "--") {
452 cleanTitle = strings.ReplaceAll(cleanTitle, "--", "-")
453 }
454
455 // Remove leading and trailing hyphens
456 cleanTitle = strings.Trim(cleanTitle, "-")
457
458 // Limit length
459 if len(cleanTitle) > 50 {
460 cleanTitle = cleanTitle[:50]
461 // Ensure we don't end with a hyphen after truncation
462 cleanTitle = strings.TrimSuffix(cleanTitle, "-")
463 }
464
465 return fmt.Sprintf("task/%s-%s", task.ID, cleanTitle)
466}
467
468// formatSolution formats the solution for the pull request
469func (a *Agent) formatSolution(task *tm.Task, solution string) string {
470 var content strings.Builder
471
472 content.WriteString(fmt.Sprintf("# Task Solution: %s\n\n", task.Title))
473 content.WriteString(fmt.Sprintf("**Task ID:** %s\n", task.ID))
474 content.WriteString(fmt.Sprintf("**Agent:** %s (%s)\n", a.Config.Name, a.Config.Role))
475 content.WriteString(fmt.Sprintf("**Completed:** %s\n\n", time.Now().Format("2006-01-02 15:04:05")))
476
477 content.WriteString("## Task Description\n\n")
478 content.WriteString(task.Description)
479 content.WriteString("\n\n")
480
481 content.WriteString("## Solution\n\n")
482 content.WriteString(solution)
483 content.WriteString("\n\n")
484
485 content.WriteString("---\n")
486 content.WriteString("*This solution was generated by AI Agent*\n")
487
488 return content.String()
489}
490
iomodo570d4262025-07-26 16:26:36 +0400491// formatPullRequestDescription formats the description for the pull request
492func (a *Agent) formatPullRequestDescription(task *tm.Task, solution string) string {
493 var content strings.Builder
494
495 content.WriteString(fmt.Sprintf("**Task ID:** %s\n", task.ID))
496 content.WriteString(fmt.Sprintf("**Title:** %s\n", task.Title))
497 content.WriteString(fmt.Sprintf("**Priority:** %s\n", task.Priority))
498
499 if task.Description != "" {
500 content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description))
501 }
502
503 if task.DueDate != nil {
504 content.WriteString(fmt.Sprintf("**Due Date:** %s\n", task.DueDate.Format("2006-01-02")))
505 }
506
507 content.WriteString("\n**Solution:**\n\n")
508 content.WriteString(solution)
509
510 return content.String()
511}
512
iomodo76f9a2d2025-07-26 12:14:40 +0400513// ptr helpers for cleaner code
514func intPtr(i int) *int {
515 return &i
516}
517
518func float64Ptr(f float64) *float64 {
519 return &f
iomodob67a3762025-07-25 20:27:04 +0400520}