refactor: extract agentic/ to standalone core/go-agentic module
Now lives at forge.lthn.ai/core/go-agentic. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
34d0f9ce41
commit
f99ca10c6c
15 changed files with 0 additions and 3373 deletions
|
|
@ -1,299 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AllowanceStatus indicates the current state of an agent's quota.
|
||||
type AllowanceStatus string
|
||||
|
||||
const (
|
||||
// AllowanceOK indicates the agent has remaining quota.
|
||||
AllowanceOK AllowanceStatus = "ok"
|
||||
// AllowanceWarning indicates the agent is at 80%+ usage.
|
||||
AllowanceWarning AllowanceStatus = "warning"
|
||||
// AllowanceExceeded indicates the agent has exceeded its quota.
|
||||
AllowanceExceeded AllowanceStatus = "exceeded"
|
||||
)
|
||||
|
||||
// AgentAllowance defines the quota limits for a single agent.
|
||||
type AgentAllowance struct {
|
||||
// AgentID is the unique identifier for the agent.
|
||||
AgentID string `json:"agent_id" yaml:"agent_id"`
|
||||
// DailyTokenLimit is the maximum tokens (in+out) per 24h. 0 means unlimited.
|
||||
DailyTokenLimit int64 `json:"daily_token_limit" yaml:"daily_token_limit"`
|
||||
// DailyJobLimit is the maximum jobs per 24h. 0 means unlimited.
|
||||
DailyJobLimit int `json:"daily_job_limit" yaml:"daily_job_limit"`
|
||||
// ConcurrentJobs is the maximum simultaneous jobs. 0 means unlimited.
|
||||
ConcurrentJobs int `json:"concurrent_jobs" yaml:"concurrent_jobs"`
|
||||
// MaxJobDuration is the maximum job duration before kill. 0 means unlimited.
|
||||
MaxJobDuration time.Duration `json:"max_job_duration" yaml:"max_job_duration"`
|
||||
// ModelAllowlist restricts which models this agent can use. Empty means all.
|
||||
ModelAllowlist []string `json:"model_allowlist,omitempty" yaml:"model_allowlist"`
|
||||
}
|
||||
|
||||
// ModelQuota defines global per-model limits across all agents.
|
||||
type ModelQuota struct {
|
||||
// Model is the model identifier (e.g. "claude-sonnet-4-5-20250929").
|
||||
Model string `json:"model" yaml:"model"`
|
||||
// DailyTokenBudget is the total tokens across all agents per 24h.
|
||||
DailyTokenBudget int64 `json:"daily_token_budget" yaml:"daily_token_budget"`
|
||||
// HourlyRateLimit is the max requests per hour.
|
||||
HourlyRateLimit int `json:"hourly_rate_limit" yaml:"hourly_rate_limit"`
|
||||
// CostCeiling stops all usage if cumulative cost exceeds this (in cents).
|
||||
CostCeiling int64 `json:"cost_ceiling" yaml:"cost_ceiling"`
|
||||
}
|
||||
|
||||
// RepoLimit defines per-repository rate limits.
|
||||
type RepoLimit struct {
|
||||
// Repo is the repository identifier (e.g. "owner/repo").
|
||||
Repo string `json:"repo" yaml:"repo"`
|
||||
// MaxDailyPRs is the maximum PRs per day. 0 means unlimited.
|
||||
MaxDailyPRs int `json:"max_daily_prs" yaml:"max_daily_prs"`
|
||||
// MaxDailyIssues is the maximum issues per day. 0 means unlimited.
|
||||
MaxDailyIssues int `json:"max_daily_issues" yaml:"max_daily_issues"`
|
||||
// CooldownAfterFailure is the wait time after a failure before retrying.
|
||||
CooldownAfterFailure time.Duration `json:"cooldown_after_failure" yaml:"cooldown_after_failure"`
|
||||
}
|
||||
|
||||
// UsageRecord tracks an agent's current usage within a quota period.
|
||||
type UsageRecord struct {
|
||||
// AgentID is the agent this record belongs to.
|
||||
AgentID string `json:"agent_id"`
|
||||
// TokensUsed is the total tokens consumed in the current period.
|
||||
TokensUsed int64 `json:"tokens_used"`
|
||||
// JobsStarted is the total jobs started in the current period.
|
||||
JobsStarted int `json:"jobs_started"`
|
||||
// ActiveJobs is the number of currently running jobs.
|
||||
ActiveJobs int `json:"active_jobs"`
|
||||
// PeriodStart is when the current quota period began.
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
}
|
||||
|
||||
// QuotaCheckResult is the outcome of a pre-dispatch allowance check.
|
||||
type QuotaCheckResult struct {
|
||||
// Allowed indicates whether the agent may proceed.
|
||||
Allowed bool `json:"allowed"`
|
||||
// Status is the current allowance state.
|
||||
Status AllowanceStatus `json:"status"`
|
||||
// Remaining is the number of tokens remaining in the period.
|
||||
RemainingTokens int64 `json:"remaining_tokens"`
|
||||
// RemainingJobs is the number of jobs remaining in the period.
|
||||
RemainingJobs int `json:"remaining_jobs"`
|
||||
// Reason explains why the check failed (if !Allowed).
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// QuotaEvent represents a change in quota usage, used for recovery.
|
||||
type QuotaEvent string
|
||||
|
||||
const (
|
||||
// QuotaEventJobStarted deducts quota when a job begins.
|
||||
QuotaEventJobStarted QuotaEvent = "job_started"
|
||||
// QuotaEventJobCompleted deducts nothing (already counted).
|
||||
QuotaEventJobCompleted QuotaEvent = "job_completed"
|
||||
// QuotaEventJobFailed returns 50% of token quota.
|
||||
QuotaEventJobFailed QuotaEvent = "job_failed"
|
||||
// QuotaEventJobCancelled returns 100% of token quota.
|
||||
QuotaEventJobCancelled QuotaEvent = "job_cancelled"
|
||||
)
|
||||
|
||||
// UsageReport is emitted by the agent runner to report token consumption.
|
||||
type UsageReport struct {
|
||||
// AgentID is the agent that consumed tokens.
|
||||
AgentID string `json:"agent_id"`
|
||||
// JobID identifies the specific job.
|
||||
JobID string `json:"job_id"`
|
||||
// Model is the model used.
|
||||
Model string `json:"model"`
|
||||
// TokensIn is the number of input tokens consumed.
|
||||
TokensIn int64 `json:"tokens_in"`
|
||||
// TokensOut is the number of output tokens consumed.
|
||||
TokensOut int64 `json:"tokens_out"`
|
||||
// Event is the type of quota event.
|
||||
Event QuotaEvent `json:"event"`
|
||||
// Timestamp is when the usage occurred.
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// AllowanceStore is the interface for persisting and querying allowance data.
|
||||
// Implementations may use Redis, SQLite, or any backing store.
|
||||
type AllowanceStore interface {
|
||||
// GetAllowance returns the quota limits for an agent.
|
||||
GetAllowance(agentID string) (*AgentAllowance, error)
|
||||
// SetAllowance persists quota limits for an agent.
|
||||
SetAllowance(a *AgentAllowance) error
|
||||
// GetUsage returns the current usage record for an agent.
|
||||
GetUsage(agentID string) (*UsageRecord, error)
|
||||
// IncrementUsage atomically adds to an agent's usage counters.
|
||||
IncrementUsage(agentID string, tokens int64, jobs int) error
|
||||
// DecrementActiveJobs reduces the active job count by 1.
|
||||
DecrementActiveJobs(agentID string) error
|
||||
// ReturnTokens adds tokens back to the agent's remaining quota.
|
||||
ReturnTokens(agentID string, tokens int64) error
|
||||
// ResetUsage clears usage counters for an agent (daily reset).
|
||||
ResetUsage(agentID string) error
|
||||
// GetModelQuota returns global limits for a model.
|
||||
GetModelQuota(model string) (*ModelQuota, error)
|
||||
// GetModelUsage returns current token usage for a model.
|
||||
GetModelUsage(model string) (int64, error)
|
||||
// IncrementModelUsage atomically adds to a model's usage counter.
|
||||
IncrementModelUsage(model string, tokens int64) error
|
||||
}
|
||||
|
||||
// MemoryStore is an in-memory AllowanceStore for testing and single-node use.
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
allowances map[string]*AgentAllowance
|
||||
usage map[string]*UsageRecord
|
||||
modelQuotas map[string]*ModelQuota
|
||||
modelUsage map[string]int64
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new in-memory allowance store.
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
allowances: make(map[string]*AgentAllowance),
|
||||
usage: make(map[string]*UsageRecord),
|
||||
modelQuotas: make(map[string]*ModelQuota),
|
||||
modelUsage: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllowance returns the quota limits for an agent.
|
||||
func (m *MemoryStore) GetAllowance(agentID string) (*AgentAllowance, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
a, ok := m.allowances[agentID]
|
||||
if !ok {
|
||||
return nil, &APIError{Code: 404, Message: "allowance not found for agent: " + agentID}
|
||||
}
|
||||
cp := *a
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// SetAllowance persists quota limits for an agent.
|
||||
func (m *MemoryStore) SetAllowance(a *AgentAllowance) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *a
|
||||
m.allowances[a.AgentID] = &cp
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUsage returns the current usage record for an agent.
|
||||
func (m *MemoryStore) GetUsage(agentID string) (*UsageRecord, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
u, ok := m.usage[agentID]
|
||||
if !ok {
|
||||
return &UsageRecord{
|
||||
AgentID: agentID,
|
||||
PeriodStart: startOfDay(time.Now().UTC()),
|
||||
}, nil
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// IncrementUsage atomically adds to an agent's usage counters.
|
||||
func (m *MemoryStore) IncrementUsage(agentID string, tokens int64, jobs int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.usage[agentID]
|
||||
if !ok {
|
||||
u = &UsageRecord{
|
||||
AgentID: agentID,
|
||||
PeriodStart: startOfDay(time.Now().UTC()),
|
||||
}
|
||||
m.usage[agentID] = u
|
||||
}
|
||||
u.TokensUsed += tokens
|
||||
u.JobsStarted += jobs
|
||||
if jobs > 0 {
|
||||
u.ActiveJobs += jobs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecrementActiveJobs reduces the active job count by 1.
|
||||
func (m *MemoryStore) DecrementActiveJobs(agentID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.usage[agentID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if u.ActiveJobs > 0 {
|
||||
u.ActiveJobs--
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReturnTokens adds tokens back to the agent's remaining quota.
|
||||
func (m *MemoryStore) ReturnTokens(agentID string, tokens int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.usage[agentID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
u.TokensUsed -= tokens
|
||||
if u.TokensUsed < 0 {
|
||||
u.TokensUsed = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetUsage clears usage counters for an agent.
|
||||
func (m *MemoryStore) ResetUsage(agentID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.usage[agentID] = &UsageRecord{
|
||||
AgentID: agentID,
|
||||
PeriodStart: startOfDay(time.Now().UTC()),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetModelQuota returns global limits for a model.
|
||||
func (m *MemoryStore) GetModelQuota(model string) (*ModelQuota, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
q, ok := m.modelQuotas[model]
|
||||
if !ok {
|
||||
return nil, &APIError{Code: 404, Message: "model quota not found: " + model}
|
||||
}
|
||||
cp := *q
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
// GetModelUsage returns current token usage for a model.
|
||||
func (m *MemoryStore) GetModelUsage(model string) (int64, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.modelUsage[model], nil
|
||||
}
|
||||
|
||||
// IncrementModelUsage atomically adds to a model's usage counter.
|
||||
func (m *MemoryStore) IncrementModelUsage(model string, tokens int64) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.modelUsage[model] += tokens
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetModelQuota sets global limits for a model (used in testing).
|
||||
func (m *MemoryStore) SetModelQuota(q *ModelQuota) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cp := *q
|
||||
m.modelQuotas[q.Model] = &cp
|
||||
}
|
||||
|
||||
// startOfDay returns midnight UTC for the given time.
|
||||
func startOfDay(t time.Time) time.Time {
|
||||
y, mo, d := t.Date()
|
||||
return time.Date(y, mo, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// AllowanceService enforces agent quota limits. It provides pre-dispatch checks,
|
||||
// runtime usage recording, and quota recovery for failed/cancelled jobs.
|
||||
type AllowanceService struct {
|
||||
store AllowanceStore
|
||||
}
|
||||
|
||||
// NewAllowanceService creates a new AllowanceService with the given store.
|
||||
func NewAllowanceService(store AllowanceStore) *AllowanceService {
|
||||
return &AllowanceService{store: store}
|
||||
}
|
||||
|
||||
// Check performs a pre-dispatch allowance check for the given agent and model.
|
||||
// It verifies daily token limits, daily job limits, concurrent job limits, and
|
||||
// model allowlists. Returns a QuotaCheckResult indicating whether the agent may proceed.
|
||||
func (s *AllowanceService) Check(agentID, model string) (*QuotaCheckResult, error) {
|
||||
const op = "AllowanceService.Check"
|
||||
|
||||
allowance, err := s.store.GetAllowance(agentID)
|
||||
if err != nil {
|
||||
return nil, log.E(op, "failed to get allowance", err)
|
||||
}
|
||||
|
||||
usage, err := s.store.GetUsage(agentID)
|
||||
if err != nil {
|
||||
return nil, log.E(op, "failed to get usage", err)
|
||||
}
|
||||
|
||||
result := &QuotaCheckResult{
|
||||
Allowed: true,
|
||||
Status: AllowanceOK,
|
||||
RemainingTokens: -1, // unlimited
|
||||
RemainingJobs: -1, // unlimited
|
||||
}
|
||||
|
||||
// Check model allowlist
|
||||
if len(allowance.ModelAllowlist) > 0 && model != "" {
|
||||
if !slices.Contains(allowance.ModelAllowlist, model) {
|
||||
result.Allowed = false
|
||||
result.Status = AllowanceExceeded
|
||||
result.Reason = "model not in allowlist: " + model
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily token limit
|
||||
if allowance.DailyTokenLimit > 0 {
|
||||
remaining := allowance.DailyTokenLimit - usage.TokensUsed
|
||||
result.RemainingTokens = remaining
|
||||
if remaining <= 0 {
|
||||
result.Allowed = false
|
||||
result.Status = AllowanceExceeded
|
||||
result.Reason = "daily token limit exceeded"
|
||||
return result, nil
|
||||
}
|
||||
ratio := float64(usage.TokensUsed) / float64(allowance.DailyTokenLimit)
|
||||
if ratio >= 0.8 {
|
||||
result.Status = AllowanceWarning
|
||||
}
|
||||
}
|
||||
|
||||
// Check daily job limit
|
||||
if allowance.DailyJobLimit > 0 {
|
||||
remaining := allowance.DailyJobLimit - usage.JobsStarted
|
||||
result.RemainingJobs = remaining
|
||||
if remaining <= 0 {
|
||||
result.Allowed = false
|
||||
result.Status = AllowanceExceeded
|
||||
result.Reason = "daily job limit exceeded"
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check concurrent jobs
|
||||
if allowance.ConcurrentJobs > 0 && usage.ActiveJobs >= allowance.ConcurrentJobs {
|
||||
result.Allowed = false
|
||||
result.Status = AllowanceExceeded
|
||||
result.Reason = "concurrent job limit reached"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Check global model quota
|
||||
if model != "" {
|
||||
modelQuota, err := s.store.GetModelQuota(model)
|
||||
if err == nil && modelQuota.DailyTokenBudget > 0 {
|
||||
modelUsage, err := s.store.GetModelUsage(model)
|
||||
if err == nil && modelUsage >= modelQuota.DailyTokenBudget {
|
||||
result.Allowed = false
|
||||
result.Status = AllowanceExceeded
|
||||
result.Reason = "global model token budget exceeded for: " + model
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RecordUsage processes a usage report, updating counters and handling quota recovery.
|
||||
func (s *AllowanceService) RecordUsage(report UsageReport) error {
|
||||
const op = "AllowanceService.RecordUsage"
|
||||
|
||||
totalTokens := report.TokensIn + report.TokensOut
|
||||
|
||||
switch report.Event {
|
||||
case QuotaEventJobStarted:
|
||||
if err := s.store.IncrementUsage(report.AgentID, 0, 1); err != nil {
|
||||
return log.E(op, "failed to increment job count", err)
|
||||
}
|
||||
|
||||
case QuotaEventJobCompleted:
|
||||
if err := s.store.IncrementUsage(report.AgentID, totalTokens, 0); err != nil {
|
||||
return log.E(op, "failed to record token usage", err)
|
||||
}
|
||||
if err := s.store.DecrementActiveJobs(report.AgentID); err != nil {
|
||||
return log.E(op, "failed to decrement active jobs", err)
|
||||
}
|
||||
// Record model-level usage
|
||||
if report.Model != "" {
|
||||
if err := s.store.IncrementModelUsage(report.Model, totalTokens); err != nil {
|
||||
return log.E(op, "failed to record model usage", err)
|
||||
}
|
||||
}
|
||||
|
||||
case QuotaEventJobFailed:
|
||||
// Record partial usage, return 50% of tokens
|
||||
if err := s.store.IncrementUsage(report.AgentID, totalTokens, 0); err != nil {
|
||||
return log.E(op, "failed to record token usage", err)
|
||||
}
|
||||
if err := s.store.DecrementActiveJobs(report.AgentID); err != nil {
|
||||
return log.E(op, "failed to decrement active jobs", err)
|
||||
}
|
||||
returnAmount := totalTokens / 2
|
||||
if returnAmount > 0 {
|
||||
if err := s.store.ReturnTokens(report.AgentID, returnAmount); err != nil {
|
||||
return log.E(op, "failed to return tokens", err)
|
||||
}
|
||||
}
|
||||
// Still record model-level usage (net of return)
|
||||
if report.Model != "" {
|
||||
if err := s.store.IncrementModelUsage(report.Model, totalTokens-returnAmount); err != nil {
|
||||
return log.E(op, "failed to record model usage", err)
|
||||
}
|
||||
}
|
||||
|
||||
case QuotaEventJobCancelled:
|
||||
// Return 100% of tokens
|
||||
if err := s.store.DecrementActiveJobs(report.AgentID); err != nil {
|
||||
return log.E(op, "failed to decrement active jobs", err)
|
||||
}
|
||||
if totalTokens > 0 {
|
||||
if err := s.store.ReturnTokens(report.AgentID, totalTokens); err != nil {
|
||||
return log.E(op, "failed to return tokens", err)
|
||||
}
|
||||
}
|
||||
// No model-level usage for cancelled jobs
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetAgent clears daily usage counters for the given agent (midnight reset).
|
||||
func (s *AllowanceService) ResetAgent(agentID string) error {
|
||||
const op = "AllowanceService.ResetAgent"
|
||||
if err := s.store.ResetUsage(agentID); err != nil {
|
||||
return log.E(op, "failed to reset usage", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- MemoryStore tests ---
|
||||
|
||||
func TestMemoryStore_SetGetAllowance_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
a := &AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
DailyTokenLimit: 100000,
|
||||
DailyJobLimit: 10,
|
||||
ConcurrentJobs: 2,
|
||||
MaxJobDuration: 30 * time.Minute,
|
||||
ModelAllowlist: []string{"claude-sonnet-4-5-20250929"},
|
||||
}
|
||||
|
||||
err := store.SetAllowance(a)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := store.GetAllowance("agent-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, a.AgentID, got.AgentID)
|
||||
assert.Equal(t, a.DailyTokenLimit, got.DailyTokenLimit)
|
||||
assert.Equal(t, a.DailyJobLimit, got.DailyJobLimit)
|
||||
assert.Equal(t, a.ConcurrentJobs, got.ConcurrentJobs)
|
||||
assert.Equal(t, a.ModelAllowlist, got.ModelAllowlist)
|
||||
}
|
||||
|
||||
func TestMemoryStore_GetAllowance_Bad_NotFound(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
_, err := store.GetAllowance("nonexistent")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestMemoryStore_IncrementUsage_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
err := store.IncrementUsage("agent-1", 5000, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, err := store.GetUsage("agent-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5000), usage.TokensUsed)
|
||||
assert.Equal(t, 1, usage.JobsStarted)
|
||||
assert.Equal(t, 1, usage.ActiveJobs)
|
||||
}
|
||||
|
||||
func TestMemoryStore_DecrementActiveJobs_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
_ = store.IncrementUsage("agent-1", 0, 2)
|
||||
_ = store.DecrementActiveJobs("agent-1")
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, 1, usage.ActiveJobs)
|
||||
}
|
||||
|
||||
func TestMemoryStore_DecrementActiveJobs_Good_FloorAtZero(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
_ = store.DecrementActiveJobs("agent-1") // no-op, no usage record
|
||||
_ = store.IncrementUsage("agent-1", 0, 0)
|
||||
_ = store.DecrementActiveJobs("agent-1") // should stay at 0
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, 0, usage.ActiveJobs)
|
||||
}
|
||||
|
||||
func TestMemoryStore_ReturnTokens_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
_ = store.IncrementUsage("agent-1", 10000, 0)
|
||||
err := store.ReturnTokens("agent-1", 5000)
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, int64(5000), usage.TokensUsed)
|
||||
}
|
||||
|
||||
func TestMemoryStore_ReturnTokens_Good_FloorAtZero(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
_ = store.IncrementUsage("agent-1", 1000, 0)
|
||||
_ = store.ReturnTokens("agent-1", 5000) // more than used
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, int64(0), usage.TokensUsed)
|
||||
}
|
||||
|
||||
func TestMemoryStore_ResetUsage_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
_ = store.IncrementUsage("agent-1", 50000, 5)
|
||||
err := store.ResetUsage("agent-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, int64(0), usage.TokensUsed)
|
||||
assert.Equal(t, 0, usage.JobsStarted)
|
||||
assert.Equal(t, 0, usage.ActiveJobs)
|
||||
}
|
||||
|
||||
func TestMemoryStore_ModelUsage_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
_ = store.IncrementModelUsage("claude-sonnet", 10000)
|
||||
_ = store.IncrementModelUsage("claude-sonnet", 5000)
|
||||
|
||||
usage, err := store.GetModelUsage("claude-sonnet")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(15000), usage)
|
||||
}
|
||||
|
||||
// --- AllowanceService.Check tests ---
|
||||
|
||||
func TestAllowanceServiceCheck_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
DailyTokenLimit: 100000,
|
||||
DailyJobLimit: 10,
|
||||
ConcurrentJobs: 2,
|
||||
})
|
||||
|
||||
result, err := svc.Check("agent-1", "")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Allowed)
|
||||
assert.Equal(t, AllowanceOK, result.Status)
|
||||
assert.Equal(t, int64(100000), result.RemainingTokens)
|
||||
assert.Equal(t, 10, result.RemainingJobs)
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Good_Warning(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
DailyTokenLimit: 100000,
|
||||
})
|
||||
_ = store.IncrementUsage("agent-1", 85000, 0)
|
||||
|
||||
result, err := svc.Check("agent-1", "")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Allowed)
|
||||
assert.Equal(t, AllowanceWarning, result.Status)
|
||||
assert.Equal(t, int64(15000), result.RemainingTokens)
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Bad_TokenLimitExceeded(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
DailyTokenLimit: 100000,
|
||||
})
|
||||
_ = store.IncrementUsage("agent-1", 100001, 0)
|
||||
|
||||
result, err := svc.Check("agent-1", "")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Allowed)
|
||||
assert.Equal(t, AllowanceExceeded, result.Status)
|
||||
assert.Contains(t, result.Reason, "daily token limit")
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Bad_JobLimitExceeded(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
DailyJobLimit: 5,
|
||||
})
|
||||
_ = store.IncrementUsage("agent-1", 0, 5)
|
||||
|
||||
result, err := svc.Check("agent-1", "")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Allowed)
|
||||
assert.Contains(t, result.Reason, "daily job limit")
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Bad_ConcurrentLimitReached(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
ConcurrentJobs: 1,
|
||||
})
|
||||
_ = store.IncrementUsage("agent-1", 0, 1) // 1 active job
|
||||
|
||||
result, err := svc.Check("agent-1", "")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Allowed)
|
||||
assert.Contains(t, result.Reason, "concurrent job limit")
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Bad_ModelNotInAllowlist(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
ModelAllowlist: []string{"claude-sonnet-4-5-20250929"},
|
||||
})
|
||||
|
||||
result, err := svc.Check("agent-1", "claude-opus-4-6")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Allowed)
|
||||
assert.Contains(t, result.Reason, "model not in allowlist")
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Good_ModelInAllowlist(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
ModelAllowlist: []string{"claude-sonnet-4-5-20250929", "claude-haiku-4-5-20251001"},
|
||||
})
|
||||
|
||||
result, err := svc.Check("agent-1", "claude-sonnet-4-5-20250929")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Allowed)
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Good_EmptyModelSkipsCheck(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
ModelAllowlist: []string{"claude-sonnet-4-5-20250929"},
|
||||
})
|
||||
|
||||
result, err := svc.Check("agent-1", "")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, result.Allowed)
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Bad_GlobalModelBudgetExceeded(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.SetAllowance(&AgentAllowance{
|
||||
AgentID: "agent-1",
|
||||
})
|
||||
store.SetModelQuota(&ModelQuota{
|
||||
Model: "claude-opus-4-6",
|
||||
DailyTokenBudget: 500000,
|
||||
})
|
||||
_ = store.IncrementModelUsage("claude-opus-4-6", 500001)
|
||||
|
||||
result, err := svc.Check("agent-1", "claude-opus-4-6")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, result.Allowed)
|
||||
assert.Contains(t, result.Reason, "global model token budget")
|
||||
}
|
||||
|
||||
func TestAllowanceServiceCheck_Bad_NoAllowance(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_, err := svc.Check("unknown-agent", "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// --- AllowanceService.RecordUsage tests ---
|
||||
|
||||
func TestAllowanceServiceRecordUsage_Good_JobStarted(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
err := svc.RecordUsage(UsageReport{
|
||||
AgentID: "agent-1",
|
||||
JobID: "job-1",
|
||||
Event: QuotaEventJobStarted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, 1, usage.JobsStarted)
|
||||
assert.Equal(t, 1, usage.ActiveJobs)
|
||||
assert.Equal(t, int64(0), usage.TokensUsed)
|
||||
}
|
||||
|
||||
func TestAllowanceServiceRecordUsage_Good_JobCompleted(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
// Start a job first
|
||||
_ = svc.RecordUsage(UsageReport{
|
||||
AgentID: "agent-1",
|
||||
JobID: "job-1",
|
||||
Event: QuotaEventJobStarted,
|
||||
})
|
||||
|
||||
err := svc.RecordUsage(UsageReport{
|
||||
AgentID: "agent-1",
|
||||
JobID: "job-1",
|
||||
Model: "claude-sonnet",
|
||||
TokensIn: 1000,
|
||||
TokensOut: 500,
|
||||
Event: QuotaEventJobCompleted,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, int64(1500), usage.TokensUsed)
|
||||
assert.Equal(t, 0, usage.ActiveJobs)
|
||||
|
||||
modelUsage, _ := store.GetModelUsage("claude-sonnet")
|
||||
assert.Equal(t, int64(1500), modelUsage)
|
||||
}
|
||||
|
||||
func TestAllowanceServiceRecordUsage_Good_JobFailed_ReturnsHalf(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = svc.RecordUsage(UsageReport{
|
||||
AgentID: "agent-1",
|
||||
JobID: "job-1",
|
||||
Event: QuotaEventJobStarted,
|
||||
})
|
||||
|
||||
err := svc.RecordUsage(UsageReport{
|
||||
AgentID: "agent-1",
|
||||
JobID: "job-1",
|
||||
Model: "claude-sonnet",
|
||||
TokensIn: 1000,
|
||||
TokensOut: 1000,
|
||||
Event: QuotaEventJobFailed,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
// 2000 tokens used, 1000 returned (50%) = 1000 net
|
||||
assert.Equal(t, int64(1000), usage.TokensUsed)
|
||||
assert.Equal(t, 0, usage.ActiveJobs)
|
||||
|
||||
// Model sees net usage (2000 - 1000 = 1000)
|
||||
modelUsage, _ := store.GetModelUsage("claude-sonnet")
|
||||
assert.Equal(t, int64(1000), modelUsage)
|
||||
}
|
||||
|
||||
func TestAllowanceServiceRecordUsage_Good_JobCancelled_ReturnsAll(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.IncrementUsage("agent-1", 5000, 1) // simulate pre-existing usage
|
||||
|
||||
err := svc.RecordUsage(UsageReport{
|
||||
AgentID: "agent-1",
|
||||
JobID: "job-1",
|
||||
TokensIn: 500,
|
||||
TokensOut: 500,
|
||||
Event: QuotaEventJobCancelled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
// 5000 pre-existing - 1000 returned = 4000
|
||||
assert.Equal(t, int64(4000), usage.TokensUsed)
|
||||
assert.Equal(t, 0, usage.ActiveJobs)
|
||||
}
|
||||
|
||||
// --- AllowanceService.ResetAgent tests ---
|
||||
|
||||
func TestAllowanceServiceResetAgent_Good(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
svc := NewAllowanceService(store)
|
||||
|
||||
_ = store.IncrementUsage("agent-1", 50000, 5)
|
||||
|
||||
err := svc.ResetAgent("agent-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
usage, _ := store.GetUsage("agent-1")
|
||||
assert.Equal(t, int64(0), usage.TokensUsed)
|
||||
assert.Equal(t, 0, usage.JobsStarted)
|
||||
}
|
||||
|
||||
// --- startOfDay helper test ---
|
||||
|
||||
func TestStartOfDay_Good(t *testing.T) {
|
||||
input := time.Date(2026, 2, 10, 15, 30, 45, 0, time.UTC)
|
||||
expected := time.Date(2026, 2, 10, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, startOfDay(input))
|
||||
}
|
||||
|
||||
// --- AllowanceStatus tests ---
|
||||
|
||||
func TestAllowanceStatus_Good_Values(t *testing.T) {
|
||||
assert.Equal(t, AllowanceStatus("ok"), AllowanceOK)
|
||||
assert.Equal(t, AllowanceStatus("warning"), AllowanceWarning)
|
||||
assert.Equal(t, AllowanceStatus("exceeded"), AllowanceExceeded)
|
||||
}
|
||||
|
|
@ -1,322 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// 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, log.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, log.E(op, "request failed", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return nil, log.E(op, "API error", err)
|
||||
}
|
||||
|
||||
var tasks []Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
|
||||
return nil, log.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, log.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, log.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, log.E(op, "request failed", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return nil, log.E(op, "API error", err)
|
||||
}
|
||||
|
||||
var task Task
|
||||
if err := json.NewDecoder(resp.Body).Decode(&task); err != nil {
|
||||
return nil, log.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, log.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, log.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, log.E(op, "request failed", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return nil, log.E(op, "API error", err)
|
||||
}
|
||||
|
||||
// Read body once to allow multiple decode attempts
|
||||
bodyData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, log.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, log.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 log.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 log.E(op, "failed to marshal update", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return log.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 log.E(op, "request failed", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return log.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 log.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 log.E(op, "failed to marshal result", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return log.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 log.E(op, "request failed", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := c.checkResponse(resp); err != nil {
|
||||
return log.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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 log.E(op, "failed to create request", err)
|
||||
}
|
||||
|
||||
c.setHeaders(req)
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return log.E(op, "request failed", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return log.E(op, fmt.Sprintf("server returned status %d", resp.StatusCode), nil)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
// Package agentic provides AI collaboration features for task management.
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// PROptions contains options for creating a pull request.
|
||||
type PROptions struct {
|
||||
// Title is the PR title.
|
||||
Title string `json:"title"`
|
||||
// Body is the PR description.
|
||||
Body string `json:"body"`
|
||||
// Draft marks the PR as a draft.
|
||||
Draft bool `json:"draft"`
|
||||
// Labels are labels to add to the PR.
|
||||
Labels []string `json:"labels"`
|
||||
// Base is the base branch (defaults to main).
|
||||
Base string `json:"base"`
|
||||
}
|
||||
|
||||
// AutoCommit creates a git commit with a task reference.
|
||||
// The commit message follows the format:
|
||||
//
|
||||
// feat(scope): description
|
||||
//
|
||||
// Task: #123
|
||||
// Co-Authored-By: Claude <noreply@anthropic.com>
|
||||
func AutoCommit(ctx context.Context, task *Task, dir string, message string) error {
|
||||
const op = "agentic.AutoCommit"
|
||||
|
||||
if task == nil {
|
||||
return log.E(op, "task is required", nil)
|
||||
}
|
||||
|
||||
if message == "" {
|
||||
return log.E(op, "commit message is required", nil)
|
||||
}
|
||||
|
||||
// Build full commit message
|
||||
fullMessage := buildCommitMessage(task, message)
|
||||
|
||||
// Stage all changes
|
||||
if _, err := runGitCommandCtx(ctx, dir, "add", "-A"); err != nil {
|
||||
return log.E(op, "failed to stage changes", err)
|
||||
}
|
||||
|
||||
// Create commit
|
||||
if _, err := runGitCommandCtx(ctx, dir, "commit", "-m", fullMessage); err != nil {
|
||||
return log.E(op, "failed to create commit", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCommitMessage formats a commit message with task reference.
|
||||
func buildCommitMessage(task *Task, message string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Write the main message
|
||||
sb.WriteString(message)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Add task reference
|
||||
sb.WriteString("Task: #")
|
||||
sb.WriteString(task.ID)
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Add co-author
|
||||
sb.WriteString("Co-Authored-By: Claude <noreply@anthropic.com>\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// CreatePR creates a pull request using the gh CLI.
|
||||
func CreatePR(ctx context.Context, task *Task, dir string, opts PROptions) (string, error) {
|
||||
const op = "agentic.CreatePR"
|
||||
|
||||
if task == nil {
|
||||
return "", log.E(op, "task is required", nil)
|
||||
}
|
||||
|
||||
// Build title if not provided
|
||||
title := opts.Title
|
||||
if title == "" {
|
||||
title = task.Title
|
||||
}
|
||||
|
||||
// Build body if not provided
|
||||
body := opts.Body
|
||||
if body == "" {
|
||||
body = buildPRBody(task)
|
||||
}
|
||||
|
||||
// Build gh command arguments
|
||||
args := []string{"pr", "create", "--title", title, "--body", body}
|
||||
|
||||
if opts.Draft {
|
||||
args = append(args, "--draft")
|
||||
}
|
||||
|
||||
if opts.Base != "" {
|
||||
args = append(args, "--base", opts.Base)
|
||||
}
|
||||
|
||||
for _, label := range opts.Labels {
|
||||
args = append(args, "--label", label)
|
||||
}
|
||||
|
||||
// Run gh pr create
|
||||
output, err := runCommandCtx(ctx, dir, "gh", args...)
|
||||
if err != nil {
|
||||
return "", log.E(op, "failed to create PR", err)
|
||||
}
|
||||
|
||||
// Extract PR URL from output
|
||||
prURL := strings.TrimSpace(output)
|
||||
|
||||
return prURL, nil
|
||||
}
|
||||
|
||||
// buildPRBody creates a PR body from task details.
|
||||
func buildPRBody(task *Task) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Summary\n\n")
|
||||
sb.WriteString(task.Description)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
sb.WriteString("## Task Reference\n\n")
|
||||
sb.WriteString("- Task ID: #")
|
||||
sb.WriteString(task.ID)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString("- Priority: ")
|
||||
sb.WriteString(string(task.Priority))
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(task.Labels) > 0 {
|
||||
sb.WriteString("- Labels: ")
|
||||
sb.WriteString(strings.Join(task.Labels, ", "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n---\n")
|
||||
sb.WriteString("Generated with AI assistance\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// SyncStatus syncs the task status back to the agentic service.
|
||||
func SyncStatus(ctx context.Context, client *Client, task *Task, update TaskUpdate) error {
|
||||
const op = "agentic.SyncStatus"
|
||||
|
||||
if client == nil {
|
||||
return log.E(op, "client is required", nil)
|
||||
}
|
||||
|
||||
if task == nil {
|
||||
return log.E(op, "task is required", nil)
|
||||
}
|
||||
|
||||
return client.UpdateTask(ctx, task.ID, update)
|
||||
}
|
||||
|
||||
// CommitAndSync commits changes and syncs task status.
|
||||
func CommitAndSync(ctx context.Context, client *Client, task *Task, dir string, message string, progress int) error {
|
||||
const op = "agentic.CommitAndSync"
|
||||
|
||||
// Create commit
|
||||
if err := AutoCommit(ctx, task, dir, message); err != nil {
|
||||
return log.E(op, "failed to commit", err)
|
||||
}
|
||||
|
||||
// Sync status if client provided
|
||||
if client != nil {
|
||||
update := TaskUpdate{
|
||||
Status: StatusInProgress,
|
||||
Progress: progress,
|
||||
Notes: "Committed: " + message,
|
||||
}
|
||||
|
||||
if err := SyncStatus(ctx, client, task, update); err != nil {
|
||||
// Log but don't fail on sync errors
|
||||
return log.E(op, "commit succeeded but sync failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushChanges pushes committed changes to the remote.
|
||||
func PushChanges(ctx context.Context, dir string) error {
|
||||
const op = "agentic.PushChanges"
|
||||
|
||||
_, err := runGitCommandCtx(ctx, dir, "push")
|
||||
if err != nil {
|
||||
return log.E(op, "failed to push changes", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateBranch creates a new branch for the task.
|
||||
func CreateBranch(ctx context.Context, task *Task, dir string) (string, error) {
|
||||
const op = "agentic.CreateBranch"
|
||||
|
||||
if task == nil {
|
||||
return "", log.E(op, "task is required", nil)
|
||||
}
|
||||
|
||||
// Generate branch name from task
|
||||
branchName := generateBranchName(task)
|
||||
|
||||
// Create and checkout branch
|
||||
_, err := runGitCommandCtx(ctx, dir, "checkout", "-b", branchName)
|
||||
if err != nil {
|
||||
return "", log.E(op, "failed to create branch", err)
|
||||
}
|
||||
|
||||
return branchName, nil
|
||||
}
|
||||
|
||||
// generateBranchName creates a branch name from task details.
|
||||
func generateBranchName(task *Task) string {
|
||||
// Determine prefix based on labels
|
||||
prefix := "feat"
|
||||
for _, label := range task.Labels {
|
||||
switch strings.ToLower(label) {
|
||||
case "bug", "bugfix", "fix":
|
||||
prefix = "fix"
|
||||
case "docs", "documentation":
|
||||
prefix = "docs"
|
||||
case "refactor":
|
||||
prefix = "refactor"
|
||||
case "test", "tests":
|
||||
prefix = "test"
|
||||
case "chore":
|
||||
prefix = "chore"
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize title for branch name
|
||||
title := strings.ToLower(task.Title)
|
||||
title = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
if r == ' ' || r == '-' || r == '_' {
|
||||
return '-'
|
||||
}
|
||||
return -1
|
||||
}, title)
|
||||
|
||||
// Remove consecutive dashes
|
||||
for strings.Contains(title, "--") {
|
||||
title = strings.ReplaceAll(title, "--", "-")
|
||||
}
|
||||
title = strings.Trim(title, "-")
|
||||
|
||||
// Truncate if too long
|
||||
if len(title) > 40 {
|
||||
title = title[:40]
|
||||
title = strings.TrimRight(title, "-")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s-%s", prefix, task.ID, title)
|
||||
}
|
||||
|
||||
// runGitCommandCtx runs a git command with context.
|
||||
func runGitCommandCtx(ctx context.Context, dir string, args ...string) (string, error) {
|
||||
return runCommandCtx(ctx, dir, "git", args...)
|
||||
}
|
||||
|
||||
// runCommandCtx runs an arbitrary command with context.
|
||||
func runCommandCtx(ctx context.Context, dir string, command string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if stderr.Len() > 0 {
|
||||
return "", fmt.Errorf("%w: %s", err, stderr.String())
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// GetCurrentBranch returns the current git branch name.
|
||||
func GetCurrentBranch(ctx context.Context, dir string) (string, error) {
|
||||
const op = "agentic.GetCurrentBranch"
|
||||
|
||||
output, err := runGitCommandCtx(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
|
||||
if err != nil {
|
||||
return "", log.E(op, "failed to get current branch", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output), nil
|
||||
}
|
||||
|
||||
// HasUncommittedChanges checks if there are uncommitted changes.
|
||||
func HasUncommittedChanges(ctx context.Context, dir string) (bool, error) {
|
||||
const op = "agentic.HasUncommittedChanges"
|
||||
|
||||
output, err := runGitCommandCtx(ctx, dir, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, log.E(op, "failed to get git status", err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(output) != "", nil
|
||||
}
|
||||
|
||||
// GetDiff returns the current diff for staged and unstaged changes.
|
||||
func GetDiff(ctx context.Context, dir string, staged bool) (string, error) {
|
||||
const op = "agentic.GetDiff"
|
||||
|
||||
args := []string{"diff"}
|
||||
if staged {
|
||||
args = append(args, "--staged")
|
||||
}
|
||||
|
||||
output, err := runGitCommandCtx(ctx, dir, args...)
|
||||
if err != nil {
|
||||
return "", log.E(op, "failed to get diff", err)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildCommitMessage(t *testing.T) {
|
||||
task := &Task{
|
||||
ID: "ABC123",
|
||||
Title: "Test Task",
|
||||
}
|
||||
|
||||
message := buildCommitMessage(task, "add new feature")
|
||||
|
||||
assert.Contains(t, message, "add new feature")
|
||||
assert.Contains(t, message, "Task: #ABC123")
|
||||
assert.Contains(t, message, "Co-Authored-By: Claude <noreply@anthropic.com>")
|
||||
}
|
||||
|
||||
func TestBuildPRBody(t *testing.T) {
|
||||
task := &Task{
|
||||
ID: "PR-456",
|
||||
Title: "Add authentication",
|
||||
Description: "Implement user authentication with OAuth2",
|
||||
Priority: PriorityHigh,
|
||||
Labels: []string{"enhancement", "security"},
|
||||
}
|
||||
|
||||
body := buildPRBody(task)
|
||||
|
||||
assert.Contains(t, body, "## Summary")
|
||||
assert.Contains(t, body, "Implement user authentication with OAuth2")
|
||||
assert.Contains(t, body, "## Task Reference")
|
||||
assert.Contains(t, body, "Task ID: #PR-456")
|
||||
assert.Contains(t, body, "Priority: high")
|
||||
assert.Contains(t, body, "Labels: enhancement, security")
|
||||
assert.Contains(t, body, "Generated with AI assistance")
|
||||
}
|
||||
|
||||
func TestBuildPRBody_NoLabels(t *testing.T) {
|
||||
task := &Task{
|
||||
ID: "PR-789",
|
||||
Title: "Fix bug",
|
||||
Description: "Fix the login bug",
|
||||
Priority: PriorityMedium,
|
||||
Labels: nil,
|
||||
}
|
||||
|
||||
body := buildPRBody(task)
|
||||
|
||||
assert.Contains(t, body, "## Summary")
|
||||
assert.Contains(t, body, "Fix the login bug")
|
||||
assert.NotContains(t, body, "Labels:")
|
||||
}
|
||||
|
||||
func TestGenerateBranchName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "feature task",
|
||||
task: &Task{
|
||||
ID: "123",
|
||||
Title: "Add user authentication",
|
||||
Labels: []string{"enhancement"},
|
||||
},
|
||||
expected: "feat/123-add-user-authentication",
|
||||
},
|
||||
{
|
||||
name: "bug fix task",
|
||||
task: &Task{
|
||||
ID: "456",
|
||||
Title: "Fix login error",
|
||||
Labels: []string{"bug"},
|
||||
},
|
||||
expected: "fix/456-fix-login-error",
|
||||
},
|
||||
{
|
||||
name: "docs task",
|
||||
task: &Task{
|
||||
ID: "789",
|
||||
Title: "Update README",
|
||||
Labels: []string{"documentation"},
|
||||
},
|
||||
expected: "docs/789-update-readme",
|
||||
},
|
||||
{
|
||||
name: "refactor task",
|
||||
task: &Task{
|
||||
ID: "101",
|
||||
Title: "Refactor auth module",
|
||||
Labels: []string{"refactor"},
|
||||
},
|
||||
expected: "refactor/101-refactor-auth-module",
|
||||
},
|
||||
{
|
||||
name: "test task",
|
||||
task: &Task{
|
||||
ID: "202",
|
||||
Title: "Add unit tests",
|
||||
Labels: []string{"test"},
|
||||
},
|
||||
expected: "test/202-add-unit-tests",
|
||||
},
|
||||
{
|
||||
name: "chore task",
|
||||
task: &Task{
|
||||
ID: "303",
|
||||
Title: "Update dependencies",
|
||||
Labels: []string{"chore"},
|
||||
},
|
||||
expected: "chore/303-update-dependencies",
|
||||
},
|
||||
{
|
||||
name: "long title truncated",
|
||||
task: &Task{
|
||||
ID: "404",
|
||||
Title: "This is a very long title that should be truncated to fit the branch name limit",
|
||||
Labels: nil,
|
||||
},
|
||||
expected: "feat/404-this-is-a-very-long-title-that-should-be",
|
||||
},
|
||||
{
|
||||
name: "special characters removed",
|
||||
task: &Task{
|
||||
ID: "505",
|
||||
Title: "Fix: user's auth (OAuth2) [important]",
|
||||
Labels: nil,
|
||||
},
|
||||
expected: "feat/505-fix-users-auth-oauth2-important",
|
||||
},
|
||||
{
|
||||
name: "no labels defaults to feat",
|
||||
task: &Task{
|
||||
ID: "606",
|
||||
Title: "New feature",
|
||||
Labels: nil,
|
||||
},
|
||||
expected: "feat/606-new-feature",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := generateBranchName(tt.task)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoCommit_Bad_NilTask(t *testing.T) {
|
||||
err := AutoCommit(context.TODO(), nil, ".", "test message")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "task is required")
|
||||
}
|
||||
|
||||
func TestAutoCommit_Bad_EmptyMessage(t *testing.T) {
|
||||
task := &Task{ID: "123", Title: "Test"}
|
||||
err := AutoCommit(context.TODO(), task, ".", "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "commit message is required")
|
||||
}
|
||||
|
||||
func TestSyncStatus_Bad_NilClient(t *testing.T) {
|
||||
task := &Task{ID: "123", Title: "Test"}
|
||||
update := TaskUpdate{Status: StatusInProgress}
|
||||
|
||||
err := SyncStatus(context.TODO(), nil, task, update)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "client is required")
|
||||
}
|
||||
|
||||
func TestSyncStatus_Bad_NilTask(t *testing.T) {
|
||||
client := &Client{BaseURL: "http://test"}
|
||||
update := TaskUpdate{Status: StatusInProgress}
|
||||
|
||||
err := SyncStatus(context.TODO(), client, nil, update)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "task is required")
|
||||
}
|
||||
|
||||
func TestCreateBranch_Bad_NilTask(t *testing.T) {
|
||||
branch, err := CreateBranch(context.TODO(), nil, ".")
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, branch)
|
||||
assert.Contains(t, err.Error(), "task is required")
|
||||
}
|
||||
|
||||
func TestCreatePR_Bad_NilTask(t *testing.T) {
|
||||
url, err := CreatePR(context.TODO(), nil, ".", PROptions{})
|
||||
assert.Error(t, err)
|
||||
assert.Empty(t, url)
|
||||
assert.Contains(t, err.Error(), "task is required")
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
errors "forge.lthn.ai/core/go/pkg/framework/core"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
"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, errors.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, errors.E("agentic.LoadConfig", "failed to load config", err)
|
||||
}
|
||||
|
||||
// Apply environment variable overrides
|
||||
applyEnvOverrides(cfg)
|
||||
|
||||
// Validate configuration
|
||||
if cfg.Token == "" {
|
||||
return nil, errors.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 {
|
||||
content, err := io.Local.Read(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// 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 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.
|
||||
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 errors.E("agentic.SaveConfig", "failed to get home directory", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Join(homeDir, ".core")
|
||||
if err := io.Local.EnsureDir(configDir); err != nil {
|
||||
return errors.E("agentic.SaveConfig", "failed to create config directory", err)
|
||||
}
|
||||
|
||||
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.
|
||||
func ConfigPath() (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", errors.E("agentic.ConfigPath", "failed to get home directory", err)
|
||||
}
|
||||
return filepath.Join(homeDir, ".core", configFileName), nil
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = 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 func() { _ = os.RemoveAll(tmpHome) }()
|
||||
|
||||
// Override HOME for the test
|
||||
originalHome := os.Getenv("HOME")
|
||||
_ = os.Setenv("HOME", tmpHome)
|
||||
defer func() { _ = 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 func() { _ = 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)
|
||||
}
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
// Package agentic provides AI collaboration features for task management.
|
||||
package agentic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
errors "forge.lthn.ai/core/go/pkg/framework/core"
|
||||
"forge.lthn.ai/core/go/pkg/io"
|
||||
)
|
||||
|
||||
// FileContent represents the content of a file for AI context.
|
||||
type FileContent struct {
|
||||
// Path is the relative path to the file.
|
||||
Path string `json:"path"`
|
||||
// Content is the file content.
|
||||
Content string `json:"content"`
|
||||
// Language is the detected programming language.
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
// TaskContext contains gathered context for AI collaboration.
|
||||
type TaskContext struct {
|
||||
// Task is the task being worked on.
|
||||
Task *Task `json:"task"`
|
||||
// Files is a list of relevant file contents.
|
||||
Files []FileContent `json:"files"`
|
||||
// GitStatus is the current git status output.
|
||||
GitStatus string `json:"git_status"`
|
||||
// RecentCommits is the recent commit log.
|
||||
RecentCommits string `json:"recent_commits"`
|
||||
// RelatedCode contains code snippets related to the task.
|
||||
RelatedCode []FileContent `json:"related_code"`
|
||||
}
|
||||
|
||||
// BuildTaskContext gathers context for AI collaboration on a task.
|
||||
func BuildTaskContext(task *Task, dir string) (*TaskContext, error) {
|
||||
const op = "agentic.BuildTaskContext"
|
||||
|
||||
if task == nil {
|
||||
return nil, errors.E(op, "task is required", nil)
|
||||
}
|
||||
|
||||
if dir == "" {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, errors.E(op, "failed to get working directory", err)
|
||||
}
|
||||
dir = cwd
|
||||
}
|
||||
|
||||
ctx := &TaskContext{
|
||||
Task: task,
|
||||
}
|
||||
|
||||
// Gather files mentioned in the task
|
||||
files, err := GatherRelatedFiles(task, dir)
|
||||
if err != nil {
|
||||
// Non-fatal: continue without files
|
||||
files = nil
|
||||
}
|
||||
ctx.Files = files
|
||||
|
||||
// Get git status
|
||||
gitStatus, _ := runGitCommand(dir, "status", "--porcelain")
|
||||
ctx.GitStatus = gitStatus
|
||||
|
||||
// Get recent commits
|
||||
recentCommits, _ := runGitCommand(dir, "log", "--oneline", "-10")
|
||||
ctx.RecentCommits = recentCommits
|
||||
|
||||
// Find related code by searching for keywords
|
||||
relatedCode, err := findRelatedCode(task, dir)
|
||||
if err != nil {
|
||||
relatedCode = nil
|
||||
}
|
||||
ctx.RelatedCode = relatedCode
|
||||
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// GatherRelatedFiles reads files mentioned in the task.
|
||||
func GatherRelatedFiles(task *Task, dir string) ([]FileContent, error) {
|
||||
const op = "agentic.GatherRelatedFiles"
|
||||
|
||||
if task == nil {
|
||||
return nil, errors.E(op, "task is required", nil)
|
||||
}
|
||||
|
||||
var files []FileContent
|
||||
|
||||
// Read files explicitly mentioned in the task
|
||||
for _, relPath := range task.Files {
|
||||
fullPath := filepath.Join(dir, relPath)
|
||||
|
||||
content, err := io.Local.Read(fullPath)
|
||||
if err != nil {
|
||||
// Skip files that don't exist
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, FileContent{
|
||||
Path: relPath,
|
||||
Content: content,
|
||||
Language: detectLanguage(relPath),
|
||||
})
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// findRelatedCode searches for code related to the task by keywords.
|
||||
func findRelatedCode(task *Task, dir string) ([]FileContent, error) {
|
||||
const op = "agentic.findRelatedCode"
|
||||
|
||||
if task == nil {
|
||||
return nil, errors.E(op, "task is required", nil)
|
||||
}
|
||||
|
||||
// Extract keywords from title and description
|
||||
keywords := extractKeywords(task.Title + " " + task.Description)
|
||||
if len(keywords) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var files []FileContent
|
||||
seen := make(map[string]bool)
|
||||
|
||||
// Search for each keyword using git grep
|
||||
for _, keyword := range keywords {
|
||||
if len(keyword) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
output, err := runGitCommand(dir, "grep", "-l", "-i", keyword, "--", "*.go", "*.ts", "*.js", "*.py")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse matched files
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || seen[line] {
|
||||
continue
|
||||
}
|
||||
seen[line] = true
|
||||
|
||||
// Limit to 10 related files
|
||||
if len(files) >= 10 {
|
||||
break
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(dir, line)
|
||||
content, err := io.Local.Read(fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Truncate large files
|
||||
if len(content) > 5000 {
|
||||
content = content[:5000] + "\n... (truncated)"
|
||||
}
|
||||
|
||||
files = append(files, FileContent{
|
||||
Path: line,
|
||||
Content: content,
|
||||
Language: detectLanguage(line),
|
||||
})
|
||||
}
|
||||
|
||||
if len(files) >= 10 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// extractKeywords extracts meaningful words from text for searching.
|
||||
func extractKeywords(text string) []string {
|
||||
// Remove common words and extract identifiers
|
||||
text = strings.ToLower(text)
|
||||
|
||||
// Split by non-alphanumeric characters
|
||||
re := regexp.MustCompile(`[^a-zA-Z0-9]+`)
|
||||
words := re.Split(text, -1)
|
||||
|
||||
// Filter stop words and short words
|
||||
stopWords := map[string]bool{
|
||||
"the": true, "a": true, "an": true, "and": true, "or": true, "but": true,
|
||||
"in": true, "on": true, "at": true, "to": true, "for": true, "of": true,
|
||||
"with": true, "by": true, "from": true, "is": true, "are": true, "was": true,
|
||||
"be": true, "been": true, "being": true, "have": true, "has": true, "had": true,
|
||||
"do": true, "does": true, "did": true, "will": true, "would": true, "could": true,
|
||||
"should": true, "may": true, "might": true, "must": true, "shall": true,
|
||||
"this": true, "that": true, "these": true, "those": true, "it": true,
|
||||
"add": true, "create": true, "update": true, "fix": true, "remove": true,
|
||||
"implement": true, "new": true, "file": true, "code": true,
|
||||
}
|
||||
|
||||
var keywords []string
|
||||
for _, word := range words {
|
||||
word = strings.TrimSpace(word)
|
||||
if len(word) >= 3 && !stopWords[word] {
|
||||
keywords = append(keywords, word)
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to first 5 keywords
|
||||
if len(keywords) > 5 {
|
||||
keywords = keywords[:5]
|
||||
}
|
||||
|
||||
return keywords
|
||||
}
|
||||
|
||||
// detectLanguage detects the programming language from a file extension.
|
||||
func detectLanguage(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
languages := map[string]string{
|
||||
".go": "go",
|
||||
".ts": "typescript",
|
||||
".tsx": "typescript",
|
||||
".js": "javascript",
|
||||
".jsx": "javascript",
|
||||
".py": "python",
|
||||
".rs": "rust",
|
||||
".java": "java",
|
||||
".kt": "kotlin",
|
||||
".swift": "swift",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".cs": "csharp",
|
||||
".fs": "fsharp",
|
||||
".scala": "scala",
|
||||
".sh": "bash",
|
||||
".bash": "bash",
|
||||
".zsh": "zsh",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".json": "json",
|
||||
".xml": "xml",
|
||||
".html": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".sql": "sql",
|
||||
".md": "markdown",
|
||||
}
|
||||
|
||||
if lang, ok := languages[ext]; ok {
|
||||
return lang
|
||||
}
|
||||
return "text"
|
||||
}
|
||||
|
||||
// runGitCommand runs a git command and returns the output.
|
||||
func runGitCommand(dir string, args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
|
||||
// FormatContext formats the TaskContext for AI consumption.
|
||||
func (tc *TaskContext) FormatContext() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("# Task Context\n\n")
|
||||
|
||||
// Task info
|
||||
sb.WriteString("## Task\n")
|
||||
sb.WriteString("ID: " + tc.Task.ID + "\n")
|
||||
sb.WriteString("Title: " + tc.Task.Title + "\n")
|
||||
sb.WriteString("Priority: " + string(tc.Task.Priority) + "\n")
|
||||
sb.WriteString("Status: " + string(tc.Task.Status) + "\n")
|
||||
sb.WriteString("\n### Description\n")
|
||||
sb.WriteString(tc.Task.Description + "\n\n")
|
||||
|
||||
// Files
|
||||
if len(tc.Files) > 0 {
|
||||
sb.WriteString("## Task Files\n")
|
||||
for _, f := range tc.Files {
|
||||
sb.WriteString("### " + f.Path + " (" + f.Language + ")\n")
|
||||
sb.WriteString("```" + f.Language + "\n")
|
||||
sb.WriteString(f.Content)
|
||||
sb.WriteString("\n```\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Git status
|
||||
if tc.GitStatus != "" {
|
||||
sb.WriteString("## Git Status\n")
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString(tc.GitStatus)
|
||||
sb.WriteString("\n```\n\n")
|
||||
}
|
||||
|
||||
// Recent commits
|
||||
if tc.RecentCommits != "" {
|
||||
sb.WriteString("## Recent Commits\n")
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString(tc.RecentCommits)
|
||||
sb.WriteString("\n```\n\n")
|
||||
}
|
||||
|
||||
// Related code
|
||||
if len(tc.RelatedCode) > 0 {
|
||||
sb.WriteString("## Related Code\n")
|
||||
for _, f := range tc.RelatedCode {
|
||||
sb.WriteString("### " + f.Path + " (" + f.Language + ")\n")
|
||||
sb.WriteString("```" + f.Language + "\n")
|
||||
sb.WriteString(f.Content)
|
||||
sb.WriteString("\n```\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildTaskContext_Good(t *testing.T) {
|
||||
// Create a temp directory with some files
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a test file
|
||||
testFile := filepath.Join(tmpDir, "main.go")
|
||||
err := os.WriteFile(testFile, []byte("package main\n\nfunc main() {}\n"), 0644)
|
||||
require.NoError(t, err)
|
||||
|
||||
task := &Task{
|
||||
ID: "test-123",
|
||||
Title: "Test Task",
|
||||
Description: "A test task description",
|
||||
Priority: PriorityMedium,
|
||||
Status: StatusPending,
|
||||
Files: []string{"main.go"},
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
ctx, err := BuildTaskContext(task, tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, ctx)
|
||||
assert.Equal(t, task, ctx.Task)
|
||||
assert.Len(t, ctx.Files, 1)
|
||||
assert.Equal(t, "main.go", ctx.Files[0].Path)
|
||||
assert.Equal(t, "go", ctx.Files[0].Language)
|
||||
}
|
||||
|
||||
func TestBuildTaskContext_Bad_NilTask(t *testing.T) {
|
||||
ctx, err := BuildTaskContext(nil, ".")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, ctx)
|
||||
assert.Contains(t, err.Error(), "task is required")
|
||||
}
|
||||
|
||||
func TestGatherRelatedFiles_Good(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create test files
|
||||
files := map[string]string{
|
||||
"app.go": "package app\n\nfunc Run() {}\n",
|
||||
"config.ts": "export const config = {};\n",
|
||||
"README.md": "# Project\n",
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
path := filepath.Join(tmpDir, name)
|
||||
err := os.WriteFile(path, []byte(content), 0644)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
task := &Task{
|
||||
ID: "task-1",
|
||||
Title: "Test",
|
||||
Files: []string{"app.go", "config.ts"},
|
||||
}
|
||||
|
||||
gathered, err := GatherRelatedFiles(task, tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, gathered, 2)
|
||||
|
||||
// Check languages detected correctly
|
||||
foundGo := false
|
||||
foundTS := false
|
||||
for _, f := range gathered {
|
||||
if f.Path == "app.go" {
|
||||
foundGo = true
|
||||
assert.Equal(t, "go", f.Language)
|
||||
}
|
||||
if f.Path == "config.ts" {
|
||||
foundTS = true
|
||||
assert.Equal(t, "typescript", f.Language)
|
||||
}
|
||||
}
|
||||
assert.True(t, foundGo, "should find app.go")
|
||||
assert.True(t, foundTS, "should find config.ts")
|
||||
}
|
||||
|
||||
func TestGatherRelatedFiles_Bad_NilTask(t *testing.T) {
|
||||
files, err := GatherRelatedFiles(nil, ".")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, files)
|
||||
}
|
||||
|
||||
func TestGatherRelatedFiles_Good_MissingFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
task := &Task{
|
||||
ID: "task-1",
|
||||
Title: "Test",
|
||||
Files: []string{"nonexistent.go", "also-missing.ts"},
|
||||
}
|
||||
|
||||
// Should not error, just return empty list
|
||||
gathered, err := GatherRelatedFiles(task, tmpDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, gathered)
|
||||
}
|
||||
|
||||
func TestDetectLanguage(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{"main.go", "go"},
|
||||
{"app.ts", "typescript"},
|
||||
{"app.tsx", "typescript"},
|
||||
{"script.js", "javascript"},
|
||||
{"script.jsx", "javascript"},
|
||||
{"main.py", "python"},
|
||||
{"lib.rs", "rust"},
|
||||
{"App.java", "java"},
|
||||
{"config.yaml", "yaml"},
|
||||
{"config.yml", "yaml"},
|
||||
{"data.json", "json"},
|
||||
{"index.html", "html"},
|
||||
{"styles.css", "css"},
|
||||
{"styles.scss", "scss"},
|
||||
{"query.sql", "sql"},
|
||||
{"README.md", "markdown"},
|
||||
{"unknown.xyz", "text"},
|
||||
{"", "text"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
result := detectLanguage(tt.path)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractKeywords(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
expected int // minimum number of keywords expected
|
||||
}{
|
||||
{
|
||||
name: "simple title",
|
||||
text: "Add user authentication feature",
|
||||
expected: 2,
|
||||
},
|
||||
{
|
||||
name: "with stop words",
|
||||
text: "The quick brown fox jumps over the lazy dog",
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "technical text",
|
||||
text: "Implement OAuth2 authentication with JWT tokens",
|
||||
expected: 3,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
text: "",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: "only stop words",
|
||||
text: "the a an and or but in on at",
|
||||
expected: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
keywords := extractKeywords(tt.text)
|
||||
assert.GreaterOrEqual(t, len(keywords), tt.expected)
|
||||
// Keywords should not exceed 5
|
||||
assert.LessOrEqual(t, len(keywords), 5)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskContext_FormatContext(t *testing.T) {
|
||||
task := &Task{
|
||||
ID: "test-456",
|
||||
Title: "Test Formatting",
|
||||
Description: "This is a test description",
|
||||
Priority: PriorityHigh,
|
||||
Status: StatusInProgress,
|
||||
}
|
||||
|
||||
ctx := &TaskContext{
|
||||
Task: task,
|
||||
Files: []FileContent{{Path: "main.go", Content: "package main", Language: "go"}},
|
||||
GitStatus: " M main.go",
|
||||
RecentCommits: "abc123 Initial commit",
|
||||
RelatedCode: []FileContent{{Path: "util.go", Content: "package util", Language: "go"}},
|
||||
}
|
||||
|
||||
formatted := ctx.FormatContext()
|
||||
|
||||
assert.Contains(t, formatted, "# Task Context")
|
||||
assert.Contains(t, formatted, "test-456")
|
||||
assert.Contains(t, formatted, "Test Formatting")
|
||||
assert.Contains(t, formatted, "## Task Files")
|
||||
assert.Contains(t, formatted, "## Git Status")
|
||||
assert.Contains(t, formatted, "## Recent Commits")
|
||||
assert.Contains(t, formatted, "## Related Code")
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed prompts/*.md
|
||||
var promptsFS embed.FS
|
||||
|
||||
// Prompt returns the content of an embedded prompt file.
|
||||
// Name should be without the .md extension (e.g., "commit").
|
||||
func Prompt(name string) string {
|
||||
data, err := promptsFS.ReadFile("prompts/" + name + ".md")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
# Commit Instructions
|
||||
|
||||
## Context
|
||||
|
||||
When asked to commit changes in these repositories:
|
||||
|
||||
1. **All changes are pre-approved** - Both tracked (modified) and untracked files have been reviewed and approved for commit
|
||||
2. **Include everything** - Commit ALL modified files AND all untracked files (including new directories)
|
||||
3. **Don't skip files** - Do not leave out untracked files assuming they need separate review
|
||||
4. **Single commit preferred** - Combine all changes into one cohesive commit unless explicitly told otherwise
|
||||
|
||||
The user has already validated these changes. Proceed with confidence.
|
||||
|
||||
## Handling Ignored Files
|
||||
|
||||
If you see untracked directories that should typically be ignored (like `node_modules/`, `vendor/`, `.cache/`, `dist/`, `build/`):
|
||||
|
||||
1. **Fix the .gitignore** - Create or update `.gitignore` to exclude these directories
|
||||
2. **Commit the .gitignore** - Include this fix in your commit
|
||||
3. **Don't ask** - Just fix it and commit
|
||||
|
||||
Common patterns to add to .gitignore:
|
||||
```
|
||||
node_modules/
|
||||
vendor/
|
||||
.cache/
|
||||
dist/
|
||||
build/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
```
|
||||
|
||||
## Commit Message Style
|
||||
|
||||
- Use conventional commit format: `type(scope): description`
|
||||
- Common types: `refactor`, `feat`, `fix`, `docs`, `chore`
|
||||
- Keep the first line under 72 characters
|
||||
- Add body for complex changes explaining the "why"
|
||||
- Include `Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>`
|
||||
|
||||
## Task
|
||||
|
||||
Review the uncommitted changes and create an appropriate commit. Be concise.
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
package agentic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"forge.lthn.ai/core/go/pkg/log"
|
||||
)
|
||||
|
||||
// Tasks for AI service
|
||||
|
||||
// TaskCommit requests Claude to create a commit.
|
||||
type TaskCommit struct {
|
||||
Path string
|
||||
Name string
|
||||
CanEdit bool // allow Write/Edit tools
|
||||
}
|
||||
|
||||
// TaskPrompt sends a custom prompt to Claude.
|
||||
type TaskPrompt struct {
|
||||
Prompt string
|
||||
WorkDir string
|
||||
AllowedTools []string
|
||||
|
||||
taskID string
|
||||
}
|
||||
|
||||
func (t *TaskPrompt) SetTaskID(id string) { t.taskID = id }
|
||||
func (t *TaskPrompt) GetTaskID() string { return t.taskID }
|
||||
|
||||
// ServiceOptions for configuring the AI service.
|
||||
type ServiceOptions struct {
|
||||
DefaultTools []string
|
||||
AllowEdit bool // global permission for Write/Edit tools
|
||||
}
|
||||
|
||||
// DefaultServiceOptions returns sensible defaults.
|
||||
func DefaultServiceOptions() ServiceOptions {
|
||||
return ServiceOptions{
|
||||
DefaultTools: []string{"Bash", "Read", "Glob", "Grep"},
|
||||
AllowEdit: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Service provides AI/Claude operations as a Core service.
|
||||
type Service struct {
|
||||
*framework.ServiceRuntime[ServiceOptions]
|
||||
}
|
||||
|
||||
// NewService creates an AI service factory.
|
||||
func NewService(opts ServiceOptions) func(*framework.Core) (any, error) {
|
||||
return func(c *framework.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: framework.NewServiceRuntime(c, opts),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// OnStartup registers task handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
||||
switch m := t.(type) {
|
||||
case TaskCommit:
|
||||
err := s.doCommit(m)
|
||||
if err != nil {
|
||||
log.Error("agentic: commit task failed", "err", err, "path", m.Path)
|
||||
}
|
||||
return nil, true, err
|
||||
|
||||
case TaskPrompt:
|
||||
err := s.doPrompt(m)
|
||||
if err != nil {
|
||||
log.Error("agentic: prompt task failed", "err", err)
|
||||
}
|
||||
return nil, true, err
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) doCommit(task TaskCommit) error {
|
||||
prompt := Prompt("commit")
|
||||
|
||||
tools := []string{"Bash", "Read", "Glob", "Grep"}
|
||||
if task.CanEdit {
|
||||
tools = []string{"Bash", "Read", "Write", "Edit", "Glob", "Grep"}
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(context.Background(), "claude", "-p", prompt, "--allowedTools", strings.Join(tools, ","))
|
||||
cmd.Dir = task.Path
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (s *Service) doPrompt(task TaskPrompt) error {
|
||||
if task.taskID != "" {
|
||||
s.Core().Progress(task.taskID, 0.1, "Starting Claude...", &task)
|
||||
}
|
||||
|
||||
opts := s.Opts()
|
||||
tools := opts.DefaultTools
|
||||
if len(tools) == 0 {
|
||||
tools = []string{"Bash", "Read", "Glob", "Grep"}
|
||||
}
|
||||
|
||||
if len(task.AllowedTools) > 0 {
|
||||
tools = task.AllowedTools
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(context.Background(), "claude", "-p", task.Prompt, "--allowedTools", strings.Join(tools, ","))
|
||||
if task.WorkDir != "" {
|
||||
cmd.Dir = task.WorkDir
|
||||
}
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdin = os.Stdin
|
||||
|
||||
if task.taskID != "" {
|
||||
s.Core().Progress(task.taskID, 0.5, "Running Claude prompt...", &task)
|
||||
}
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
if task.taskID != "" {
|
||||
if err != nil {
|
||||
s.Core().Progress(task.taskID, 1.0, "Failed: "+err.Error(), &task)
|
||||
} else {
|
||||
s.Core().Progress(task.taskID, 1.0, "Completed", &task)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
140
agentic/types.go
140
agentic/types.go
|
|
@ -1,140 +0,0 @@
|
|||
// 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