Files
kandev/apps/backend/internal/agent/settings/controller/agent_discovery.go
T
2026-09-25 10:31:58 +01:00

623 lines
20 KiB
Go

package controller
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/pkg/agent"
)
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 {
var loginCmd *dto.LoginCommandDTO
if ag, ok := c.agentRegistry.Get(result.Name); ok {
loginCmd = buildLoginCommandDTO(ag)
}
payload = append(payload, dto.AgentDiscoveryDTO{
Name: result.Name,
SupportsMCP: result.SupportsMCP,
MCPConfigPath: result.MCPConfigPath,
InstallationPaths: result.InstallationPaths,
Available: result.Available,
MatchedPath: result.MatchedPath,
LoginCommand: loginCmd,
})
}
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}
}
payload = append(payload, c.buildAvailableAgentDTO(ctx, ag, availability, now))
}
tools := c.detectTools()
return &dto.ListAvailableAgentsResponse{Agents: payload, Tools: tools, Total: len(payload)}, nil
}
// HasAvailableAgents returns true if at least one agent is detected as installed.
func (c *Controller) HasAvailableAgents(ctx context.Context) (bool, error) {
results, err := c.detectAgents(ctx)
if err != nil {
return false, err
}
for _, r := range results {
if r.Available {
return true, nil
}
}
return false, nil
}
func (c *Controller) InvalidateDiscoveryCache() {
if c.discovery != nil {
c.discovery.InvalidateCache()
}
}
func (c *Controller) buildAvailableAgentDTO(ctx context.Context, ag agents.Agent, availability discovery.Availability, now time.Time) dto.AvailableAgentDTO {
displayName := ag.DisplayName()
if displayName == "" {
displayName = ag.Name()
}
modelConfig := c.buildModelConfigFromHostUtility(ag.ID())
capabilities := dto.AgentCapabilitiesDTO{
SupportsSessionResume: availability.Capabilities.SupportsSessionResume,
SupportsShell: availability.Capabilities.SupportsShell,
SupportsWorkspaceOnly: availability.Capabilities.SupportsWorkspaceOnly,
}
var permissionSettings map[string]dto.PermissionSettingDTO
if permSettings := agents.CatalogPermissionSettings(ag); 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,
}
}
}
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,
AutoInjectPrompt: pt.AutoInjectPrompt,
SubmitSequence: pt.SubmitSequence,
}
if pt.MCPStrategy != nil {
passthroughConfig.MCPInjection = pt.MCPStrategy.Describe()
}
}
loginCommand := buildLoginCommandDTO(ag)
runtimeUpdate := c.buildRuntimeUpdateDTO(ctx, ag, availability.Available)
return dto.AvailableAgentDTO{
Name: ag.ID(),
DisplayName: displayName,
Description: ag.Description(),
InstallScript: ag.InstallScript(),
SupportsMCP: availability.SupportsMCP,
MCPConfigPath: availability.MCPConfigPath,
InstallationPaths: availability.InstallationPaths,
Available: availability.Available,
MatchedPath: availability.MatchedPath,
Capabilities: capabilities,
ModelConfig: modelConfig,
PermissionSettings: permissionSettings,
PassthroughConfig: passthroughConfig,
LoginCommand: loginCommand,
RuntimeUpdate: runtimeUpdate,
UpdatedAt: now,
}
}
func (c *Controller) buildRuntimeUpdateDTO(ctx context.Context, ag agents.Agent, available bool) *dto.RuntimeUpdateDTO {
if !available {
return nil
}
managed, ok := ag.(agents.ManagedNPMRuntimeAgent)
if !ok {
return nil
}
spec := managed.ManagedNPMRuntime()
if spec.Package == "" {
return nil
}
defaultVersion := spec.DefaultVersionOrPinned()
item := &dto.RuntimeUpdateDTO{
Supported: true,
Package: spec.Package,
DefaultVersion: defaultVersion,
EffectiveVersion: defaultVersion,
}
if c.managedRuntimeSelections != nil {
if selection, found, err := c.managedRuntimeSelections.Get(ctx, ag.ID(), spec.Package); err == nil && found &&
selection.Package == spec.Package {
item.ActiveVersion = selection.Version
item.EffectiveVersion = selection.Version
}
}
if c.runtimeUpdater != nil {
if caps, found := c.runtimeUpdater.CurrentCapabilities(ag.ID()); found {
item.CurrentVersion = caps.AgentVersion
}
return item
}
if c.hostUtility != nil {
if caps, found := c.hostUtility.Get(ag.ID()); found {
item.CurrentVersion = caps.AgentVersion
}
}
return item
}
// buildLoginCommandDTO surfaces the interactive login command for agents that
// implement LoginAgent. Nil for agents without an interactive login.
func buildLoginCommandDTO(ag agents.Agent) *dto.LoginCommandDTO {
loginAg, ok := ag.(agents.LoginAgent)
if !ok {
return nil
}
lc := loginAg.LoginCommand()
if lc == nil || len(lc.Cmd) == 0 {
return nil
}
return &dto.LoginCommandDTO{
Cmd: lc.Cmd,
Description: lc.Description,
}
}
// buildModelConfigFromHostUtility reads cached ACP probe data for the agent
// type and produces a ModelConfigDTO with models, modes, and status. The
// capability cache can be empty while the asynchronous host-utility probe
// is still starting, so the dynamic-support flag comes from the registered
// agent capability rather than from cache presence.
func (c *Controller) buildModelConfigFromHostUtility(agentID string) dto.ModelConfigDTO {
// Always initialize slices so JSON marshals as [] not null — the
// frontend uses .some()/.find() on these without null checks.
cfg := dto.ModelConfigDTO{
SupportsDynamicModels: c.agentSupportsDynamicModelConfig(agentID),
AvailableModels: []dto.ModelEntryDTO{},
AvailableModes: []dto.ModeEntryDTO{},
}
if c.hostUtility == nil {
cfg.Status = "not_configured"
return cfg
}
caps, ok := c.hostUtility.Get(agentID)
if !ok {
cfg.Status = "not_configured"
return cfg
}
cfg.SupportsDynamicModels = true
cfg.Status = string(caps.Status)
cfg.Error = caps.Error
cfg.DefaultModel = caps.CurrentModelID
cfg.CurrentModelID = caps.CurrentModelID
cfg.CurrentModeID = caps.CurrentModeID
cfg.ConfigOptions = configOptionDTOs(caps.ConfigOptions)
for _, m := range caps.Models {
cfg.AvailableModels = append(cfg.AvailableModels, dto.ModelEntryDTO{
ID: m.ID,
Name: m.Name,
Description: m.Description,
IsDefault: m.ID == caps.CurrentModelID,
Meta: m.Meta,
})
}
for _, m := range caps.Modes {
cfg.AvailableModes = append(cfg.AvailableModes, dto.ModeEntryDTO{
ID: m.ID,
Name: m.Name,
Description: m.Description,
Meta: m.Meta,
})
}
for _, c := range caps.Commands {
cfg.AvailableCommands = append(cfg.AvailableCommands, dto.CommandEntryDTO{
Name: c.Name,
Description: c.Description,
})
}
return cfg
}
func (c *Controller) agentSupportsDynamicModelConfig(agentID string) bool {
if c.agentRegistry == nil {
return false
}
ag, ok := c.agentRegistry.Get(agentID)
if !ok {
return false
}
ia, ok := ag.(agents.InferenceAgent)
if !ok {
return false
}
if cfg := ia.InferenceConfig(); cfg == nil || !cfg.Supported {
return false
}
runtime := ag.Runtime()
return runtime != nil && runtime.Protocol == agent.ProtocolACP
}
func configOptionDTOs(options []hostutility.ConfigOption) []dto.ConfigOptionDTO {
out := make([]dto.ConfigOptionDTO, 0, len(options))
for _, opt := range options {
choices := make([]dto.ConfigOptionChoiceDTO, 0, len(opt.Options))
for _, choice := range opt.Options {
choices = append(choices, dto.ConfigOptionChoiceDTO{
Value: choice.Value,
Name: choice.Name,
Description: choice.Description,
})
}
out = append(out, dto.ConfigOptionDTO{
Type: opt.Type,
ID: opt.ID,
Name: opt.Name,
Description: opt.Description,
CurrentValue: opt.CurrentValue,
Category: opt.Category,
Options: choices,
})
}
return out
}
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
}
if err := c.syncAgentFromDiscovery(ctx, result); err != nil {
return err
}
}
return nil
}
// profileSyncParams holds resolved parameters used when syncing agent profiles.
type profileSyncParams struct {
displayName string
defaultModel string
isPassthrough bool
autoApprove bool
allowIndexing bool
skipPermissions bool
}
// updateExistingProfiles syncs non-user-modified profiles with current agent defaults.
func (c *Controller) updateExistingProfiles(ctx context.Context, profiles []*models.AgentProfile, p profileSyncParams) error {
for _, profile := range profiles {
if profile.UserModified {
continue
}
updated := false
if profile.AgentDisplayName != p.displayName {
profile.AgentDisplayName = p.displayName
updated = true
}
if profile.Model != p.defaultModel {
profile.Model = p.defaultModel
updated = true
}
resolvedName := profile.Model
if p.isPassthrough || resolvedName == "" {
resolvedName = p.displayName
}
if profile.Name != resolvedName {
profile.Name = resolvedName
updated = true
}
if profile.AutoApprove != p.autoApprove {
profile.AutoApprove = p.autoApprove
updated = true
}
if profile.AllowIndexing != p.allowIndexing {
profile.AllowIndexing = p.allowIndexing
updated = true
}
if profile.DangerouslySkipPermissions != p.skipPermissions {
profile.DangerouslySkipPermissions = p.skipPermissions
updated = true
}
if p.isPassthrough && !profile.CLIPassthrough {
profile.CLIPassthrough = true
updated = true
}
if updated {
if err := c.repo.UpdateAgentProfile(ctx, profile); err != nil {
return err
}
}
}
return nil
}
func (c *Controller) syncAgentFromDiscovery(ctx context.Context, result discovery.Availability) error {
agentConfig, ok := c.agentRegistry.Get(result.Name)
if !ok {
// Agent detected on filesystem but not in registry (e.g. KANDEV_MOCK_AGENT=only
// suppresses real agents). Skip silently rather than aborting the entire sync.
return nil
}
displayName, err := c.resolveDisplayName(agentConfig, result.Name)
if err != nil {
return err
}
defaultModel, isPassthroughOnly, err := resolveDefaultModel(agentConfig, result.Name)
if err != nil {
return err
}
agent, err := c.upsertAgent(ctx, result)
if err != nil {
return err
}
profiles, err := c.repo.ListAgentProfiles(ctx, agent.ID)
if err != nil {
return err
}
p := c.buildProfileSyncParams(ctx, agentConfig, result.Name, displayName, defaultModel, isPassthroughOnly)
if len(profiles) > 0 {
return c.updateExistingProfiles(ctx, profiles, p)
}
// No live profiles. Only seed a default for an agent that has never been
// provisioned. If soft-deleted rows exist the user deliberately removed
// the profile(s); recreating one here would resurrect it on every restart.
// (A soft-deleted row implies a user deletion, not system orphan cleanup —
// see ProfileReconciler.reconcileAgent for the disjoint-enabled-set reasoning.)
hadProfiles, err := c.repo.HasDeletedAgentProfiles(ctx, agent.ID)
if err != nil {
return err
}
if hadProfiles {
return nil
}
return c.createDefaultProfile(ctx, agent.ID, p)
}
// resolveDisplayName returns the display name for an agent config, falling back to its
// internal name, and returns an error if no name can be determined.
func (c *Controller) resolveDisplayName(agentConfig agents.Agent, agentName string) (string, error) {
displayName := agentConfig.DisplayName()
if displayName == "" {
displayName = agentConfig.Name()
}
if displayName == "" {
return "", fmt.Errorf("unknown agent display name: %s", agentName)
}
return displayName, nil
}
// buildProfileSyncParams assembles the parameters needed to create or update agent profiles.
func (c *Controller) buildProfileSyncParams(
_ context.Context,
agentConfig agents.Agent,
_, displayName, defaultModel string,
isPassthroughOnly bool,
) profileSyncParams {
autoApprove, allowIndexing, skipPermissions := resolvePermissionDefaults(agents.CatalogPermissionSettings(agentConfig))
return profileSyncParams{
displayName: displayName,
defaultModel: defaultModel,
isPassthrough: isPassthroughOnly,
autoApprove: autoApprove,
allowIndexing: allowIndexing,
skipPermissions: skipPermissions,
}
}
// createDefaultProfile creates the initial agent profile when none exist for an agent.
// The model may be empty — the profile reconciler fills it from the host
// utility capability cache on boot.
func (c *Controller) createDefaultProfile(ctx context.Context, agentID string, p profileSyncParams) error {
profileName := p.displayName
if !p.isPassthrough && p.defaultModel != "" {
profileName = p.defaultModel
}
defaultProfile := &models.AgentProfile{
AgentID: agentID,
Name: profileName,
Model: p.defaultModel,
AgentDisplayName: p.displayName,
AutoApprove: p.autoApprove,
AllowIndexing: p.allowIndexing,
DangerouslySkipPermissions: p.skipPermissions,
CLIPassthrough: p.isPassthrough,
CursorMCPAuthEnabled: true,
}
return c.repo.CreateAgentProfile(ctx, defaultProfile)
}
func resolveDefaultModel(agentConfig agents.Agent, _ string) (string, bool, error) {
// Default models come from the host utility capability cache after probing.
// The legacy bootstrap path leaves the model empty and lets the reconciler
// heal it against the cache. TUI-only agents still seed as passthrough so
// the terminal UI is enabled by default.
if agents.IsPassthroughOnly(agentConfig) {
return "passthrough", true, nil
}
// Mock agent is not probed (not an InferenceAgent) but needs a concrete
// model for E2E tests that exercise the ModelSelector UI. It must be one
// of the models the mock agent actually advertises (mock-fast /
// mock-smart): the no-silent-model-fallback policy fails session start
// explicitly when the profile model is absent from the advertised list,
// and "mock-default" is not advertised.
if agentConfig.ID() == "mock-agent" {
return "mock-fast", false, nil
}
return "", false, nil
}
func (c *Controller) upsertAgent(ctx context.Context, result discovery.Availability) (*models.Agent, error) {
agent, err := c.repo.GetAgentByName(ctx, result.Name)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, 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 nil, err
}
return agent, nil
}
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 nil, err
}
}
return agent, nil
}
// detectTools checks for recommended CLI tools (e.g. gh) and returns their status.
func (c *Controller) detectTools() []dto.ToolStatusDTO {
ghTool := dto.ToolStatusDTO{
Name: "gh",
DisplayName: "GitHub CLI",
Description: "Required for GitHub integration features (PRs, reviews, webhooks).",
InfoURL: "https://cli.github.com",
}
switch runtime.GOOS {
case "darwin":
ghTool.InstallScript = "brew install gh"
case "windows":
ghTool.InstallScript = "winget install --id GitHub.cli"
default:
// No single command works across all Linux distros; rely on info_url.
ghTool.InstallScript = ""
}
if path, err := exec.LookPath("gh"); err == nil {
ghTool.Available = true
ghTool.MatchedPath = path
}
return []dto.ToolStatusDTO{ghTool}
}
// detectAgents runs discovery and forces mock-agent available when enabled.
//
// In E2E mock mode (KANDEV_E2E_MOCK=true) the filesystem discovery walk is
// skipped entirely: all enabled agents are synthesised as available. This
// avoids the 15-second detectAll timeout under CPU contention and prevents
// the "seedData fixture timeout: listAgents returned 0 agents" flake.
func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) {
if os.Getenv("KANDEV_E2E_MOCK") == "true" {
return c.synthAvailabilityFromRegistry(), nil
}
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
}
// synthAvailabilityFromRegistry builds Availability records for every enabled
// inference agent without hitting the filesystem. Virtual families remain
// visible through their durable settings rows, but they are not discovery
// results and must never receive a concrete default profile.
//
// We still call IsInstalled() per-agent to copy over the agent's static
// capability flags (SupportsMCP, MCPConfigPaths). Without those, downstream
// code sees SupportsMCP=false for every mock agent — which silently disables
// plan mode in the chat UI (planModeAvailable is false → the toggle only
// flips the layout, not the chat input state).
func (c *Controller) synthAvailabilityFromRegistry() []discovery.Availability {
enabled := c.agentRegistry.ListEnabled()
results := make([]discovery.Availability, 0, len(enabled))
for _, ag := range enabled {
if agents.IsVirtualAgent(ag) {
continue
}
av := discovery.Availability{
Name: ag.ID(),
Available: true,
Capabilities: discovery.Capabilities{
SupportsSessionResume: true,
},
}
// IsInstalled is a pure local check on mock-agent (no filesystem walk),
// so it's safe to call here without contention. Pull the static
// SupportsMCP flag so the UI can offer plan mode in E2E runs.
if probe, err := ag.IsInstalled(context.Background()); err == nil && probe != nil {
av.SupportsMCP = probe.SupportsMCP
if len(probe.MCPConfigPaths) > 0 {
av.MCPConfigPath = probe.MCPConfigPaths[0]
}
}
results = append(results, av)
}
return results
}