Merge branch 'fix/io-migration-agentic' into new

# Conflicts:
#	pkg/agentic/config.go
#	pkg/agentic/context.go
This commit is contained in:
Snider 2026-02-08 21:28:09 +00:00
commit 0a553dcf6e
2 changed files with 70 additions and 114 deletions

View file

@ -1,26 +1,25 @@
package agentic package agentic
import ( import (
"bufio"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/host-uk/core/pkg/config" "github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/log" "gopkg.in/yaml.v3"
) )
// Config holds the configuration for connecting to the core-agentic service. // Config holds the configuration for connecting to the core-agentic service.
type Config struct { type Config struct {
// BaseURL is the URL of the core-agentic API server. // BaseURL is the URL of the core-agentic API server.
BaseURL string `yaml:"base_url" json:"base_url" mapstructure:"base_url"` BaseURL string `yaml:"base_url" json:"base_url"`
// Token is the authentication token for API requests. // Token is the authentication token for API requests.
Token string `yaml:"token" json:"token" mapstructure:"token"` Token string `yaml:"token" json:"token"`
// DefaultProject is the project to use when none is specified. // DefaultProject is the project to use when none is specified.
DefaultProject string `yaml:"default_project" json:"default_project" mapstructure:"default_project"` DefaultProject string `yaml:"default_project" json:"default_project"`
// AgentID is the identifier for this agent (optional, used for claiming tasks). // AgentID is the identifier for this agent (optional, used for claiming tasks).
AgentID string `yaml:"agent_id" json:"agent_id" mapstructure:"agent_id"` AgentID string `yaml:"agent_id" json:"agent_id"`
} }
// configFileName is the name of the YAML config file. // configFileName is the name of the YAML config file.
@ -33,9 +32,10 @@ const envFileName = ".env"
const DefaultBaseURL = "https://api.core-agentic.dev" const DefaultBaseURL = "https://api.core-agentic.dev"
// LoadConfig loads the agentic configuration from the specified directory. // LoadConfig loads the agentic configuration from the specified directory.
// It uses the centralized config service. // It first checks for a .env file, then falls back to ~/.core/agentic.yaml.
// If dir is empty, it checks the current directory first.
// //
// Environment variables take precedence (prefix: AGENTIC_): // Environment variables take precedence:
// - AGENTIC_BASE_URL: API base URL // - AGENTIC_BASE_URL: API base URL
// - AGENTIC_TOKEN: Authentication token // - AGENTIC_TOKEN: Authentication token
// - AGENTIC_PROJECT: Default project // - AGENTIC_PROJECT: Default project
@ -58,6 +58,7 @@ func LoadConfig(dir string) (*Config, error) {
} }
// Try loading from current directory .env // Try loading from current directory .env
if dir == "" {
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err == nil { if err == nil {
envPath := filepath.Join(cwd, envFileName) envPath := filepath.Join(cwd, envFileName)
@ -68,23 +69,17 @@ func LoadConfig(dir string) (*Config, error) {
} }
} }
} }
}
// Try loading from ~/.core/agentic.yaml // Try loading from ~/.core/agentic.yaml
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return nil, log.E("agentic.LoadConfig", "failed to get home directory", err) return nil, errors.E("agentic.LoadConfig", "failed to get home directory", err)
} }
configPath := filepath.Join(homeDir, ".core", configFileName) configPath := filepath.Join(homeDir, ".core", configFileName)
if io.Local.IsFile(configPath) { if err := loadYAMLConfig(configPath, cfg); err != nil && !os.IsNotExist(err) {
// Use centralized config service to load the YAML file return nil, errors.E("agentic.LoadConfig", "failed to load config", err)
c, err := config.New(config.WithPath(configPath))
if err != nil {
return nil, log.E("agentic.LoadConfig", "failed to initialize config", err)
}
if err := c.Get("", cfg); err != nil {
return nil, log.E("agentic.LoadConfig", "failed to load config", err)
}
} }
// Apply environment variable overrides // Apply environment variable overrides
@ -92,25 +87,21 @@ func LoadConfig(dir string) (*Config, error) {
// Validate configuration // Validate configuration
if cfg.Token == "" { if cfg.Token == "" {
log.Security("agentic authentication failed: no token configured", "user", log.Username()) return nil, errors.E("agentic.LoadConfig", "no authentication token configured", nil)
return nil, log.E("agentic.LoadConfig", "no authentication token configured", nil)
} }
log.Security("agentic configuration loaded", "user", log.Username(), "baseURL", cfg.BaseURL)
return cfg, nil return cfg, nil
} }
// loadEnvFile reads a .env file and extracts agentic configuration. // loadEnvFile reads a .env file and extracts agentic configuration.
func loadEnvFile(path string, cfg *Config) error { func loadEnvFile(path string, cfg *Config) error {
file, err := os.Open(path) content, err := io.Local.Read(path)
if err != nil { if err != nil {
return err return err
} }
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file) for _, line := range strings.Split(content, "\n") {
for scanner.Scan() { line = strings.TrimSpace(line)
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments // Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") { if line == "" || strings.HasPrefix(line, "#") {
@ -141,7 +132,17 @@ func loadEnvFile(path string, cfg *Config) error {
} }
} }
return scanner.Err() return nil
}
// loadYAMLConfig reads configuration from a YAML file.
func loadYAMLConfig(path string, cfg *Config) error {
content, err := io.Local.Read(path)
if err != nil {
return err
}
return yaml.Unmarshal([]byte(content), cfg)
} }
// applyEnvOverrides applies environment variable overrides to the config. // applyEnvOverrides applies environment variable overrides to the config.
@ -162,25 +163,35 @@ func applyEnvOverrides(cfg *Config) {
// SaveConfig saves the configuration to ~/.core/agentic.yaml. // SaveConfig saves the configuration to ~/.core/agentic.yaml.
func SaveConfig(cfg *Config) error { func SaveConfig(cfg *Config) error {
path, err := ConfigPath() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return err return errors.E("agentic.SaveConfig", "failed to get home directory", err)
} }
data := make(map[string]any) configDir := filepath.Join(homeDir, ".core")
data["base_url"] = cfg.BaseURL if err := io.Local.EnsureDir(configDir); err != nil {
data["token"] = cfg.Token return errors.E("agentic.SaveConfig", "failed to create config directory", err)
data["default_project"] = cfg.DefaultProject }
data["agent_id"] = cfg.AgentID
return config.Save(io.Local, path, data) configPath := filepath.Join(configDir, configFileName)
data, err := yaml.Marshal(cfg)
if err != nil {
return errors.E("agentic.SaveConfig", "failed to marshal config", err)
}
if err := io.Local.Write(configPath, string(data)); err != nil {
return errors.E("agentic.SaveConfig", "failed to write config file", err)
}
return nil
} }
// ConfigPath returns the path to the config file in the user's home directory. // ConfigPath returns the path to the config file in the user's home directory.
func ConfigPath() (string, error) { func ConfigPath() (string, error) {
homeDir, err := os.UserHomeDir() homeDir, err := os.UserHomeDir()
if err != nil { if err != nil {
return "", log.E("agentic.ConfigPath", "failed to get home directory", err) return "", errors.E("agentic.ConfigPath", "failed to get home directory", err)
} }
return filepath.Join(homeDir, ".core", configFileName), nil return filepath.Join(homeDir, ".core", configFileName), nil
} }

