Files
kandev/apps/backend/internal/agent/api/handlers.go
T
jcfs 20f5f43fb3 feat: complete acp-go-sdk integration with task state updates
- Use acp-go-sdk for all ACP communication in agentctl
- Mark agent as completed when Prompt() returns successfully
- Update task state to COMPLETED when agent completes
- Update task state to FAILED when agent fails and retries exhausted
- Enhanced E2E test to verify agent actually creates files
- E2E test now validates full flow: task creation -> agent work -> file creation -> task completion
2026-01-10 16:46:35 +00:00

426 lines
11 KiB
Go

package api
import (
"bufio"
"context"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/common/errors"
"github.com/kandev/kandev/internal/common/logger"
)
// DockerClient interface for container operations needed by handlers
type DockerClient interface {
GetContainerLogs(ctx context.Context, containerID string, follow bool, tail string) (io.ReadCloser, error)
}
// ACPManager interface for sending commands to agents
type ACPManager interface {
Prompt(ctx context.Context, instanceID, message string) error
Cancel(ctx context.Context, instanceID, reason string) error
GetSessionID(instanceID string) (string, bool)
}
// Handler contains HTTP handlers for the agent manager API
type Handler struct {
lifecycle *lifecycle.Manager
registry *registry.Registry
docker DockerClient
acp ACPManager
logger *logger.Logger
}
// NewHandler creates a new API handler
func NewHandler(
lm *lifecycle.Manager,
reg *registry.Registry,
docker DockerClient,
acp ACPManager,
log *logger.Logger,
) *Handler {
return &Handler{
lifecycle: lm,
registry: reg,
docker: docker,
acp: acp,
logger: log.WithFields(zap.String("component", "agent-api")),
}
}
// LaunchAgent launches a new agent for a task
// POST /api/v1/agents/launch
func (h *Handler) LaunchAgent(c *gin.Context) {
var req LaunchAgentRequest
if err := c.ShouldBindJSON(&req); err != nil {
appErr := errors.BadRequest("invalid request body: " + err.Error())
c.JSON(appErr.HTTPStatus, appErr)
return
}
launchReq := &lifecycle.LaunchRequest{
TaskID: req.TaskID,
AgentType: req.AgentType,
WorkspacePath: req.WorkspacePath,
Env: req.Env,
Metadata: req.Metadata,
}
instance, err := h.lifecycle.Launch(c.Request.Context(), launchReq)
if err != nil {
h.logger.Error("failed to launch agent", zap.Error(err))
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "disabled") {
appErr := errors.BadRequest(err.Error())
c.JSON(appErr.HTTPStatus, appErr)
return
}
if strings.Contains(err.Error(), "already has an agent running") {
appErr := errors.Conflict(err.Error())
c.JSON(appErr.HTTPStatus, appErr)
return
}
appErr := errors.InternalError("failed to launch agent", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
resp := instanceToResponse(instance)
c.JSON(http.StatusCreated, resp)
}
// StopAgent stops an agent instance
// DELETE /api/v1/agents/:instanceId
func (h *Handler) StopAgent(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
var req StopAgentRequest
// Bind JSON body if present, but don't require it
_ = c.ShouldBindJSON(&req)
err := h.lifecycle.StopAgent(c.Request.Context(), instanceID, req.Force)
if err != nil {
h.logger.Error("failed to stop agent", zap.String("instance_id", instanceID), zap.Error(err))
if strings.Contains(err.Error(), "not found") {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
appErr := errors.InternalError("failed to stop agent", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
c.JSON(http.StatusOK, gin.H{"message": "agent stopped successfully"})
}
// GetAgentStatus returns agent instance status
// GET /api/v1/agents/:instanceId/status
func (h *Handler) GetAgentStatus(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
instance, found := h.lifecycle.GetInstance(instanceID)
if !found {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
resp := instanceToResponse(instance)
c.JSON(http.StatusOK, resp)
}
// GetAgentLogs returns agent logs
// GET /api/v1/agents/:instanceId/logs
func (h *Handler) GetAgentLogs(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
instance, found := h.lifecycle.GetInstance(instanceID)
if !found {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
// Get tail parameter, default to 100 lines
tail := c.DefaultQuery("tail", "100")
if h.docker == nil {
// Docker client not available, return empty logs
c.JSON(http.StatusOK, LogsResponse{
Logs: []LogEntry{},
Total: 0,
})
return
}
reader, err := h.docker.GetContainerLogs(c.Request.Context(), instance.ContainerID, false, tail)
if err != nil {
h.logger.Error("failed to get container logs",
zap.String("instance_id", instanceID),
zap.String("container_id", instance.ContainerID),
zap.Error(err))
appErr := errors.InternalError("failed to get agent logs", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
defer reader.Close()
// Parse the logs - Docker log format has 8-byte header
logs := []LogEntry{}
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
// Skip Docker header bytes if present
if len(line) > 8 {
line = line[8:]
}
logs = append(logs, LogEntry{
Timestamp: time.Now(), // Container logs include timestamp
Message: line,
Stream: "stdout",
})
}
c.JSON(http.StatusOK, LogsResponse{
Logs: logs,
Total: len(logs),
})
}
// ListAgents returns all active agent instances
// GET /api/v1/agents
func (h *Handler) ListAgents(c *gin.Context) {
instances := h.lifecycle.ListInstances()
agents := make([]AgentInstanceResponse, 0, len(instances))
for _, instance := range instances {
agents = append(agents, instanceToResponse(instance))
}
c.JSON(http.StatusOK, AgentsListResponse{
Agents: agents,
Total: len(agents),
})
}
// ListAgentTypes returns available agent types
// GET /api/v1/agents/types
func (h *Handler) ListAgentTypes(c *gin.Context) {
configs := h.registry.List()
types := make([]AgentTypeResponse, 0, len(configs))
for _, cfg := range configs {
types = append(types, AgentTypeResponse{
ID: cfg.ID,
Name: cfg.Name,
Description: cfg.Description,
Image: cfg.Image,
Capabilities: cfg.Capabilities,
Enabled: cfg.Enabled,
})
}
c.JSON(http.StatusOK, AgentTypesListResponse{
Types: types,
Total: len(types),
})
}
// GetAgentType returns a specific agent type
// GET /api/v1/agents/types/:typeId
func (h *Handler) GetAgentType(c *gin.Context) {
typeID := c.Param("typeId")
if typeID == "" {
appErr := errors.BadRequest("typeId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
cfg, err := h.registry.Get(typeID)
if err != nil {
appErr := errors.NotFound("agent type", typeID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
resp := AgentTypeResponse{
ID: cfg.ID,
Name: cfg.Name,
Description: cfg.Description,
Image: cfg.Image,
Capabilities: cfg.Capabilities,
Enabled: cfg.Enabled,
}
c.JSON(http.StatusOK, resp)
}
// HealthCheck returns health status
// GET /health
func (h *Handler) HealthCheck(c *gin.Context) {
c.JSON(http.StatusOK, HealthResponse{
Status: "healthy",
Timestamp: time.Now(),
})
}
// SendPrompt sends a prompt to an agent
// POST /api/v1/agents/:instanceId/prompt
func (h *Handler) SendPrompt(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
// Check if agent exists
_, found := h.lifecycle.GetInstance(instanceID)
if !found {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
var req SendPromptRequest
if err := c.ShouldBindJSON(&req); err != nil {
appErr := errors.BadRequest("invalid request body: " + err.Error())
c.JSON(appErr.HTTPStatus, appErr)
return
}
if h.acp == nil {
appErr := errors.InternalError("ACP manager not available", nil)
c.JSON(appErr.HTTPStatus, appErr)
return
}
if err := h.acp.Prompt(c.Request.Context(), instanceID, req.Message); err != nil {
h.logger.Error("failed to send prompt to agent",
zap.String("instance_id", instanceID),
zap.Error(err))
appErr := errors.InternalError("failed to send prompt", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
sessionID, _ := h.acp.GetSessionID(instanceID)
c.JSON(http.StatusOK, SendPromptResponse{
Success: true,
SessionID: sessionID,
Message: "prompt sent successfully",
})
}
// CancelAgent cancels an agent's current operation
// POST /api/v1/agents/:instanceId/cancel
func (h *Handler) CancelAgent(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
// Check if agent exists
_, found := h.lifecycle.GetInstance(instanceID)
if !found {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
var req CancelAgentRequest
_ = c.ShouldBindJSON(&req) // Optional body
if h.acp == nil {
appErr := errors.InternalError("ACP manager not available", nil)
c.JSON(appErr.HTTPStatus, appErr)
return
}
if err := h.acp.Cancel(c.Request.Context(), instanceID, req.Reason); err != nil {
h.logger.Error("failed to cancel agent operation",
zap.String("instance_id", instanceID),
zap.Error(err))
appErr := errors.InternalError("failed to cancel operation", err)
c.JSON(appErr.HTTPStatus, appErr)
return
}
c.JSON(http.StatusOK, CancelAgentResponse{
Success: true,
Message: "cancel request sent",
})
}
// GetSession returns the ACP session info for an agent
// GET /api/v1/agents/:instanceId/session
func (h *Handler) GetSession(c *gin.Context) {
instanceID := c.Param("instanceId")
if instanceID == "" {
appErr := errors.BadRequest("instanceId is required")
c.JSON(appErr.HTTPStatus, appErr)
return
}
// Check if agent exists
instance, found := h.lifecycle.GetInstance(instanceID)
if !found {
appErr := errors.NotFound("agent instance", instanceID)
c.JSON(appErr.HTTPStatus, appErr)
return
}
sessionID := ""
if h.acp != nil {
sessionID, _ = h.acp.GetSessionID(instanceID)
}
c.JSON(http.StatusOK, GetSessionResponse{
InstanceID: instanceID,
SessionID: sessionID,
Status: string(instance.Status),
})
}
// instanceToResponse converts an AgentInstance to an AgentInstanceResponse
func instanceToResponse(instance *lifecycle.AgentInstance) AgentInstanceResponse {
return AgentInstanceResponse{
ID: instance.ID,
TaskID: instance.TaskID,
AgentType: instance.AgentType,
ContainerID: instance.ContainerID,
Status: string(instance.Status),
Progress: instance.Progress,
StartedAt: instance.StartedAt,
FinishedAt: instance.FinishedAt,
ExitCode: instance.ExitCode,
ErrorMessage: instance.ErrorMessage,
Metadata: instance.Metadata,
}
}