diff --git a/apps/backend/internal/agent/acpdbg/runner.go b/apps/backend/internal/agent/acpdbg/runner.go index cd5f14cff9..6d62fed510 100644 --- a/apps/backend/internal/agent/acpdbg/runner.go +++ b/apps/backend/internal/agent/acpdbg/runner.go @@ -60,7 +60,11 @@ type Runner struct { oobMu sync.Mutex oobBuf []Frame oobClosed bool - oobWake chan struct{} + // shutdownStarted is guarded by oobMu so signalShutdown and pushOOB have + // one ordering point. This prevents a frame from being appended after + // shutdown has completed its handoff to the read loop. + shutdownStarted bool + oobWake chan struct{} // responses[id] is closed by the read loop when a response with that // id is recorded; the mu-protected map stores the frame itself. mu sync.Mutex @@ -196,7 +200,12 @@ func (r *Runner) watchContext(ctx context.Context) { } func (r *Runner) signalShutdown() { - r.shutdownOnce.Do(func() { close(r.shutdownCh) }) + r.shutdownOnce.Do(func() { + close(r.shutdownCh) + r.oobMu.Lock() + r.shutdownStarted = true + r.oobMu.Unlock() + }) } // Path returns the JSONL file path the runner is writing to. @@ -271,7 +280,7 @@ func (r *Runner) DrainOOBUntil(ctx context.Context, handler func(Frame) bool) er // replay burst. func (r *Runner) pushOOB(frame Frame) { r.oobMu.Lock() - if r.oobClosed { + if r.oobClosed || r.shutdownStarted { r.oobMu.Unlock() return } diff --git a/apps/backend/internal/agent/hostutility/manager.go b/apps/backend/internal/agent/hostutility/manager.go index ebe09a6f0b..4e2de825df 100644 --- a/apps/backend/internal/agent/hostutility/manager.go +++ b/apps/backend/internal/agent/hostutility/manager.go @@ -18,6 +18,7 @@ import ( "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/registry" agentctlclient "github.com/kandev/kandev/internal/agent/runtime/agentctl" + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/system/storage" @@ -37,12 +38,15 @@ import ( // - RefreshAgent: re-runs the probe against the existing instance. // - Stop(ctx): deletes each instance from agentctl and removes the tmp parent. type Manager struct { - registry *registry.Registry - controlHost string - controlPort int - controlClient *agentctlclient.ControlClient - authToken string // per-launch auth token for instance clients - log *logger.Logger + registry *registry.Registry + controlHost string + controlPort int + controlClient *agentctlclient.ControlClient + authToken string // per-launch auth token for instance clients + log *logger.Logger + profileResolver interface { + Resolve(context.Context, string) (*settingsmodels.AgentProfile, error) + } parentTmpDir string tempArtifacts *tempartifacts.Registry @@ -60,6 +64,13 @@ type Manager struct { stopped bool } +// SetProfileResolver wires the profile eligibility and launch-policy reader. +func (m *Manager) SetProfileResolver(resolver interface { + Resolve(context.Context, string) (*settingsmodels.AgentProfile, error) +}) { + m.profileResolver = resolver +} + // instance is a single warm agentctl instance bound to an agent type. type instance struct { agentType string diff --git a/apps/backend/internal/agent/hostutility/public.go b/apps/backend/internal/agent/hostutility/public.go index 958233dd7f..f54ade81ab 100644 --- a/apps/backend/internal/agent/hostutility/public.go +++ b/apps/backend/internal/agent/hostutility/public.go @@ -7,9 +7,69 @@ import ( "time" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/settings/cliflags" agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" ) +// ExecuteProfilePrompt resolves a complete profile snapshot at call start +// and executes it through the host utility instance. A missing or ineligible +// profile fails before dispatch; no agent/model fallback is used. +func (m *Manager) ExecuteProfilePrompt(ctx context.Context, profileID, prompt string) (*PromptResult, error) { + if m.profileResolver == nil { + return nil, errors.New("utility profile resolver is not configured") + } + profile, err := m.profileResolver.Resolve(ctx, profileID) + if err != nil { + return nil, err + } + cliFlags, err := cliflags.Resolve(profile.CLIFlags) + if err != nil { + return nil, fmt.Errorf("resolve profile cli flags: %w", err) + } + var prefix []string + if profile.CommandPrefix != "" { + if err := cliflags.ValidateCommandPrefix(profile.CommandPrefix); err != nil { + return nil, err + } + prefix, err = cliflags.Tokenise(profile.CommandPrefix) + if err != nil { + return nil, err + } + } + env := make(map[string]string, len(profile.EnvVars)) + for _, value := range profile.EnvVars { + if value.Key != "" && value.SecretID == "" { + env[value.Key] = value.Value + } + } + inst, ia, err := m.getInstance(ctx, profile.AgentID) + if err != nil { + return nil, err + } + cfg := ia.InferenceConfig() + resolved := m.resolveModel(profile.AgentID, profile.Model, ia) + autoApprove := profile.AutoApprove + req := &agentctlutil.PromptRequest{ + Prompt: prompt, + AgentID: profile.AgentID, + Model: resolved, + Mode: profile.Mode, + AutoApprovePermissions: &autoApprove, + InferenceConfig: &agentctlutil.InferenceConfigDTO{ + Command: cfg.Command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir, + Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: cliFlags, CommandPrefix: prefix, + }, + } + resp, err := inst.client.InferencePrompt(ctx, req) + if err != nil { + return nil, err + } + if !resp.Success { + return nil, errors.New(resp.Error) + } + return &PromptResult{Response: resp.Response, Model: resp.Model, PromptTokens: resp.PromptTokens, ResponseTokens: resp.ResponseTokens, DurationMs: resp.DurationMs}, nil +} + // GetAll returns a snapshot of every probed agent type's capabilities. func (m *Manager) GetAll() []AgentCapabilities { if m == nil || m.cache == nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/utility.go b/apps/backend/internal/agent/runtime/lifecycle/utility.go index eb7537e054..75687223e8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/utility.go +++ b/apps/backend/internal/agent/runtime/lifecycle/utility.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/settings/cliflags" "github.com/kandev/kandev/internal/agentctl/server/utility" ) @@ -63,6 +64,72 @@ func (m *Manager) ExecuteInferencePrompt(ctx context.Context, sessionID, agentID return client.InferencePrompt(ctx, req) } +// ExecuteInferenceProfilePrompt resolves a profile once at dispatch time and +// applies its model, mode, config options, environment, flags, prefix, and +// permission policy to a session-bound utility call. +// +//nolint:cyclop // profile execution validates several independent runtime inputs. +func (m *Manager) ExecuteInferenceProfilePrompt(ctx context.Context, sessionID, profileID, prompt string) (*utility.PromptResponse, error) { + if m.profileResolver == nil { + return nil, fmt.Errorf("agent profile resolver is not configured") + } + profile, err := m.profileResolver.ResolveProfile(ctx, profileID) + if err != nil { + return nil, err + } + if profile == nil || profile.AgentID == "" { + return nil, fmt.Errorf("agent profile %q is not executable", profileID) + } + if sessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + ia, ok := m.registry.GetInferenceAgent(profile.AgentID) + if !ok { + return nil, fmt.Errorf("agent %q does not support inference", profile.AgentID) + } + cfg := ia.InferenceConfig() + if cfg == nil || !cfg.Supported { + return nil, fmt.Errorf("agent %q inference not supported", profile.AgentID) + } + execution, err := m.GetOrEnsureExecution(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("no execution available for session %s: %w", sessionID, err) + } + client := execution.GetAgentCtlClient() + if client == nil { + return nil, fmt.Errorf("agentctl client not available for session %s", sessionID) + } + flags, err := cliflags.Resolve(profile.CLIFlags) + if err != nil { + return nil, fmt.Errorf("resolve profile cli flags: %w", err) + } + var prefix []string + if profile.CommandPrefix != "" { + if err := cliflags.ValidateCommandPrefix(profile.CommandPrefix); err != nil { + return nil, err + } + prefix, err = cliflags.Tokenise(profile.CommandPrefix) + if err != nil { + return nil, err + } + } + env := make(map[string]string, len(profile.EnvVars)) + for _, value := range profile.EnvVars { + if value.Key != "" && value.SecretID == "" { + env[value.Key] = value.Value + } + } + autoApprove := profile.AutoApprove + return client.InferencePrompt(ctx, &utility.PromptRequest{ + Prompt: prompt, AgentID: profile.AgentID, Model: profile.Model, Mode: profile.Mode, + AutoApprovePermissions: &autoApprove, + InferenceConfig: &utility.InferenceConfigDTO{ + Command: cfg.Command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: execution.WorkspacePath, + Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: flags, CommandPrefix: prefix, + }, + }) +} + // ListInferenceAgents returns agents that support inference with their models. // Only returns agents that are actually installed on the system. func (m *Manager) ListInferenceAgents() []InferenceAgentInfo { diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 3e8928cd13..b06572ae00 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -58,6 +58,7 @@ type Controller struct { watcherDeps WatcherDependencyChecker routingTierDeps RoutingTierDependencyChecker automationDeps AutomationDependencyChecker + utilityDeps UtilityDependencyChecker mcpService *mcpconfig.Service modelCache *modelfetcher.Cache hostUtility hostUtilityProvider @@ -97,6 +98,12 @@ func (c *Controller) SetAutomationDependencyChecker(a AutomationDependencyChecke c.automationDeps = a } +// SetUtilityDependencyChecker wires utility-agent binding lookups used by +// disable and delete confirmation flows. +func (c *Controller) SetUtilityDependencyChecker(u UtilityDependencyChecker) { + c.utilityDeps = u +} + // ErrProfileInUseDetail is returned when a profile cannot be deleted because // active sessions or external integration watchers reference it. The UI uses // the breakdown to render a "this will also disable N watchers — continue?" @@ -106,12 +113,23 @@ type ErrProfileInUseDetail struct { Watchers []WatcherReference RoutingTiers []RoutingTierReference Automations []AutomationReference + UtilityAgents []UtilityAgentReference } func (e *ErrProfileInUseDetail) Error() string { return fmt.Sprintf( - "agent profile is used by %d active session(s), %d watcher(s), %d routing tier(s), and %d automation(s)", - len(e.ActiveSessions), len(e.Watchers), len(e.RoutingTiers), len(e.Automations)) + "agent profile is used by %d active session(s), %d watcher(s), %d routing tier(s), %d automation(s), and %d utility agent(s)", + len(e.ActiveSessions), len(e.Watchers), len(e.RoutingTiers), len(e.Automations), len(e.UtilityAgents)) +} + +type UtilityAgentReference struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type UtilityDependencyChecker interface { + ListUtilityAgentsByAgentProfile(context.Context, string) ([]UtilityAgentReference, error) + ClearUtilityAgentProfileBindings(context.Context, string) error } // WatcherReference points at one issue/PR watcher row that uses the profile diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 1258bfca9f..ad530aa3e7 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -149,6 +149,7 @@ type UpdateProfileRequest struct { // CommandPrefix replaces the value when non-nil. Nil means "leave // unchanged" — the UI always sends the desired value on save. CommandPrefix *string + Force bool } func enabledOnlyUpdate(req UpdateProfileRequest) bool { @@ -190,6 +191,15 @@ func (c *Controller) UpdateProfile(ctx context.Context, req UpdateProfileRequest profile.CLIPassthrough = *req.CLIPassthrough } if req.Enabled != nil { + if !*req.Enabled && !req.Force && c.utilityDeps != nil { + refs, err := c.utilityDeps.ListUtilityAgentsByAgentProfile(ctx, req.ID) + if err != nil { + return nil, fmt.Errorf("check utility agents using this profile: %w", err) + } + if len(refs) > 0 { + return nil, &ErrProfileInUseDetail{UtilityAgents: refs} + } + } profile.Enabled = *req.Enabled } if enabledOnlyUpdate(req) { @@ -318,6 +328,11 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) ( // it. Automations have no such preflight, which is the whole reason they // are handled above instead of here. if force { + if c.utilityDeps != nil { + if err := c.utilityDeps.ClearUtilityAgentProfileBindings(ctx, id); err != nil { + return nil, fmt.Errorf("clear utility agents using this profile: %w", err) + } + } c.disableReferencingWatchers(ctx, id, profile.Name) } result := toProfileDTO(profile) @@ -340,7 +355,18 @@ func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID strin if len(routingTierRefs) > 0 { return &ErrProfileInUseDetail{RoutingTiers: routingTierRefs} } + var utilityRefs []UtilityAgentReference + if c.utilityDeps != nil { + refs, err := c.utilityDeps.ListUtilityAgentsByAgentProfile(ctx, profileID) + if err != nil { + return fmt.Errorf("check utility agents using this profile: %w", err) + } + utilityRefs = refs + } if c.sessionChecker == nil { + if !force && len(utilityRefs) > 0 { + return &ErrProfileInUseDetail{UtilityAgents: utilityRefs} + } return nil } if !force { @@ -375,11 +401,12 @@ func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID strin // profile. Nothing is running, so it never appears in the active-session // list, but its next firing would go looking for a profile that is gone — // and a schedule fails quietly, hours later, with nobody watching. - if len(activeTasks) > 0 || len(watcherRefs) > 0 || len(automationRefs) > 0 { + if len(activeTasks) > 0 || len(watcherRefs) > 0 || len(automationRefs) > 0 || len(utilityRefs) > 0 { return &ErrProfileInUseDetail{ ActiveSessions: activeTasks, Watchers: watcherRefs, Automations: automationRefs, + UtilityAgents: utilityRefs, } } } diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 4a2aed5db6..255b3b6c09 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -19,6 +19,8 @@ import ( "go.uber.org/zap" ) +const queryTrue = "true" + var availableAgentsBroadcastTimeout = 10 * time.Second type Handlers struct { @@ -580,6 +582,7 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { CLIFlags: body.CLIFlags, EnvVars: body.EnvVars, CommandPrefix: body.CommandPrefix, + Force: c.Query("force") == queryTrue, }) if err != nil { if err == controller.ErrAgentProfileNotFound { @@ -590,6 +593,11 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + var inUseErr *controller.ErrProfileInUseDetail + if errors.As(err, &inUseErr) { + c.JSON(http.StatusConflict, gin.H{"error": "agent profile is in use", "utility_agents": inUseErr.UtilityAgents}) + return + } h.logger.Error("failed to update profile", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"}) return @@ -619,6 +627,7 @@ func (h *Handlers) httpDeleteProfile(c *gin.Context) { "watchers": inUseErr.Watchers, "routing_tiers": inUseErr.RoutingTiers, "automations": inUseErr.Automations, + "utility_agents": inUseErr.UtilityAgents, }) return } diff --git a/apps/backend/internal/agentctl/server/utility/acp_executor.go b/apps/backend/internal/agentctl/server/utility/acp_executor.go index 6dd9f77c3d..0bb1d2fa92 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_executor.go +++ b/apps/backend/internal/agentctl/server/utility/acp_executor.go @@ -21,6 +21,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/acpcompat" acpclient "github.com/kandev/kandev/internal/agentctl/server/acp" "github.com/kandev/kandev/internal/agentctl/sessionmodel" + agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/agentctl/types/streams" "go.uber.org/zap" ) @@ -81,7 +82,19 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) // Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that // the executable name is not derived from tainted input. //nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags - cmd := exec.CommandContext(ctx, resolvedCmd, args[1:]...) + cmdArgs := args[1:] + if len(cfg.CommandPrefix) > 0 { + args = append(append([]string{}, cfg.CommandPrefix...), args...) + resolvedCmd = resolveProbeCommand(args[0]) + if resolvedCmd == "" { + return &PromptResponse{Success: false, Error: fmt.Sprintf("command prefix %q is not an allowed ACP command", args[0])}, nil + } + cmdArgs = args[1:] + } + cmdArgs = append(cmdArgs, cfg.CLIFlags...) + // Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that + // the executable name is not derived from tainted input. + cmd := exec.CommandContext(ctx, resolvedCmd, cmdArgs...) cmd.Dir = workDir cmd.Env = sanitizeEnvForAgent(req.InferenceConfig) configureACPCommand(cmd, e.logger) @@ -118,7 +131,7 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest) e.logger.Warn("ACP inference: dropping unsupported MCP server transport", zap.String("name", name)) } - response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.AgentID, req.Prompt, model, modelConfigOptions, req.Mode, mcpServers) + response, err := e.executeACPSession(ctx, stdin, stdout, workDir, req.AgentID, req.Prompt, model, modelConfigOptions, req.Mode, req.AutoApprovePermissions, mcpServers) if err != nil { e.logger.Error("ACP inference failed", zap.String("agent_id", req.AgentID), @@ -154,6 +167,7 @@ func (e *ACPInferenceExecutor) executeACPSession( model string, modelConfigOptions map[string]string, mode string, + autoApprovePermissions *bool, mcpServers []acp.McpServer, ) (string, error) { // Collect response text from updates @@ -173,11 +187,17 @@ func (e *ACPInferenceExecutor) executeACPSession( } // Create ACP client - client := acpclient.NewClient( + clientOptions := []acpclient.ClientOption{ acpclient.WithLogger(e.logger), acpclient.WithWorkspaceRoot(workDir), acpclient.WithUpdateHandler(updateHandler), - ) + } + if autoApprovePermissions != nil && !*autoApprovePermissions { + clientOptions = append(clientOptions, acpclient.WithPermissionHandler(func(context.Context, *agentctltypes.PermissionRequest) (*agentctltypes.PermissionResponse, error) { + return &agentctltypes.PermissionResponse{Cancelled: true}, nil + })) + } + client := acpclient.NewClient(clientOptions...) // Create ACP connection conn := acp.NewClientSideConnection(client, stdin, stdout) diff --git a/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go b/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go index 4f8b3102cb..7e031cc7e1 100644 --- a/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go +++ b/apps/backend/internal/agentctl/server/utility/acp_probe_capabilities_test.go @@ -126,7 +126,7 @@ func TestExecuteACPSession_AdvertisesParameterizedModelPickerToCursor(t *testing defer cancel() if _, err := e.executeACPSession(ctx, c2aW, a2cR, t.TempDir(), - acpcompat.CursorAgentID, "hello", "", nil, "", nil); err != nil { + acpcompat.CursorAgentID, "hello", "", nil, "", nil, nil); err != nil { t.Fatalf("executeACPSession: %v", err) } diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go index 762a3b61e4..4aaa3139eb 100644 --- a/apps/backend/internal/agentctl/server/utility/types.go +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -18,6 +18,10 @@ type PromptRequest struct { // If empty, no session/set_mode call is made and the agent default is used. Mode string `json:"mode,omitempty"` + // Profile-owned launch policy. These values are resolved by the backend + // at call start and are never accepted from an external client. + AutoApprovePermissions *bool `json:"auto_approve_permissions,omitempty"` + // InferenceConfig is the agent's inference configuration. // This is passed from the backend which has access to the agent registry. InferenceConfig *InferenceConfigDTO `json:"inference_config,omitempty"` @@ -196,7 +200,9 @@ type InferenceConfigDTO struct { Env map[string]string `json:"env,omitempty"` // StripEnv lists environment variables to strip from the inference // subprocess environment entirely (not just set to empty). - StripEnv []string `json:"strip_env,omitempty"` + StripEnv []string `json:"strip_env,omitempty"` + CLIFlags []string `json:"cli_flags,omitempty"` + CommandPrefix []string `json:"command_prefix,omitempty"` } // PromptResponse is the response from executing a utility prompt. diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index c0cc607c8a..457d1cd957 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -52,6 +52,7 @@ import ( runtimeskill "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/utility/profilebinding" // WebSocket gateway gateways "github.com/kandev/kandev/internal/gateway/websocket" @@ -60,6 +61,7 @@ import ( notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" usercontroller "github.com/kandev/kandev/internal/user/controller" + userservice "github.com/kandev/kandev/internal/user/service" userstore "github.com/kandev/kandev/internal/user/store" utilitycontroller "github.com/kandev/kandev/internal/utility/controller" @@ -525,6 +527,7 @@ func startAgentInfrastructure( github: services.GitHub, log: log, }) + agentSettingsController.SetUtilityDependencyChecker(&utilityDepsAdapter{svc: services.Utility, userSvc: services.User}) agentSettingsController.SetRoutingTierDependencyChecker(&routingTierDepsAdapter{ repo: repos.Office, }) @@ -721,17 +724,44 @@ func startGatewayAndServe( agentctlclient.WithControlAuthToken(cfg.Agent.StandaloneAuthToken)) hostUtilityMgr := hostutility.NewManager(agentRegistry, cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, hostControlClient, log) hostUtilityMgr.SetAuthToken(cfg.Agent.StandaloneAuthToken) + hostUtilityMgr.SetProfileResolver(profilebinding.New(repos.AgentSettings, func(agentID string) bool { + _, ok := agentRegistry.GetInferenceAgent(agentID) + return ok + })) // Wire the host utility manager into the settings controller so // /api/v1/agent-models/:agentName reads live capability data. agentSettingsController.SetHostUtility(hostUtilityMgr) profileReconciler := agentsettingscontroller.NewProfileReconciler(hostUtilityMgr, agentRegistry, repos.AgentSettings, log) + go func() { + if err := hostUtilityMgr.Start(ctx); err != nil { + log.Warn("host utility manager bootstrap error", zap.Error(err)) + } + // Reconcile profiles against fresh probe results — seeds defaults for + // newly probed agents, heals stale profile models/modes, cleans up + // orphans referencing removed agents. + if err := profileReconciler.Run(ctx); err != nil { + log.Warn("profile reconciler error", zap.Error(err)) + } + if migrated, err := services.Utility.MigrateLegacyBindings(ctx); err != nil { + log.Warn("utility profile migration failed", zap.Error(err)) + } else if migrated > 0 { + log.Info("migrated utility profile bindings", zap.Int("updated", migrated)) + } + migrateDefaultUtilityProfile(ctx, services.User, repos.AgentSettings, agentRegistry, log) + }() + addCleanup(func() error { + stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + hostUtilityMgr.Stop(stopCtx) + return nil + }) // Wire Host.InvokeUtilityAgent (ADR 0048): plugins delegate one-shot LLM // calls to the utility agent selected in each plugin's configuration and // runs them through the sessionless host-utility tier, at the first point // where hostUtilityMgr is live. if services.Plugins != nil && services.Utility != nil { - services.Plugins.SetUtilityAgent(pluginsUtilityAgentAdapter{svc: services.Utility}, pluginsHostUtilityAdapter{mgr: hostUtilityMgr}) + services.Plugins.SetUtilityAgent(pluginsUtilityAgentAdapter{svc: services.Utility, userSvc: services.User}, pluginsHostUtilityAdapter{mgr: hostUtilityMgr}) } if err := orchestratorSvc.Start(ctx); err != nil { @@ -1910,3 +1940,31 @@ func awaitShutdown( zap.Int("pid", os.Getpid())) runGracefulShutdown(server, listeners, scheduling, orchestratorSvc, lifecycleMgr, runCleanups, log) } + +// migrateDefaultUtilityProfile upgrades the portable user's legacy default +// pair only when it identifies one eligible profile. Ambiguous and missing +// values remain untouched for a later retry or user repair. +func migrateDefaultUtilityProfile( + ctx context.Context, + userSvc *userservice.Service, + profiles settingsstore.Repository, + agentRegistry *registry.Registry, + log *logger.Logger, +) { + settings, err := userSvc.GetUserSettings(ctx) + if err != nil || settings.DefaultUtilityAgentProfileID != "" || (settings.DefaultUtilityAgentID == "" && settings.DefaultUtilityModel == "") { + return + } + resolver := profilebinding.New(profiles, func(agentID string) bool { + _, ok := agentRegistry.GetInferenceAgent(agentID) + return ok + }) + profile, err := resolver.MatchLegacy(ctx, settings.DefaultUtilityAgentID, settings.DefaultUtilityModel) + if err != nil || profile == nil { + return + } + profileID := profile.ID + if _, err := userSvc.UpdateUserSettings(ctx, &userservice.UpdateUserSettingsRequest{DefaultUtilityAgentProfileID: &profileID}); err != nil { + log.Warn("failed to persist migrated default utility profile", zap.Error(err)) + } +} diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index a8f756c9f5..ccce975fa7 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -38,6 +38,8 @@ import ( sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + utilityservice "github.com/kandev/kandev/internal/utility/service" wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" ) @@ -577,6 +579,55 @@ type automationDepsAdapter struct { store *automationpkg.Store } +type utilityDepsAdapter struct { + svc *utilityservice.Service + userSvc *userservice.Service +} + +func (a *utilityDepsAdapter) ListUtilityAgentsByAgentProfile(ctx context.Context, profileID string) ([]agentsettingscontroller.UtilityAgentReference, error) { + if a == nil || a.svc == nil { + return nil, nil + } + agents, err := a.svc.ListAgents(ctx) + if err != nil { + return nil, err + } + refs := make([]agentsettingscontroller.UtilityAgentReference, 0) + defaultProfileID := "" + if a.userSvc != nil { + defaultProfileID, err = a.userSvc.GetDefaultUtilityAgentProfileID(ctx) + if err != nil { + return nil, err + } + } + for _, agent := range agents { + if agent != nil && (agent.AgentProfileID == profileID || + (agent.ProfileBindingState == utilitymodels.ProfileBindingInherit && defaultProfileID == profileID)) { + refs = append(refs, agentsettingscontroller.UtilityAgentReference{ID: agent.ID, Name: agent.Name}) + } + } + return refs, nil +} + +func (a *utilityDepsAdapter) ClearUtilityAgentProfileBindings(ctx context.Context, profileID string) error { + if a == nil || a.svc == nil { + return nil + } + if err := a.svc.ClearAgentProfileBindings(ctx, profileID); err != nil { + return err + } + if a.userSvc != nil { + defaultProfileID, err := a.userSvc.GetDefaultUtilityAgentProfileID(ctx) + if err != nil { + return err + } + if defaultProfileID == profileID { + return a.svc.ClearInheritedProfileBindings(ctx) + } + } + return nil +} + func (a *automationDepsAdapter) ListEnabledAutomationsByAgentProfile( ctx context.Context, profileID string, ) ([]agentsettingscontroller.AutomationReference, error) { diff --git a/apps/backend/internal/backendapp/review_wiring.go b/apps/backend/internal/backendapp/review_wiring.go index 269a516717..ae9f7863ef 100644 --- a/apps/backend/internal/backendapp/review_wiring.go +++ b/apps/backend/internal/backendapp/review_wiring.go @@ -146,6 +146,17 @@ func (l reviewUtilityLookup) CodeReviewAgent(ctx context.Context) (string, strin return agent.AgentID, agent.Model, agent.Enabled, true, nil } +func (l reviewUtilityLookup) CodeReviewAgentProfile(ctx context.Context) (string, bool, bool, error) { + if l.agents == nil { + return "", false, false, nil + } + agent, err := l.agents.GetAgentByID(ctx, utilitystore.CodeReviewAgentID) + if err != nil || agent == nil { + return "", false, false, err + } + return agent.AgentProfileID, agent.Enabled, true, nil +} + // reviewDefaultsLookup resolves the user's default utility agent/model pair. type reviewDefaultsLookup struct { settings defaultUtilitySettingsGetter @@ -162,6 +173,19 @@ func (l reviewDefaultsLookup) DefaultUtilitySettings(ctx context.Context) (strin return l.settings.GetDefaultUtilitySettings(ctx) } +func (l reviewDefaultsLookup) DefaultUtilityProfileID(ctx context.Context) (string, error) { + if l.settings == nil { + return "", nil + } + provider, ok := l.settings.(interface { + GetDefaultUtilityAgentProfileID(context.Context) (string, error) + }) + if !ok { + return "", nil + } + return provider.GetDefaultUtilityAgentProfileID(ctx) +} + // reviewTemplateSource serves the code-review utility agent's stored prompt, so // a user who edits that prompt changes how reviews are performed. type reviewTemplateSource struct { @@ -206,9 +230,23 @@ type sessionInferenceRunner interface { ExecuteInferencePrompt(ctx context.Context, sessionID, agentID, model, prompt string) (*agentctlutil.PromptResponse, error) } +type sessionProfileInferenceRunner interface { + ExecuteInferenceProfilePrompt(ctx context.Context, sessionID, profileID, prompt string) (*agentctlutil.PromptResponse, error) +} + func (r reviewInference) Run(ctx context.Context, identity review.ReviewerIdentity, sessionID, prompt string) (*review.PromptResult, error) { if r.session != nil && sessionID != "" { - resp, err := r.session.ExecuteInferencePrompt(ctx, sessionID, identity.AgentID, identity.Model, prompt) + var resp *agentctlutil.PromptResponse + var err error + if identity.ProfileID != "" { + if profileRunner, ok := r.session.(sessionProfileInferenceRunner); ok { + resp, err = profileRunner.ExecuteInferenceProfilePrompt(ctx, sessionID, identity.ProfileID, prompt) + } else { + err = fmt.Errorf("profile-aware review executor is unavailable") + } + } else { + resp, err = r.session.ExecuteInferencePrompt(ctx, sessionID, identity.AgentID, identity.Model, prompt) + } if err == nil && resp != nil && resp.Success { return &review.PromptResult{ Response: resp.Response, @@ -228,7 +266,13 @@ func (r reviewInference) Run(ctx context.Context, identity review.ReviewerIdenti if r.host == nil { return nil, fmt.Errorf("no inference executor is configured") } - result, err := r.host.ExecutePrompt(ctx, identity.AgentID, identity.Model, identity.Mode, prompt) + var result *hostutility.PromptResult + var err error + if identity.ProfileID != "" { + result, err = r.host.ExecuteProfilePrompt(ctx, identity.ProfileID, prompt) + } else { + result, err = r.host.ExecutePrompt(ctx, identity.AgentID, identity.Model, identity.Mode, prompt) + } if err != nil { return nil, err } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 65a1d84000..617a2229e5 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -36,6 +36,8 @@ import ( taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/task/share" userservice "github.com/kandev/kandev/internal/user/service" + utilitymodels "github.com/kandev/kandev/internal/utility/models" + "github.com/kandev/kandev/internal/utility/profilebinding" utilityservice "github.com/kandev/kandev/internal/utility/service" wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" @@ -61,6 +63,10 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc) promptSvc := promptservice.NewService(repos.Prompts) utilitySvc := utilityservice.NewService(repos.Utility) + utilitySvc.SetProfileResolver(profilebinding.New(repos.AgentSettings, func(agentID string) bool { + _, ok := agentRegistry.GetInferenceAgent(agentID) + return ok + })) workflowSvc := workflowservice.NewService(repos.Workflow, log) taskSvc := taskservice.NewService( taskservice.Repos{ @@ -711,8 +717,8 @@ type pluginsHostUtilityAdapter struct { mgr *hostutility.Manager } -func (a pluginsHostUtilityAdapter) ExecutePrompt(ctx context.Context, agentType, model, mode, prompt string) (string, error) { - res, err := a.mgr.ExecutePrompt(ctx, agentType, model, mode, prompt) +func (a pluginsHostUtilityAdapter) ExecuteProfilePrompt(ctx context.Context, profileID, prompt string) (string, error) { + res, err := a.mgr.ExecuteProfilePrompt(ctx, profileID, prompt) if err != nil { return "", err } @@ -720,7 +726,8 @@ func (a pluginsHostUtilityAdapter) ExecutePrompt(ctx context.Context, agentType, } type pluginsUtilityAgentAdapter struct { - svc *utilityservice.Service + svc *utilityservice.Service + userSvc *userservice.Service } func (a pluginsUtilityAgentAdapter) GetAgentByID(ctx context.Context, id string) (*plugins.UtilityAgent, error) { @@ -731,7 +738,14 @@ func (a pluginsUtilityAgentAdapter) GetAgentByID(ctx context.Context, id string) } return nil, err } - return &plugins.UtilityAgent{Name: agent.Name, AgentID: agent.AgentID, Model: agent.Model, Enabled: agent.Enabled}, nil + profileID := agent.AgentProfileID + if profileID == "" && agent.ProfileBindingState == utilitymodels.ProfileBindingInherit && a.userSvc != nil { + profileID, err = a.userSvc.GetDefaultUtilityAgentProfileID(ctx) + if err != nil { + return nil, err + } + } + return &plugins.UtilityAgent{Name: agent.Name, AgentID: agent.AgentID, Model: agent.Model, AgentProfileID: profileID, ProfileBindingState: agent.ProfileBindingState, Enabled: agent.Enabled}, nil } // pluginsTaskWriterAdapter adapts the task service to the plugins package's diff --git a/apps/backend/internal/plugins/host_data_wire_test.go b/apps/backend/internal/plugins/host_data_wire_test.go index 60c1c84989..1373953e24 100644 --- a/apps/backend/internal/plugins/host_data_wire_test.go +++ b/apps/backend/internal/plugins/host_data_wire_test.go @@ -335,15 +335,14 @@ func TestPluginHostData_Wire_InvokeUtilityAgent(t *testing.T) { t.Run("Succeeds", func(t *testing.T) { d := newTestDataHost(manifest.Capabilities{AgentInvoke: true}) - d.utilAgents.agent = &UtilityAgent{Name: "summarizer", AgentID: "claude-acp", Model: "claude-opus-4-8", Enabled: true} + d.utilAgents.agent = &UtilityAgent{Name: "summarizer", AgentID: "claude-acp", Model: "claude-opus-4-8", AgentProfileID: "profile-42", ProfileBindingState: "explicit", Enabled: true} d.utilRun.text = "summary text" host := dialPluginHostOverWire(t, d.host) got, err := host.InvokeUtilityAgent(context.Background(), "summarize") require.NoError(t, err) require.Equal(t, "summary text", got) - require.Equal(t, "claude-acp", d.utilRun.gotAgentType) - require.Equal(t, "claude-opus-4-8", d.utilRun.gotModel) + require.Equal(t, "profile-42", d.utilRun.gotProfileID) }) } diff --git a/apps/backend/internal/plugins/host_utility.go b/apps/backend/internal/plugins/host_utility.go index e8857134e9..5a2ad43f5d 100644 --- a/apps/backend/internal/plugins/host_utility.go +++ b/apps/backend/internal/plugins/host_utility.go @@ -26,10 +26,12 @@ var ErrUtilityAgentNotFound = errors.New("utility agent not found") // UtilityAgent is the execution-relevant portion of a configured utility // agent. backendapp adapts internal/utility/service.Service to this shape. type UtilityAgent struct { - Name string - AgentID string - Model string - Enabled bool + Name string + AgentID string + Model string + AgentProfileID string + ProfileBindingState string + Enabled bool } type utilityAgentSource interface { @@ -39,7 +41,7 @@ type utilityAgentSource interface { // utilityRunner runs a one-shot completion for an agent type + model and // returns the response text. type utilityRunner interface { - ExecutePrompt(ctx context.Context, agentType, model, mode, prompt string) (string, error) + ExecuteProfilePrompt(ctx context.Context, profileID, prompt string) (string, error) } // InvokeUtilityAgent runs the named utility agent selected in this plugin's @@ -78,7 +80,10 @@ func (h *pluginHost) InvokeUtilityAgent(ctx context.Context, prompt string) (str if !agent.Enabled { return "", status.Errorf(codes.FailedPrecondition, "configured utility agent %q is disabled", agentID) } - return runner.ExecutePrompt(ctx, agent.AgentID, agent.Model, "", prompt) + if agent.ProfileBindingState == "unconfigured" || agent.AgentProfileID == "" { + return "", status.Errorf(codes.FailedPrecondition, "configured utility agent %q has no usable agent profile", agentID) + } + return runner.ExecuteProfilePrompt(ctx, agent.AgentProfileID, prompt) } func errNoUtilityAgent() error { diff --git a/apps/backend/internal/plugins/host_utility_test.go b/apps/backend/internal/plugins/host_utility_test.go index f613b747dc..fbbe3751a5 100644 --- a/apps/backend/internal/plugins/host_utility_test.go +++ b/apps/backend/internal/plugins/host_utility_test.go @@ -32,17 +32,15 @@ func (f *fakeConfigReader) GetConfig(string) (map[string]any, error) { return f. type fakeUtilityRunner struct { calls int - gotAgentType string - gotModel string - gotMode string + gotProfileID string gotPrompt string text string err error } -func (f *fakeUtilityRunner) ExecutePrompt(_ context.Context, agentType, model, mode, prompt string) (string, error) { +func (f *fakeUtilityRunner) ExecuteProfilePrompt(_ context.Context, profileID, prompt string) (string, error) { f.calls++ - f.gotAgentType, f.gotModel, f.gotMode, f.gotPrompt = agentType, model, mode, prompt + f.gotProfileID, f.gotPrompt = profileID, prompt return f.text, f.err } @@ -50,7 +48,7 @@ func configuredUtilityHost(t *testing.T) *testDataHost { t.Helper() d := newTestDataHost(manifest.Capabilities{AgentInvoke: true}) d.host.configs = &fakeConfigReader{configs: map[string]any{utilityAgentConfigKey: "utility-agent-42"}} - d.utilAgents.agent = &UtilityAgent{Name: "summarizer", AgentID: "claude-acp", Model: "claude-opus-4-8", Enabled: true} + d.utilAgents.agent = &UtilityAgent{Name: "summarizer", AgentID: "claude-acp", Model: "claude-opus-4-8", AgentProfileID: "profile-42", ProfileBindingState: "explicit", Enabled: true} d.utilRun.text = "the summary" return d } @@ -70,8 +68,8 @@ func TestPluginHost_InvokeUtilityAgent_UsesPluginConfiguredUtilityAgent(t *testi if err != nil || got != "the summary" { t.Fatalf("InvokeUtilityAgent() = (%q, %v)", got, err) } - if d.utilRun.gotAgentType != "claude-acp" || d.utilRun.gotModel != "claude-opus-4-8" { - t.Fatalf("runner got (%q, %q)", d.utilRun.gotAgentType, d.utilRun.gotModel) + if d.utilRun.gotProfileID != "profile-42" { + t.Fatalf("runner got profile %q", d.utilRun.gotProfileID) } if d.utilAgents.gotSelector != "utility-agent-42" { t.Fatalf("looked up utility agent %q", d.utilAgents.gotSelector) diff --git a/apps/backend/internal/review/resolver.go b/apps/backend/internal/review/resolver.go index 693f8f9ec4..28554f173d 100644 --- a/apps/backend/internal/review/resolver.go +++ b/apps/backend/internal/review/resolver.go @@ -11,9 +11,10 @@ import ( // It is deliberately separate from the task's own agent profile: the point of the // feature is that a different model can review than implemented. type ReviewerIdentity struct { - AgentID string - Model string - Mode string + ProfileID string + AgentID string + Model string + Mode string // Source names where the identity came from, for the run record and logs: // "agent_profile", "utility_agent", or "user_default". Source string @@ -49,12 +50,20 @@ type UtilityAgentLookup interface { CodeReviewAgent(ctx context.Context) (agentID, model string, enabled, found bool, err error) } +type UtilityProfileLookup interface { + CodeReviewAgentProfile(ctx context.Context) (profileID string, enabled, found bool, err error) +} + // DefaultsLookup resolves the user's default utility agent/model pair, used when // the code-review utility agent leaves the model blank. type DefaultsLookup interface { DefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) } +type DefaultProfileLookup interface { + DefaultUtilityProfileID(ctx context.Context) (string, error) +} + // Resolver picks the reviewer identity for a run. type Resolver struct { Profiles ProfileLookup @@ -135,10 +144,11 @@ func (r *Resolver) resolveFromProfile(ctx context.Context, profileID string) (Re ErrAgentUnavailable, describeProfile(profile, profileID)) } return ReviewerIdentity{ - AgentID: profile.AgentID, - Model: model, - Mode: profile.Mode, - Source: SourceAgentProfile, + ProfileID: profileID, + AgentID: profile.AgentID, + Model: model, + Mode: profile.Mode, + Source: SourceAgentProfile, }, nil } @@ -163,6 +173,16 @@ func (r *Resolver) sameAgentDefaultModel(ctx context.Context, agentID string) st } func (r *Resolver) resolveFromUtilityAgent(ctx context.Context) (ReviewerIdentity, error) { + if profileLookup, ok := r.Utility.(UtilityProfileLookup); ok { + profileID, enabled, found, err := profileLookup.CodeReviewAgentProfile(ctx) + if err != nil { + return ReviewerIdentity{}, fmt.Errorf("%w: load code-review utility profile: %v", ErrAgentUnavailable, err) + } + if !found || !enabled || profileID == "" { + return ReviewerIdentity{}, fmt.Errorf("%w: code-review utility profile is not configured", errDeferToDefaults) + } + return ReviewerIdentity{ProfileID: profileID, Source: SourceUtilityAgent}, nil + } if r.Utility == nil { return ReviewerIdentity{}, fmt.Errorf("%w: %w: no code-review utility agent is wired", ErrAgentUnavailable, errDeferToDefaults) @@ -205,6 +225,16 @@ func (r *Resolver) resolveFromUtilityAgent(ctx context.Context) (ReviewerIdentit } func (r *Resolver) resolveFromDefaults(ctx context.Context) (ReviewerIdentity, error) { + if profileLookup, ok := r.Defaults.(DefaultProfileLookup); ok { + profileID, err := profileLookup.DefaultUtilityProfileID(ctx) + if err != nil { + return ReviewerIdentity{}, fmt.Errorf("%w: load default utility profile: %v", ErrAgentUnavailable, err) + } + if profileID == "" { + return ReviewerIdentity{}, fmt.Errorf("%w: no default utility profile configured", ErrAgentUnavailable) + } + return ReviewerIdentity{ProfileID: profileID, Source: SourceUserDefault}, nil + } if r.Defaults == nil { return ReviewerIdentity{}, fmt.Errorf("%w: no default utility agent is configured", ErrAgentUnavailable) } diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 647d923ff3..5c315dd0fc 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -87,6 +87,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS AzureDevOpsBrowsePreferences: req.AzureDevOpsBrowsePreferences.ServiceValue(), DefaultUtilityAgentID: req.DefaultUtilityAgentID, DefaultUtilityModel: req.DefaultUtilityModel, + DefaultUtilityAgentProfileID: req.DefaultUtilityAgentProfileID, KeyboardShortcuts: req.KeyboardShortcuts, TerminalLinkBehavior: req.TerminalLinkBehavior, TerminalFontFamily: req.TerminalFontFamily, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index f2a9cab949..3cf67f1910 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -59,6 +59,7 @@ type UserSettingsDTO struct { AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences,omitempty"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` + DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"` TerminalLinkBehavior string `json:"terminal_link_behavior"` TerminalFontFamily string `json:"terminal_font_family"` @@ -129,6 +130,7 @@ type UpdateUserSettingsRequest struct { AzureDevOpsBrowsePreferences NullableRawMessage `json:"azure_devops_browse_preferences,omitempty"` DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` DefaultUtilityModel *string `json:"default_utility_model,omitempty"` + DefaultUtilityAgentProfileID *string `json:"default_utility_agent_profile_id,omitempty"` KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"` TerminalLinkBehavior *string `json:"terminal_link_behavior,omitempty"` TerminalFontFamily *string `json:"terminal_font_family,omitempty"` @@ -260,6 +262,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { AzureDevOpsBrowsePreferences: settings.AzureDevOpsBrowsePreferences, DefaultUtilityAgentID: settings.DefaultUtilityAgentID, DefaultUtilityModel: settings.DefaultUtilityModel, + DefaultUtilityAgentProfileID: settings.DefaultUtilityAgentProfileID, KeyboardShortcuts: settings.KeyboardShortcuts, TerminalLinkBehavior: settings.TerminalLinkBehavior, TerminalFontFamily: settings.TerminalFontFamily, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 1597306dd0..6353314025 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -108,8 +108,9 @@ type UserSettings struct { AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents - KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides - TerminalLinkBehavior string `json:"terminal_link_behavior"` // "new_tab" | "browser_panel" + DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` + KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides + TerminalLinkBehavior string `json:"terminal_link_behavior"` // "new_tab" | "browser_panel" TerminalFontFamily string `json:"terminal_font_family"` TerminalFontSize int `json:"terminal_font_size"` ChangesPanelLayout string `json:"changes_panel_layout"` // "flat" | "tree" diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index f7599dee3b..d39884023c 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -78,6 +78,7 @@ type UpdateUserSettingsRequest struct { AzureDevOpsBrowsePreferences **json.RawMessage DefaultUtilityAgentID *string DefaultUtilityModel *string + DefaultUtilityAgentProfileID *string KeyboardShortcuts *map[string]interface{} TerminalLinkBehavior *string TerminalFontFamily *string @@ -135,6 +136,15 @@ func (s *Service) GetDefaultUtilitySettings(ctx context.Context) (agentID, model return settings.DefaultUtilityAgentID, settings.DefaultUtilityModel, nil } +// GetDefaultUtilityAgentProfileID returns the profile used by new built-in utility actions. +func (s *Service) GetDefaultUtilityAgentProfileID(ctx context.Context) (string, error) { + settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + if err != nil { + return "", err + } + return settings.DefaultUtilityAgentProfileID, nil +} + func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) { settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) if err != nil { @@ -327,6 +337,9 @@ func applyUtilityPreferences(settings *models.UserSettings, req *UpdateUserSetti if req.DefaultUtilityModel != nil { settings.DefaultUtilityModel = strings.TrimSpace(*req.DefaultUtilityModel) } + if req.DefaultUtilityAgentProfileID != nil { + settings.DefaultUtilityAgentProfileID = strings.TrimSpace(*req.DefaultUtilityAgentProfileID) + } } func applyKeyboardShortcuts(settings *models.UserSettings, value *map[string]interface{}) error { @@ -737,6 +750,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, + "default_utility_agent_profile_id": settings.DefaultUtilityAgentProfileID, "keyboard_shortcuts": settings.KeyboardShortcuts, "terminal_link_behavior": settings.TerminalLinkBehavior, "terminal_font_family": settings.TerminalFontFamily, diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 7d5747190d..5985cc67ab 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -560,6 +560,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "azure_devops_browse_preferences": settings.AzureDevOpsBrowsePreferences, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, + "default_utility_agent_profile_id": settings.DefaultUtilityAgentProfileID, "keyboard_shortcuts": keyboardShortcuts, "terminal_link_behavior": settings.TerminalLinkBehavior, "terminal_font_family": settings.TerminalFontFamily, @@ -745,6 +746,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin AzureDevOpsBrowsePreferences json.RawMessage `json:"azure_devops_browse_preferences"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` + DefaultUtilityAgentProfileID string `json:"default_utility_agent_profile_id"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` TerminalLinkBehavior string `json:"terminal_link_behavior"` TerminalFontFamily string `json:"terminal_font_family"` @@ -854,6 +856,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.AzureDevOpsBrowsePreferences = payload.AzureDevOpsBrowsePreferences settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID settings.DefaultUtilityModel = payload.DefaultUtilityModel + settings.DefaultUtilityAgentProfileID = payload.DefaultUtilityAgentProfileID settings.KeyboardShortcuts = payload.KeyboardShortcuts if settings.KeyboardShortcuts == nil { settings.KeyboardShortcuts = map[string]interface{}{} diff --git a/apps/backend/internal/utility/controller/controller.go b/apps/backend/internal/utility/controller/controller.go index 20113fc59b..ff5e0fcd69 100644 --- a/apps/backend/internal/utility/controller/controller.go +++ b/apps/backend/internal/utility/controller/controller.go @@ -52,6 +52,8 @@ func (c *Controller) CreateAgent(ctx context.Context, req dto.CreateUtilityAgent req.Prompt, req.AgentID, req.Model, + req.AgentProfileID, + req.ProfileBindingState, ) if err != nil { return nil, err @@ -70,6 +72,8 @@ func (c *Controller) UpdateAgent(ctx context.Context, id string, req dto.UpdateU req.Prompt, req.AgentID, req.Model, + req.AgentProfileID, + req.ProfileBindingState, req.Enabled, ) if err != nil { @@ -112,8 +116,8 @@ func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePr } // CreateCall creates a call record for tracking. -func (c *Controller) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model string) (string, error) { - call, err := c.svc.CreateCall(ctx, utilityID, sessionID, resolvedPrompt, model) +func (c *Controller) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model, profileID string) (string, error) { + call, err := c.svc.CreateCall(ctx, utilityID, sessionID, resolvedPrompt, model, profileID) if err != nil { return "", err } diff --git a/apps/backend/internal/utility/dto/dto.go b/apps/backend/internal/utility/dto/dto.go index 36650b8253..28d9129515 100644 --- a/apps/backend/internal/utility/dto/dto.go +++ b/apps/backend/internal/utility/dto/dto.go @@ -9,16 +9,18 @@ import ( // UtilityAgentDTO represents a utility agent for API responses. type UtilityAgentDTO struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Prompt string `json:"prompt"` - AgentID string `json:"agent_id"` - Model string `json:"model"` - Builtin bool `json:"builtin"` - Enabled bool `json:"enabled"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Prompt string `json:"prompt"` + AgentID string `json:"agent_id"` + Model string `json:"model"` + AgentProfileID string `json:"agent_profile_id"` + ProfileBindingState string `json:"profile_binding_state"` + Builtin bool `json:"builtin"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // UtilityAgentsResponse is the response for listing utility agents. @@ -28,21 +30,25 @@ type UtilityAgentsResponse struct { // CreateUtilityAgentRequest is the request for creating a utility agent. type CreateUtilityAgentRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Prompt string `json:"prompt" binding:"required"` - AgentID string `json:"agent_id" binding:"required"` - Model string `json:"model" binding:"required"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Prompt string `json:"prompt" binding:"required"` + AgentID string `json:"agent_id,omitempty"` // legacy migration input + Model string `json:"model,omitempty"` // legacy migration input + AgentProfileID string `json:"agent_profile_id"` + ProfileBindingState string `json:"profile_binding_state,omitempty"` } // UpdateUtilityAgentRequest is the request for updating a utility agent. type UpdateUtilityAgentRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - Prompt *string `json:"prompt,omitempty"` - AgentID *string `json:"agent_id,omitempty"` - Model *string `json:"model,omitempty"` - Enabled *bool `json:"enabled,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` + AgentID *string `json:"agent_id,omitempty"` // legacy migration input + Model *string `json:"model,omitempty"` // legacy migration input + AgentProfileID *string `json:"agent_profile_id,omitempty"` + ProfileBindingState *string `json:"profile_binding_state,omitempty"` + Enabled *bool `json:"enabled,omitempty"` } // UtilityAgentCallDTO represents a call for API responses. @@ -53,6 +59,7 @@ type UtilityAgentCallDTO struct { ResolvedPrompt string `json:"resolved_prompt"` Response string `json:"response"` Model string `json:"model"` + AgentProfileID string `json:"agent_profile_id"` PromptTokens int `json:"prompt_tokens"` ResponseTokens int `json:"response_tokens"` DurationMs int `json:"duration_ms"` @@ -75,16 +82,18 @@ type TemplateVariablesResponse struct { // FromUtilityAgent converts a model to a DTO. func FromUtilityAgent(agent *models.UtilityAgent) UtilityAgentDTO { return UtilityAgentDTO{ - ID: agent.ID, - Name: agent.Name, - Description: agent.Description, - Prompt: agent.Prompt, - AgentID: agent.AgentID, - Model: agent.Model, - Builtin: agent.Builtin, - Enabled: agent.Enabled, - CreatedAt: agent.CreatedAt.UTC().Format(time.RFC3339), - UpdatedAt: agent.UpdatedAt.UTC().Format(time.RFC3339), + ID: agent.ID, + Name: agent.Name, + Description: agent.Description, + Prompt: agent.Prompt, + AgentID: agent.AgentID, + Model: agent.Model, + AgentProfileID: agent.AgentProfileID, + ProfileBindingState: agent.ProfileBindingState, + Builtin: agent.Builtin, + Enabled: agent.Enabled, + CreatedAt: agent.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: agent.UpdatedAt.UTC().Format(time.RFC3339), } } @@ -97,6 +106,7 @@ func FromUtilityAgentCall(call *models.UtilityAgentCall) UtilityAgentCallDTO { ResolvedPrompt: call.ResolvedPrompt, Response: call.Response, Model: call.Model, + AgentProfileID: call.AgentProfileID, PromptTokens: call.PromptTokens, ResponseTokens: call.ResponseTokens, DurationMs: call.DurationMs, diff --git a/apps/backend/internal/utility/handlers/handlers.go b/apps/backend/internal/utility/handlers/handlers.go index 1ead7fb158..4481a37703 100644 --- a/apps/backend/internal/utility/handlers/handlers.go +++ b/apps/backend/internal/utility/handlers/handlers.go @@ -16,6 +16,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/utility/controller" "github.com/kandev/kandev/internal/utility/dto" + "github.com/kandev/kandev/internal/utility/profilebinding" "github.com/kandev/kandev/internal/utility/service" ) @@ -27,6 +28,10 @@ type InferenceExecutor interface { ListInferenceAgentsWithContext(ctx context.Context) []lifecycle.InferenceAgentInfo } +type profileInferenceExecutor interface { + ExecuteInferenceProfilePrompt(ctx context.Context, sessionID, profileID, prompt string) (*agentctlutil.PromptResponse, error) +} + // HostUtilityExecutor runs sessionless utility prompts via the long-lived // per-agent-type host agentctl instances and exposes the cached per-agent // capabilities (models, modes) populated by the boot-time ACP probe. @@ -36,12 +41,20 @@ type HostUtilityExecutor interface { Refresh(ctx context.Context, agentType string) (hostutility.AgentCapabilities, error) } +type profileHostUtilityExecutor interface { + ExecuteProfilePrompt(ctx context.Context, profileID, prompt string) (*hostutility.PromptResult, error) +} + // UserSettingsProvider provides user settings for default utility agent/model. type UserSettingsProvider interface { // GetDefaultUtilitySettings returns the user's default utility agent/model settings. GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) } +type userUtilityProfileSettingsProvider interface { + GetDefaultUtilityAgentProfileID(ctx context.Context) (string, error) +} + // Handlers provides HTTP handlers for utility agents. type Handlers struct { controller *controller.Controller @@ -94,6 +107,8 @@ func (h *Handlers) httpGetAgent(c *gin.Context) { status := http.StatusInternalServerError if errors.Is(err, service.ErrAgentNotFound) { status = http.StatusNotFound + } else if errors.Is(err, service.ErrProfileRequired) || errors.Is(err, service.ErrProfileUnconfigured) || errors.Is(err, profilebinding.ErrProfileNotFound) || errors.Is(err, profilebinding.ErrProfileIneligible) { + status = http.StatusPreconditionFailed } h.logger.Error("failed to get utility agent", zap.Error(err)) c.JSON(status, gin.H{"error": "failed to get utility agent"}) @@ -182,6 +197,14 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { if err == nil && (agentID != "" || model != "") { defaults = &service.DefaultUtilitySettings{AgentID: agentID, Model: model} } + if profileProvider, ok := h.userSettings.(userUtilityProfileSettingsProvider); ok { + if profileID, profileErr := profileProvider.GetDefaultUtilityAgentProfileID(ctx); profileErr == nil { + if defaults == nil { + defaults = &service.DefaultUtilitySettings{} + } + defaults.ProfileID = profileID + } + } } sessionless := req.SessionID == "" @@ -209,7 +232,7 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { } // Create call record for tracking - callID, err := h.controller.CreateCall(ctx, req.UtilityAgentID, req.SessionID, prepared.ResolvedPrompt, prepared.Model) + callID, err := h.controller.CreateCall(ctx, req.UtilityAgentID, req.SessionID, prepared.ResolvedPrompt, prepared.Model, prepared.AgentProfileID) if err != nil { h.logger.Error("failed to create call record", zap.Error(err)) c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{Error: "failed to create call record"}) @@ -222,7 +245,18 @@ func (h *Handlers) httpExecutePrompt(c *gin.Context) { } // Execute via agentctl using an existing task session's agentctl - resp, err := h.executor.ExecuteInferencePrompt(ctx, req.SessionID, prepared.AgentCLI, prepared.Model, prepared.ResolvedPrompt) + var resp *agentctlutil.PromptResponse + if prepared.AgentProfileID != "" { + profileExecutor, ok := h.executor.(profileInferenceExecutor) + if !ok { + _ = h.controller.FailCall(ctx, callID, "profile-aware inference executor is unavailable", 0) + c.JSON(http.StatusServiceUnavailable, dto.ExecutePromptResponse{CallID: callID, Error: "profile-aware inference executor is unavailable"}) + return + } + resp, err = profileExecutor.ExecuteInferenceProfilePrompt(ctx, req.SessionID, prepared.AgentProfileID, prepared.ResolvedPrompt) + } else { + resp, err = h.executor.ExecuteInferencePrompt(ctx, req.SessionID, prepared.AgentCLI, prepared.Model, prepared.ResolvedPrompt) + } if err != nil { h.logger.Error("failed to execute prompt", zap.Error(err), zap.String("call_id", callID)) _ = h.controller.FailCall(ctx, callID, err.Error(), 0) @@ -270,7 +304,19 @@ func (h *Handlers) executeSessionless(c *gin.Context, ctx context.Context, prepa }) return } - result, err := h.hostExecutor.ExecutePrompt(ctx, prepared.AgentCLI, prepared.Model, "", prepared.ResolvedPrompt) + var result *hostutility.PromptResult + var err error + if prepared.AgentProfileID != "" { + profileExecutor, ok := h.hostExecutor.(profileHostUtilityExecutor) + if !ok { + _ = h.controller.FailCall(ctx, callID, "profile-aware host utility executor is unavailable", 0) + c.JSON(http.StatusServiceUnavailable, dto.ExecutePromptResponse{CallID: callID, Error: "profile-aware host utility executor is unavailable"}) + return + } + result, err = profileExecutor.ExecuteProfilePrompt(ctx, prepared.AgentProfileID, prepared.ResolvedPrompt) + } else { + result, err = h.hostExecutor.ExecutePrompt(ctx, prepared.AgentCLI, prepared.Model, "", prepared.ResolvedPrompt) + } if err != nil { h.logger.Error("failed to execute sessionless prompt", zap.Error(err), zap.String("call_id", callID)) _ = h.controller.FailCall(ctx, callID, err.Error(), 0) diff --git a/apps/backend/internal/utility/models/models.go b/apps/backend/internal/utility/models/models.go index 5fa4c9e136..d3749e5415 100644 --- a/apps/backend/internal/utility/models/models.go +++ b/apps/backend/internal/utility/models/models.go @@ -2,19 +2,27 @@ package models import "time" +const ( + ProfileBindingInherit = "inherit" + ProfileBindingExplicit = "explicit" + ProfileBindingUnconfigured = "unconfigured" +) + // UtilityAgent represents a configured utility agent for quick one-shot tasks. // It references an inference-capable agent (like claude-acp, amp) by ID. type UtilityAgent struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Prompt string `json:"prompt" db:"prompt"` - AgentID string `json:"agent_id" db:"agent_id"` // Reference to inference agent (e.g., "claude-acp") - Model string `json:"model" db:"model"` // Model to use (e.g., "claude-haiku-4-5") - Builtin bool `json:"builtin" db:"builtin"` // Built-in agents cannot be deleted - Enabled bool `json:"enabled" db:"enabled"` // Whether agent is enabled - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Prompt string `json:"prompt" db:"prompt"` + AgentID string `json:"agent_id" db:"agent_id"` // Reference to inference agent (e.g., "claude-acp") + Model string `json:"model" db:"model"` // Model to use (e.g., "claude-haiku-4-5") + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` + ProfileBindingState string `json:"profile_binding_state" db:"profile_binding_state"` + Builtin bool `json:"builtin" db:"builtin"` // Built-in agents cannot be deleted + Enabled bool `json:"enabled" db:"enabled"` // Whether agent is enabled + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } // UtilityAgentCall represents a single invocation of a utility agent. @@ -25,6 +33,7 @@ type UtilityAgentCall struct { ResolvedPrompt string `json:"resolved_prompt" db:"resolved_prompt"` Response string `json:"response" db:"response"` Model string `json:"model" db:"model"` + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"` ResponseTokens int `json:"response_tokens" db:"response_tokens"` DurationMs int `json:"duration_ms" db:"duration_ms"` diff --git a/apps/backend/internal/utility/profilebinding/resolver.go b/apps/backend/internal/utility/profilebinding/resolver.go new file mode 100644 index 0000000000..6df2101728 --- /dev/null +++ b/apps/backend/internal/utility/profilebinding/resolver.go @@ -0,0 +1,97 @@ +package profilebinding + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +var ( + ErrProfileNotFound = errors.New("agent profile not found") + ErrProfileIneligible = errors.New("agent profile is not eligible for utility execution") + ErrLegacyBindingAmbiguous = errors.New("legacy utility binding does not match exactly one profile") +) + +type profileRepository interface { + GetAgentProfileIncludingDeleted(ctx context.Context, id string) (*models.AgentProfile, error) + ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) + ListAgents(ctx context.Context) ([]*models.Agent, error) + GetAgent(ctx context.Context, id string) (*models.Agent, error) +} + +// Resolver is the single utility-agent profile eligibility boundary. +type Resolver struct { + profiles profileRepository + isInference func(agentID string) bool +} + +// LegacyMatcher resolves an old agent/model pair to one eligible profile. +// It is intentionally separate from Resolve so migrations can distinguish +// an ambiguous or unavailable legacy value without changing saved bindings. +type LegacyMatcher interface { + MatchLegacy(ctx context.Context, agentID, model string) (*models.AgentProfile, error) +} + +func New(profiles profileRepository, isInference func(agentID string) bool) *Resolver { + return &Resolver{profiles: profiles, isInference: isInference} +} + +func (r *Resolver) Resolve(ctx context.Context, id string) (*models.AgentProfile, error) { + profile, err := r.profiles.GetAgentProfileIncludingDeleted(ctx, id) + if err != nil { + return nil, err + } + if profile == nil { + return nil, ErrProfileNotFound + } + agent, err := r.profiles.GetAgent(ctx, profile.AgentID) + if err != nil { + return nil, err + } + if agent == nil || !eligible(profile, r.isInference, agent.Name) { + return nil, ErrProfileIneligible + } + resolved := *profile + resolved.AgentID = agent.Name + return &resolved, nil +} + +//nolint:cyclop // legacy matching has independent eligibility and ambiguity checks. +func (r *Resolver) MatchLegacy(ctx context.Context, agentID, model string) (*models.AgentProfile, error) { + matches := make([]*models.AgentProfile, 0, 1) + agents, err := r.profiles.ListAgents(ctx) + if err != nil { + return nil, err + } + for _, agent := range agents { + if agent == nil || (agent.ID != agentID && agent.Name != agentID) { + continue + } + profiles, err := r.profiles.ListAgentProfiles(ctx, agent.ID) + if err != nil { + return nil, err + } + for _, profile := range profiles { + if profile != nil && profile.Model == model && eligible(profile, r.isInference, agent.Name) { + matches = append(matches, profile) + } + } + } + if len(matches) != 1 { + return nil, ErrLegacyBindingAmbiguous + } + resolved := *matches[0] + for _, agent := range agents { + if agent != nil && (agent.ID == resolved.AgentID || agent.Name == agentID) { + resolved.AgentID = agent.Name + break + } + } + return &resolved, nil +} + +func eligible(profile *models.AgentProfile, isInference func(string) bool, agentID string) bool { + return profile.DeletedAt == nil && profile.Enabled && profile.WorkspaceID == "" && + !profile.CLIPassthrough && isInference != nil && isInference(agentID) +} diff --git a/apps/backend/internal/utility/profilebinding/resolver_test.go b/apps/backend/internal/utility/profilebinding/resolver_test.go new file mode 100644 index 0000000000..74443c83e2 --- /dev/null +++ b/apps/backend/internal/utility/profilebinding/resolver_test.go @@ -0,0 +1,102 @@ +package profilebinding + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +type fakeProfiles struct { + byID map[string]*models.AgentProfile + byAg map[string][]*models.AgentProfile + err error + agents []*models.Agent +} + +func (f *fakeProfiles) ListAgents(context.Context) ([]*models.Agent, error) { + if len(f.agents) == 0 { + return []*models.Agent{{ID: "codex-acp", Name: "codex-acp"}}, f.err + } + return f.agents, f.err +} + +func (f *fakeProfiles) GetAgent(_ context.Context, id string) (*models.Agent, error) { + for _, agent := range f.agents { + if agent.ID == id { + return agent, f.err + } + } + return &models.Agent{ID: id, Name: id}, f.err +} + +func (f *fakeProfiles) GetAgentProfileIncludingDeleted(context.Context, string) (*models.AgentProfile, error) { + return f.byID["profile-1"], f.err +} + +func (f *fakeProfiles) ListAgentProfiles(_ context.Context, agentID string) ([]*models.AgentProfile, error) { + return f.byAg[agentID], f.err +} + +func TestResolverMatchLegacyRequiresExactlyOneEligibleProfile(t *testing.T) { + profiles := &fakeProfiles{byAg: map[string][]*models.AgentProfile{ + "codex-acp": { + {ID: "profile-1", AgentID: "codex-acp", Model: "gpt-5", Enabled: true}, + {ID: "profile-2", AgentID: "codex-acp", Model: "gpt-5", Enabled: true}, + }, + }} + resolver := New(profiles, func(string) bool { return true }) + + _, err := resolver.MatchLegacy(context.Background(), "codex-acp", "gpt-5") + if !errors.Is(err, ErrLegacyBindingAmbiguous) { + t.Fatalf("MatchLegacy() error = %v, want ambiguous binding", err) + } +} + +func TestResolverMatchLegacyReturnsTheOnlyEligibleProfile(t *testing.T) { + profiles := &fakeProfiles{byAg: map[string][]*models.AgentProfile{ + "codex-acp": { + {ID: "disabled", AgentID: "codex-acp", Model: "gpt-5", Enabled: false}, + {ID: "profile-1", AgentID: "codex-acp", Model: "gpt-5", Enabled: true}, + }, + }} + resolver := New(profiles, func(string) bool { return true }) + + got, err := resolver.MatchLegacy(context.Background(), "codex-acp", "gpt-5") + if err != nil { + t.Fatalf("MatchLegacy() error = %v", err) + } + if got.ID != "profile-1" { + t.Fatalf("profile ID = %q, want profile-1", got.ID) + } +} + +func TestResolverRejectsWorkspaceCliAndDeletedProfiles(t *testing.T) { + workspace := "workspace-1" + profiles := &fakeProfiles{byAg: map[string][]*models.AgentProfile{ + "codex-acp": { + {ID: "workspace", AgentID: "codex-acp", Model: "gpt-5", Enabled: true, WorkspaceID: workspace}, + {ID: "cli", AgentID: "codex-acp", Model: "gpt-5", Enabled: true, CLIPassthrough: true}, + }, + }} + resolver := New(profiles, func(string) bool { return true }) + if _, err := resolver.MatchLegacy(context.Background(), "codex-acp", "gpt-5"); !errors.Is(err, ErrLegacyBindingAmbiguous) { + t.Fatalf("MatchLegacy() error = %v, want no eligible match", err) + } +} + +func TestResolverResolveReturnsRequestedEligibleProfile(t *testing.T) { + profiles := &fakeProfiles{byID: map[string]*models.AgentProfile{ + "profile-1": {ID: "profile-1", AgentID: "codex-acp", Model: "gpt-5", Enabled: true}, + }} + resolver := New(profiles, func(string) bool { return true }) + + got, err := resolver.Resolve(context.Background(), "profile-1") + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if got.ID != "profile-1" || got.AgentID != "codex-acp" { + t.Fatalf("Resolve() = %#v, want requested eligible profile", got) + } +} diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go index a1a0a2d8a9..7ff391185b 100644 --- a/apps/backend/internal/utility/service/service.go +++ b/apps/backend/internal/utility/service/service.go @@ -4,25 +4,41 @@ import ( "context" "database/sql" "errors" + "fmt" "strings" "time" + agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/utility/models" + "github.com/kandev/kandev/internal/utility/profilebinding" "github.com/kandev/kandev/internal/utility/store" "github.com/kandev/kandev/internal/utility/template" ) var ( - ErrAgentNotFound = errors.New("utility agent not found") - ErrInvalidAgent = errors.New("invalid utility agent") - ErrCallNotFound = errors.New("utility agent call not found") - ErrBuiltinAgent = errors.New("cannot modify built-in agent") + ErrAgentNotFound = errors.New("utility agent not found") + ErrInvalidAgent = errors.New("invalid utility agent") + ErrCallNotFound = errors.New("utility agent call not found") + ErrBuiltinAgent = errors.New("cannot modify built-in agent") + ErrProfileRequired = errors.New("utility agent profile is required") + ErrProfileUnconfigured = errors.New("utility agent profile is not configured") ) +type ProfileResolver interface { + Resolve(ctx context.Context, id string) (*agentsettingsmodels.AgentProfile, error) + MatchLegacy(ctx context.Context, agentID, model string) (*agentsettingsmodels.AgentProfile, error) +} + // Service provides business logic for utility agents. type Service struct { - repo store.Repository - templateEngine *template.Engine + repo store.Repository + templateEngine *template.Engine + profileResolver ProfileResolver +} + +// SetProfileResolver wires the operator-owned profile eligibility boundary. +func (s *Service) SetProfileResolver(resolver ProfileResolver) { + s.profileResolver = resolver } // NewService creates a new utility agents service. @@ -38,6 +54,83 @@ func (s *Service) ListAgents(ctx context.Context) ([]*models.UtilityAgent, error return s.repo.ListAgents(ctx) } +// ClearAgentProfileBindings marks utility agents that reference a deleted +// profile as unconfigured. This keeps forced profile deletion fail-closed. +func (s *Service) ClearAgentProfileBindings(ctx context.Context, profileID string) error { + agents, err := s.repo.ListAgents(ctx) + if err != nil { + return err + } + for _, agent := range agents { + if agent == nil || agent.AgentProfileID != profileID { + continue + } + agent.AgentProfileID = "" + agent.ProfileBindingState = models.ProfileBindingUnconfigured + if err := s.repo.UpdateAgent(ctx, agent); err != nil { + return err + } + } + return nil +} + +// ClearInheritedProfileBindings marks built-in agents that inherit a deleted +// default profile as unconfigured. +func (s *Service) ClearInheritedProfileBindings(ctx context.Context) error { + agents, err := s.repo.ListAgents(ctx) + if err != nil { + return err + } + for _, agent := range agents { + if agent == nil || agent.ProfileBindingState != models.ProfileBindingInherit { + continue + } + agent.ProfileBindingState = models.ProfileBindingUnconfigured + if err := s.repo.UpdateAgent(ctx, agent); err != nil { + return err + } + } + return nil +} + +// MigrateLegacyBindings upgrades old agent/model selections after profile +// reconciliation. The operation is idempotent and leaves explicit bindings +// untouched. A built-in row with inherit state is also left untouched. +func (s *Service) MigrateLegacyBindings(ctx context.Context) (int, error) { + if s.profileResolver == nil { + return 0, nil + } + agents, err := s.repo.ListAgents(ctx) + if err != nil { + return 0, err + } + updated := 0 + for _, agent := range agents { + if agent == nil || agent.AgentProfileID != "" || + agent.ProfileBindingState == models.ProfileBindingInherit || + agent.ProfileBindingState == models.ProfileBindingUnconfigured { + continue + } + profile, matchErr := s.profileResolver.MatchLegacy(ctx, agent.AgentID, agent.Model) + switch { + case matchErr == nil && profile != nil: + agent.AgentProfileID = profile.ID + agent.ProfileBindingState = models.ProfileBindingExplicit + case errors.Is(matchErr, profilebinding.ErrLegacyBindingAmbiguous): + agent.ProfileBindingState = models.ProfileBindingUnconfigured + case matchErr != nil: + return updated, matchErr + default: + agent.ProfileBindingState = models.ProfileBindingUnconfigured + } + if err := s.repo.UpdateAgent(ctx, agent); err != nil { + return updated, err + } + updated++ + } + return updated, nil +} + // GetAgentByID returns a utility agent by ID. func (s *Service) GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) { agent, err := s.repo.GetAgentByID(ctx, id) @@ -63,23 +156,38 @@ func (s *Service) GetAgentByName(ctx context.Context, name string) (*models.Util } // CreateAgent creates a new utility agent. -func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, agentID, model string) (*models.UtilityAgent, error) { +func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, agentID, model, profileID, bindingState string) (*models.UtilityAgent, error) { name = strings.TrimSpace(name) prompt = strings.TrimSpace(prompt) agentID = strings.TrimSpace(agentID) model = strings.TrimSpace(model) - if name == "" || prompt == "" || agentID == "" || model == "" { + profileID = strings.TrimSpace(profileID) + bindingState = strings.TrimSpace(bindingState) + if name == "" || prompt == "" { + return nil, ErrInvalidAgent + } + if s.profileResolver != nil { + if profileID == "" { + return nil, ErrProfileRequired + } + if _, err := s.profileResolver.Resolve(ctx, profileID); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidAgent, err) + } + bindingState = models.ProfileBindingExplicit + } else if agentID == "" || model == "" { return nil, ErrInvalidAgent } agent := &models.UtilityAgent{ - Name: name, - Description: description, - Prompt: prompt, - AgentID: agentID, - Model: model, - Builtin: false, + Name: name, + Description: description, + Prompt: prompt, + AgentID: agentID, + Model: model, + AgentProfileID: profileID, + ProfileBindingState: bindingState, + Builtin: false, // Custom agents validate agent_id + model above, so they're always // configured and ready to run at creation time. The DB's NOT NULL // column persists the field value (not the schema default), so we @@ -95,7 +203,9 @@ func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, ag } // UpdateAgent updates an existing utility agent. -func (s *Service) UpdateAgent(ctx context.Context, id string, name, description, prompt, agentID, model *string, enabled *bool) (*models.UtilityAgent, error) { +// +//nolint:cyclop // each optional update field has independent validation. +func (s *Service) UpdateAgent(ctx context.Context, id string, name, description, prompt, agentID, model, profileID, bindingState *string, enabled *bool) (*models.UtilityAgent, error) { agent, err := s.repo.GetAgentByID(ctx, id) if err != nil { return nil, ErrAgentNotFound @@ -124,10 +234,27 @@ func (s *Service) UpdateAgent(ctx context.Context, id string, name, description, if model != nil { agent.Model = strings.TrimSpace(*model) } + if profileID != nil { + agent.AgentProfileID = strings.TrimSpace(*profileID) + } + if bindingState != nil { + agent.ProfileBindingState = strings.TrimSpace(*bindingState) + } + if s.profileResolver != nil { + if agent.Builtin && agent.ProfileBindingState == models.ProfileBindingInherit { + agent.AgentProfileID = "" + } else if agent.AgentProfileID == "" { + return nil, ErrProfileRequired + } else if _, err := s.profileResolver.Resolve(ctx, agent.AgentProfileID); err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidAgent, err) + } else { + agent.ProfileBindingState = models.ProfileBindingExplicit + } + } if enabled != nil { // Allow enabling even with empty agent_id/model - defaults can be used at execution time. // For custom (non-builtin) agents, we still require agent_id and model. - if *enabled && !agent.Builtin && (agent.AgentID == "" || agent.Model == "") { + if *enabled && !agent.Builtin && s.profileResolver == nil && (agent.AgentID == "" || agent.Model == "") { return nil, ErrInvalidAgent } agent.Enabled = *enabled @@ -167,8 +294,9 @@ func (s *Service) GetAvailableVariables() []template.VariableInfo { // DefaultUtilitySettings contains the user's default utility agent/model settings. type DefaultUtilitySettings struct { - AgentID string - Model string + AgentID string + Model string + ProfileID string } // PreparePromptRequest prepares a prompt request by resolving the template. @@ -189,6 +317,34 @@ func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tm return nil, err } + if s.profileResolver != nil { + var profileID string + switch agent.ProfileBindingState { + case models.ProfileBindingUnconfigured: + return nil, ErrProfileUnconfigured + case models.ProfileBindingInherit: + if defaults != nil { + profileID = defaults.ProfileID + } + default: + profileID = agent.AgentProfileID + } + if profileID == "" { + return nil, ErrProfileRequired + } + profile, err := s.profileResolver.Resolve(ctx, profileID) + if err != nil { + return nil, err + } + return &PromptRequest{ + UtilityID: utilityID, + ResolvedPrompt: resolvedPrompt, + AgentCLI: profile.AgentID, + Model: profile.Model, + AgentProfileID: profile.ID, + }, nil + } + // Use agent-specific values when fully configured. If the model is empty, // treat the default agent/model as an inseparable pair so a default model // from one provider is never sent to another provider's ACP config. @@ -217,10 +373,11 @@ type PromptRequest struct { ResolvedPrompt string AgentCLI string // The inference agent ID (e.g., "claude-acp", "amp") Model string // The model to use + AgentProfileID string } // CreateCall creates a new call record (for tracking history). -func (s *Service) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model string) (*models.UtilityAgentCall, error) { +func (s *Service) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model string, profileID ...string) (*models.UtilityAgentCall, error) { call := &models.UtilityAgentCall{ UtilityID: utilityID, SessionID: sessionID, @@ -229,6 +386,9 @@ func (s *Service) CreateCall(ctx context.Context, utilityID, sessionID, resolved Status: "pending", CreatedAt: time.Now().UTC(), } + if len(profileID) > 0 { + call.AgentProfileID = profileID[0] + } if err := s.repo.CreateCall(ctx, call); err != nil { return nil, err } diff --git a/apps/backend/internal/utility/service/service_test.go b/apps/backend/internal/utility/service/service_test.go index 809966669f..8327f79e0a 100644 --- a/apps/backend/internal/utility/service/service_test.go +++ b/apps/backend/internal/utility/service/service_test.go @@ -5,10 +5,42 @@ import ( "database/sql" "testing" + agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/utility/models" "github.com/kandev/kandev/internal/utility/template" ) +type fakeProfileResolver struct { + profile *agentsettingsmodels.AgentProfile + err error +} + +func (r fakeProfileResolver) Resolve(context.Context, string) (*agentsettingsmodels.AgentProfile, error) { + return r.profile, r.err +} +func (r fakeProfileResolver) MatchLegacy(context.Context, string, string) (*agentsettingsmodels.AgentProfile, error) { + return r.profile, r.err +} + +func TestMigrateLegacyBindingsUpdatesOnlyUnambiguousRows(t *testing.T) { + repo := &fakeRepository{agents: map[string]*models.UtilityAgent{ + "legacy": {ID: "legacy", AgentID: "codex-acp", Model: "gpt-5", ProfileBindingState: models.ProfileBindingExplicit}, + "inherit": {ID: "inherit", ProfileBindingState: models.ProfileBindingInherit}, + }} + svc := NewService(repo) + svc.SetProfileResolver(fakeProfileResolver{profile: &agentsettingsmodels.AgentProfile{ID: "profile-1"}}) + updated, err := svc.MigrateLegacyBindings(context.Background()) + if err != nil || updated != 1 { + t.Fatalf("MigrateLegacyBindings() = (%d, %v), want (1, nil)", updated, err) + } + if repo.agents["legacy"].AgentProfileID != "profile-1" || repo.agents["legacy"].ProfileBindingState != models.ProfileBindingExplicit { + t.Fatalf("legacy row was not migrated: %#v", repo.agents["legacy"]) + } + if repo.agents["inherit"].ProfileBindingState != models.ProfileBindingInherit { + t.Fatalf("inherit row changed: %#v", repo.agents["inherit"]) + } +} + type fakeRepository struct { agents map[string]*models.UtilityAgent } diff --git a/apps/backend/internal/utility/store/builtins.go b/apps/backend/internal/utility/store/builtins.go index 90dd33014d..3ab4153e14 100644 --- a/apps/backend/internal/utility/store/builtins.go +++ b/apps/backend/internal/utility/store/builtins.go @@ -13,8 +13,8 @@ import ( func (r *sqliteRepository) seedBuiltinAgents() error { for _, agent := range getBuiltinAgents() { _, err := r.db.Exec(r.db.Rebind(` - INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, 1, 0, ?, ?) + INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, agent_profile_id, profile_binding_state, builtin, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, '', 'inherit', 1, 0, ?, ?) ON CONFLICT(id) DO NOTHING `), agent.ID, agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, agent.CreatedAt, agent.UpdatedAt) diff --git a/apps/backend/internal/utility/store/sqlite.go b/apps/backend/internal/utility/store/sqlite.go index 6241cadb91..cfe9bbbc78 100644 --- a/apps/backend/internal/utility/store/sqlite.go +++ b/apps/backend/internal/utility/store/sqlite.go @@ -49,6 +49,8 @@ func (r *sqliteRepository) initSchema() error { prompt TEXT NOT NULL, agent_id TEXT NOT NULL DEFAULT 'claude-acp', model TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL DEFAULT '', + profile_binding_state TEXT NOT NULL DEFAULT 'explicit', builtin INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL, @@ -62,6 +64,7 @@ func (r *sqliteRepository) initSchema() error { resolved_prompt TEXT NOT NULL DEFAULT '', response TEXT NOT NULL DEFAULT '', model TEXT NOT NULL DEFAULT '', + agent_profile_id TEXT NOT NULL DEFAULT '', prompt_tokens INTEGER NOT NULL DEFAULT 0, response_tokens INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, @@ -82,6 +85,9 @@ func (r *sqliteRepository) initSchema() error { // Add enabled column if it doesn't exist (migration for existing DBs) _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`) + _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`) + _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN profile_binding_state TEXT NOT NULL DEFAULT 'explicit'`) + _, _ = r.db.Exec(`ALTER TABLE utility_agent_calls ADD COLUMN agent_profile_id TEXT NOT NULL DEFAULT ''`) // Heal pre-existing custom agents that were created with enabled=0 due // to a bug in CreateAgent (the Enabled field on the model defaulted to @@ -158,7 +164,7 @@ func (r *sqliteRepository) Close() error { func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.UtilityAgent, error) { rows, err := r.ro.QueryContext(ctx, ` - SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at + SELECT id, name, description, prompt, agent_id, model, agent_profile_id, profile_binding_state, builtin, enabled, created_at, updated_at FROM utility_agents ORDER BY builtin DESC, name ASC `) @@ -177,7 +183,7 @@ func (r *sqliteRepository) scanAgentRows(rows *sql.Rows) ([]*models.UtilityAgent var builtinInt, enabledInt int if err := rows.Scan( &agent.ID, &agent.Name, &agent.Description, &agent.Prompt, - &agent.AgentID, &agent.Model, &builtinInt, &enabledInt, + &agent.AgentID, &agent.Model, &agent.AgentProfileID, &agent.ProfileBindingState, &builtinInt, &enabledInt, &agent.CreatedAt, &agent.UpdatedAt, ); err != nil { return nil, err @@ -191,7 +197,7 @@ func (r *sqliteRepository) scanAgentRows(rows *sql.Rows) ([]*models.UtilityAgent func (r *sqliteRepository) GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at + SELECT id, name, description, prompt, agent_id, model, agent_profile_id, profile_binding_state, builtin, enabled, created_at, updated_at FROM utility_agents WHERE id = ? `), id) return r.scanAgentRow(row) @@ -199,7 +205,7 @@ func (r *sqliteRepository) GetAgentByID(ctx context.Context, id string) (*models func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at + SELECT id, name, description, prompt, agent_id, model, agent_profile_id, profile_binding_state, builtin, enabled, created_at, updated_at FROM utility_agents WHERE name = ? `), name) return r.scanAgentRow(row) @@ -210,7 +216,7 @@ func (r *sqliteRepository) scanAgentRow(row *sql.Row) (*models.UtilityAgent, err var builtinInt, enabledInt int if err := row.Scan( &agent.ID, &agent.Name, &agent.Description, &agent.Prompt, - &agent.AgentID, &agent.Model, &builtinInt, &enabledInt, + &agent.AgentID, &agent.Model, &agent.AgentProfileID, &agent.ProfileBindingState, &builtinInt, &enabledInt, &agent.CreatedAt, &agent.UpdatedAt, ); err != nil { return nil, err @@ -242,10 +248,10 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Utilit } _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, agent_profile_id, profile_binding_state, builtin, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), agent.ID, agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, - builtinInt, enabledInt, agent.CreatedAt, agent.UpdatedAt) + agent.AgentProfileID, agent.ProfileBindingState, builtinInt, enabledInt, agent.CreatedAt, agent.UpdatedAt) return err } @@ -265,9 +271,9 @@ func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Utilit _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE utility_agents - SET name = ?, description = ?, prompt = ?, agent_id = ?, model = ?, enabled = ?, updated_at = ? + SET name = ?, description = ?, prompt = ?, agent_id = ?, model = ?, agent_profile_id = ?, profile_binding_state = ?, enabled = ?, updated_at = ? WHERE id = ? - `), agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, enabledInt, agent.UpdatedAt, agent.ID) + `), agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, agent.AgentProfileID, agent.ProfileBindingState, enabledInt, agent.UpdatedAt, agent.ID) return err } @@ -281,7 +287,7 @@ func (r *sqliteRepository) ListCalls(ctx context.Context, utilityID string, limi limit = 50 } rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` - SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at + SELECT id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at FROM utility_agent_calls WHERE utility_id = ? ORDER BY created_at DESC @@ -301,7 +307,7 @@ func (r *sqliteRepository) scanCallRows(rows *sql.Rows) ([]*models.UtilityAgentC call := &models.UtilityAgentCall{} if err := rows.Scan( &call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response, - &call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, + &call.Model, &call.AgentProfileID, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, &call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt, ); err != nil { return nil, err @@ -313,13 +319,13 @@ func (r *sqliteRepository) scanCallRows(rows *sql.Rows) ([]*models.UtilityAgentC func (r *sqliteRepository) GetCallByID(ctx context.Context, id string) (*models.UtilityAgentCall, error) { row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at + SELECT id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at FROM utility_agent_calls WHERE id = ? `), id) call := &models.UtilityAgentCall{} if err := row.Scan( &call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response, - &call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, + &call.Model, &call.AgentProfileID, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, &call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt, ); err != nil { return nil, err @@ -339,10 +345,10 @@ func (r *sqliteRepository) CreateCall(ctx context.Context, call *models.UtilityA } _, err := r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO utility_agent_calls (id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO utility_agent_calls (id, utility_id, session_id, resolved_prompt, response, model, agent_profile_id, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), call.ID, call.UtilityID, call.SessionID, call.ResolvedPrompt, call.Response, - call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs, + call.Model, call.AgentProfileID, call.PromptTokens, call.ResponseTokens, call.DurationMs, call.Status, call.ErrorMessage, call.CreatedAt, call.CompletedAt) return err } @@ -353,9 +359,9 @@ func (r *sqliteRepository) UpdateCall(ctx context.Context, call *models.UtilityA } _, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE utility_agent_calls - SET response = ?, model = ?, prompt_tokens = ?, response_tokens = ?, duration_ms = ?, status = ?, error_message = ?, completed_at = ? + SET response = ?, model = ?, agent_profile_id = ?, prompt_tokens = ?, response_tokens = ?, duration_ms = ?, status = ?, error_message = ?, completed_at = ? WHERE id = ? - `), call.Response, call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs, + `), call.Response, call.Model, call.AgentProfileID, call.PromptTokens, call.ResponseTokens, call.DurationMs, call.Status, call.ErrorMessage, call.CompletedAt, call.ID) return err } diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index b78f6d5dc8..358087da7d 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -128,11 +128,15 @@ export async function updateAgentProfileAction( command_prefix?: string; env_vars?: ProfileEnvVar[]; }, + force = false, ): Promise { - const raw = await agentSettingsRequest(`${apiBaseUrl}/api/v1/agent-profiles/${id}`, { - method: "PATCH", - body: JSON.stringify(payload), - }); + const raw = await agentSettingsRequest( + `${apiBaseUrl}/api/v1/agent-profiles/${id}${force ? "?force=true" : ""}`, + { + method: "PATCH", + body: JSON.stringify(payload), + }, + ); return normalizeAgentProfile(raw); } @@ -141,6 +145,7 @@ import type { AutomationReference, RoutingTierReference, WatcherReference, + UtilityAgentReference, } from "@/lib/types/agent-profile-errors"; export type DeleteProfileResult = @@ -151,9 +156,11 @@ export type DeleteProfileResult = watchers: WatcherReference[]; routingTiers: RoutingTierReference[]; automations: AutomationReference[]; + utilityAgents: UtilityAgentReference[]; } | { status: "error"; message: string }; +// eslint-disable-next-line complexity export async function deleteAgentProfileAction( id: string, force?: boolean, @@ -175,7 +182,11 @@ export async function deleteAgentProfileAction( // conflict (the new self-heal path) must still pop the dialog. if ( response.status === 409 && - (body.active_sessions || body.watchers || body.routing_tiers || body.automations) + (body.active_sessions || + body.watchers || + body.routing_tiers || + body.automations || + body.utility_agents) ) { return { status: "conflict", @@ -183,6 +194,7 @@ export async function deleteAgentProfileAction( watchers: body.watchers ?? [], routingTiers: body.routing_tiers ?? [], automations: body.automations ?? [], + utilityAgents: body.utility_agents ?? [], }; } return { diff --git a/apps/web/app/settings/utility-agents/page.tsx b/apps/web/app/settings/utility-agents/page.tsx index ff155bb127..0cb7acea2e 100644 --- a/apps/web/app/settings/utility-agents/page.tsx +++ b/apps/web/app/settings/utility-agents/page.tsx @@ -1,11 +1,9 @@ -import { ConfigChatAgentSection } from "@/components/settings/config-chat-agent-section"; import { UtilityAgentsSection } from "@/components/settings/utility-agents-section"; export default function UtilityAgentsSettingsPage() { return (
-
); } diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 5615b79463..2b6d9a80fd 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -210,6 +210,7 @@ export const Combobox = memo(function Combobox({ align={popoverAlign} portal={popoverPortal} portalContainer={portalContainer} + onWheel={(event) => event.stopPropagation()} > void; + onConfirm: () => void; +}) { + const { t } = useTranslation(); + return ( + 0} + onOpenChange={(open) => { + if (!open) onCancel(); + }} + > + + + {t("agents:disableProfile")} + + {t("agents:disableProfileUtilityWarning")} + + +
    + {agents.map((agent) => ( +
  • {agent.name || agent.id}
  • + ))} +
+ + {t("common:cancel")} + + {t("agents:disableProfileAnyway")} + + +
+
+ ); +} + // AgentProfileDeleteConflict carries the structured 409 payload from the // backend. `open` is separate from the lists so a watcher-only conflict // (no active sessions) still pops the dialog. @@ -72,6 +113,7 @@ export type AgentProfileDeleteConflict = { watchers: WatcherReference[]; routingTiers: RoutingTierReference[]; automations: AutomationReference[]; + utilityAgents?: UtilityAgentReference[]; }; type AgentProfileDeleteConflictDialogProps = { @@ -80,6 +122,7 @@ type AgentProfileDeleteConflictDialogProps = { onConfirm: () => void; }; +// eslint-disable-next-line complexity export function AgentProfileDeleteConflictDialog({ conflict, onOpenChange, @@ -91,6 +134,7 @@ export function AgentProfileDeleteConflictDialog({ const watchers = conflict?.watchers ?? []; const routingTiers = conflict?.routingTiers ?? []; const automations = conflict?.automations ?? []; + const utilityAgents = conflict?.utilityAgents ?? []; const hasHardBlockers = routingTiers.length > 0; const watchersByKind = groupWatchersByKind(watchers); const workspaces = useAppStore((s) => s.workspaces.items); @@ -128,6 +172,7 @@ export function AgentProfileDeleteConflictDialog({ automations={automations} workspaceLabels={new Map(workspaces.map((w) => [w.id, w.name]))} /> + {hasHardBlockers ? (

{t("agents:changeTierMappingsFirst")}

) : ( @@ -152,6 +197,27 @@ export function AgentProfileDeleteConflictDialog({ ); } +function UtilityAgentConflictSection({ + utilityAgents, +}: { + utilityAgents: UtilityAgentReference[]; +}) { + const { t } = useTranslation(); + if (utilityAgents.length === 0) return null; + return ( +
+

{t("agents:conflictUtilityAgentsTitle")}

+
    + {utilityAgents.map((agent) => ( +
  • + {agent.name || agent.id} +
  • + ))} +
+
+ ); +} + function SessionConflictSection({ title, sessions, diff --git a/apps/web/components/settings/agent-profile-page-state.ts b/apps/web/components/settings/agent-profile-page-state.ts index ab2ea1bd0e..c2ed57d495 100644 --- a/apps/web/components/settings/agent-profile-page-state.ts +++ b/apps/web/components/settings/agent-profile-page-state.ts @@ -18,6 +18,7 @@ import type { useToast } from "@/components/toast-provider"; import type { AgentProfileDeleteConflict } from "@/components/settings/agent-profile-delete-dialog"; import { t as translate } from "@/lib/i18n"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import { ApiError } from "@/lib/api/client"; import type { Agent, AgentProfile, PermissionSetting } from "@/lib/types/http"; export type SaveStatus = "idle" | "loading" | "success" | "error"; @@ -65,6 +66,7 @@ type ProfileEditorActionsOptions = { settingsAgents: Agent[]; syncAgentsToStore: (agents: Agent[]) => void; toast: ReturnType["toast"]; + onUtilityConflict?: (agents: Array<{ id: string; name: string }>) => void; }; export function useProfileSave({ @@ -77,8 +79,10 @@ export function useProfileSave({ settingsAgents, syncAgentsToStore, toast, + onUtilityConflict, }: ProfileEditorActionsOptions) { - return async () => { + // eslint-disable-next-line complexity + return async (force = false) => { if (!draft.name.trim()) { toast({ title: translate("agents:profileNameRequiredTitle"), @@ -91,23 +95,27 @@ export function useProfileSave({ // default", which is applied through ACP session model selection at session start. setSaveStatus("loading"); try { - const updated = await updateAgentProfileAction(draft.id, { - name: draft.name, - model: draft.model, - mode: draft.mode, - config_options: draft.configOptions ?? {}, - ...permissionsToProfilePatch(draft), - cli_passthrough: draft.cliPassthrough, - // Omit an unchanged enabled value so a profile editor save cannot - // resurrect a concurrent list-toggle response from its stale draft. - enabled: - (draft.enabled ?? true) !== (savedProfile.enabled ?? true) - ? (draft.enabled ?? true) - : undefined, - cli_flags: draft.cliFlags, - command_prefix: draft.commandPrefix ?? "", - env_vars: draft.envVars ?? [], - }); + const updated = await updateAgentProfileAction( + draft.id, + { + name: draft.name, + model: draft.model, + mode: draft.mode, + config_options: draft.configOptions ?? {}, + ...permissionsToProfilePatch(draft), + cli_passthrough: draft.cliPassthrough, + // Omit an unchanged enabled value so a profile editor save cannot + // resurrect a concurrent list-toggle response from its stale draft. + enabled: + (draft.enabled ?? true) !== (savedProfile.enabled ?? true) + ? (draft.enabled ?? true) + : undefined, + cli_flags: draft.cliFlags, + command_prefix: draft.commandPrefix ?? "", + env_vars: draft.envVars ?? [], + }, + force, + ); setSavedProfile(updated); setDraft((current) => preserveNewerProfileDraft(current, draft, updated)); const nextAgents = settingsAgents.map((agentItem: Agent) => @@ -123,6 +131,15 @@ export function useProfileSave({ syncAgentsToStore(nextAgents); setSaveStatus("success"); } catch (error) { + if ( + error instanceof ApiError && + error.status === 409 && + Array.isArray((error.body as { utility_agents?: unknown[] } | null)?.utility_agents) + ) { + onUtilityConflict?.( + (error.body as { utility_agents: Array<{ id: string; name: string }> }).utility_agents, + ); + } setSaveStatus("error"); toast({ title: translate("agents:failedToSaveProfile"), @@ -180,6 +197,7 @@ export function useProfileDelete( watchers: result.watchers, routingTiers: result.routingTiers, automations: result.automations, + utilityAgents: result.utilityAgents, }); } else { toast({ @@ -201,6 +219,7 @@ export function useProfileDelete( watchers: result.watchers, routingTiers: result.routingTiers, automations: result.automations, + utilityAgents: result.utilityAgents, }); } else if (result.status === "error") { toast({ diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 0fa7130ff4..cb19c9610e 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -19,6 +19,7 @@ import { toAgentProfilePatch } from "@/app/settings/agents/[agentId]/agent-save- import { AgentProfileDeleteConfirmDialog, AgentProfileDeleteConflictDialog, + AgentProfileDisableConflictDialog, type AgentProfileDeleteConflict, } from "@/components/settings/agent-profile-delete-dialog"; import { @@ -48,6 +49,7 @@ import type { PermissionSetting, PassthroughConfig, } from "@/lib/types/http"; +import type { UtilityAgentReference } from "@/lib/types/agent-profile-errors"; import { useAppStore } from "@/components/state-provider"; import { AgentLogo } from "@/components/agent-logo"; import { ProfileMcpConfigCard } from "@/app/settings/agents/[agentId]/profile-mcp-config-card"; @@ -342,6 +344,7 @@ function ProfileEditorBody({ ); } +// eslint-disable-next-line max-lines-per-function function ProfileEditor({ agent, profile, @@ -358,6 +361,7 @@ function ProfileEditor({ const { items: secrets } = useSecrets(); const { draft, setDraft, savedProfile, setSavedProfile, setSaveStatus, isDirty } = useProfileEditorState(profile, permissionSettings); + const [utilityConflict, setUtilityConflict] = useState([]); const updateDraft = useCallback( (patch: Partial) => { setDraft((current) => { @@ -382,6 +386,7 @@ function ProfileEditor({ settingsAgents, syncAgentsToStore, toast, + onUtilityConflict: setUtilityConflict, }); useSettingsSaveContributor({ id: `agent-profile:${draft.id}`, @@ -389,7 +394,7 @@ function ProfileEditor({ isDirty, canSave: Boolean(draft.name.trim()) && !modelConfigResolutionPending, invalidReason: profileSaveInvalidReason(draft.name, modelConfigResolutionPending, t), - save: handleSave, + save: () => handleSave(), discard: () => setDraft(savedProfile), }); const deleteState = useProfileDelete(agent, draft, settingsAgents, syncAgentsToStore, toast); @@ -429,6 +434,15 @@ function ProfileEditor({ + setUtilityConflict([])} + onConfirm={async () => { + setUtilityConflict([]); + await handleSave(true); + }} + /> + - +

{t("settings:configChatAgentTitle")}

{t("settings:configChatAgentDescription")}

- +
+ + setDraftProfileId(value === "none" ? "" : value)} + fallback={{ value: "none", label: t("settings:configChatAgentNoDefault") }} + unavailableValue={ + draftProfileId && !profiles.some((profile) => profile.id === draftProfileId) + ? draftProfileId + : undefined + } + testId="utility-profile-picker-config-chat" + triggerClassName="w-full max-w-sm font-normal" + includeWorkspaceProfiles + /> +
); diff --git a/apps/web/components/settings/utility-agent-dialog.tsx b/apps/web/components/settings/utility-agent-dialog.tsx index 127dc0eb70..aff4e1eb36 100644 --- a/apps/web/components/settings/utility-agent-dialog.tsx +++ b/apps/web/components/settings/utility-agent-dialog.tsx @@ -1,26 +1,20 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { ModelCombobox } from "@/components/settings/model-combobox"; import { type UtilityAgent, createUtilityAgent, updateUtilityAgent, getTemplateVariables, - listInferenceAgents, - type TemplateVariable, - type InferenceAgent, - type InferenceModel, } from "@/lib/api/domains/utility-api"; -import { InferenceAgentStatusNote } from "./inference-agent-status"; -import { useInferenceAgents } from "./use-inference-agents"; +import { useAppStore } from "@/components/state-provider"; import { ScriptEditor } from "./profile-edit/script-editor"; +import { UtilityAgentProfilePicker } from "./utility-agent-profile-picker"; import type { ScriptPlaceholder } from "./profile-edit/script-editor-completions"; type Props = { @@ -29,278 +23,155 @@ type Props = { agent: UtilityAgent | null; onSuccess: () => void; }; - -type FormState = { - name: string; - description: string; - prompt: string; - agent_id: string; - model: string; -}; - -/** Opening delimiter of the prompt-template placeholder syntax, typed verbatim. */ +type FormState = { name: string; description: string; prompt: string; profile_id: string }; const PROMPT_VARIABLE_SIGIL = "{{"; +const defaultFormState: FormState = { name: "", description: "", prompt: "", profile_id: "" }; -const defaultFormState: FormState = { - name: "", - description: "", - prompt: "", - agent_id: "claude-acp", - model: "", -}; - -function toScriptPlaceholders(variables: TemplateVariable[]): ScriptPlaceholder[] { - return variables.map((v) => ({ - key: v.name, - description: v.description, - example: v.example, +function toScriptPlaceholders( + variables: { name: string; description: string; example: string }[], +): ScriptPlaceholder[] { + return variables.map((variable) => ({ + key: variable.name, + description: variable.description, + example: variable.example, executor_types: [], })); } -type AgentModelSelectProps = { - agentId: string; - model: string; - inferenceAgents: InferenceAgent[]; - selectedAgent: InferenceAgent | undefined; - availableModels: InferenceModel[]; - onAgentChange: (agentId: string) => void; - onModelChange: (model: string) => void; - onRefresh: () => Promise | void; -}; - -function AgentModelSelect({ - agentId, - model, - inferenceAgents, - selectedAgent, - availableModels, - onAgentChange, - onModelChange, - onRefresh, -}: AgentModelSelectProps) { - const { t } = useTranslation(); - return ( -
-
-
- - -
-
- - m.is_default)?.id} - placeholder={t("settings:utilitySelectModel")} - disabled={availableModels.length === 0} - /> -
-
- -
- ); -} - -type UtilityAgentFormProps = { - form: FormState; - setForm: React.Dispatch>; - isBuiltin: boolean; - inferenceAgents: InferenceAgent[]; - selectedAgent: InferenceAgent | undefined; - availableModels: InferenceModel[]; - placeholders: ScriptPlaceholder[]; - onRefreshAgent: () => Promise | void; -}; - -function UtilityAgentForm({ - form, - setForm, - isBuiltin, - inferenceAgents, - selectedAgent, - availableModels, - placeholders, - onRefreshAgent, -}: UtilityAgentFormProps) { - const { t } = useTranslation(); - return ( -
-
- - setForm((f) => ({ ...f, name: e.target.value }))} - placeholder={t("settings:utilityAgentNamePlaceholder")} - disabled={isBuiltin} - /> -
-
- - setForm((f) => ({ ...f, description: e.target.value }))} - placeholder={t("settings:utilityAgentDescriptionPlaceholder")} - /> -
- setForm((f) => ({ ...f, agent_id: v, model: "" }))} - onModelChange={(v) => setForm((f) => ({ ...f, model: v }))} - onRefresh={onRefreshAgent} - /> -
- -
- setForm((f) => ({ ...f, prompt: v }))} - language="plaintext" - height="200px" - placeholders={placeholders} - lineNumbers="off" - /> -
-

- - Type {PROMPT_VARIABLE_SIGIL} to see available variables with autocomplete - -

-
-
- ); -} - +// eslint-disable-next-line max-lines-per-function export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Props) { const { t } = useTranslation(); + const profiles = useAppStore((state) => state.agentProfiles.items); const [form, setForm] = useState(defaultFormState); const [saving, setSaving] = useState(false); const [placeholders, setPlaceholders] = useState([]); - const { inferenceAgents, setInferenceAgents, refreshAgent } = useInferenceAgents(); const isEdit = Boolean(agent); - - // Fetch template variables and inference agents useEffect(() => { getTemplateVariables() .then(({ variables }) => setPlaceholders(toScriptPlaceholders(variables))) .catch(() => setPlaceholders([])); - - listInferenceAgents() - .then(({ agents }) => setInferenceAgents(agents)) - .catch(() => setInferenceAgents([])); - }, [setInferenceAgents]); - - const selectedAgent = useMemo( - () => inferenceAgents.find((a) => a.id === form.agent_id), - [inferenceAgents, form.agent_id], - ); - const availableModels = selectedAgent?.models ?? []; - const refreshCurrentAgent = () => refreshAgent(form.agent_id); - - // Auto-select default model when agent changes + }, [open]); useEffect(() => { - if (selectedAgent && !form.model) { - const defaultModel = (selectedAgent.models ?? []).find((m) => m.is_default); - if (defaultModel) { - setForm((f) => ({ ...f, model: defaultModel.id })); - } - } - }, [selectedAgent, form.model]); - - useEffect(() => { - if (agent) { - setForm({ - name: agent.name, - description: agent.description, - prompt: agent.prompt, - agent_id: agent.agent_id || "claude-acp", - model: agent.model || "", - }); - } else { - setForm(defaultFormState); - } + setForm( + agent + ? { + name: agent.name, + description: agent.description, + prompt: agent.prompt, + profile_id: agent.agent_profile_id || "", + } + : defaultFormState, + ); }, [agent, open]); - - const handleSubmit = async () => { + const eligible = profiles.filter( + (profile) => profile.enabled !== false && !profile.cli_passthrough && !profile.workspace_id, + ); + let submitLabel = t("settings:utilityAgentDialogCreate"); + if (saving) submitLabel = t("settings:saving"); + else if (isEdit) submitLabel = t("settings:utilityAgentDialogSave"); + const submit = async () => { + if (!form.name.trim() || !form.prompt.trim() || !form.profile_id) return; setSaving(true); try { const data = { name: form.name, description: form.description, prompt: form.prompt, - agent_id: form.agent_id, - model: form.model, + agent_profile_id: form.profile_id, + profile_binding_state: "explicit", }; - - if (isEdit && agent) { - await updateUtilityAgent(agent.id, data); - } else { - await createUtilityAgent(data); - } + if (agent) await updateUtilityAgent(agent.id, data); + else await createUtilityAgent(data); onSuccess(); } catch (error) { - console.error("Failed to save agent:", error); + console.error("Failed to save utility agent", error); } finally { setSaving(false); } }; - - const dialogTitle = isEdit - ? t("settings:utilityAgentDialogEditTitle") - : t("settings:utilityAgentDialogCreateTitle"); - const getSubmitLabel = () => { - if (saving) return t("settings:saving"); - return isEdit ? t("settings:utilityAgentDialogSave") : t("settings:utilityAgentDialogCreate"); - }; - return ( - {dialogTitle} + + {isEdit + ? t("settings:utilityAgentDialogEditTitle") + : t("settings:utilityAgentDialogCreateTitle")} + - +
+
+ + setForm((current) => ({ ...current, name: event.target.value }))} + placeholder={t("settings:utilityAgentNamePlaceholder")} + disabled={agent?.builtin ?? false} + /> +
+
+ + + setForm((current) => ({ ...current, description: event.target.value })) + } + placeholder={t("settings:utilityAgentDescriptionPlaceholder")} + /> +
+
+ + setForm((current) => ({ ...current, profile_id: value }))} + unavailableValue={ + form.profile_id && !eligible.some((profile) => profile.id === form.profile_id) + ? form.profile_id + : undefined + } + testId="utility-profile-picker-custom" + triggerClassName="w-full" + /> + {form.profile_id && !eligible.some((profile) => profile.id === form.profile_id) && ( +

{t("settings:utilityProfileNeedsRepair")}

+ )} +
+
+ +
+ setForm((current) => ({ ...current, prompt: value }))} + language="plaintext" + height="200px" + placeholders={placeholders} + lineNumbers="off" + /> +
+