View file

@ -3,20 +3,16 @@ package agentic
import ( import (
"bytes" "bytes"
goio "io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"github.com/host-uk/core/pkg/ai" "github.com/host-uk/core/pkg/errors"
"github.com/host-uk/core/pkg/io" "github.com/host-uk/core/pkg/io"
"github.com/host-uk/core/pkg/log"
) )
const maxContextBytes = 5000
// FileContent represents the content of a file for AI context. // FileContent represents the content of a file for AI context.
type FileContent struct { type FileContent struct {
// Path is the relative path to the file. // Path is the relative path to the file.
@ -39,8 +35,6 @@ type TaskContext struct {
RecentCommits string `json:"recent_commits"` RecentCommits string `json:"recent_commits"`
// RelatedCode contains code snippets related to the task. // RelatedCode contains code snippets related to the task.
RelatedCode []FileContent `json:"related_code"` RelatedCode []FileContent `json:"related_code"`
// RAGContext contains relevant documentation from the vector database.
RAGContext string `json:"rag_context,omitempty"`
} }
// BuildTaskContext gathers context for AI collaboration on a task. // BuildTaskContext gathers context for AI collaboration on a task.
@ -48,13 +42,13 @@ func BuildTaskContext(task *Task, dir string) (*TaskContext, error) {
const op = "agentic.BuildTaskContext" const op = "agentic.BuildTaskContext"
if task == nil { if task == nil {
return nil, log.E(op, "task is required", nil) return nil, errors.E(op, "task is required", nil)
} }
if dir == "" { if dir == "" {
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
return nil, log.E(op, "failed to get working directory", err) return nil, errors.E(op, "failed to get working directory", err)
} }
dir = cwd dir = cwd
} }
@ -86,13 +80,6 @@ func BuildTaskContext(task *Task, dir string) (*TaskContext, error) {
} }
ctx.RelatedCode = relatedCode ctx.RelatedCode = relatedCode
// Query RAG for relevant documentation (graceful degradation)
ragCtx := ai.QueryRAGForTask(ai.TaskInfo{
Title: task.Title,
Description: task.Description,
})
ctx.RAGContext = ragCtx
return ctx, nil return ctx, nil
} }
@ -101,31 +88,24 @@ func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) {
const op = "agentic.GatherRelatedFiles" const op = "agentic.GatherRelatedFiles"
if task == nil { if task == nil {
return nil, log.E(op, "task is required", nil) return nil, errors.E(op, "task is required", nil)
} }
var files []FileContent var files []FileContent
// Read files explicitly mentioned in the task // Read files explicitly mentioned in the task
for _, relPath := range task.Files { for _, relPath := range task.Files {
fullPath := relPath fullPath := filepath.Join(dir, relPath)
if !filepath.IsAbs(relPath) {
fullPath = filepath.Join(dir, relPath)
}
content, truncated, err := readAndTruncate(fullPath) content, err := io.Local.Read(fullPath)
if err != nil { if err != nil {
// Skip files that don't exist
continue continue
} }
contentStr := string(content)
if truncated {
contentStr += "\n... (truncated)"
}
files = append(files, FileContent{ files = append(files, FileContent{
Path: relPath, Path: relPath,
Content: contentStr, Content: content,
Language: detectLanguage(relPath), Language: detectLanguage(relPath),
}) })
} }
@ -138,7 +118,7 @@ func findRelatedCode(task *Task, dir string) ([]FileContent, error) {
const op = "agentic.findRelatedCode" const op = "agentic.findRelatedCode"
if task == nil { if task == nil {
return nil, log.E(op, "task is required", nil) return nil, errors.E(op, "task is required", nil)
} }
// Extract keywords from title and description // Extract keywords from title and description
@ -174,24 +154,20 @@ func findRelatedCode(task *Task, dir string) ([]FileContent, error) {
break break
} }
fullPath := line fullPath := filepath.Join(dir, line)
if !filepath.IsAbs(line) { content, err := io.Local.Read(fullPath)
fullPath = filepath.Join(dir, line)
}
content, truncated, err := readAndTruncate(fullPath)
if err != nil { if err != nil {
continue continue
} }
contentStr := string(content) // Truncate large files
if truncated { if len(content) > 5000 {
contentStr += "\n... (truncated)" content = content[:5000] + "\n... (truncated)"
} }
files = append(files, FileContent{ files = append(files, FileContent{
Path: line, Path: line,
Content: contentStr, Content: content,
Language: detectLanguage(line), Language: detectLanguage(line),
}) })
} }
@ -286,30 +262,6 @@ func detectLanguage(path string) string {
return "text" return "text"
} }
// readAndTruncate reads up to maxContextBytes from a file.
func readAndTruncate(path string) ([]byte, bool, error) {
f, err := io.Local.ReadStream(path)
if err != nil {
return nil, false, err
}
defer func() { _ = f.Close() }()
// Read up to maxContextBytes + 1 to detect truncation
reader := goio.LimitReader(f, maxContextBytes+1)
content, err := goio.ReadAll(reader)
if err != nil {
return nil, false, err
}
truncated := false
if len(content) > maxContextBytes {
content = content[:maxContextBytes]
truncated = true
}
return content, truncated, nil
}
// runGitCommand runs a git command and returns the output. // runGitCommand runs a git command and returns the output.
func runGitCommand(dir string, args ...string) (string, error) { func runGitCommand(dir string, args ...string) (string, error) {
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
@ -379,12 +331,5 @@ func (tc *TaskContext) FormatContext() string {
} }
} }
// Relevant documentation from RAG
if tc.RAGContext != "" {
sb.WriteString("## Relevant Documentation\n")
sb.WriteString(tc.RAGContext)
sb.WriteString("\n\n")
}
return sb.String() return sb.String()
} }