feat(agentic): implement core-agentic API client
Add pkg/agentic for AI-assisted task management: - API client for core-agentic service - Task listing, claiming, updating, completion - Config from .env or ~/.core/agentic.yaml CLI commands: - core dev tasks - list available tasks - core dev task <id> - show/claim task - core dev task --auto - AI picks highest priority - core dev task:update <id> - update progress - core dev task:complete <id> - mark complete Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
605ee023ca
commit
3b6427f324
9 changed files with 1666 additions and 0 deletions
442
cmd/core/cmd/agentic.go
Normal file
442
cmd/core/cmd/agentic.go
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/host-uk/core/pkg/agentic"
|
||||
"github.com/leaanthony/clir"
|
||||
)
|
||||
|
||||
var (
|
||||
taskIDStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
|
||||
taskTitleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#e2e8f0")) // gray-200
|
||||
|
||||
taskPriorityHighStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#ef4444")) // red-500
|
||||
|
||||
taskPriorityMediumStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#f59e0b")) // amber-500
|
||||
|
||||
taskPriorityLowStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
||||
taskStatusPendingStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6b7280")) // gray-500
|
||||
|
||||
taskStatusInProgressStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#3b82f6")) // blue-500
|
||||
|
||||
taskStatusCompletedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#22c55e")) // green-500
|
||||
|
||||
taskStatusBlockedStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ef4444")) // red-500
|
||||
|
||||
taskLabelStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#a78bfa")) // violet-400
|
||||
)
|
||||
|
||||
// AddAgenticCommands adds the agentic task management commands to the dev command.
|
||||
func AddAgenticCommands(parent *clir.Command) {
|
||||
// core dev tasks - list available tasks
|
||||
addTasksCommand(parent)
|
||||
|
||||
// core dev task <id> - show task details and claim
|
||||
addTaskCommand(parent)
|
||||
|
||||
// core dev task:update <id> - update task
|
||||
addTaskUpdateCommand(parent)
|
||||
|
||||
// core dev task:complete <id> - mark task complete
|
||||
addTaskCompleteCommand(parent)
|
||||
}
|
||||
|
||||
func addTasksCommand(parent *clir.Command) {
|
||||
var status string
|
||||
var priority string
|
||||
var labels string
|
||||
var limit int
|
||||
var project string
|
||||
|
||||
cmd := parent.NewSubCommand("tasks", "List available tasks from core-agentic")
|
||||
cmd.LongDescription("Lists tasks from the core-agentic service.\n\n" +
|
||||
"Configuration is loaded from:\n" +
|
||||
" 1. Environment variables (AGENTIC_TOKEN, AGENTIC_BASE_URL)\n" +
|
||||
" 2. .env file in current directory\n" +
|
||||
" 3. ~/.core/agentic.yaml\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev tasks\n" +
|
||||
" core dev tasks --status pending --priority high\n" +
|
||||
" core dev tasks --labels bug,urgent")
|
||||
|
||||
cmd.StringFlag("status", "Filter by status (pending, in_progress, completed, blocked)", &status)
|
||||
cmd.StringFlag("priority", "Filter by priority (critical, high, medium, low)", &priority)
|
||||
cmd.StringFlag("labels", "Filter by labels (comma-separated)", &labels)
|
||||
cmd.IntFlag("limit", "Max number of tasks to return (default 20)", &limit)
|
||||
cmd.StringFlag("project", "Filter by project", &project)
|
||||
|
||||
cmd.Action(func() error {
|
||||
if limit == 0 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
cfg, err := agentic.LoadConfig("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
client := agentic.NewClientFromConfig(cfg)
|
||||
|
||||
opts := agentic.ListOptions{
|
||||
Limit: limit,
|
||||
Project: project,
|
||||
}
|
||||
|
||||
if status != "" {
|
||||
opts.Status = agentic.TaskStatus(status)
|
||||
}
|
||||
if priority != "" {
|
||||
opts.Priority = agentic.TaskPriority(priority)
|
||||
}
|
||||
if labels != "" {
|
||||
opts.Labels = strings.Split(labels, ",")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tasks, err := client.ListTasks(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list tasks: %w", err)
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
fmt.Println("No tasks found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
printTaskList(tasks)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addTaskCommand(parent *clir.Command) {
|
||||
var autoSelect bool
|
||||
var claim bool
|
||||
|
||||
cmd := parent.NewSubCommand("task", "Show task details or auto-select a task")
|
||||
cmd.LongDescription("Shows details of a specific task or auto-selects the highest priority task.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev task abc123 # Show task details\n" +
|
||||
" core dev task abc123 --claim # Show and claim the task\n" +
|
||||
" core dev task --auto # Auto-select highest priority pending task")
|
||||
|
||||
cmd.BoolFlag("auto", "Auto-select highest priority pending task", &autoSelect)
|
||||
cmd.BoolFlag("claim", "Claim the task after showing details", &claim)
|
||||
|
||||
cmd.Action(func() error {
|
||||
cfg, err := agentic.LoadConfig("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
client := agentic.NewClientFromConfig(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var task *agentic.Task
|
||||
|
||||
// Get the task ID from remaining args
|
||||
args := os.Args
|
||||
var taskID string
|
||||
|
||||
// Find the task ID in args (after "task" subcommand)
|
||||
for i, arg := range args {
|
||||
if arg == "task" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
taskID = args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if autoSelect {
|
||||
// Auto-select: find highest priority pending task
|
||||
tasks, err := client.ListTasks(ctx, agentic.ListOptions{
|
||||
Status: agentic.StatusPending,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list tasks: %w", err)
|
||||
}
|
||||
|
||||
if len(tasks) == 0 {
|
||||
fmt.Println("No pending tasks available.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by priority (critical > high > medium > low)
|
||||
priorityOrder := map[agentic.TaskPriority]int{
|
||||
agentic.PriorityCritical: 0,
|
||||
agentic.PriorityHigh: 1,
|
||||
agentic.PriorityMedium: 2,
|
||||
agentic.PriorityLow: 3,
|
||||
}
|
||||
|
||||
sort.Slice(tasks, func(i, j int) bool {
|
||||
return priorityOrder[tasks[i].Priority] < priorityOrder[tasks[j].Priority]
|
||||
})
|
||||
|
||||
task = &tasks[0]
|
||||
claim = true // Auto-select implies claiming
|
||||
} else {
|
||||
if taskID == "" {
|
||||
return fmt.Errorf("task ID required (or use --auto)")
|
||||
}
|
||||
|
||||
task, err = client.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get task: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
printTaskDetails(task)
|
||||
|
||||
if claim && task.Status == agentic.StatusPending {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s Claiming task...\n", dimStyle.Render(">>"))
|
||||
|
||||
claimedTask, err := client.ClaimTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to claim task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Task claimed successfully!\n", successStyle.Render(">>"))
|
||||
fmt.Printf(" Status: %s\n", formatTaskStatus(claimedTask.Status))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addTaskUpdateCommand(parent *clir.Command) {
|
||||
var status string
|
||||
var progress int
|
||||
var notes string
|
||||
|
||||
cmd := parent.NewSubCommand("task:update", "Update task status or progress")
|
||||
cmd.LongDescription("Updates a task's status, progress, or adds notes.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev task:update abc123 --status in_progress\n" +
|
||||
" core dev task:update abc123 --progress 50 --notes 'Halfway done'")
|
||||
|
||||
cmd.StringFlag("status", "New status (pending, in_progress, completed, blocked)", &status)
|
||||
cmd.IntFlag("progress", "Progress percentage (0-100)", &progress)
|
||||
cmd.StringFlag("notes", "Notes about the update", ¬es)
|
||||
|
||||
cmd.Action(func() error {
|
||||
// Find task ID from args
|
||||
args := os.Args
|
||||
var taskID string
|
||||
for i, arg := range args {
|
||||
if arg == "task:update" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
taskID = args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if taskID == "" {
|
||||
return fmt.Errorf("task ID required")
|
||||
}
|
||||
|
||||
if status == "" && progress == 0 && notes == "" {
|
||||
return fmt.Errorf("at least one of --status, --progress, or --notes required")
|
||||
}
|
||||
|
||||
cfg, err := agentic.LoadConfig("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
client := agentic.NewClientFromConfig(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
update := agentic.TaskUpdate{
|
||||
Progress: progress,
|
||||
Notes: notes,
|
||||
}
|
||||
if status != "" {
|
||||
update.Status = agentic.TaskStatus(status)
|
||||
}
|
||||
|
||||
if err := client.UpdateTask(ctx, taskID, update); err != nil {
|
||||
return fmt.Errorf("failed to update task: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Task %s updated successfully\n", successStyle.Render(">>"), taskID)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func addTaskCompleteCommand(parent *clir.Command) {
|
||||
var output string
|
||||
var failed bool
|
||||
var errorMsg string
|
||||
|
||||
cmd := parent.NewSubCommand("task:complete", "Mark a task as completed")
|
||||
cmd.LongDescription("Marks a task as completed with optional output and artifacts.\n\n" +
|
||||
"Examples:\n" +
|
||||
" core dev task:complete abc123 --output 'Feature implemented'\n" +
|
||||
" core dev task:complete abc123 --failed --error 'Build failed'")
|
||||
|
||||
cmd.StringFlag("output", "Summary of the completed work", &output)
|
||||
cmd.BoolFlag("failed", "Mark the task as failed", &failed)
|
||||
cmd.StringFlag("error", "Error message if failed", &errorMsg)
|
||||
|
||||
cmd.Action(func() error {
|
||||
// Find task ID from args
|
||||
args := os.Args
|
||||
var taskID string
|
||||
for i, arg := range args {
|
||||
if arg == "task:complete" && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
taskID = args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if taskID == "" {
|
||||
return fmt.Errorf("task ID required")
|
||||
}
|
||||
|
||||
cfg, err := agentic.LoadConfig("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
|
||||
client := agentic.NewClientFromConfig(cfg)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result := agentic.TaskResult{
|
||||
Success: !failed,
|
||||
Output: output,
|
||||
ErrorMessage: errorMsg,
|
||||
}
|
||||
|
||||
if err := client.CompleteTask(ctx, taskID, result); err != nil {
|
||||
return fmt.Errorf("failed to complete task: %w", err)
|
||||
}
|
||||
|
||||
if failed {
|
||||
fmt.Printf("%s Task %s marked as failed\n", errorStyle.Render(">>"), taskID)
|
||||
} else {
|
||||
fmt.Printf("%s Task %s completed successfully\n", successStyle.Render(">>"), taskID)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func printTaskList(tasks []agentic.Task) {
|
||||
fmt.Printf("\n%d task(s) found:\n\n", len(tasks))
|
||||
|
||||
for _, task := range tasks {
|
||||
id := taskIDStyle.Render(task.ID)
|
||||
title := taskTitleStyle.Render(truncate(task.Title, 50))
|
||||
priority := formatTaskPriority(task.Priority)
|
||||
status := formatTaskStatus(task.Status)
|
||||
|
||||
line := fmt.Sprintf(" %s %s %s %s", id, priority, status, title)
|
||||
|
||||
if len(task.Labels) > 0 {
|
||||
labels := taskLabelStyle.Render("[" + strings.Join(task.Labels, ", ") + "]")
|
||||
line += " " + labels
|
||||
}
|
||||
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", dimStyle.Render("Use 'core dev task <id>' to view details"))
|
||||
}
|
||||
|
||||
func printTaskDetails(task *agentic.Task) {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("ID:"), taskIDStyle.Render(task.ID))
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Title:"), taskTitleStyle.Render(task.Title))
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Priority:"), formatTaskPriority(task.Priority))
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Status:"), formatTaskStatus(task.Status))
|
||||
|
||||
if task.Project != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Project:"), task.Project)
|
||||
}
|
||||
|
||||
if len(task.Labels) > 0 {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Labels:"), taskLabelStyle.Render(strings.Join(task.Labels, ", ")))
|
||||
}
|
||||
|
||||
if task.ClaimedBy != "" {
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Claimed by:"), task.ClaimedBy)
|
||||
}
|
||||
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Created:"), formatAge(task.CreatedAt))
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", dimStyle.Render("Description:"))
|
||||
fmt.Println(task.Description)
|
||||
|
||||
if len(task.Files) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s\n", dimStyle.Render("Related files:"))
|
||||
for _, f := range task.Files {
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(task.Dependencies) > 0 {
|
||||
fmt.Println()
|
||||
fmt.Printf("%s %s\n", dimStyle.Render("Blocked by:"), strings.Join(task.Dependencies, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func formatTaskPriority(p agentic.TaskPriority) string {
|
||||
switch p {
|
||||
case agentic.PriorityCritical:
|
||||
return taskPriorityHighStyle.Render("[CRITICAL]")
|
||||
case agentic.PriorityHigh:
|
||||
return taskPriorityHighStyle.Render("[HIGH]")
|
||||
case agentic.PriorityMedium:
|
||||
return taskPriorityMediumStyle.Render("[MEDIUM]")
|
||||
case agentic.PriorityLow:
|
||||
return taskPriorityLowStyle.Render("[LOW]")
|
||||
default:
|
||||
return dimStyle.Render("[" + string(p) + "]")
|
||||
}
|
||||
}
|
||||
|
||||
func formatTaskStatus(s agentic.TaskStatus) string {
|
||||
switch s {
|
||||
case agentic.StatusPending:
|
||||
return taskStatusPendingStyle.Render("pending")
|
||||
case agentic.StatusInProgress:
|
||||
return taskStatusInProgressStyle.Render("in_progress")
|
||||
case agentic.StatusCompleted:
|
||||
return taskStatusCompletedStyle.Render("completed")
|
||||
case agentic.StatusBlocked:
|
||||
return taskStatusBlockedStyle.Render("blocked")
|
||||
default:
|
||||
return dimStyle.Render(string(s))
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ func Execute() error {
|
|||
devCmd := app.NewSubCommand("dev", "Development tools for Core Framework")
|
||||
AddAPICommands(devCmd)
|
||||
AddSyncCommand(devCmd)
|
||||
AddAgenticCommands(devCmd)
|
||||
AddBuildCommand(app)
|
||||
AddTviewCommand(app)
|
||||
AddWorkCommand(app)
|
||||
|
|
|
|||
1
go.work
1
go.work
|
|
@ -7,6 +7,7 @@ use (
|
|||
./cmd/core-mcp
|
||||
./cmd/examples/core-static-di
|
||||
./cmd/lthn-desktop
|
||||
./pkg/agentic
|
||||
./pkg/build
|
||||
./pkg/cache
|
||||
./pkg/config
|
||||
|
|
|
|||
328
pkg/agentic/client.go
Normal file
328
pkg/agentic/client.go
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/host-uk/core/pkg/core"
|
||||
)
|
||||
|
||||
// Client is the API client for the core-agentic service.
|
||||
type Client struct {
|
||||
// BaseURL is the base URL of the API server.
|
||||
BaseURL string
|
||||
// Token is the authentication token.
|
||||
Token string
|
||||
// HTTPClient is the HTTP client used for requests.
|
||||
HTTPClient *http.Client
|
||||
// AgentID is the identifier for this agent when claiming tasks.
|
||||
AgentID string
|
||||
}
|
||||
|
||||
// NewClient creates a new agentic API client with the given base URL and token.
|
||||
func NewClient(baseURL, token string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimSuffix(baseURL, "/"),
|
||||
Token: token,
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientFromConfig creates a new client from a Config struct.
|
||||
func NewClientFromConfig(cfg *Config) *Client {
|
||||
client := NewClient(cfg.BaseURL, cfg.Token)
|
||||
client.AgentID = cfg.AgentID
|
||||
return client
|
||||
}
|
||||
|
||||
// ListTasks retrieves a list of tasks matching the given options.
|
||||
func (c *Client) ListTasks(ctx context.Context, opts ListOptions) ([]Task, error) {
|
||||
const op = "agentic.Client.ListTasks"
|
||||
|
||||
// Build query parameters
|
||||
params := url.Values{}
|
||||
if opts.Status != "" {
|
||||
params.Set("status", string(opts.Status))
|
||||
}
|
||||
if opts.Priority != "" {
|
||||
params.Set("priority", string(opts.Priority))
|
||||
}
|
||||
if opts.Project != "" {
|
||||
params.Set("project", opts.Project)
|
||||
}
|
||||
if opts.ClaimedBy != "" {
|
||||
params.Set("claimed_by", opts.ClaimedBy)
|
||||
}
|
||||
if opts.Limit > 0 {
|
||||
params.Set("limit", strconv.Itoa(opts.Limit))
|
||||
}
|
||||
if len(opts.Labels) > 0 {
|
||||
params.Set("labels", strings.Join(opts.Labels, ","))
|
||||
}
|
||||
|
||||
endpoint := c.BaseURL + "/api/tasks"
|
||||
if len(params) > 0 {
|
||||
endpoint += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, core.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, core.E(op, "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return nil, core.E(op, "API error", err)
|
||||
}
|
||||
|
||||
var tasks []Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
|
||||
return nil, core.E(op, "failed to decode response", err)
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// GetTask retrieves a single task by its ID.
|
||||
func (c *Client) GetTask(ctx context.Context, id string) (*Task, error) {
|
||||
const op = "agentic.Client.GetTask"
|
||||
|
||||
if id == "" {
|
||||
return nil, core.E(op, "task ID is required", nil)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/tasks/%s", c.BaseURL, url.PathEscape(id))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, core.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, core.E(op, "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return nil, core.E(op, "API error", err)
|
||||
}
|
||||
|
||||
var task Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
|
||||
return nil, core.E(op, "failed to decode response", err)
|
||||
}
|
||||
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// ClaimTask claims a task for the current agent.
|
||||
func (c *Client) ClaimTask(ctx context.Context, id string) (*Task, error) {
|
||||
const op = "agentic.Client.ClaimTask"
|
||||
|
||||
if id == "" {
|
||||
return nil, core.E(op, "task ID is required", nil)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/tasks/%s/claim", c.BaseURL, url.PathEscape(id))
|
||||
|
||||
// Include agent ID in the claim request if available
|
||||
var body io.Reader
|
||||
if c.AgentID != "" {
|
||||
data, _ := json.Marshal(map[string]string{"agent_id": c.AgentID})
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
|
||||
if err != nil {
|
||||
return nil, core.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, core.E(op, "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return nil, core.E(op, "API error", err)
|
||||
}
|
||||
|
||||
// Read body once to allow multiple decode attempts
|
||||
bodyData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, core.E(op, "failed to read response", err)
|
||||
}
|
||||
|
||||
// Try decoding as ClaimResponse first
|
||||
var result ClaimResponse
|
||||
if err := json.Unmarshal(bodyData, &result); err == nil && result.Task != nil {
|
||||
return result.Task, nil
|
||||
}
|
||||
|
||||
// Try decoding as just a Task for simpler API responses
|
||||
var task Task
|
||||
if err := json.Unmarshal(bodyData, &task); err != nil {
|
||||
return nil, core.E(op, "failed to decode response", err)
|
||||
}
|
||||
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// UpdateTask updates a task with new status, progress, or notes.
|
||||
func (c *Client) UpdateTask(ctx context.Context, id string, update TaskUpdate) error {
|
||||
const op = "agentic.Client.UpdateTask"
|
||||
|
||||
if id == "" {
|
||||
return core.E(op, "task ID is required", nil)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/tasks/%s", c.BaseURL, url.PathEscape(id))
|
||||
|
||||
data, err := json.Marshal(update)
|
||||
if err != nil {
|
||||
return core.E(op, "failed to marshal update", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return core.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return core.E(op, "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return core.E(op, "API error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CompleteTask marks a task as completed with the given result.
|
||||
func (c *Client) CompleteTask(ctx context.Context, id string, result TaskResult) error {
|
||||
const op = "agentic.Client.CompleteTask"
|
||||
|
||||
if id == "" {
|
||||
return core.E(op, "task ID is required", nil)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/api/tasks/%s/complete", c.BaseURL, url.PathEscape(id))
|
||||
|
||||
data, err := json.Marshal(result)
|
||||
if err != nil {
|
||||
return core.E(op, "failed to marshal result", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return core.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return core.E(op, "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return core.E(op, "API error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setHeaders adds common headers to the request.
|
||||
func (c *Client) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "core-agentic-client/1.0")
|
||||
}
|
||||
|
||||
// checkResponse checks if the response indicates an error.
|
||||
func (c *Client) checkResponse(resp *http.Response) error {
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Try to parse as APIError
|
||||
var apiErr APIError
|
||||
if err := json.Unmarshal(body, &apiErr); err == nil && apiErr.Message != "" {
|
||||
apiErr.Code = resp.StatusCode
|
||||
return &apiErr
|
||||
}
|
||||
|
||||
// Return generic error
|
||||
return &APIError{
|
||||
Code: resp.StatusCode,
|
||||
Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)),
|
||||
Details: string(body),
|
||||
}
|
||||
}
|
||||
|
||||
// mustReadAll reads all bytes from a reader, returning empty slice on error.
|
||||
func mustReadAll(r io.Reader) []byte {
|
||||
data, _ := io.ReadAll(r)
|
||||
return data
|
||||
}
|
||||
|
||||
// Ping tests the connection to the API server.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
const op = "agentic.Client.Ping"
|
||||
|
||||
endpoint := c.BaseURL + "/api/health"
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return core.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return core.E(op, "request failed", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return core.E(op, fmt.Sprintf("server returned status %d", resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
356
pkg/agentic/client_test.go
Normal file
356
pkg/agentic/client_test.go
Normal file
|
|
@ -0,0 +1,356 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Test fixtures
|
||||
var testTask = Task{
|
||||
ID: "task-123",
|
||||
Title: "Implement feature X",
|
||||
Description: "Add the new feature X to the system",
|
||||
Priority: PriorityHigh,
|
||||
Status: StatusPending,
|
||||
Labels: []string{"feature", "backend"},
|
||||
Files: []string{"pkg/feature/feature.go"},
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
Project: "core",
|
||||
}
|
||||
|
||||
var testTasks = []Task{
|
||||
testTask,
|
||||
{
|
||||
ID: "task-456",
|
||||
Title: "Fix bug Y",
|
||||
Description: "Fix the bug in component Y",
|
||||
Priority: PriorityCritical,
|
||||
Status: StatusPending,
|
||||
Labels: []string{"bug", "urgent"},
|
||||
CreatedAt: time.Now().Add(-2 * time.Hour),
|
||||
Project: "core",
|
||||
},
|
||||
}
|
||||
|
||||
func TestNewClient_Good(t *testing.T) {
|
||||
client := NewClient("https://api.example.com", "test-token")
|
||||
|
||||
assert.Equal(t, "https://api.example.com", client.BaseURL)
|
||||
assert.Equal(t, "test-token", client.Token)
|
||||
assert.NotNil(t, client.HTTPClient)
|
||||
}
|
||||
|
||||
func TestNewClient_Good_TrailingSlash(t *testing.T) {
|
||||
client := NewClient("https://api.example.com/", "test-token")
|
||||
|
||||
assert.Equal(t, "https://api.example.com", client.BaseURL)
|
||||
}
|
||||
|
||||
func TestNewClientFromConfig_Good(t *testing.T) {
|
||||
cfg := &Config{
|
||||
BaseURL: "https://api.example.com",
|
||||
Token: "config-token",
|
||||
AgentID: "agent-001",
|
||||
}
|
||||
|
||||
client := NewClientFromConfig(cfg)
|
||||
|
||||
assert.Equal(t, "https://api.example.com", client.BaseURL)
|
||||
assert.Equal(t, "config-token", client.Token)
|
||||
assert.Equal(t, "agent-001", client.AgentID)
|
||||
}
|
||||
|
||||
func TestClient_ListTasks_Good(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/tasks", r.URL.Path)
|
||||
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(testTasks)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
tasks, err := client.ListTasks(context.Background(), ListOptions{})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, tasks, 2)
|
||||
assert.Equal(t, "task-123", tasks[0].ID)
|
||||
assert.Equal(t, "task-456", tasks[1].ID)
|
||||
}
|
||||
|
||||
func TestClient_ListTasks_Good_WithFilters(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
assert.Equal(t, "pending", query.Get("status"))
|
||||
assert.Equal(t, "high", query.Get("priority"))
|
||||
assert.Equal(t, "core", query.Get("project"))
|
||||
assert.Equal(t, "10", query.Get("limit"))
|
||||
assert.Equal(t, "bug,urgent", query.Get("labels"))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode([]Task{testTask})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
opts := ListOptions{
|
||||
Status: StatusPending,
|
||||
Priority: PriorityHigh,
|
||||
Project: "core",
|
||||
Limit: 10,
|
||||
Labels: []string{"bug", "urgent"},
|
||||
}
|
||||
|
||||
tasks, err := client.ListTasks(context.Background(), opts)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, tasks, 1)
|
||||
}
|
||||
|
||||
func TestClient_ListTasks_Bad_ServerError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(APIError{Message: "internal error"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
tasks, err := client.ListTasks(context.Background(), ListOptions{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, tasks)
|
||||
assert.Contains(t, err.Error(), "internal error")
|
||||
}
|
||||
|
||||
func TestClient_GetTask_Good(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodGet, r.Method)
|
||||
assert.Equal(t, "/api/tasks/task-123", r.URL.Path)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(testTask)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
task, err := client.GetTask(context.Background(), "task-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "task-123", task.ID)
|
||||
assert.Equal(t, "Implement feature X", task.Title)
|
||||
assert.Equal(t, PriorityHigh, task.Priority)
|
||||
}
|
||||
|
||||
func TestClient_GetTask_Bad_EmptyID(t *testing.T) {
|
||||
client := NewClient("https://api.example.com", "test-token")
|
||||
task, err := client.GetTask(context.Background(), "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, task)
|
||||
assert.Contains(t, err.Error(), "task ID is required")
|
||||
}
|
||||
|
||||
func TestClient_GetTask_Bad_NotFound(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
json.NewEncoder(w).Encode(APIError{Message: "task not found"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
task, err := client.GetTask(context.Background(), "nonexistent")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, task)
|
||||
assert.Contains(t, err.Error(), "task not found")
|
||||
}
|
||||
|
||||
func TestClient_ClaimTask_Good(t *testing.T) {
|
||||
claimedTask := testTask
|
||||
claimedTask.Status = StatusInProgress
|
||||
claimedTask.ClaimedBy = "agent-001"
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/tasks/task-123/claim", r.URL.Path)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(ClaimResponse{Task: &claimedTask})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
client.AgentID = "agent-001"
|
||||
task, err := client.ClaimTask(context.Background(), "task-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusInProgress, task.Status)
|
||||
assert.Equal(t, "agent-001", task.ClaimedBy)
|
||||
}
|
||||
|
||||
func TestClient_ClaimTask_Good_SimpleResponse(t *testing.T) {
|
||||
// Some APIs might return just the task without wrapping
|
||||
claimedTask := testTask
|
||||
claimedTask.Status = StatusInProgress
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(claimedTask)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
task, err := client.ClaimTask(context.Background(), "task-123")
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "task-123", task.ID)
|
||||
}
|
||||
|
||||
func TestClient_ClaimTask_Bad_EmptyID(t *testing.T) {
|
||||
client := NewClient("https://api.example.com", "test-token")
|
||||
task, err := client.ClaimTask(context.Background(), "")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, task)
|
||||
assert.Contains(t, err.Error(), "task ID is required")
|
||||
}
|
||||
|
||||
func TestClient_ClaimTask_Bad_AlreadyClaimed(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
json.NewEncoder(w).Encode(APIError{Message: "task already claimed"})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
task, err := client.ClaimTask(context.Background(), "task-123")
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, task)
|
||||
assert.Contains(t, err.Error(), "task already claimed")
|
||||
}
|
||||
|
||||
func TestClient_UpdateTask_Good(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPatch, r.Method)
|
||||
assert.Equal(t, "/api/tasks/task-123", r.URL.Path)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
|
||||
var update TaskUpdate
|
||||
err := json.NewDecoder(r.Body).Decode(&update)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StatusInProgress, update.Status)
|
||||
assert.Equal(t, 50, update.Progress)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
err := client.UpdateTask(context.Background(), "task-123", TaskUpdate{
|
||||
Status: StatusInProgress,
|
||||
Progress: 50,
|
||||
Notes: "Making progress",
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_UpdateTask_Bad_EmptyID(t *testing.T) {
|
||||
client := NewClient("https://api.example.com", "test-token")
|
||||
err := client.UpdateTask(context.Background(), "", TaskUpdate{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "task ID is required")
|
||||
}
|
||||
|
||||
func TestClient_CompleteTask_Good(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "/api/tasks/task-123/complete", r.URL.Path)
|
||||
|
||||
var result TaskResult
|
||||
err := json.NewDecoder(r.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Success)
|
||||
assert.Equal(t, "Feature implemented", result.Output)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
err := client.CompleteTask(context.Background(), "task-123", TaskResult{
|
||||
Success: true,
|
||||
Output: "Feature implemented",
|
||||
Artifacts: []string{"pkg/feature/feature.go"},
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_CompleteTask_Bad_EmptyID(t *testing.T) {
|
||||
client := NewClient("https://api.example.com", "test-token")
|
||||
err := client.CompleteTask(context.Background(), "", TaskResult{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "task ID is required")
|
||||
}
|
||||
|
||||
func TestClient_Ping_Good(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/api/health", r.URL.Path)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL, "test-token")
|
||||
err := client.Ping(context.Background())
|
||||
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Ping_Bad_ServerDown(t *testing.T) {
|
||||
client := NewClient("http://localhost:99999", "test-token")
|
||||
client.HTTPClient.Timeout = 100 * time.Millisecond
|
||||
|
||||
err := client.Ping(context.Background())
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "request failed")
|
||||
}
|
||||
|
||||
func TestAPIError_Error_Good(t *testing.T) {
|
||||
err := &APIError{
|
||||
Code: 404,
|
||||
Message: "task not found",
|
||||
}
|
||||
|
||||
assert.Equal(t, "task not found", err.Error())
|
||||
|
||||
err.Details = "task-123 does not exist"
|
||||
assert.Equal(t, "task not found: task-123 does not exist", err.Error())
|
||||
}
|
||||
|
||||
func TestTaskStatus_Good(t *testing.T) {
|
||||
assert.Equal(t, TaskStatus("pending"), StatusPending)
|
||||
assert.Equal(t, TaskStatus("in_progress"), StatusInProgress)
|
||||
assert.Equal(t, TaskStatus("completed"), StatusCompleted)
|
||||
assert.Equal(t, TaskStatus("blocked"), StatusBlocked)
|
||||
}
|
||||
|
||||
func TestTaskPriority_Good(t *testing.T) {
|
||||
assert.Equal(t, TaskPriority("critical"), PriorityCritical)
|
||||
assert.Equal(t, TaskPriority("high"), PriorityHigh)
|
||||
assert.Equal(t, TaskPriority("medium"), PriorityMedium)
|
||||
assert.Equal(t, TaskPriority("low"), PriorityLow)
|
||||
}
|
||||
199
pkg/agentic/config.go
Normal file
199
pkg/agentic/config.go
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/core"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds the configuration for connecting to the core-agentic service.
|
||||
type Config struct {
|
||||
// BaseURL is the URL of the core-agentic API server.
|
||||
BaseURL string `yaml:"base_url" json:"base_url"`
|
||||
// Token is the authentication token for API requests.
|
||||
Token string `yaml:"token" json:"token"`
|
||||
// DefaultProject is the project to use when none is specified.
|
||||
DefaultProject string `yaml:"default_project" json:"default_project"`
|
||||
// AgentID is the identifier for this agent (optional, used for claiming tasks).
|
||||
AgentID string `yaml:"agent_id" json:"agent_id"`
|
||||
}
|
||||
|
||||
// configFileName is the name of the YAML config file.
|
||||
const configFileName = "agentic.yaml"
|
||||
|
||||
// envFileName is the name of the environment file.
|
||||
const envFileName = ".env"
|
||||
|
||||
// DefaultBaseURL is the default API endpoint if none is configured.
|
||||
const DefaultBaseURL = "https://api.core-agentic.dev"
|
||||
|
||||
// LoadConfig loads the agentic configuration from the specified directory.
|
||||
// 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:
|
||||
// - AGENTIC_BASE_URL: API base URL
|
||||
// - AGENTIC_TOKEN: Authentication token
|
||||
// - AGENTIC_PROJECT: Default project
|
||||
// - AGENTIC_AGENT_ID: Agent identifier
|
||||
func LoadConfig(dir string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
BaseURL: DefaultBaseURL,
|
||||
}
|
||||
|
||||
// Try loading from .env file in the specified directory
|
||||
if dir != "" {
|
||||
envPath := filepath.Join(dir, envFileName)
|
||||
if err := loadEnvFile(envPath, cfg); err == nil {
|
||||
// Successfully loaded from .env
|
||||
applyEnvOverrides(cfg)
|
||||
if cfg.Token != "" {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading from current directory .env
|
||||
if dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err == nil {
|
||||
envPath := filepath.Join(cwd, envFileName)
|
||||
if err := loadEnvFile(envPath, cfg); err == nil {
|
||||
applyEnvOverrides(cfg)
|
||||
if cfg.Token != "" {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading from ~/.core/agentic.yaml
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, core.E("agentic.LoadConfig", "failed to get home directory", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(homeDir, ".core", configFileName)
|
||||
if err := loadYAMLConfig(configPath, cfg); err != nil && !os.IsNotExist(err) {
|
||||
return nil, core.E("agentic.LoadConfig", "failed to load config", err)
|
||||
}
|
||||
|
||||
// Apply environment variable overrides
|
||||
applyEnvOverrides(cfg)
|
||||
|
||||
// Validate configuration
|
||||
if cfg.Token == "" {
|
||||
return nil, core.E("agentic.LoadConfig", "no authentication token configured", nil)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// loadEnvFile reads a .env file and extracts agentic configuration.
|
||||
func loadEnvFile(path string, cfg *Config) error {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
|
||||
// Skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse KEY=value
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
// Remove quotes if present
|
||||
value = strings.Trim(value, `"'`)
|
||||
|
||||
switch key {
|
||||
case "AGENTIC_BASE_URL":
|
||||
cfg.BaseURL = value
|
||||
case "AGENTIC_TOKEN":
|
||||
cfg.Token = value
|
||||
case "AGENTIC_PROJECT":
|
||||
cfg.DefaultProject = value
|
||||
case "AGENTIC_AGENT_ID":
|
||||
cfg.AgentID = value
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// loadYAMLConfig reads configuration from a YAML file.
|
||||
func loadYAMLConfig(path string, cfg *Config) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return yaml.Unmarshal(data, cfg)
|
||||
}
|
||||
|
||||
// applyEnvOverrides applies environment variable overrides to the config.
|
||||
func applyEnvOverrides(cfg *Config) {
|
||||
if v := os.Getenv("AGENTIC_BASE_URL"); v != "" {
|
||||
cfg.BaseURL = v
|
||||
}
|
||||
if v := os.Getenv("AGENTIC_TOKEN"); v != "" {
|
||||
cfg.Token = v
|
||||
}
|
||||
if v := os.Getenv("AGENTIC_PROJECT"); v != "" {
|
||||
cfg.DefaultProject = v
|
||||
}
|
||||
if v := os.Getenv("AGENTIC_AGENT_ID"); v != "" {
|
||||
cfg.AgentID = v
|
||||
}
|
||||
}
|
||||
|
||||
// SaveConfig saves the configuration to ~/.core/agentic.yaml.
|
||||
func SaveConfig(cfg *Config) error {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return core.E("agentic.SaveConfig", "failed to get home directory", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(homeDir, ".core")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return core.E("agentic.SaveConfig", "failed to create config directory", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, configFileName)
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return core.E("agentic.SaveConfig", "failed to marshal config", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, data, 0600); err != nil {
|
||||
return core.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.
|
||||
func ConfigPath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", core.E("agentic.ConfigPath", "failed to get home directory", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".core", configFileName), nil
|
||||
}
|
||||
185
pkg/agentic/config_test.go
Normal file
185
pkg/agentic/config_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadConfig_Good_FromEnvFile(t *testing.T) {
|
||||
// Create temp directory with .env file
|
||||
tmpDir, err := os.MkdirTemp("", "agentic-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
envContent := `
|
||||
AGENTIC_BASE_URL=https://test.api.com
|
||||
AGENTIC_TOKEN=test-token-123
|
||||
AGENTIC_PROJECT=my-project
|
||||
AGENTIC_AGENT_ID=agent-001
|
||||
`
|
||||
err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := LoadConfig(tmpDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://test.api.com", cfg.BaseURL)
|
||||
assert.Equal(t, "test-token-123", cfg.Token)
|
||||
assert.Equal(t, "my-project", cfg.DefaultProject)
|
||||
assert.Equal(t, "agent-001", cfg.AgentID)
|
||||
}
|
||||
|
||||
func TestLoadConfig_Good_FromEnvVars(t *testing.T) {
|
||||
// Create temp directory with .env file (partial config)
|
||||
tmpDir, err := os.MkdirTemp("", "agentic-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
envContent := `
|
||||
AGENTIC_TOKEN=env-file-token
|
||||
`
|
||||
err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set environment variables that should override
|
||||
os.Setenv("AGENTIC_BASE_URL", "https://env-override.com")
|
||||
os.Setenv("AGENTIC_TOKEN", "env-override-token")
|
||||
defer func() {
|
||||
os.Unsetenv("AGENTIC_BASE_URL")
|
||||
os.Unsetenv("AGENTIC_TOKEN")
|
||||
}()
|
||||
|
||||
cfg, err := LoadConfig(tmpDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://env-override.com", cfg.BaseURL)
|
||||
assert.Equal(t, "env-override-token", cfg.Token)
|
||||
}
|
||||
|
||||
func TestLoadConfig_Bad_NoToken(t *testing.T) {
|
||||
// Create temp directory without config
|
||||
tmpDir, err := os.MkdirTemp("", "agentic-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create empty .env
|
||||
err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(""), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure no env vars are set
|
||||
os.Unsetenv("AGENTIC_TOKEN")
|
||||
os.Unsetenv("AGENTIC_BASE_URL")
|
||||
|
||||
_, err = LoadConfig(tmpDir)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no authentication token")
|
||||
}
|
||||
|
||||
func TestLoadConfig_Good_EnvFileWithQuotes(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agentic-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Test with quoted values
|
||||
envContent := `
|
||||
AGENTIC_TOKEN="quoted-token"
|
||||
AGENTIC_BASE_URL='single-quoted-url'
|
||||
`
|
||||
err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := LoadConfig(tmpDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "quoted-token", cfg.Token)
|
||||
assert.Equal(t, "single-quoted-url", cfg.BaseURL)
|
||||
}
|
||||
|
||||
func TestLoadConfig_Good_EnvFileWithComments(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agentic-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
envContent := `
|
||||
# This is a comment
|
||||
AGENTIC_TOKEN=token-with-comments
|
||||
|
||||
# Another comment
|
||||
AGENTIC_PROJECT=commented-project
|
||||
`
|
||||
err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := LoadConfig(tmpDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "token-with-comments", cfg.Token)
|
||||
assert.Equal(t, "commented-project", cfg.DefaultProject)
|
||||
}
|
||||
|
||||
func TestSaveConfig_Good(t *testing.T) {
|
||||
// Create temp home directory
|
||||
tmpHome, err := os.MkdirTemp("", "agentic-home")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpHome)
|
||||
|
||||
// Override HOME for the test
|
||||
originalHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpHome)
|
||||
defer os.Setenv("HOME", originalHome)
|
||||
|
||||
cfg := &Config{
|
||||
BaseURL: "https://saved.api.com",
|
||||
Token: "saved-token",
|
||||
DefaultProject: "saved-project",
|
||||
AgentID: "saved-agent",
|
||||
}
|
||||
|
||||
err = SaveConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify file was created
|
||||
configPath := filepath.Join(tmpHome, ".core", "agentic.yaml")
|
||||
_, err = os.Stat(configPath)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Read back the config
|
||||
data, err := os.ReadFile(configPath)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(data), "saved.api.com")
|
||||
assert.Contains(t, string(data), "saved-token")
|
||||
}
|
||||
|
||||
func TestConfigPath_Good(t *testing.T) {
|
||||
path, err := ConfigPath()
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, path, ".core")
|
||||
assert.Contains(t, path, "agentic.yaml")
|
||||
}
|
||||
|
||||
func TestLoadConfig_Good_DefaultBaseURL(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agentic-test")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Only provide token, should use default base URL
|
||||
envContent := `
|
||||
AGENTIC_TOKEN=test-token
|
||||
`
|
||||
err = os.WriteFile(filepath.Join(tmpDir, ".env"), []byte(envContent), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Clear any env overrides
|
||||
os.Unsetenv("AGENTIC_BASE_URL")
|
||||
|
||||
cfg, err := LoadConfig(tmpDir)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DefaultBaseURL, cfg.BaseURL)
|
||||
}
|
||||
14
pkg/agentic/go.mod
Normal file
14
pkg/agentic/go.mod
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
module github.com/host-uk/core/pkg/agentic
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/host-uk/core/pkg/core v0.0.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
)
|
||||
140
pkg/agentic/types.go
Normal file
140
pkg/agentic/types.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// Package agentic provides an API client for core-agentic, an AI-assisted task
|
||||
// management service. It enables developers and AI agents to discover, claim,
|
||||
// and complete development tasks.
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TaskStatus represents the state of a task in the system.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
// StatusPending indicates the task is available to be claimed.
|
||||
StatusPending TaskStatus = "pending"
|
||||
// StatusInProgress indicates the task has been claimed and is being worked on.
|
||||
StatusInProgress TaskStatus = "in_progress"
|
||||
// StatusCompleted indicates the task has been successfully completed.
|
||||
StatusCompleted TaskStatus = "completed"
|
||||
// StatusBlocked indicates the task cannot proceed due to dependencies.
|
||||
StatusBlocked TaskStatus = "blocked"
|
||||
)
|
||||
|
||||
// TaskPriority represents the urgency level of a task.
|
||||
type TaskPriority string
|
||||
|
||||
const (
|
||||
// PriorityCritical indicates the task requires immediate attention.
|
||||
PriorityCritical TaskPriority = "critical"
|
||||
// PriorityHigh indicates the task is important and should be addressed soon.
|
||||
PriorityHigh TaskPriority = "high"
|
||||
// PriorityMedium indicates the task has normal priority.
|
||||
PriorityMedium TaskPriority = "medium"
|
||||
// PriorityLow indicates the task can be addressed when time permits.
|
||||
PriorityLow TaskPriority = "low"
|
||||
)
|
||||
|
||||
// Task represents a development task in the core-agentic system.
|
||||
type Task struct {
|
||||
// ID is the unique identifier for the task.
|
||||
ID string `json:"id"`
|
||||
// Title is the short description of the task.
|
||||
Title string `json:"title"`
|
||||
// Description provides detailed information about what needs to be done.
|
||||
Description string `json:"description"`
|
||||
// Priority indicates the urgency of the task.
|
||||
Priority TaskPriority `json:"priority"`
|
||||
// Status indicates the current state of the task.
|
||||
Status TaskStatus `json:"status"`
|
||||
// Labels are tags used to categorize the task.
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
// Files lists the files that are relevant to this task.
|
||||
Files []string `json:"files,omitempty"`
|
||||
// CreatedAt is when the task was created.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// UpdatedAt is when the task was last modified.
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
// ClaimedBy is the identifier of the agent or developer who claimed the task.
|
||||
ClaimedBy string `json:"claimed_by,omitempty"`
|
||||
// ClaimedAt is when the task was claimed.
|
||||
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
|
||||
// Project is the project this task belongs to.
|
||||
Project string `json:"project,omitempty"`
|
||||
// Dependencies lists task IDs that must be completed before this task.
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
// Blockers lists task IDs that this task is blocking.
|
||||
Blockers []string `json:"blockers,omitempty"`
|
||||
}
|
||||
|
||||
// TaskUpdate contains fields that can be updated on a task.
|
||||
type TaskUpdate struct {
|
||||
// Status is the new status for the task.
|
||||
Status TaskStatus `json:"status,omitempty"`
|
||||
// Progress is a percentage (0-100) indicating completion.
|
||||
Progress int `json:"progress,omitempty"`
|
||||
// Notes are additional comments about the update.
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// TaskResult contains the outcome of a completed task.
|
||||
type TaskResult struct {
|
||||
// Success indicates whether the task was completed successfully.
|
||||
Success bool `json:"success"`
|
||||
// Output is the result or summary of the completed work.
|
||||
Output string `json:"output,omitempty"`
|
||||
// Artifacts are files or resources produced by the task.
|
||||
Artifacts []string `json:"artifacts,omitempty"`
|
||||
// ErrorMessage contains details if the task failed.
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// ListOptions specifies filters for listing tasks.
|
||||
type ListOptions struct {
|
||||
// Status filters tasks by their current status.
|
||||
Status TaskStatus `json:"status,omitempty"`
|
||||
// Labels filters tasks that have all specified labels.
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
// Priority filters tasks by priority level.
|
||||
Priority TaskPriority `json:"priority,omitempty"`
|
||||
// Limit is the maximum number of tasks to return.
|
||||
Limit int `json:"limit,omitempty"`
|
||||
// Project filters tasks by project.
|
||||
Project string `json:"project,omitempty"`
|
||||
// ClaimedBy filters tasks claimed by a specific agent.
|
||||
ClaimedBy string `json:"claimed_by,omitempty"`
|
||||
}
|
||||
|
||||
// APIError represents an error response from the API.
|
||||
type APIError struct {
|
||||
// Code is the HTTP status code.
|
||||
Code int `json:"code"`
|
||||
// Message is the error description.
|
||||
Message string `json:"message"`
|
||||
// Details provides additional context about the error.
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the error interface for APIError.
|
||||
func (e *APIError) Error() string {
|
||||
if e.Details != "" {
|
||||
return e.Message + ": " + e.Details
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// ClaimResponse is returned when a task is successfully claimed.
|
||||
type ClaimResponse struct {
|
||||
// Task is the claimed task with updated fields.
|
||||
Task *Task `json:"task"`
|
||||
// Message provides additional context about the claim.
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// CompleteResponse is returned when a task is completed.
|
||||
type CompleteResponse struct {
|
||||
// Task is the completed task with final status.
|
||||
Task *Task `json:"task"`
|
||||
// Message provides additional context about the completion.
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue