937 lines
27 KiB
Go
937 lines
27 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/agent/agents"
|
|
"github.com/kandev/kandev/internal/agent/discovery"
|
|
"github.com/kandev/kandev/internal/agent/mcpconfig"
|
|
"github.com/kandev/kandev/internal/agent/registry"
|
|
"github.com/kandev/kandev/internal/agent/settings/dto"
|
|
"github.com/kandev/kandev/internal/agent/settings/modelfetcher"
|
|
"github.com/kandev/kandev/internal/agent/settings/models"
|
|
"github.com/kandev/kandev/internal/agent/settings/store"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// buildCommandString builds a display-friendly command string with proper quoting.
|
|
func buildCommandString(cmd []string) string {
|
|
var parts []string
|
|
for _, arg := range cmd {
|
|
if strings.ContainsAny(arg, " \t\n\"'`$\\") {
|
|
escaped := strings.ReplaceAll(arg, "\"", "\\\"")
|
|
parts = append(parts, "\""+escaped+"\"")
|
|
} else {
|
|
parts = append(parts, arg)
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
var (
|
|
ErrAgentNotFound = errors.New("agent not found")
|
|
ErrAgentProfileNotFound = errors.New("agent profile not found")
|
|
ErrAgentProfileInUse = errors.New("agent profile is used by an active agent session")
|
|
ErrAgentMcpUnsupported = errors.New("mcp not supported by agent")
|
|
ErrModelRequired = errors.New("model is required for agent profiles")
|
|
ErrLogoNotAvailable = errors.New("logo not available for agent")
|
|
)
|
|
|
|
type Controller struct {
|
|
repo store.Repository
|
|
discovery *discovery.Registry
|
|
agentRegistry *registry.Registry
|
|
sessionChecker SessionChecker
|
|
mcpService *mcpconfig.Service
|
|
modelCache *modelfetcher.Cache
|
|
logger *logger.Logger
|
|
}
|
|
|
|
type SessionChecker interface {
|
|
HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error)
|
|
}
|
|
|
|
func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry *registry.Registry, sessionChecker SessionChecker, log *logger.Logger,
|
|
) *Controller {
|
|
return &Controller{
|
|
repo: repo,
|
|
discovery: discoveryRegistry,
|
|
agentRegistry: agentRegistry,
|
|
sessionChecker: sessionChecker,
|
|
mcpService: mcpconfig.NewService(repo),
|
|
modelCache: modelfetcher.NewCache(),
|
|
logger: log.WithFields(zap.String("component", "agent-settings-controller")),
|
|
}
|
|
}
|
|
|
|
// EnsureDefaultMcpConfig ensures all agent profiles that support MCP have
|
|
// MCP enabled by default. The kandev MCP server is automatically injected
|
|
// by agentctl at session creation time, so profiles don't need to explicitly
|
|
// configure it.
|
|
func (c *Controller) EnsureDefaultMcpConfig(ctx context.Context) error {
|
|
agents, err := c.repo.ListAgents(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, agent := range agents {
|
|
if !agent.SupportsMCP {
|
|
continue
|
|
}
|
|
|
|
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, profile := range profiles {
|
|
// Check if MCP config already exists
|
|
existingConfig, err := c.repo.GetAgentProfileMcpConfig(ctx, profile.ID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
|
|
// Skip if config already exists (don't overwrite user settings)
|
|
if existingConfig != nil {
|
|
continue
|
|
}
|
|
|
|
// Create default MCP config with MCP enabled but no servers configured.
|
|
// The kandev MCP server is automatically injected by agentctl when
|
|
// creating a new session. Users can add additional external MCP servers.
|
|
config := &models.AgentProfileMcpConfig{
|
|
ProfileID: profile.ID,
|
|
Enabled: true,
|
|
Servers: map[string]interface{}{},
|
|
Meta: map[string]interface{}{},
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}
|
|
|
|
if err := c.repo.UpsertAgentProfileMcpConfig(ctx, config); err != nil {
|
|
c.logger.Warn("failed to create default MCP config for profile",
|
|
zap.String("profile_id", profile.ID),
|
|
zap.String("profile_name", profile.Name),
|
|
zap.Error(err))
|
|
continue
|
|
}
|
|
|
|
c.logger.Info("created default MCP config for profile",
|
|
zap.String("profile_id", profile.ID),
|
|
zap.String("profile_name", profile.Name))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Controller) ListDiscovery(ctx context.Context) (*dto.ListDiscoveryResponse, error) {
|
|
results, err := c.detectAgents(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload := make([]dto.AgentDiscoveryDTO, 0, len(results))
|
|
for _, result := range results {
|
|
payload = append(payload, dto.AgentDiscoveryDTO{
|
|
Name: result.Name,
|
|
SupportsMCP: result.SupportsMCP,
|
|
MCPConfigPath: result.MCPConfigPath,
|
|
InstallationPaths: result.InstallationPaths,
|
|
Available: result.Available,
|
|
MatchedPath: result.MatchedPath,
|
|
})
|
|
}
|
|
return &dto.ListDiscoveryResponse{Agents: payload, Total: len(payload)}, nil
|
|
}
|
|
|
|
func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailableAgentsResponse, error) {
|
|
results, err := c.detectAgents(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
availabilityByName := make(map[string]discovery.Availability, len(results))
|
|
for _, result := range results {
|
|
availabilityByName[result.Name] = result
|
|
}
|
|
|
|
enabled := c.agentRegistry.ListEnabled()
|
|
now := time.Now().UTC()
|
|
payload := make([]dto.AvailableAgentDTO, 0, len(enabled))
|
|
for _, ag := range enabled {
|
|
availability, ok := availabilityByName[ag.ID()]
|
|
if !ok {
|
|
availability = discovery.Availability{
|
|
Name: ag.ID(),
|
|
Available: false,
|
|
}
|
|
}
|
|
|
|
displayName := ag.DisplayName()
|
|
if displayName == "" {
|
|
displayName = ag.Name()
|
|
}
|
|
|
|
// Get models from the agent
|
|
var modelEntries []dto.ModelEntryDTO
|
|
var supportsDynamic bool
|
|
modelList, err := ag.ListModels(ctx)
|
|
if err == nil && modelList != nil {
|
|
supportsDynamic = modelList.SupportsDynamic
|
|
modelEntries = make([]dto.ModelEntryDTO, 0, len(modelList.Models))
|
|
for _, model := range modelList.Models {
|
|
modelEntries = append(modelEntries, dto.ModelEntryDTO{
|
|
ID: model.ID,
|
|
Name: model.Name,
|
|
Provider: model.Provider,
|
|
ContextWindow: model.ContextWindow,
|
|
IsDefault: model.IsDefault,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Get discovery result for capabilities
|
|
disc, discErr := ag.IsInstalled(ctx)
|
|
var capabilities dto.AgentCapabilitiesDTO
|
|
if discErr == nil && disc != nil {
|
|
capabilities = dto.AgentCapabilitiesDTO{
|
|
SupportsSessionResume: disc.Capabilities.SupportsSessionResume,
|
|
SupportsShell: disc.Capabilities.SupportsShell,
|
|
SupportsWorkspaceOnly: disc.Capabilities.SupportsWorkspaceOnly,
|
|
}
|
|
}
|
|
|
|
// Convert permission settings
|
|
var permissionSettings map[string]dto.PermissionSettingDTO
|
|
permSettings := ag.PermissionSettings()
|
|
if permSettings != nil {
|
|
permissionSettings = make(map[string]dto.PermissionSettingDTO, len(permSettings))
|
|
for key, setting := range permSettings {
|
|
permissionSettings[key] = dto.PermissionSettingDTO{
|
|
Supported: setting.Supported,
|
|
Default: setting.Default,
|
|
Label: setting.Label,
|
|
Description: setting.Description,
|
|
ApplyMethod: setting.ApplyMethod,
|
|
CLIFlag: setting.CLIFlag,
|
|
CLIFlagValue: setting.CLIFlagValue,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert passthrough config
|
|
var passthroughConfig *dto.PassthroughConfigDTO
|
|
if ptAgent, ok := ag.(agents.PassthroughAgent); ok {
|
|
pt := ptAgent.PassthroughConfig()
|
|
passthroughConfig = &dto.PassthroughConfigDTO{
|
|
Supported: pt.Supported,
|
|
Label: pt.Label,
|
|
Description: pt.Description,
|
|
}
|
|
}
|
|
|
|
payload = append(payload, dto.AvailableAgentDTO{
|
|
Name: ag.ID(),
|
|
DisplayName: displayName,
|
|
SupportsMCP: availability.SupportsMCP,
|
|
MCPConfigPath: availability.MCPConfigPath,
|
|
InstallationPaths: availability.InstallationPaths,
|
|
Available: availability.Available,
|
|
MatchedPath: availability.MatchedPath,
|
|
Capabilities: capabilities,
|
|
ModelConfig: dto.ModelConfigDTO{
|
|
DefaultModel: ag.DefaultModel(),
|
|
AvailableModels: modelEntries,
|
|
SupportsDynamicModels: supportsDynamic,
|
|
},
|
|
PermissionSettings: permissionSettings,
|
|
PassthroughConfig: passthroughConfig,
|
|
UpdatedAt: now,
|
|
})
|
|
}
|
|
return &dto.ListAvailableAgentsResponse{Agents: payload, Total: len(payload)}, nil
|
|
}
|
|
|
|
func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, error) {
|
|
agents, err := c.repo.ListAgents(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload := make([]dto.AgentDTO, 0, len(agents))
|
|
for _, agent := range agents {
|
|
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
payload = append(payload, toAgentDTO(agent, profiles))
|
|
}
|
|
return &dto.ListAgentsResponse{Agents: payload, Total: len(payload)}, nil
|
|
}
|
|
|
|
func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error {
|
|
results, err := c.detectAgents(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, result := range results {
|
|
if !result.Available {
|
|
continue
|
|
}
|
|
agentConfig, ok := c.agentRegistry.Get(result.Name)
|
|
if !ok {
|
|
return fmt.Errorf("unknown agent: %s", result.Name)
|
|
}
|
|
displayName := agentConfig.DisplayName()
|
|
if displayName == "" {
|
|
displayName = agentConfig.Name()
|
|
}
|
|
if displayName == "" {
|
|
return fmt.Errorf("unknown agent display name: %s", result.Name)
|
|
}
|
|
defaultModel := agentConfig.DefaultModel()
|
|
isPassthroughOnly := false
|
|
if defaultModel == "" {
|
|
if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && ptAgent.PassthroughConfig().Supported {
|
|
isPassthroughOnly = true
|
|
defaultModel = "passthrough"
|
|
} else {
|
|
return fmt.Errorf("unknown agent default model: %s", result.Name)
|
|
}
|
|
}
|
|
agent, err := c.repo.GetAgentByName(ctx, result.Name)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return err
|
|
}
|
|
if errors.Is(err, sql.ErrNoRows) || agent == nil {
|
|
agent = &models.Agent{
|
|
Name: result.Name,
|
|
SupportsMCP: result.SupportsMCP,
|
|
MCPConfigPath: result.MCPConfigPath,
|
|
}
|
|
if err := c.repo.CreateAgent(ctx, agent); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
updated := false
|
|
if agent.SupportsMCP != result.SupportsMCP {
|
|
agent.SupportsMCP = result.SupportsMCP
|
|
updated = true
|
|
}
|
|
if agent.MCPConfigPath != result.MCPConfigPath {
|
|
agent.MCPConfigPath = result.MCPConfigPath
|
|
updated = true
|
|
}
|
|
if updated {
|
|
if err := c.repo.UpdateAgent(ctx, agent); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
autoApprove, allowIndexing, skipPermissions := resolvePermissionDefaults(agentConfig.PermissionSettings())
|
|
|
|
// Fetch model list once per agent (not per profile) to resolve display names
|
|
modelList, listErr := agentConfig.ListModels(ctx)
|
|
if listErr != nil {
|
|
c.logger.Warn("failed to list models during profile sync, using model ID as name",
|
|
zap.String("agent", result.Name), zap.Error(listErr))
|
|
}
|
|
|
|
if len(profiles) > 0 {
|
|
for _, profile := range profiles {
|
|
if profile.UserModified {
|
|
continue
|
|
}
|
|
updated := false
|
|
|
|
if profile.AgentDisplayName != displayName {
|
|
profile.AgentDisplayName = displayName
|
|
updated = true
|
|
}
|
|
if profile.Model != defaultModel {
|
|
profile.Model = defaultModel
|
|
updated = true
|
|
}
|
|
|
|
// Re-resolve profile name from model display name
|
|
resolvedName := resolveModelDisplayName(modelList, profile.Model)
|
|
if isPassthroughOnly {
|
|
resolvedName = displayName
|
|
}
|
|
if profile.Name != resolvedName {
|
|
profile.Name = resolvedName
|
|
updated = true
|
|
}
|
|
|
|
if profile.AutoApprove != autoApprove {
|
|
profile.AutoApprove = autoApprove
|
|
updated = true
|
|
}
|
|
if profile.AllowIndexing != allowIndexing {
|
|
profile.AllowIndexing = allowIndexing
|
|
updated = true
|
|
}
|
|
if profile.DangerouslySkipPermissions != skipPermissions {
|
|
profile.DangerouslySkipPermissions = skipPermissions
|
|
updated = true
|
|
}
|
|
if isPassthroughOnly && !profile.CLIPassthrough {
|
|
profile.CLIPassthrough = true
|
|
updated = true
|
|
}
|
|
|
|
if updated {
|
|
if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
profileName := resolveModelDisplayName(modelList, defaultModel)
|
|
if isPassthroughOnly {
|
|
profileName = displayName
|
|
}
|
|
defaultProfile := &models.AgentProfile{
|
|
AgentID: agent.ID,
|
|
Name: profileName,
|
|
Model: defaultModel,
|
|
AgentDisplayName: displayName,
|
|
AutoApprove: autoApprove,
|
|
AllowIndexing: allowIndexing,
|
|
DangerouslySkipPermissions: skipPermissions,
|
|
CLIPassthrough: isPassthroughOnly,
|
|
}
|
|
if err := c.repo.CreateAgentProfile(ctx, defaultProfile); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, error) {
|
|
agent, err := c.repo.GetAgent(ctx, id)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrAgentNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := toAgentDTO(agent, profiles)
|
|
return &result, nil
|
|
}
|
|
|
|
type UpdateAgentProfileMcpConfigRequest struct {
|
|
Enabled bool
|
|
Servers map[string]mcpconfig.ServerDef
|
|
Meta map[string]any
|
|
}
|
|
|
|
func (c *Controller) GetAgentProfileMcpConfig(ctx context.Context, profileID string) (*dto.AgentProfileMcpConfigDTO, error) {
|
|
config, err := c.mcpService.GetConfigByProfileID(ctx, profileID)
|
|
if err != nil {
|
|
if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
|
|
return nil, ErrAgentProfileNotFound
|
|
}
|
|
if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) {
|
|
return nil, ErrAgentMcpUnsupported
|
|
}
|
|
return nil, err
|
|
}
|
|
return &dto.AgentProfileMcpConfigDTO{
|
|
ProfileID: config.ProfileID,
|
|
Enabled: config.Enabled,
|
|
Servers: config.Servers,
|
|
Meta: config.Meta,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Controller) UpdateAgentProfileMcpConfig(ctx context.Context, profileID string, req UpdateAgentProfileMcpConfigRequest) (*dto.AgentProfileMcpConfigDTO, error) {
|
|
config, err := c.mcpService.UpsertConfigByProfileID(ctx, profileID, &mcpconfig.ProfileConfig{
|
|
Enabled: req.Enabled,
|
|
Servers: req.Servers,
|
|
Meta: req.Meta,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, mcpconfig.ErrAgentProfileNotFound) {
|
|
return nil, ErrAgentProfileNotFound
|
|
}
|
|
if errors.Is(err, mcpconfig.ErrAgentMcpUnsupported) {
|
|
return nil, ErrAgentMcpUnsupported
|
|
}
|
|
return nil, err
|
|
}
|
|
return &dto.AgentProfileMcpConfigDTO{
|
|
ProfileID: config.ProfileID,
|
|
Enabled: config.Enabled,
|
|
Servers: config.Servers,
|
|
Meta: config.Meta,
|
|
}, nil
|
|
}
|
|
|
|
type CreateAgentRequest struct {
|
|
Name string
|
|
WorkspaceID *string
|
|
Profiles []CreateAgentProfileRequest
|
|
}
|
|
|
|
type CreateAgentProfileRequest struct {
|
|
Name string
|
|
Model string
|
|
AutoApprove bool
|
|
DangerouslySkipPermissions bool
|
|
}
|
|
|
|
func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (*dto.AgentDTO, error) {
|
|
if req.Name == "" {
|
|
return nil, fmt.Errorf("name is required")
|
|
}
|
|
existing, err := c.repo.GetAgentByName(ctx, req.Name)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
if err == nil && existing != nil {
|
|
return nil, fmt.Errorf("agent already configured: %s", req.Name)
|
|
}
|
|
discoveryResults, err := c.detectAgents(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var matched *discovery.Availability
|
|
for _, result := range discoveryResults {
|
|
if result.Name == req.Name {
|
|
matched = &result
|
|
break
|
|
}
|
|
}
|
|
if matched == nil {
|
|
return nil, fmt.Errorf("unknown agent: %s", req.Name)
|
|
}
|
|
if !matched.Available {
|
|
return nil, fmt.Errorf("agent not installed: %s", req.Name)
|
|
}
|
|
agentConfig, agOk := c.agentRegistry.Get(req.Name)
|
|
if !agOk {
|
|
return nil, fmt.Errorf("unknown agent: %s", req.Name)
|
|
}
|
|
displayName := agentConfig.DisplayName()
|
|
if displayName == "" {
|
|
displayName = agentConfig.Name()
|
|
}
|
|
if displayName == "" {
|
|
return nil, fmt.Errorf("unknown agent display name: %s", req.Name)
|
|
}
|
|
agent := &models.Agent{
|
|
Name: matched.Name,
|
|
WorkspaceID: req.WorkspaceID,
|
|
SupportsMCP: matched.SupportsMCP,
|
|
MCPConfigPath: matched.MCPConfigPath,
|
|
}
|
|
if err := c.repo.CreateAgent(ctx, agent); err != nil {
|
|
return nil, err
|
|
}
|
|
profiles := make([]*models.AgentProfile, 0, len(req.Profiles))
|
|
for _, profileReq := range req.Profiles {
|
|
profile := &models.AgentProfile{
|
|
AgentID: agent.ID,
|
|
Name: profileReq.Name,
|
|
AgentDisplayName: displayName,
|
|
Model: profileReq.Model,
|
|
AutoApprove: profileReq.AutoApprove,
|
|
DangerouslySkipPermissions: profileReq.DangerouslySkipPermissions,
|
|
}
|
|
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
|
|
return nil, err
|
|
}
|
|
profiles = append(profiles, profile)
|
|
}
|
|
result := toAgentDTO(agent, profiles)
|
|
return &result, nil
|
|
}
|
|
|
|
type UpdateAgentRequest struct {
|
|
ID string
|
|
WorkspaceID *string
|
|
SupportsMCP *bool
|
|
MCPConfigPath *string
|
|
}
|
|
|
|
func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (*dto.AgentDTO, error) {
|
|
agent, err := c.repo.GetAgent(ctx, req.ID)
|
|
if err != nil {
|
|
return nil, ErrAgentNotFound
|
|
}
|
|
if req.WorkspaceID != nil {
|
|
agent.WorkspaceID = req.WorkspaceID
|
|
}
|
|
if req.SupportsMCP != nil {
|
|
agent.SupportsMCP = *req.SupportsMCP
|
|
}
|
|
if req.MCPConfigPath != nil {
|
|
agent.MCPConfigPath = *req.MCPConfigPath
|
|
}
|
|
if err := c.repo.UpdateAgent(ctx, agent); err != nil {
|
|
return nil, err
|
|
}
|
|
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := toAgentDTO(agent, profiles)
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
|
|
if err := c.repo.DeleteAgent(ctx, id); err != nil {
|
|
if strings.Contains(err.Error(), "agent not found") {
|
|
return ErrAgentNotFound
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type CreateProfileRequest struct {
|
|
AgentID string
|
|
Name string
|
|
Model string
|
|
AutoApprove bool
|
|
DangerouslySkipPermissions bool
|
|
AllowIndexing bool
|
|
CLIPassthrough bool
|
|
}
|
|
|
|
func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest) (*dto.AgentProfileDTO, error) {
|
|
// Validate that model is provided
|
|
if strings.TrimSpace(req.Model) == "" {
|
|
return nil, ErrModelRequired
|
|
}
|
|
agent, err := c.repo.GetAgent(ctx, req.AgentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agentConfig, agOk := c.agentRegistry.Get(agent.Name)
|
|
if !agOk {
|
|
return nil, fmt.Errorf("unknown agent: %s", agent.Name)
|
|
}
|
|
displayName := agentConfig.DisplayName()
|
|
if displayName == "" {
|
|
displayName = agentConfig.Name()
|
|
}
|
|
if displayName == "" {
|
|
return nil, fmt.Errorf("unknown agent display name: %s", agent.Name)
|
|
}
|
|
profile := &models.AgentProfile{
|
|
AgentID: req.AgentID,
|
|
Name: req.Name,
|
|
AgentDisplayName: displayName,
|
|
Model: req.Model,
|
|
AutoApprove: req.AutoApprove,
|
|
DangerouslySkipPermissions: req.DangerouslySkipPermissions,
|
|
AllowIndexing: req.AllowIndexing,
|
|
CLIPassthrough: req.CLIPassthrough,
|
|
UserModified: true,
|
|
}
|
|
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
|
|
return nil, err
|
|
}
|
|
result := toProfileDTO(profile)
|
|
return &result, nil
|
|
}
|
|
|
|
type UpdateProfileRequest struct {
|
|
ID string
|
|
Name *string
|
|
Model *string
|
|
AutoApprove *bool
|
|
DangerouslySkipPermissions *bool
|
|
AllowIndexing *bool
|
|
CLIPassthrough *bool
|
|
}
|
|
|
|
func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*dto.AgentProfileDTO, error) {
|
|
profile, err := c.repo.GetAgentProfile(ctx, req.ID)
|
|
if err != nil {
|
|
return nil, ErrAgentProfileNotFound
|
|
}
|
|
if req.Name != nil {
|
|
profile.Name = *req.Name
|
|
}
|
|
if req.Model != nil {
|
|
// Validate that model is not empty
|
|
if strings.TrimSpace(*req.Model) == "" {
|
|
return nil, ErrModelRequired
|
|
}
|
|
profile.Model = *req.Model
|
|
}
|
|
if req.AutoApprove != nil {
|
|
profile.AutoApprove = *req.AutoApprove
|
|
}
|
|
if req.DangerouslySkipPermissions != nil {
|
|
profile.DangerouslySkipPermissions = *req.DangerouslySkipPermissions
|
|
}
|
|
if req.AllowIndexing != nil {
|
|
profile.AllowIndexing = *req.AllowIndexing
|
|
}
|
|
if req.CLIPassthrough != nil {
|
|
profile.CLIPassthrough = *req.CLIPassthrough
|
|
}
|
|
profile.UserModified = true
|
|
if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil {
|
|
return nil, err
|
|
}
|
|
result := toProfileDTO(profile)
|
|
return &result, nil
|
|
}
|
|
|
|
func (c *Controller) DeleteProfile(ctx context.Context, id string) (*dto.AgentProfileDTO, error) {
|
|
profile, err := c.repo.GetAgentProfile(ctx, id)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "agent profile not found") {
|
|
return nil, ErrAgentProfileNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
if c.sessionChecker != nil {
|
|
active, err := c.sessionChecker.HasActiveTaskSessionsByAgentProfile(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if active {
|
|
return nil, ErrAgentProfileInUse
|
|
}
|
|
}
|
|
if err := c.repo.DeleteAgentProfile(ctx, id); err != nil {
|
|
if strings.Contains(err.Error(), "agent profile not found") {
|
|
return nil, ErrAgentProfileNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
result := toProfileDTO(profile)
|
|
return &result, nil
|
|
}
|
|
|
|
func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO {
|
|
profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles))
|
|
for _, profile := range profiles {
|
|
profileDTOs = append(profileDTOs, toProfileDTO(profile))
|
|
}
|
|
return dto.AgentDTO{
|
|
ID: agent.ID,
|
|
Name: agent.Name,
|
|
WorkspaceID: agent.WorkspaceID,
|
|
SupportsMCP: agent.SupportsMCP,
|
|
MCPConfigPath: agent.MCPConfigPath,
|
|
Profiles: profileDTOs,
|
|
CreatedAt: agent.CreatedAt,
|
|
UpdatedAt: agent.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO {
|
|
return dto.AgentProfileDTO{
|
|
ID: profile.ID,
|
|
AgentID: profile.AgentID,
|
|
Name: profile.Name,
|
|
AgentDisplayName: profile.AgentDisplayName,
|
|
Model: profile.Model,
|
|
AutoApprove: profile.AutoApprove,
|
|
DangerouslySkipPermissions: profile.DangerouslySkipPermissions,
|
|
AllowIndexing: profile.AllowIndexing,
|
|
CLIPassthrough: profile.CLIPassthrough,
|
|
UserModified: profile.UserModified,
|
|
CreatedAt: profile.CreatedAt,
|
|
UpdatedAt: profile.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
// GetAgentLogo returns the SVG logo bytes for the given agent and variant.
|
|
func (c *Controller) GetAgentLogo(ctx context.Context, agentName string, variant agents.LogoVariant) ([]byte, error) {
|
|
ag, ok := c.agentRegistry.Get(agentName)
|
|
if !ok {
|
|
return nil, ErrAgentNotFound
|
|
}
|
|
data := ag.Logo(variant)
|
|
if len(data) == 0 {
|
|
return nil, ErrLogoNotAvailable
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// resolvePermissionDefaults extracts the default permission values from agent settings.
|
|
func resolvePermissionDefaults(permSettings map[string]agents.PermissionSetting) (autoApprove, allowIndexing, skipPermissions bool) {
|
|
if permSettings == nil {
|
|
return
|
|
}
|
|
if s, exists := permSettings["auto_approve"]; exists {
|
|
autoApprove = s.Default
|
|
}
|
|
if s, exists := permSettings["allow_indexing"]; exists {
|
|
allowIndexing = s.Default
|
|
}
|
|
if s, exists := permSettings["dangerously_skip_permissions"]; exists {
|
|
skipPermissions = s.Default
|
|
}
|
|
return
|
|
}
|
|
|
|
// resolveModelDisplayName returns the display name for a model ID from the model list,
|
|
// falling back to the model ID itself if not found.
|
|
func resolveModelDisplayName(modelList *agents.ModelList, modelID string) string {
|
|
if modelList != nil {
|
|
for _, m := range modelList.Models {
|
|
if m.ID == modelID {
|
|
return m.Name
|
|
}
|
|
}
|
|
}
|
|
return modelID
|
|
}
|
|
|
|
// detectAgents runs discovery and forces mock-agent available when enabled.
|
|
func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) {
|
|
results, err := c.discovery.Detect(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Force mock-agent as available when enabled (skip file-presence discovery)
|
|
agentConfig, ok := c.agentRegistry.Get("mock-agent")
|
|
if ok && agentConfig.Enabled() {
|
|
for i := range results {
|
|
if results[i].Name == "mock-agent" {
|
|
results[i].Available = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// CommandPreviewRequest contains the draft settings for command preview
|
|
type CommandPreviewRequest struct {
|
|
Model string
|
|
PermissionSettings map[string]bool
|
|
CLIPassthrough bool
|
|
}
|
|
|
|
// PreviewAgentCommand generates a preview of the CLI command that will be executed
|
|
func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, req CommandPreviewRequest) (*dto.CommandPreviewResponse, error) {
|
|
agentConfig, ok := c.agentRegistry.Get(agentName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent type %q not found in registry", agentName)
|
|
}
|
|
|
|
var cmd agents.Command
|
|
if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && req.CLIPassthrough {
|
|
cmd = ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{
|
|
Model: req.Model,
|
|
Prompt: "{prompt}",
|
|
PermissionValues: req.PermissionSettings,
|
|
})
|
|
} else {
|
|
cmd = agentConfig.BuildCommand(agents.CommandOptions{
|
|
Model: req.Model,
|
|
PermissionValues: req.PermissionSettings,
|
|
})
|
|
}
|
|
|
|
return &dto.CommandPreviewResponse{
|
|
Supported: true,
|
|
Command: cmd.Args(),
|
|
CommandString: buildCommandString(cmd.Args()),
|
|
}, nil
|
|
}
|
|
|
|
// FetchDynamicModels fetches models for an agent, optionally refreshing the cache
|
|
func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, refresh bool) (*dto.DynamicModelsResponse, error) {
|
|
ag, ok := c.agentRegistry.Get(agentName)
|
|
if !ok {
|
|
return nil, fmt.Errorf("agent %q not found", agentName)
|
|
}
|
|
|
|
// Check cache unless refresh is requested
|
|
if !refresh {
|
|
if entry, exists := c.modelCache.Get(agentName); exists && entry.IsValid() {
|
|
cachedAt := entry.CachedAt
|
|
modelDTOs := modelsToDTO(entry.Models)
|
|
var errStr *string
|
|
if entry.Error != nil {
|
|
s := entry.Error.Error()
|
|
errStr = &s
|
|
}
|
|
return &dto.DynamicModelsResponse{
|
|
AgentName: agentName,
|
|
Models: modelDTOs,
|
|
Cached: true,
|
|
CachedAt: &cachedAt,
|
|
Error: errStr,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// Fetch models from the agent
|
|
modelList, err := ag.ListModels(ctx)
|
|
if err != nil {
|
|
c.logger.Warn("model fetch failed",
|
|
zap.String("agent", agentName),
|
|
zap.Error(err))
|
|
c.modelCache.Set(agentName, nil, err)
|
|
s := err.Error()
|
|
return &dto.DynamicModelsResponse{
|
|
AgentName: agentName,
|
|
Models: nil,
|
|
Cached: false,
|
|
Error: &s,
|
|
}, nil
|
|
}
|
|
|
|
models := modelList.Models
|
|
|
|
// Cache the result if dynamic models are supported
|
|
if modelList.SupportsDynamic {
|
|
c.modelCache.Set(agentName, models, nil)
|
|
cachedAt := time.Now()
|
|
return &dto.DynamicModelsResponse{
|
|
AgentName: agentName,
|
|
Models: modelsToDTO(models),
|
|
Cached: true,
|
|
CachedAt: &cachedAt,
|
|
}, nil
|
|
}
|
|
|
|
return &dto.DynamicModelsResponse{
|
|
AgentName: agentName,
|
|
Models: modelsToDTO(models),
|
|
Cached: false,
|
|
}, nil
|
|
}
|
|
|
|
// modelsToDTO converts agent models to DTOs.
|
|
func modelsToDTO(models []agents.Model) []dto.ModelEntryDTO {
|
|
dtos := make([]dto.ModelEntryDTO, 0, len(models))
|
|
for _, m := range models {
|
|
dtos = append(dtos, dto.ModelEntryDTO{
|
|
ID: m.ID,
|
|
Name: m.Name,
|
|
Provider: m.Provider,
|
|
ContextWindow: m.ContextWindow,
|
|
IsDefault: m.IsDefault,
|
|
Source: m.Source,
|
|
})
|
|
}
|
|
return dtos
|
|
}
|