+ + Type {PROMPT_VARIABLE_SIGIL} to see available variables with + autocomplete + +

+
+
-
diff --git a/apps/web/components/settings/utility-agent-profile-picker.test.ts b/apps/web/components/settings/utility-agent-profile-picker.test.ts new file mode 100644 index 0000000000..ba3c84dd33 --- /dev/null +++ b/apps/web/components/settings/utility-agent-profile-picker.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { utilityProfileEligibility } from "./utility-agent-profile-picker"; +import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; + +const profile = (overrides: Partial = {}): AgentProfileOption => ({ + id: "profile-1", + label: "Mock • mock-default", + agent_id: "mock", + agent_name: "Mock", + cli_passthrough: false, + enabled: true, + ...overrides, +}); + +describe("utilityProfileEligibility", () => { + it.each([ + ["enabled global profile", profile(), true], + ["omitted enabled flag", profile({ enabled: undefined }), true], + ["disabled profile", profile({ enabled: false }), false], + ["CLI passthrough profile", profile({ cli_passthrough: true }), false], + ["workspace profile", profile({ workspace_id: "workspace-1" }), false], + ])("returns %s = %s", (_label, candidate, expected) => { + expect(utilityProfileEligibility(candidate)).toBe(expected); + }); +}); diff --git a/apps/web/components/settings/utility-agent-profile-picker.tsx b/apps/web/components/settings/utility-agent-profile-picker.tsx new file mode 100644 index 0000000000..3e7a163c0a --- /dev/null +++ b/apps/web/components/settings/utility-agent-profile-picker.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Combobox, type ComboboxOption } from "@/components/combobox"; +import { AgentLogo } from "@/components/agent-logo"; +import { cn } from "@/lib/utils"; +import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; + +export const utilityProfileEligibility = (profile: AgentProfileOption) => + profile.enabled !== false && !profile.cli_passthrough && !profile.workspace_id; + +type UtilityAgentProfilePickerProps = { + profiles: AgentProfileOption[]; + value: string; + onValueChange: (value: string) => void; + testId: string; + fallback?: { value: string; label: string }; + unavailableValue?: string; + unavailableLabel?: string; + triggerClassName?: string; + includeWorkspaceProfiles?: boolean; +}; + +function ProfileLabel({ profile, label }: { profile: AgentProfileOption; label: string }) { + return ( + + + + + {label} + + ); +} + +export function UtilityAgentProfilePicker({ + profiles, + value, + onValueChange, + testId, + fallback, + unavailableValue, + unavailableLabel, + triggerClassName, + includeWorkspaceProfiles = false, +}: UtilityAgentProfilePickerProps) { + const { t } = useTranslation(); + const selectableProfiles = useMemo( + () => + profiles.filter((profile) => includeWorkspaceProfiles || utilityProfileEligibility(profile)), + [includeWorkspaceProfiles, profiles], + ); + const selectedProfile = selectableProfiles.find((profile) => profile.id === value); + const unavailableId = unavailableValue ?? value; + const hasUnavailableValue = Boolean( + unavailableId && unavailableId !== fallback?.value && !selectedProfile, + ); + + const options = useMemo(() => { + const result: ComboboxOption[] = []; + if (fallback) result.push({ value: fallback.value, label: fallback.label }); + if (hasUnavailableValue) { + result.push({ + value: unavailableId, + label: unavailableLabel ?? t("settings:utilityUnavailableProfile", { name: unavailableId }), + disabled: true, + keywords: [unavailableId], + }); + } + result.push( + ...selectableProfiles.map((profile) => ({ + value: profile.id, + label: profile.label || profile.id, + keywords: [profile.label, profile.agent_name, profile.id], + renderLabel: () => , + })), + ); + return result; + }, [fallback, hasUnavailableValue, selectableProfiles, t, unavailableId, unavailableLabel]); + + const resolvedValue = value || fallback?.value || ""; + return ( + { + if (nextValue === fallback?.value) { + onValueChange(""); + return; + } + if (!nextValue && value && value !== fallback?.value) { + onValueChange(value); + return; + } + onValueChange(nextValue); + }} + placeholder={t("settings:utilitySelectProfile")} + searchPlaceholder={t("settings:utilitySearchProfiles")} + emptyMessage={t("settings:utilityNoProfilesFound")} + ariaLabel={t("settings:utilityAgentProfile")} + testId={testId} + dropdownTestId={`${testId}-dropdown`} + triggerClassName={cn( + "border-border dark:bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground font-normal", + triggerClassName, + )} + className="max-h-[min(60vh,24rem)]" + /> + ); +} diff --git a/apps/web/components/settings/utility-agents-section.tsx b/apps/web/components/settings/utility-agents-section.tsx index 1e6324f5eb..bc234766d9 100644 --- a/apps/web/components/settings/utility-agents-section.tsx +++ b/apps/web/components/settings/utility-agents-section.tsx @@ -5,36 +5,24 @@ import { useTranslation } from "react-i18next"; import { IconWand } from "@tabler/icons-react"; import { listUtilityAgents, - listInferenceAgents, updateUtilityAgent, deleteUtilityAgent, type UtilityAgent, - type InferenceAgent, } from "@/lib/api/domains/utility-api"; import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; import { SettingsSection } from "@/components/settings/settings-section"; import { UtilityAgentDialog } from "@/components/settings/utility-agent-dialog"; +import { ConfigChatAgentSection } from "@/components/settings/config-chat-agent-section"; import { DefaultModelSection, PerActionOverridesSection, CustomAgentsSection, USE_DEFAULT, } from "@/components/settings/utility-sections"; -import { useInferenceAgents } from "@/components/settings/use-inference-agents"; +import { useAppStore } from "@/components/state-provider"; import { useSettingsSaveContributor } from "./settings-save-provider"; export { isUtilityAgentDirty } from "./utility-dirty"; -function buildAllModels(inferenceAgents: InferenceAgent[]) { - return inferenceAgents.flatMap((ia) => - (ia.models ?? []).map((m) => ({ - value: `${ia.id}|${m.id}`, - label: `${ia.display_name} / ${m.name}`, - agentName: ia.display_name, - modelName: m.name, - })), - ); -} - export function replaceCustomUtilityAgents( current: UtilityAgent[], customAgents: UtilityAgent[], @@ -56,6 +44,14 @@ export function mergeRefreshedUtilityAgents( ...agent, agent_id: draft.agent_id !== baseline.agent_id ? draft.agent_id : agent.agent_id, model: draft.model !== baseline.model ? draft.model : agent.model, + agent_profile_id: + draft.agent_profile_id !== baseline.agent_profile_id + ? draft.agent_profile_id + : agent.agent_profile_id, + profile_binding_state: + draft.profile_binding_state !== baseline.profile_binding_state + ? draft.profile_binding_state + : agent.profile_binding_state, enabled: draft.enabled !== baseline.enabled ? draft.enabled : agent.enabled, }; }); @@ -66,218 +62,124 @@ function updateBuiltinDraft( value: string, setAgents: React.Dispatch>, ) { - const isDefault = value === USE_DEFAULT; - const [agentId, model] = isDefault ? ["", ""] : value.split("|"); + const inherit = value === USE_DEFAULT; setAgents((prev) => - prev.map((a) => (a.id === agent.id ? { ...a, agent_id: agentId, model, enabled: true } : a)), + prev.map((item) => + item.id === agent.id + ? { + ...item, + agent_profile_id: inherit ? "" : value, + profile_binding_state: inherit ? "inherit" : "explicit", + enabled: true, + } + : item, + ), ); } -type UtilityDraftRegistration = { - agents: UtilityAgent[]; - savedAgents: UtilityAgent[]; - defaultAgentId: string; - defaultModel: string; - savedDefault: { agentId: string; model: string }; - loading: boolean; - setAgents: React.Dispatch>; - setSavedAgents: React.Dispatch>; - setDefaultAgentId: React.Dispatch>; - setDefaultModel: React.Dispatch>; - setSavedDefault: React.Dispatch>; -}; - -function utilityDraftRevision( - agents: UtilityAgent[], - defaultAgentId: string, - defaultModel: string, -) { +function revision(agents: UtilityAgent[], profileId: string) { return JSON.stringify({ - defaultAgentId, - defaultModel, + profileId, builtins: agents .filter((agent) => agent.builtin) - .map(({ id, agent_id, model, enabled }) => ({ id, agent_id, model, enabled })), + .map((agent) => ({ + id: agent.id, + agent_profile_id: agent.agent_profile_id, + profile_binding_state: agent.profile_binding_state, + enabled: agent.enabled, + })), }); } -function useUtilityDraftRegistration(state: UtilityDraftRegistration) { - const draftRevision = utilityDraftRevision( - state.agents, - state.defaultAgentId, - state.defaultModel, - ); - const savedRevision = utilityDraftRevision( - state.savedAgents, - state.savedDefault.agentId, - state.savedDefault.model, - ); - - useSettingsSaveContributor({ - id: "utility-agents", - revision: draftRevision, - isDirty: !state.loading && draftRevision !== savedRevision, - save: async () => { - const submittedAgents = state.agents; - const submittedDefault = { agentId: state.defaultAgentId, model: state.defaultModel }; - const changedBuiltins = submittedAgents.filter((agent) => { - if (!agent.builtin) return false; - const previous = state.savedAgents.find((saved) => saved.id === agent.id); - return ( - !previous || - previous.agent_id !== agent.agent_id || - previous.model !== agent.model || - previous.enabled !== agent.enabled - ); - }); - await Promise.all([ - updateUserSettings({ - default_utility_agent_id: submittedDefault.agentId, - default_utility_model: submittedDefault.model, - }), - ...changedBuiltins.map((agent) => - updateUtilityAgent(agent.id, { - agent_id: agent.agent_id, - model: agent.model, - enabled: agent.enabled, - }), - ), - ]); - state.setSavedDefault(submittedDefault); - state.setSavedAgents(submittedAgents); - }, - discard: () => { - state.setDefaultAgentId(state.savedDefault.agentId); - state.setDefaultModel(state.savedDefault.model); - state.setAgents(state.savedAgents); - }, - }); -} - -function useUtilityAgentsData() { +// eslint-disable-next-line max-lines-per-function +export function UtilityAgentsSection() { + const { t } = useTranslation(); + const profiles = useAppStore((state) => state.agentProfiles.items); const [agents, setAgents] = useState([]); const [savedAgents, setSavedAgents] = useState([]); - const { inferenceAgents, setInferenceAgents, refreshAgent } = useInferenceAgents(); - const [defaultAgentId, setDefaultAgentId] = useState(""); - const [defaultModel, setDefaultModel] = useState(""); - const [savedDefault, setSavedDefault] = useState({ agentId: "", model: "" }); + const [defaultProfileId, setDefaultProfileId] = useState(""); + const [savedDefaultProfileId, setSavedDefaultProfileId] = useState(""); const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingAgent, setEditingAgent] = useState(null); const fetchData = useCallback(async () => { try { - const [agentsRes, inferenceRes, settingsRes] = await Promise.all([ + const [agentsRes, settingsRes] = await Promise.all([ listUtilityAgents({ cache: "no-store" }), - listInferenceAgents(), fetchUserSettings({ cache: "no-store" }), ]); - const defaultDraft = { - agentId: settingsRes.settings.default_utility_agent_id || "", - model: settingsRes.settings.default_utility_model || "", - }; setAgents(agentsRes.agents); setSavedAgents(agentsRes.agents); - setInferenceAgents(inferenceRes.agents); - setDefaultAgentId(defaultDraft.agentId); - setDefaultModel(defaultDraft.model); - setSavedDefault(defaultDraft); + const profileId = settingsRes.settings.default_utility_agent_profile_id || ""; + setDefaultProfileId(profileId); + setSavedDefaultProfileId(profileId); } catch { setAgents([]); - setInferenceAgents([]); + setSavedAgents([]); } finally { setLoading(false); } - }, [setInferenceAgents]); - + }, []); useEffect(() => { void fetchData(); }, [fetchData]); - const refreshCustomAgents = useCallback(async () => { - try { - const response = await listUtilityAgents({ cache: "no-store" }); - setAgents((current) => mergeRefreshedUtilityAgents(current, savedAgents, response.agents)); - setSavedAgents(response.agents); - } catch { - // The dialog save already succeeded; keep the current list until the next refresh. - } - }, [savedAgents]); - - return { - agents, - savedAgents, - inferenceAgents, - defaultAgentId, - defaultModel, - savedDefault, - loading, - refreshAgent, - refreshCustomAgents, - setAgents, - setSavedAgents, - setDefaultAgentId, - setDefaultModel, - setSavedDefault, - }; -} - -export function UtilityAgentsSection() { - const { t } = useTranslation(); - const data = useUtilityAgentsData(); - const { - agents, - savedAgents, - inferenceAgents, - defaultAgentId, - defaultModel, - savedDefault, - loading, - refreshAgent, - refreshCustomAgents, - setAgents, - setSavedAgents, - setDefaultAgentId, - setDefaultModel, - setSavedDefault, - } = data; - const [dialogOpen, setDialogOpen] = useState(false); - const [editingAgent, setEditingAgent] = useState(null); - - const builtins = useMemo(() => agents.filter((a) => a.builtin), [agents]); - const customAgents = useMemo(() => agents.filter((a) => !a.builtin), [agents]); - const allModels = useMemo(() => buildAllModels(inferenceAgents), [inferenceAgents]); - - const handleDefaultChange = (agentId: string, model: string) => { - setDefaultAgentId(agentId); - setDefaultModel(model); - }; - - useUtilityDraftRegistration({ - agents, - savedAgents, - defaultAgentId, - defaultModel, - savedDefault, - loading, - setAgents, - setSavedAgents, - setDefaultAgentId, - setDefaultModel, - setSavedDefault, + const draftRevision = revision(agents, defaultProfileId); + const savedRevision = revision(savedAgents, savedDefaultProfileId); + useSettingsSaveContributor({ + id: "utility-agents", + revision: draftRevision, + isDirty: !loading && draftRevision !== savedRevision, + save: async () => { + const changed = agents.filter( + (agent) => + agent.builtin && + (() => { + const saved = savedAgents.find((item) => item.id === agent.id); + return ( + !saved || + saved.agent_profile_id !== agent.agent_profile_id || + saved.profile_binding_state !== agent.profile_binding_state || + saved.enabled !== agent.enabled + ); + })(), + ); + await Promise.all([ + updateUserSettings({ default_utility_agent_profile_id: defaultProfileId }), + ...changed.map((agent) => + updateUtilityAgent(agent.id, { + agent_profile_id: agent.agent_profile_id, + profile_binding_state: agent.profile_binding_state, + enabled: agent.enabled, + }), + ), + ]); + setSavedAgents(agents); + setSavedDefaultProfileId(defaultProfileId); + }, + discard: () => { + setAgents(savedAgents); + setDefaultProfileId(savedDefaultProfileId); + }, }); - const openEditDialog = (agent: UtilityAgent | null) => { - setEditingAgent(agent); - setDialogOpen(true); - }; - + const builtins = useMemo(() => agents.filter((agent) => agent.builtin), [agents]); + const customAgents = useMemo(() => agents.filter((agent) => !agent.builtin), [agents]); const closeDialog = () => { setDialogOpen(false); setEditingAgent(null); - void refreshCustomAgents(); + void listUtilityAgents({ cache: "no-store" }).then((response) => { + setAgents((current) => + replaceCustomUtilityAgents( + current, + response.agents.filter((agent) => !agent.builtin), + ), + ); + setSavedAgents(response.agents); + }); }; - if (loading) return null; - return ( <>
+ updateBuiltinDraft(agent, value, setAgents)} - onEdit={openEditDialog} + profiles={profiles} + defaultLabel={ + defaultProfileId + ? profiles.find((profile) => profile.id === defaultProfileId)?.label || + defaultProfileId + : t("settings:default") + } + onProfileChange={(agent, value) => updateBuiltinDraft(agent, value, setAgents)} + onEdit={(agent) => { + setEditingAgent(agent); + setDialogOpen(true); + }} savedBuiltins={savedAgents.filter((agent) => agent.builtin)} /> openEditDialog(null)} - onEdit={openEditDialog} + profiles={profiles} + onAdd={() => { + setEditingAgent(null); + setDialogOpen(true); + }} + onEdit={(agent) => { + setEditingAgent(agent); + setDialogOpen(true); + }} onDelete={async (agent) => { try { await deleteUtilityAgent(agent.id); - setAgents((prev) => prev.filter((a) => a.id !== agent.id)); - setSavedAgents((prev) => prev.filter((a) => a.id !== agent.id)); - } catch { - // Error already logged by API layer + setAgents((items) => items.filter((item) => item.id !== agent.id)); + setSavedAgents((items) => items.filter((item) => item.id !== agent.id)); + } catch (error) { + console.error("Failed to delete utility agent", error); } }} /> diff --git a/apps/web/components/settings/utility-dirty.ts b/apps/web/components/settings/utility-dirty.ts index 58804b7d49..72b6fa2466 100644 --- a/apps/web/components/settings/utility-dirty.ts +++ b/apps/web/components/settings/utility-dirty.ts @@ -5,6 +5,8 @@ export function isUtilityAgentDirty(draft: UtilityAgent, saved: UtilityAgent | u !saved || draft.agent_id !== saved.agent_id || draft.model !== saved.model || + draft.agent_profile_id !== saved.agent_profile_id || + draft.profile_binding_state !== saved.profile_binding_state || draft.enabled !== saved.enabled ); } diff --git a/apps/web/components/settings/utility-sections.tsx b/apps/web/components/settings/utility-sections.tsx index 25c92e661d..ead6b3b686 100644 --- a/apps/web/components/settings/utility-sections.tsx +++ b/apps/web/components/settings/utility-sections.tsx @@ -5,98 +5,33 @@ import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { CardAction, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectSeparator, - SelectTrigger, - SelectValue, -} from "@kandev/ui/select"; -import { - configOptionToModelOptions, - isModelConfigOption, - ModelConfigSelector, - type SelectConfigOption, - usableConfigOptions, -} from "@/components/model-config-selector"; -import { InferenceAgentStatusNote } from "@/components/settings/inference-agent-status"; -import type { UtilityAgent, InferenceAgent } from "@/lib/api/domains/utility-api"; +import type { UtilityAgent } from "@/lib/api/domains/utility-api"; +import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; import { SettingsCard } from "@/components/settings/settings-card"; import { STANDALONE_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/standalone"; import { isUtilityAgentDirty } from "@/components/settings/utility-dirty"; +import { + UtilityAgentProfilePicker, + utilityProfileEligibility, +} from "@/components/settings/utility-agent-profile-picker"; -const USE_DEFAULT = "__USE_DEFAULT__"; - -export type ModelOption = { value: string; label: string; agentName: string; modelName: string }; - -type ModelGroup = { agentName: string; models: ModelOption[] }; - -function groupModelsByAgent(models: ModelOption[]): ModelGroup[] { - const map = new Map(); - for (const m of models) { - const list = map.get(m.agentName); - if (list) list.push(m); - else map.set(m.agentName, [m]); - } - return Array.from(map, ([agentName, items]) => ({ agentName, models: items })); -} - -function utilityConfigOptions(agent: InferenceAgent | undefined): SelectConfigOption[] { - return usableConfigOptions( - agent?.config_options?.map((option) => ({ - type: option.type, - id: option.id, - name: option.name, - currentValue: option.current_value, - category: option.category, - options: option.options, - })), - ); -} - -// Default model selector section -type DefaultModelSectionProps = { - inferenceAgents: InferenceAgent[]; - defaultAgentId: string; - defaultModel: string; - onDefaultChange: (agentId: string, model: string) => void; - onRefreshAgent: (agentId: string) => Promise | void; - isDirty: boolean; -}; +export const USE_DEFAULT = "__USE_DEFAULT__"; +const UNCONFIGURED = "__UNCONFIGURED__"; export function DefaultModelSection({ - inferenceAgents, - defaultAgentId, - defaultModel, - onDefaultChange, - onRefreshAgent, + profiles, + profileId, + onProfileChange, isDirty, -}: DefaultModelSectionProps) { +}: { + profiles: AgentProfileOption[]; + profileId: string; + onProfileChange: (profileId: string) => void; + isDirty: boolean; +}) { const { t } = useTranslation(); - const selectedAgent = inferenceAgents.find((a) => a.id === defaultAgentId); - const modelOptions = selectedAgent?.models ?? []; - const currentModelId = modelOptions.find((m) => m.is_default)?.id; - const configOptions = utilityConfigOptions(selectedAgent); - const modelConfig = configOptions.find(isModelConfigOption); - const selectorModels = modelConfig - ? configOptionToModelOptions(modelConfig) - : modelOptions.map((model) => ({ - id: model.id, - name: model.name, - description: model.description || (model.id !== model.name ? model.id : undefined), - usageMultiplier: - typeof model.meta?.copilotUsage === "string" ? model.meta.copilotUsage : undefined, - })); - const selectedModel = defaultModel || modelConfig?.currentValue || currentModelId || null; - const selectedConfigOptions = configOptions.map((option) => ({ - ...option, - currentValue: - isModelConfigOption(option) && selectedModel ? selectedModel : option.currentValue, - })); - + const eligibleProfiles = profiles.filter(utilityProfileEligibility); + const selected = eligibleProfiles.find((profile) => profile.id === profileId); return ( - +

{t("settings:utilityDefaultModelTitle")}

@@ -112,75 +47,50 @@ export function DefaultModelSection({

{t("settings:utilityDefaultModelDescription")}

-
-
- - -
-
- - onDefaultChange(defaultAgentId, v)} - disabled={!defaultAgentId} - placeholder={t("settings:utilitySelectModel")} - ariaLabel={t("settings:utilityDefaultModelAriaLabel")} - /> -
-
- {defaultAgentId && ( - onRefreshAgent(defaultAgentId)} +
+ + onProfileChange(value === USE_DEFAULT ? "" : value)} + fallback={{ value: USE_DEFAULT, label: t("settings:utilityNoDefaultProfile") }} + unavailableValue={profileId && !selected ? profileId : undefined} + testId="utility-profile-picker-default" + triggerClassName="w-full max-w-sm font-normal" /> - )} + {profileId && !selected && ( +

{t("settings:utilityProfileNeedsRepair")}

+ )} +
); } -// Builtin action row -type BuiltinActionRowProps = { - agent: UtilityAgent; - allModels: ModelOption[]; - defaultLabel: string; - onModelChange: (agent: UtilityAgent, value: string) => void; - onEdit: (agent: UtilityAgent) => void; - isDirty: boolean; -}; - export function BuiltinActionRow({ agent, - allModels, + profiles, defaultLabel, - onModelChange, + onProfileChange, onEdit, isDirty, -}: BuiltinActionRowProps) { - const currentValue = - agent.agent_id && agent.model ? `${agent.agent_id}|${agent.model}` : USE_DEFAULT; - +}: { + agent: UtilityAgent; + profiles: AgentProfileOption[]; + defaultLabel: string; + onProfileChange: (agent: UtilityAgent, value: string) => void; + onEdit: (agent: UtilityAgent) => void; + isDirty: boolean; +}) { + const { t } = useTranslation(); + let currentValue = USE_DEFAULT; + if (agent.profile_binding_state === "unconfigured") { + currentValue = UNCONFIGURED; + } else if (agent.profile_binding_state !== "inherit" && agent.agent_profile_id) { + currentValue = agent.agent_profile_id; + } return (
-
{agent.name}
+
{agent.name}

{agent.description}

- + onProfileChange(agent, value)} + fallback={{ value: USE_DEFAULT, label: defaultLabel }} + unavailableValue={currentValue === UNCONFIGURED ? currentValue : undefined} + unavailableLabel={ + currentValue === UNCONFIGURED ? t("settings:utilityProfileNeedsRepair") : undefined + } + testId={`utility-profile-picker-action-${agent.id}`} + triggerClassName="min-w-0 flex-1 md:w-[280px] md:flex-none" + />