diff --git a/apps/backend/cmd/mock-agent/handler.go b/apps/backend/cmd/mock-agent/handler.go index b40052515c..6cb6c60647 100644 --- a/apps/backend/cmd/mock-agent/handler.go +++ b/apps/backend/cmd/mock-agent/handler.go @@ -435,6 +435,8 @@ func handlePrompt(e *emitter, prompt, model string) { emitSubagentSequence(e, model) case strings.EqualFold(cmd, "/subtask") || strings.HasPrefix(strings.ToLower(cmd), "/subtask "): emitCreateSubtask(e, cmd, model) + case strings.EqualFold(cmd, "/e2e:utility-profile"): + e.text("utility profile model: " + model) case strings.HasPrefix(cmd, "/e2e:"): rest := strings.TrimPrefix(cmd, "/e2e:") scenarioName, _, _ := strings.Cut(strings.TrimSpace(rest), " ") diff --git a/apps/backend/cmd/mock-agent/main.go b/apps/backend/cmd/mock-agent/main.go index 385a4fbd8e..d4e9b6ddf8 100644 --- a/apps/backend/cmd/mock-agent/main.go +++ b/apps/backend/cmd/mock-agent/main.go @@ -21,6 +21,7 @@ import ( // fixtures select for slow responses; it must be advertised for the // no-silent-model-fallback strict policy to accept it. const ( + modelConfigID = "model" modelFast = "mock-fast" modelSmart = "mock-smart" modelSlow = "mock-slow" @@ -224,7 +225,7 @@ func mockSessionConfigOptionsForModel(model string) []acp.SessionConfigOption { {Select: &acp.SessionConfigOptionSelect{ Category: &modelCat, CurrentValue: acp.SessionConfigValueId(model), - Id: "model", + Id: modelConfigID, Name: "Model", Options: acp.SessionConfigSelectOptions{Ungrouped: &modelOptions}, Type: "select", @@ -368,13 +369,24 @@ func (a *mockAgent) Prompt(ctx context.Context, req acp.PromptRequest) (acp.Prom return resp, err } e := &emitter{ctx: promptCtx, conn: a.conn, sid: req.SessionId} - handlePrompt(e, prompt, a.model) + handlePrompt(e, prompt, a.sessionModel(req.SessionId)) if promptCtx.Err() != nil { return acp.PromptResponse{StopReason: acp.StopReasonCancelled}, nil } return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil } +func (a *mockAgent) sessionModel(sessionID acp.SessionId) string { + a.mu.Lock() + defer a.mu.Unlock() + for _, option := range a.sessionConfig[sessionID] { + if option.Select != nil && option.Select.Id == modelConfigID && option.Select.CurrentValue != "" { + return string(option.Select.CurrentValue) + } + } + return a.model +} + // Cancel handles session cancellation. func (a *mockAgent) Cancel(_ context.Context, req acp.CancelNotification) error { a.mu.Lock() @@ -425,7 +437,7 @@ func (a *mockAgent) SetSessionConfigOption(_ context.Context, req acp.SetSession if !found { return acp.SetSessionConfigOptionResponse{}, fmt.Errorf("unknown mock config option %q", req.ValueId.ConfigId) } - if req.ValueId.ConfigId == "model" { + if req.ValueId.ConfigId == modelConfigID { modeValue := mockConfigOptionValue(options, "mode") options = mockSessionConfigOptionsForModel(string(req.ValueId.Value)) for i := range options { diff --git a/apps/backend/cmd/mock-agent/mock_agent_test.go b/apps/backend/cmd/mock-agent/mock_agent_test.go index 741366831d..67e05877ad 100644 --- a/apps/backend/cmd/mock-agent/mock_agent_test.go +++ b/apps/backend/cmd/mock-agent/mock_agent_test.go @@ -187,6 +187,20 @@ func newCapturingUpdater() *capturingUpdater { return &capturingUpdater{anySeen: make(chan struct{}), textSeen: make(chan struct{})} } +func TestHandlePromptUtilityProfileUsesSelectedModel(t *testing.T) { + updater := newCapturingUpdater() + handlePrompt(&emitter{ + ctx: context.Background(), + conn: updater, + sid: acp.SessionId("utility-profile-session"), + }, "/e2e:utility-profile", modelSmart) + + texts := updater.textMessages() + if len(texts) != 1 || texts[0] != "utility profile model: "+modelSmart { + t.Fatalf("utility profile response = %v, want [%q]", texts, "utility profile model: "+modelSmart) + } +} + func (u *capturingUpdater) SessionUpdate(_ context.Context, n acp.SessionNotification) error { u.mu.Lock() u.notes = append(u.notes, n) diff --git a/apps/backend/cmd/mock-agent/session_config_test.go b/apps/backend/cmd/mock-agent/session_config_test.go index 1818a4dd0a..49309bb32c 100644 --- a/apps/backend/cmd/mock-agent/session_config_test.go +++ b/apps/backend/cmd/mock-agent/session_config_test.go @@ -152,6 +152,43 @@ func TestSetSessionConfigOptionModelChangesAvailableOptions(t *testing.T) { } } +func TestPromptUsesCurrentSessionModel(t *testing.T) { + updater := newCapturingUpdater() + agent := &mockAgent{ + model: "mock-default", + conn: updater, + sessions: make(map[acp.SessionId]bool), + promptCancels: make(map[acp.SessionId]context.CancelFunc), + sessionConfig: make(map[acp.SessionId][]acp.SessionConfigOption), + commandsEmitted: make(map[acp.SessionId]bool), + } + session, err := agent.NewSession(context.Background(), acp.NewSessionRequest{}) + if err != nil { + t.Fatalf("NewSession: %v", err) + } + if _, err := agent.SetSessionConfigOption(context.Background(), acp.SetSessionConfigOptionRequest{ + ValueId: &acp.SetSessionConfigOptionValueId{ + SessionId: session.SessionId, + ConfigId: "model", + Value: modelSmart, + }, + }); err != nil { + t.Fatalf("set model: %v", err) + } + + if _, err := agent.Prompt(context.Background(), acp.PromptRequest{ + SessionId: session.SessionId, + Prompt: []acp.ContentBlock{acp.TextBlock("/e2e:utility-profile")}, + }); err != nil { + t.Fatalf("Prompt: %v", err) + } + + texts := updater.textMessages() + if len(texts) == 0 || texts[len(texts)-1] != "utility profile model: "+modelSmart { + t.Fatalf("utility profile response = %v, want final response %q", texts, "utility profile model: "+modelSmart) + } +} + func TestSessionConfigIsIsolatedAcrossNewSessions(t *testing.T) { agent := &mockAgent{ sessions: make(map[acp.SessionId]bool), diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml index 0219a9ee87..0853cc8dd7 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml +++ b/apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml @@ -25,6 +25,7 @@ runtime: capabilities: events: ["task.created"] api_write: ["tasks"] + agent_invoke: true state: true secrets: true user_state: true @@ -39,6 +40,12 @@ actions: - key: "connection-status" scope: "workspace" max_body_bytes: 4096 + - key: "utility-default" + scope: "workspace" + max_body_bytes: 4096 + - key: "utility-preference" + scope: "workspace" + max_body_bytes: 4096 - key: "repositories.inspect" scope: "workspace" max_body_bytes: 16384 @@ -115,3 +122,8 @@ config_schema: greeting: type: string title: "Greeting" + agent_profile: + type: string + format: agent-profile + title: "Utility agent profile" + description: "Optional profile used for utility-agent completion actions." diff --git a/apps/backend/cmd/plugin-fixture/fixture_package_test.go b/apps/backend/cmd/plugin-fixture/fixture_package_test.go index fe94026b68..a471ab89b2 100644 --- a/apps/backend/cmd/plugin-fixture/fixture_package_test.go +++ b/apps/backend/cmd/plugin-fixture/fixture_package_test.go @@ -38,6 +38,8 @@ func TestFixtureManifest_ParsesAndValidates(t *testing.T) { } require.Equal(t, "workspace", actions[repositoryInspectActionKey].ResourceScope) require.Equal(t, "workspace", actions[repositoryBranchesActionKey].ResourceScope) + require.Equal(t, "workspace", actions[utilityDefaultAction].ResourceScope) + require.Equal(t, "workspace", actions[utilityPreferenceAction].ResourceScope) require.Equal(t, "task", actions["link-pull-request"].ResourceScope) require.Len(t, m.ReferenceSources, 1) require.Equal(t, "fixture-pull-requests", m.ReferenceSources[0].Source) diff --git a/apps/backend/cmd/plugin-fixture/main_test.go b/apps/backend/cmd/plugin-fixture/main_test.go index f74ffbc81a..d41b249daa 100644 --- a/apps/backend/cmd/plugin-fixture/main_test.go +++ b/apps/backend/cmd/plugin-fixture/main_test.go @@ -154,6 +154,55 @@ func TestHandleAction_ReturnsAuthenticatedConnectionStatus(t *testing.T) { require.JSONEq(t, `{"connected":true,"workspace_id":"workspace-42"}`, string(resp.Body)) } +func TestHandleAction_UtilityDefaultOmitsOptions(t *testing.T) { + p := &fixturePlugin{dataDir: t.TempDir()} + host := &fakeHost{utilityText: "executed mock-fast"} + p.SetHost(host) + + resp, err := p.HandleAction(context.Background(), &pluginsdk.PluginActionRequest{ + ActionKey: utilityDefaultAction, + }) + require.NoError(t, err) + require.JSONEq(t, `{"response":"executed mock-fast"}`, string(resp.Body)) + require.Empty(t, host.lastUtilityOptions) +} + +func TestHandleAction_UtilityPreferenceReadsConfigAndPassesProfile(t *testing.T) { + p := &fixturePlugin{dataDir: t.TempDir()} + host := &fakeHost{ + configValues: map[string]any{"agent_profile": "profile-b"}, + utilityText: "executed mock-smart", + } + p.SetHost(host) + + resp, err := p.HandleAction(context.Background(), &pluginsdk.PluginActionRequest{ + ActionKey: utilityPreferenceAction, + }) + require.NoError(t, err) + require.JSONEq(t, `{"response":"executed mock-smart"}`, string(resp.Body)) + require.Equal(t, []pluginsdk.UtilityAgentOptions{{ProfileID: "profile-b"}}, host.lastUtilityOptions) +} + +func TestHandleAction_UtilityPreferenceEmptyConfigDelegatesDefault(t *testing.T) { + p := &fixturePlugin{dataDir: t.TempDir()} + host := &fakeHost{configValues: map[string]any{"agent_profile": ""}, utilityText: "default"} + p.SetHost(host) + + _, err := p.HandleAction(context.Background(), &pluginsdk.PluginActionRequest{ActionKey: utilityPreferenceAction}) + require.NoError(t, err) + require.Equal(t, []pluginsdk.UtilityAgentOptions{{}}, host.lastUtilityOptions) +} + +func TestHandleAction_UtilityPreferenceRejectsWrongConfigType(t *testing.T) { + p := &fixturePlugin{dataDir: t.TempDir()} + host := &fakeHost{configValues: map[string]any{"agent_profile": 42}} + p.SetHost(host) + + _, err := p.HandleAction(context.Background(), &pluginsdk.PluginActionRequest{ActionKey: utilityPreferenceAction}) + require.EqualError(t, err, `plugin-fixture: config "agent_profile" must be a string`) + require.Empty(t, host.lastUtilityOptions) +} + func TestHandleAction_InspectsFixtureRepository(t *testing.T) { p := &fixturePlugin{dataDir: t.TempDir()} @@ -366,8 +415,13 @@ type setStateCall struct { type fakeHost struct { pluginsdk.UnimplementedHostData - setStateCalls []setStateCall - setStateErr error + setStateCalls []setStateCall + setStateErr error + configValues map[string]any + configErr error + utilityText string + utilityErr error + lastUtilityOptions []pluginsdk.UtilityAgentOptions // Host data API write recording (ADR 0043 phase 2). createdTaskID string @@ -424,12 +478,19 @@ func (h *fakeHost) ListState(context.Context, string, string) ([]pluginsdk.State return nil, nil } -func (h *fakeHost) GetConfig(context.Context) (map[string]any, error) { return nil, nil } +func (h *fakeHost) GetConfig(context.Context) (map[string]any, error) { + return h.configValues, h.configErr +} func (h *fakeHost) GetSecret(context.Context, string) (string, bool, error) { return "", false, nil } func (h *fakeHost) SetSecret(context.Context, string, string) error { return nil } func (h *fakeHost) DeleteSecret(context.Context, string) error { return nil } func (h *fakeHost) RevealSecret(context.Context, string) (string, error) { return "", nil } +func (h *fakeHost) InvokeUtilityAgent(_ context.Context, _ string, options ...pluginsdk.UtilityAgentOptions) (string, error) { + h.lastUtilityOptions = append([]pluginsdk.UtilityAgentOptions(nil), options...) + return h.utilityText, h.utilityErr +} + func (h *fakeHost) EmitEvent(context.Context, string, map[string]any) error { return nil } var _ pluginsdk.Host = (*fakeHost)(nil) diff --git a/apps/backend/cmd/plugin-fixture/plugin.go b/apps/backend/cmd/plugin-fixture/plugin.go index 664363a250..3f98534c25 100644 --- a/apps/backend/cmd/plugin-fixture/plugin.go +++ b/apps/backend/cmd/plugin-fixture/plugin.go @@ -35,6 +35,9 @@ const ( fixtureCredentialHost = "bitbucket.example.test" fixtureCredentialPath = "/scm/TEAM/fixture" connectionStatusAction = "connection-status" + utilityDefaultAction = "utility-default" + utilityPreferenceAction = "utility-preference" + utilityProfilePrompt = "/e2e:utility-profile" repositoryInspectActionKey = "repositories.inspect" repositoryBranchesActionKey = "repositories.branches" searchPurpose = "search" @@ -181,6 +184,10 @@ func (p *fixturePlugin) HandleAction(ctx context.Context, req *pluginsdk.PluginA response["connected"] = false response["error"] = "connection unavailable" } + case utilityDefaultAction: + return p.invokeUtilityAgent(ctx, false) + case utilityPreferenceAction: + return p.invokeUtilityAgent(ctx, true) case "link-pull-request": response["linked"] = true response[fixtureTaskIDKey] = req.Context.TaskID @@ -201,6 +208,46 @@ func (p *fixturePlugin) HandleAction(ctx context.Context, req *pluginsdk.PluginA return &pluginsdk.PluginActionResponse{Body: body}, nil } +func (p *fixturePlugin) invokeUtilityAgent(ctx context.Context, usePreference bool) (*pluginsdk.PluginActionResponse, error) { + host := p.Host() + if host == nil { + return nil, fmt.Errorf("plugin-fixture: host unavailable") + } + + options, err := p.utilityAgentOptions(ctx, usePreference) + if err != nil { + return nil, err + } + response, err := host.InvokeUtilityAgent(ctx, utilityProfilePrompt, options...) + if err != nil { + return nil, fmt.Errorf("plugin-fixture: invoke utility agent: %w", err) + } + body, err := json.Marshal(map[string]string{"response": response}) + if err != nil { + return nil, fmt.Errorf("plugin-fixture: marshaling utility response: %w", err) + } + return &pluginsdk.PluginActionResponse{Body: body}, nil +} + +func (p *fixturePlugin) utilityAgentOptions(ctx context.Context, usePreference bool) ([]pluginsdk.UtilityAgentOptions, error) { + if !usePreference { + return nil, nil + } + config, err := p.Host().GetConfig(ctx) + if err != nil { + return nil, fmt.Errorf("plugin-fixture: read config: %w", err) + } + profileID := "" + if raw, ok := config["agent_profile"]; ok { + var valid bool + profileID, valid = raw.(string) + if !valid { + return nil, fmt.Errorf("plugin-fixture: config %q must be a string", "agent_profile") + } + } + return []pluginsdk.UtilityAgentOptions{{ProfileID: profileID}}, nil +} + func (p *fixturePlugin) inspectRepository(body []byte) (*pluginsdk.PluginActionResponse, error) { var request struct { URL string `json:"url"` diff --git a/apps/backend/internal/agent/settings/controller/agent_crud.go b/apps/backend/internal/agent/settings/controller/agent_crud.go index 6954c5294e..561dce00a5 100644 --- a/apps/backend/internal/agent/settings/controller/agent_crud.go +++ b/apps/backend/internal/agent/settings/controller/agent_crud.go @@ -27,7 +27,7 @@ func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.AgentDTO, er if err != nil { return nil, err } - result := toAgentDTO(agent, filterGlobalProfiles(profiles)) + result := c.toAgentDTO(agent, filterGlobalProfiles(profiles)) if err := c.decorateAgentDTO(ctx, &result); err != nil { return nil, err } @@ -47,7 +47,7 @@ func (c *Controller) ListAgents(ctx context.Context) (*dto.ListAgentsResponse, e if err != nil { return nil, err } - entry := toAgentDTO(agent, filterGlobalProfiles(profiles)) + entry := c.toAgentDTO(agent, filterGlobalProfiles(profiles)) if err := c.decorateAgentDTO(ctx, &entry); err != nil { return nil, err } @@ -182,7 +182,7 @@ func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (* if err != nil { return nil, err } - result := toAgentDTO(agent, profiles) + result := c.toAgentDTO(agent, profiles) c.applyCapabilityStatus(&result, agent.Name) return &result, nil } @@ -309,7 +309,7 @@ func (c *Controller) UpdateAgent(ctx context.Context, req UpdateAgentRequest) (* if err != nil { return nil, err } - result := toAgentDTO(agent, filterGlobalProfiles(profiles)) + result := c.toAgentDTO(agent, filterGlobalProfiles(profiles)) return &result, nil } diff --git a/apps/backend/internal/agent/settings/controller/custom_tui.go b/apps/backend/internal/agent/settings/controller/custom_tui.go index 3d73c7bf87..122ac6e956 100644 --- a/apps/backend/internal/agent/settings/controller/custom_tui.go +++ b/apps/backend/internal/agent/settings/controller/custom_tui.go @@ -116,7 +116,7 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT } profiles := []*models.AgentProfile{profile} - result := toAgentDTO(agent, profiles) + result := c.toAgentDTO(agent, profiles) return &result, nil } @@ -200,6 +200,6 @@ func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) if err != nil { return nil, err } - result := toAgentDTO(agent, filterGlobalProfiles(profiles)) + result := c.toAgentDTO(agent, filterGlobalProfiles(profiles)) return &result, nil } diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index ad2eb3a31b..05143fec16 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -1052,7 +1052,7 @@ func (c *Controller) cleanupEphemeralTasks(ctx context.Context, profileID string } } -func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO { +func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentDTO { profileDTOs := make([]dto.AgentProfileDTO, 0, len(profiles)) for _, profile := range profiles { profileDTOs = append(profileDTOs, toProfileDTO(profile)) @@ -1078,6 +1078,9 @@ func toAgentDTO(agent *models.Agent, profiles []*models.AgentProfile) dto.AgentD MCPStrategy: agent.TUIConfig.MCPStrategy, } } + if c.agentRegistry != nil { + _, result.InferenceCapable = c.agentRegistry.GetInferenceAgent(agent.Name) + } return result } diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 96f48f825f..47b574294e 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -143,8 +143,11 @@ type AgentDTO struct { // flag agents that need login or reinstallation without fetching the // full model config separately. "" for agents that aren't probed // (mock, tui-only). - CapabilityStatus string `json:"capability_status,omitempty"` - CapabilityError string `json:"capability_error,omitempty"` + CapabilityStatus string `json:"capability_status,omitempty"` + CapabilityError string `json:"capability_error,omitempty"` + // InferenceCapable identifies agents accepted by the sessionless host + // utility runner. Profile pickers use it to prevent invalid selections. + InferenceCapable bool `json:"inference_capable"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index d9b74cc538..e3c738fcc2 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -629,7 +629,7 @@ func (h *Handlers) httpCreateProfile(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create profile"}) return } - h.broadcastProfileEvent(ws.ActionAgentProfileCreated, resp) + h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileCreated, resp) c.JSON(http.StatusOK, resp) } @@ -686,7 +686,7 @@ func (h *Handlers) httpUpdateProfile(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update profile"}) return } - h.broadcastProfileEvent(ws.ActionAgentProfileUpdated, resp) + h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileUpdated, resp) c.JSON(http.StatusOK, resp) } @@ -716,7 +716,7 @@ func (h *Handlers) httpDuplicateProfile(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to duplicate profile"}) return } - h.broadcastProfileEvent(ws.ActionAgentProfileCreated, resp) + h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileCreated, resp) c.JSON(http.StatusOK, resp) } @@ -728,12 +728,24 @@ func (h *Handlers) httpDuplicateProfile(c *gin.Context) { // filterGlobalProfiles, and the WS path must not contradict that. When the // hub does not support workspace routing (test fakes), the office event is // dropped fail-closed. -func (h *Handlers) broadcastProfileEvent(action string, profile *dto.AgentProfileDTO) { +func (h *Handlers) broadcastProfileEvent(ctx context.Context, action string, profile *dto.AgentProfileDTO) { if h.hub == nil { return } + // Profile events can arrive before settings-agent hydration. Include the + // capability needed by sessionless pickers so they need not guess. + inferenceCapable := false + if action != ws.ActionAgentProfileDeleted { + agent, err := h.controller.GetAgent(ctx, profile.AgentID) + if err != nil { + h.logger.Warn("failed to load agent capability for profile event", zap.Error(err)) + } else { + inferenceCapable = agent.InferenceCapable + } + } notification, _ := ws.NewNotification(action, gin.H{ - "profile": profile, + "profile": profile, + "inference_capable": inferenceCapable, }) if profile.WorkspaceID != "" { if workspaceHub, ok := h.hub.(interface { @@ -771,7 +783,7 @@ func (h *Handlers) httpDeleteProfile(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete profile"}) return } - h.broadcastProfileEvent(ws.ActionAgentProfileDeleted, profile) + h.broadcastProfileEvent(c.Request.Context(), ws.ActionAgentProfileDeleted, profile) c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/apps/backend/internal/agent/settings/handlers/profile_crud_handlers_test.go b/apps/backend/internal/agent/settings/handlers/profile_crud_handlers_test.go index 38065cdd42..3d4b77a03e 100644 --- a/apps/backend/internal/agent/settings/handlers/profile_crud_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/profile_crud_handlers_test.go @@ -8,6 +8,7 @@ import ( "reflect" "testing" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/settings/controller" "github.com/kandev/kandev/internal/agent/settings/dto" ws "github.com/kandev/kandev/pkg/websocket" @@ -97,6 +98,37 @@ func TestCreateProfileEndpoint(t *testing.T) { } }) + t.Run("broadcast includes the owning agent's inference capability", func(t *testing.T) { + repo := newFakeSettingsRepo() + seedAgent(repo, "agent-1", "profile-agent", false) + hub := &duplicateHub{} + router, _, reg := newSettingsHarness(t, repo, hub) + inferenceAgent := agents.NewMockAgentWithID("profile-agent", "profile-agent", "Profile Agent") + inferenceAgent.SetEnabled(true) + if err := reg.Register(inferenceAgent); err != nil { + t.Fatalf("register inference agent: %v", err) + } + + response := doSettingsRequest(router, http.MethodPost, "/api/v1/agents/agent-1/profiles", + `{"name":"Fast","model":"model-x"}`) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + payloads := hub.payloads(ws.ActionAgentProfileCreated) + if len(payloads) != 1 { + t.Fatalf("recorded %d profile-created broadcasts, want 1", len(payloads)) + } + var envelope struct { + InferenceCapable bool `json:"inference_capable"` + } + if err := json.Unmarshal(payloads[0], &envelope); err != nil { + t.Fatalf("decode profile-created payload: %v", err) + } + if !envelope.InferenceCapable { + t.Fatal("profile-created capability = false, want true for mock-agent") + } + }) + t.Run("rejections keep their specific message and write nothing", func(t *testing.T) { cases := []struct { name string diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 9d8a166e6a..2d400e42f1 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1032,22 +1032,26 @@ func startGatewayAndServe( // Per-instance servers enforce the same single rotating credential as // the control server -- see config.AgentConfig's field doc. hostUtilityMgr.SetAuthToken(cfg.Agent.StandaloneAuthToken) - hostUtilityMgr.SetProfileResolver(profilebinding.New(repos.AgentSettings, func(agentID string) bool { + pluginProfileResolver := profilebinding.New(repos.AgentSettings, func(agentID string) bool { _, ok := agentRegistry.GetInferenceAgent(agentID) return ok - })) + }) + hostUtilityMgr.SetProfileResolver(pluginProfileResolver) hostUtilityMgr.SetManagedRuntimeSelectionStore(services.ManagedRuntimeSelections) // 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) - // 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, userSvc: services.User}, pluginsHostUtilityAdapter{mgr: hostUtilityMgr}) + // Wire Host.InvokeUtilityAgent at the first point where the sessionless + // host-utility tier is live. The host reads the platform default from user + // settings; plugins provide any per-call override explicitly. + if services.Plugins != nil && services.User != nil { + services.Plugins.SetUtilityAgent( + pluginsDefaultUtilityProfileAdapter{source: services.User}, + pluginsAgentProfileAdapter{resolver: pluginProfileResolver}, + pluginsHostUtilityAdapter{mgr: hostUtilityMgr}, + ) } bootstrap := ctx.Value(bootstrapContextKey{}).(*bootstrapRuntime) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index ecca0f0ebd..aaea7e7f2d 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -2,6 +2,7 @@ package backendapp import ( "context" + "database/sql" "errors" "fmt" "net/url" @@ -52,7 +53,6 @@ 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" @@ -1388,6 +1388,10 @@ func initPluginsService( return svc } +type pluginHostUtilityManager interface { + ExecuteProfilePrompt(ctx context.Context, profileID, prompt string) (*hostutility.PromptResult, error) +} + func initPluginsServiceRequired( cfg *config.Config, dbPool *db.Pool, @@ -1416,42 +1420,66 @@ func recordPluginStores(tracker *requiredstores.Tracker, initErrors plugins.Stor } type pluginsHostUtilityAdapter struct { - mgr *hostutility.Manager + mgr pluginHostUtilityManager } func (a pluginsHostUtilityAdapter) ExecuteProfilePrompt(ctx context.Context, profileID, prompt string) (string, error) { res, err := a.mgr.ExecuteProfilePrompt(ctx, profileID, prompt) + if errors.Is(err, profilebinding.ErrProfileNotFound) { + return "", plugins.ErrAgentProfileNotFound + } + if errors.Is(err, profilebinding.ErrProfileIneligible) { + return "", plugins.ErrAgentProfileIneligible + } if err != nil { return "", err } return res.Response, nil } -type pluginsUtilityAgentAdapter struct { - svc *utilityservice.Service - userSvc *userservice.Service +type pluginsDefaultUtilityProfileSource interface { + GetDefaultUtilityAgentProfileID(ctx context.Context) (string, error) } -func (a pluginsUtilityAgentAdapter) GetAgentByID(ctx context.Context, id string) (*plugins.UtilityAgent, error) { - agent, err := a.svc.GetAgentByID(ctx, id) +type pluginsDefaultUtilityProfileAdapter struct { + source pluginsDefaultUtilityProfileSource +} + +func (a pluginsDefaultUtilityProfileAdapter) GetDefaultUtilityAgentProfileID(ctx context.Context) (string, error) { + return a.source.GetDefaultUtilityAgentProfileID(ctx) +} + +type pluginAgentProfileResolver interface { + Resolve(ctx context.Context, id string) (*agentsettingsmodels.AgentProfile, error) +} + +// pluginsAgentProfileAdapter keeps agent-settings storage errors at the +// backend boundary. The plugins package only receives the narrow execution +// eligibility record and its typed missing-profile sentinel. +type pluginsAgentProfileAdapter struct { + resolver pluginAgentProfileResolver +} + +func (a pluginsAgentProfileAdapter) GetProfileByID(ctx context.Context, id string) (*plugins.AgentProfile, error) { + profile, err := a.resolver.Resolve(ctx, id) + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, profilebinding.ErrProfileNotFound) { + return nil, plugins.ErrAgentProfileNotFound + } + if errors.Is(err, profilebinding.ErrProfileIneligible) { + return nil, plugins.ErrAgentProfileIneligible + } if err != nil { - if errors.Is(err, utilityservice.ErrAgentNotFound) { - return nil, plugins.ErrUtilityAgentNotFound - } return nil, err } - profileID := agent.AgentProfileID - bindingState := agent.ProfileBindingState - if utilitymodels.UsesDefaultProfile(agent) && a.userSvc != nil { - profileID, err = a.userSvc.GetDefaultUtilityAgentProfileID(ctx) - if err != nil { - return nil, err - } - if profileID != "" { - bindingState = utilitymodels.ProfileBindingExplicit - } + if profile == nil { + return nil, plugins.ErrAgentProfileNotFound } - return &plugins.UtilityAgent{Name: agent.Name, AgentID: agent.AgentID, Model: agent.Model, AgentProfileID: profileID, ProfileBindingState: bindingState, Enabled: agent.Enabled}, nil + return &plugins.AgentProfile{ + Enabled: profile.Enabled, + CLIPassthrough: profile.CLIPassthrough, + WorkspaceID: profile.WorkspaceID, + InferenceCapable: true, + }, nil } // pluginsTaskWriterAdapter adapts the task service to the plugins package's diff --git a/apps/backend/internal/backendapp/services_plugin_utility_test.go b/apps/backend/internal/backendapp/services_plugin_utility_test.go index 13c8781625..34dde8225f 100644 --- a/apps/backend/internal/backendapp/services_plugin_utility_test.go +++ b/apps/backend/internal/backendapp/services_plugin_utility_test.go @@ -6,92 +6,109 @@ import ( "errors" "testing" - "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/agent/hostutility" + agentsettingsmodels "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/plugins" - userModels "github.com/kandev/kandev/internal/user/models" - userService "github.com/kandev/kandev/internal/user/service" - userStore "github.com/kandev/kandev/internal/user/store" - utilitymodels "github.com/kandev/kandev/internal/utility/models" - utilityservice "github.com/kandev/kandev/internal/utility/service" - utilitystore "github.com/kandev/kandev/internal/utility/store" + "github.com/kandev/kandev/internal/utility/profilebinding" ) -type pluginUtilityAgentRepository struct { - utilitystore.Repository +type pluginHostUtilityManagerStub struct { err error } -func (r *pluginUtilityAgentRepository) GetAgentByID(context.Context, string) (*utilitymodels.UtilityAgent, error) { - return nil, r.err +func (s pluginHostUtilityManagerStub) ExecuteProfilePrompt(context.Context, string, string) (*hostutility.PromptResult, error) { + return nil, s.err } -func TestPluginsUtilityAgentAdapter_MapsTypedNotFound(t *testing.T) { - adapter := pluginsUtilityAgentAdapter{ - svc: utilityservice.NewService(&pluginUtilityAgentRepository{err: sql.ErrNoRows}), - } +type pluginDefaultUtilityProfileSourceStub struct { + profileID string + err error +} - _, err := adapter.GetAgentByID(context.Background(), "missing") - if !errors.Is(err, plugins.ErrUtilityAgentNotFound) { - t.Fatalf("GetAgentByID() error = %v, want plugin not-found error", err) +func (s pluginDefaultUtilityProfileSourceStub) GetDefaultUtilityAgentProfileID(context.Context) (string, error) { + return s.profileID, s.err +} + +func TestPluginsDefaultUtilityProfileAdapterDelegates(t *testing.T) { + adapter := pluginsDefaultUtilityProfileAdapter{source: pluginDefaultUtilityProfileSourceStub{profileID: "profile-1"}} + + got, err := adapter.GetDefaultUtilityAgentProfileID(context.Background()) + if err != nil { + t.Fatalf("GetDefaultUtilityAgentProfileID() error = %v", err) + } + if got != "profile-1" { + t.Fatalf("GetDefaultUtilityAgentProfileID() = %q, want profile-1", got) } } -func TestPluginsUtilityAgentAdapter_PreservesOperationalFailure(t *testing.T) { - storeErr := errors.New("utility agent database unavailable") - adapter := pluginsUtilityAgentAdapter{ - svc: utilityservice.NewService(&pluginUtilityAgentRepository{err: storeErr}), - } +func TestPluginsDefaultUtilityProfileAdapterPreservesOperationalFailure(t *testing.T) { + storeErr := errors.New("user settings unavailable") + adapter := pluginsDefaultUtilityProfileAdapter{source: pluginDefaultUtilityProfileSourceStub{err: storeErr}} - _, err := adapter.GetAgentByID(context.Background(), "configured") + _, err := adapter.GetDefaultUtilityAgentProfileID(context.Background()) if !errors.Is(err, storeErr) { - t.Fatalf("GetAgentByID() error = %v, want store error", err) + t.Fatalf("GetDefaultUtilityAgentProfileID() error = %v, want %v", err, storeErr) } } -func TestPluginsUtilityAgentAdapter_ResolvesEmptyBuiltinThroughDefault(t *testing.T) { - log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) +func TestPluginsAgentProfileAdapter_MapsTypedNotFound(t *testing.T) { + adapter := pluginsAgentProfileAdapter{resolver: &pluginAgentProfileResolverStub{err: sql.ErrNoRows}} + + _, err := adapter.GetProfileByID(context.Background(), "missing") + if !errors.Is(err, plugins.ErrAgentProfileNotFound) { + t.Fatalf("GetProfileByID() error = %v, want plugin not-found error", err) + } +} + +func TestPluginsAgentProfileAdapter_PreservesOperationalFailure(t *testing.T) { + storeErr := errors.New("agent profile database unavailable") + adapter := pluginsAgentProfileAdapter{resolver: &pluginAgentProfileResolverStub{err: storeErr}} + + _, err := adapter.GetProfileByID(context.Background(), "configured") + if !errors.Is(err, storeErr) { + t.Fatalf("GetProfileByID() error = %v, want store error", err) + } +} + +func TestPluginsAgentProfileAdapter_PreservesEligibilityFields(t *testing.T) { + adapter := pluginsAgentProfileAdapter{resolver: &pluginAgentProfileResolverStub{profile: &agentsettingsmodels.AgentProfile{ + Enabled: true, + CLIPassthrough: false, + WorkspaceID: "", + }}} + + got, err := adapter.GetProfileByID(context.Background(), "profile-1") if err != nil { - t.Fatalf("NewLogger() error = %v", err) + t.Fatalf("GetProfileByID() error = %v", err) } - userRepo := &pluginUserRepository{ - getSettings: &userModels.UserSettings{DefaultUtilityAgentProfileID: "default-profile"}, - } - adapter := pluginsUtilityAgentAdapter{ - svc: utilityservice.NewService(&utilityAgentRepositoryStub{agent: &utilitymodels.UtilityAgent{ - ID: "builtin", - Builtin: true, - ProfileBindingState: utilitymodels.ProfileBindingInherit, - }}), - userSvc: userService.NewService(userRepo, bus.NewMemoryEventBus(log), log), - } - - got, err := adapter.GetAgentByID(context.Background(), "builtin") - if err != nil { - t.Fatalf("GetAgentByID() error = %v", err) - } - if got.AgentProfileID != "default-profile" { - t.Fatalf("AgentProfileID = %q, want %q", got.AgentProfileID, "default-profile") - } - if got.ProfileBindingState != utilitymodels.ProfileBindingExplicit { - t.Fatalf("ProfileBindingState = %q, want %q", got.ProfileBindingState, utilitymodels.ProfileBindingExplicit) + if !got.Enabled || got.CLIPassthrough || got.WorkspaceID != "" || !got.InferenceCapable { + t.Fatalf("GetProfileByID() = %+v, want an eligible profile", got) } } -type utilityAgentRepositoryStub struct { - utilitystore.Repository - agent *utilitymodels.UtilityAgent +func TestPluginsAgentProfileAdapter_MapsIneligibleProfile(t *testing.T) { + adapter := pluginsAgentProfileAdapter{resolver: &pluginAgentProfileResolverStub{err: profilebinding.ErrProfileIneligible}} + + _, err := adapter.GetProfileByID(context.Background(), "not-inference-capable") + if !errors.Is(err, plugins.ErrAgentProfileIneligible) { + t.Fatalf("GetProfileByID() error = %v, want plugin ineligible error", err) + } } -func (r *utilityAgentRepositoryStub) GetAgentByID(context.Context, string) (*utilitymodels.UtilityAgent, error) { - return r.agent, nil +func TestPluginsHostUtilityAdapter_MapsRevalidationIneligibleProfile(t *testing.T) { + adapter := pluginsHostUtilityAdapter{mgr: pluginHostUtilityManagerStub{err: profilebinding.ErrProfileIneligible}} + + _, err := adapter.ExecuteProfilePrompt(context.Background(), "profile-1", "prompt") + if !errors.Is(err, plugins.ErrAgentProfileIneligible) { + t.Fatalf("ExecuteProfilePrompt() error = %v, want plugin ineligible error", err) + } } -type pluginUserRepository struct { - userStore.Repository - getSettings *userModels.UserSettings +type pluginAgentProfileResolverStub struct { + profile *agentsettingsmodels.AgentProfile + err error } -func (r *pluginUserRepository) GetUserSettings(context.Context, string) (*userModels.UserSettings, error) { - return r.getSettings, nil +func (s *pluginAgentProfileResolverStub) Resolve(context.Context, string) (*agentsettingsmodels.AgentProfile, error) { + return s.profile, s.err } diff --git a/apps/backend/internal/mcp/handlers/config_agent_handlers.go b/apps/backend/internal/mcp/handlers/config_agent_handlers.go index 20bf4bb370..ab9218beed 100644 --- a/apps/backend/internal/mcp/handlers/config_agent_handlers.go +++ b/apps/backend/internal/mcp/handlers/config_agent_handlers.go @@ -219,12 +219,23 @@ func (h *Handlers) handleUpdateAgentProfile(ctx context.Context, msg *ws.Message // publishAgentProfileEvent publishes an agent profile event to the event bus. // The payload wraps the profile in a "profile" key to match the format expected // by existing frontend WS handlers (same format as HTTP agent settings handlers). -func (h *Handlers) publishAgentProfileEvent(ctx context.Context, eventType string, profile interface{}) { +func (h *Handlers) publishAgentProfileEvent(ctx context.Context, eventType string, profile *agentsettingsdto.AgentProfileDTO) { if h.eventBus == nil || profile == nil { return } + // Profile events can arrive before settings-agent hydration (the profile + // still carries its owning agent ID on delete), so include the capability + // needed by sessionless pickers, mirroring the HTTP settings publisher. + inferenceCapable := false + agent, err := h.agentSettingsCtrl.GetAgent(ctx, profile.AgentID) + if err != nil { + h.logger.Warn("failed to load agent capability for profile event", zap.Error(err)) + } else if agent != nil { + inferenceCapable = agent.InferenceCapable + } data := map[string]interface{}{ - "profile": profile, + "profile": profile, + "inference_capable": inferenceCapable, } if err := h.eventBus.Publish(ctx, eventType, bus.NewEvent(eventType, "mcp-handlers", data)); err != nil { h.logger.Error("failed to publish agent profile event", diff --git a/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go b/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go index b05c4b35a8..b4e7d46fc5 100644 --- a/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go +++ b/apps/backend/internal/mcp/handlers/config_agent_handlers_test.go @@ -15,6 +15,8 @@ import ( settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" ws "github.com/kandev/kandev/pkg/websocket" ) @@ -215,3 +217,65 @@ func TestHandleDeleteAgentProfile_InUseStaysAValidationError(t *testing.T) { require.NoError(t, err) assertWSError(t, resp, ws.ErrorCodeValidation) } + +// A profile event published by the MCP config handlers must carry the owning +// agent's sessionless inference capability, matching the HTTP settings +// publisher, so capability-scoped pickers can classify profiles that arrive +// before settings-agent hydration. +func TestPublishAgentProfileEvent_IncludesInferenceCapability(t *testing.T) { + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + require.NoError(t, err) + eventBus := bus.NewMemoryEventBus(log) + + db, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + repo, cleanup, err := settingsstore.Provide(db, db, log) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup() }) + + agentRegistry := registry.NewRegistry(log) + agentRegistry.LoadDefaults() + ctrl := agentsettingscontroller.NewController(repo, nil, agentRegistry, nil, log) + h := &Handlers{logger: log, agentSettingsCtrl: ctrl, eventBus: eventBus} + + agent := &settingsmodels.Agent{Name: "opencode-acp"} + require.NoError(t, repo.CreateAgent(context.Background(), agent)) + + type profileEvent struct { + Profile settingsdto.AgentProfileDTO `json:"profile"` + InferenceCapable bool `json:"inference_capable"` + } + received := make(chan profileEvent, 1) + _, subErr := eventBus.Subscribe(events.AgentProfileCreated, func(ctx context.Context, event *bus.Event) error { + data, marshalErr := json.Marshal(event.Data) + if marshalErr != nil { + return marshalErr + } + var decoded profileEvent + if decodeErr := json.Unmarshal(data, &decoded); decodeErr != nil { + return decodeErr + } + received <- decoded + return nil + }) + require.NoError(t, subErr) + + msg := makeWSMessage(t, ws.ActionMCPCreateAgentProfile, map[string]interface{}{ + "agent_id": agent.ID, + "name": "Capability", + "model": "gpt-5", + }) + resp, err := h.handleCreateAgentProfile(context.Background(), msg) + require.NoError(t, err) + created := decodeAgentProfileResponse(t, resp) + + select { + case got := <-received: + require.Equal(t, created.ID, got.Profile.ID) + _, inference := agentRegistry.GetInferenceAgent(agent.Name) + require.Equal(t, inference, got.InferenceCapable) + default: + t.Fatalf("no agent_profile.created event was published") + } +} diff --git a/apps/backend/internal/plugins/config_test.go b/apps/backend/internal/plugins/config_test.go index bb4b7b98de..5c50930e86 100644 --- a/apps/backend/internal/plugins/config_test.go +++ b/apps/backend/internal/plugins/config_test.go @@ -143,6 +143,20 @@ func TestMergeMaskedSecretsDropsMaskWithNoStoredValue(t *testing.T) { } } +func TestMergeMaskedSecretsDoesNotPreserveLegacyUtilitySelector(t *testing.T) { + schema := map[string]any{"properties": map[string]any{ + "agent_profile": map[string]any{"type": "string", "format": "agent-profile"}, + }} + merged := mergeMaskedSecrets( + map[string]any{"agent_profile": "profile-1"}, + map[string]any{"utility_agent": "utility-agent-1"}, + schema, + ) + if _, found := merged["utility_agent"]; found { + t.Fatalf("mergeMaskedSecrets() retained the legacy selector: %v", merged) + } +} + func TestValidateConfigSchema(t *testing.T) { schema := testConfigSchema() cases := []struct { diff --git a/apps/backend/internal/plugins/host.go b/apps/backend/internal/plugins/host.go index 23eee787b1..272a9c56ce 100644 --- a/apps/backend/internal/plugins/host.go +++ b/apps/backend/internal/plugins/host.go @@ -49,10 +49,9 @@ type pluginHost struct { // GetConfig to know which fields are secret (and therefore stored as // vault references to resolve back to cleartext). configSchema map[string]any - - state *state.Store - secrets SecretVault - bus bus.EventBus + state *state.Store + secrets SecretVault + bus bus.EventBus // configs reads the plugin's operator-editable config for the ungated // GetConfig RPC. Satisfied by store.Store; nil in tests that build a @@ -95,15 +94,12 @@ type pluginHost struct { // See host_interactions.go. interactionDeps func() interactionResponder - // utilityDeps returns the live utility-agent dependencies (ADR 0048) at - // call time rather than a spawn-time snapshot. hostUtilityMgr is - // constructed late in boot — after StartActivePlugins has already spawned - // boot-active plugins — so snapshotting here would strand those hosts with - // nil deps and make InvokeUtilityAgent return Unimplemented for their whole - // lifetime. Reading live (under Service.mu) lets the later SetUtilityAgent - // wiring take effect without a plugin restart. nil on a bare test host. + // utilityDeps returns the live utility invocation dependencies at call time + // rather than a spawn-time snapshot. The runner is constructed late in boot, + // after boot-active plugins can spawn, so reading live lets later wiring take + // effect without a plugin restart. nil on a bare test host. // See host_utility.go. - utilityDeps func() (utilityAgentSource, utilityRunner) + utilityDeps func() (utilityDefaultProfileSource, agentProfileSource, utilityRunner) } var _ pluginsdk.Host = (*pluginHost)(nil) diff --git a/apps/backend/internal/plugins/host_data_test.go b/apps/backend/internal/plugins/host_data_test.go index cb218f424e..8382cbe99f 100644 --- a/apps/backend/internal/plugins/host_data_test.go +++ b/apps/backend/internal/plugins/host_data_test.go @@ -131,13 +131,27 @@ func (f *fakeWorkflowStepLister) ListStepsByWorkflow(_ context.Context, workflow } type fakeAgentProfileDataSource struct { - resp *agentsettingsdto.ListAgentsResponse + resp *agentsettingsdto.ListAgentsResponse + profilesByID map[string]*AgentProfile + profileErr error + profileCalls int } func (f *fakeAgentProfileDataSource) ListAgents(context.Context) (*agentsettingsdto.ListAgentsResponse, error) { return f.resp, nil } +func (f *fakeAgentProfileDataSource) GetProfileByID(_ context.Context, id string) (*AgentProfile, error) { + f.profileCalls++ + if f.profileErr != nil { + return nil, f.profileErr + } + if profile, ok := f.profilesByID[id]; ok { + return profile, nil + } + return nil, ErrAgentProfileNotFound +} + type fakeSessionCodeStatsSource struct { calls int lastFilter analyticsmodels.SessionCodeStatsFilter @@ -284,18 +298,18 @@ func (f *fakeTaskStarter) StartTask(_ context.Context, taskID string, launch Tas // tests can both drive Host calls and assert against the fakes' recorded // state. type testDataHost struct { - host *pluginHost - tasks *fakeTaskDataSource - workflows *fakeWorkflowLister - steps *fakeWorkflowStepLister - profiles *fakeAgentProfileDataSource - codeStats *fakeSessionCodeStatsSource - messages *fakeMessageDataSource - utilAgents *fakeUtilityAgentSource - utilRun *fakeUtilityRunner - taskWriter *fakeTaskWriter - messenger *fakeMessenger - starter *fakeTaskStarter + host *pluginHost + tasks *fakeTaskDataSource + workflows *fakeWorkflowLister + steps *fakeWorkflowStepLister + profiles *fakeAgentProfileDataSource + codeStats *fakeSessionCodeStatsSource + messages *fakeMessageDataSource + defaultProfile *fakeDefaultUtilityProfileSource + utilRun *fakeUtilityRunner + taskWriter *fakeTaskWriter + messenger *fakeMessenger + starter *fakeTaskStarter interactions *fakeInteractionDataSource responder *fakeInteractionResponder @@ -306,17 +320,17 @@ type testDataHost struct { // resource) so each test only needs to vary caps. func newTestDataHost(caps manifest.Capabilities) *testDataHost { d := &testDataHost{ - tasks: &fakeTaskDataSource{}, - workflows: &fakeWorkflowLister{}, - steps: &fakeWorkflowStepLister{}, - profiles: &fakeAgentProfileDataSource{resp: &agentsettingsdto.ListAgentsResponse{}}, - codeStats: &fakeSessionCodeStatsSource{}, - messages: &fakeMessageDataSource{}, - utilAgents: &fakeUtilityAgentSource{}, - utilRun: &fakeUtilityRunner{text: "ok"}, - taskWriter: &fakeTaskWriter{}, - messenger: &fakeMessenger{}, - starter: &fakeTaskStarter{}, + tasks: &fakeTaskDataSource{}, + workflows: &fakeWorkflowLister{}, + steps: &fakeWorkflowStepLister{}, + profiles: &fakeAgentProfileDataSource{resp: &agentsettingsdto.ListAgentsResponse{}}, + codeStats: &fakeSessionCodeStatsSource{}, + messages: &fakeMessageDataSource{}, + defaultProfile: &fakeDefaultUtilityProfileSource{}, + utilRun: &fakeUtilityRunner{text: "ok"}, + taskWriter: &fakeTaskWriter{}, + messenger: &fakeMessenger{}, + starter: &fakeTaskStarter{}, interactions: &fakeInteractionDataSource{}, responder: &fakeInteractionResponder{}, @@ -332,9 +346,9 @@ func newTestDataHost(caps manifest.Capabilities) *testDataHost { messageData: d.messages, interactionData: d.interactions, taskWriter: d.taskWriter, - configs: &fakeConfigReader{configs: map[string]any{utilityAgentConfigKey: "utility-agent-42"}}, - utilityDeps: func() (utilityAgentSource, utilityRunner) { - return d.utilAgents, d.utilRun + configs: &fakeConfigReader{configs: map[string]any{"utility_agent": "utility-agent-42"}}, + utilityDeps: func() (utilityDefaultProfileSource, agentProfileSource, utilityRunner) { + return d.defaultProfile, d.profiles, d.utilRun }, writeDeps: func() (taskMessenger, taskStarter) { return d.messenger, d.starter diff --git a/apps/backend/internal/plugins/host_data_wire_test.go b/apps/backend/internal/plugins/host_data_wire_test.go index dc53d9cc9b..4d37b6c850 100644 --- a/apps/backend/internal/plugins/host_data_wire_test.go +++ b/apps/backend/internal/plugins/host_data_wire_test.go @@ -356,8 +356,8 @@ func TestPluginHostData_Wire_Writes(t *testing.T) { // TestPluginHostData_Wire_InvokeUtilityAgent proves the agent_invoke gate and // the one-shot completion round-trip over the real transport: an undeclared -// manifest is PermissionDenied, and a declared one resolves the configured -// profile and returns the runner's text. +// manifest is PermissionDenied, and a declared one resolves the platform +// default or an explicit profile and returns the runner's text. func TestPluginHostData_Wire_InvokeUtilityAgent(t *testing.T) { t.Run("DeniedWithoutCapability", func(t *testing.T) { d := newTestDataHost(manifest.Capabilities{}) @@ -371,16 +371,36 @@ func TestPluginHostData_Wire_InvokeUtilityAgent(t *testing.T) { require.Equal(t, "capability 'agent_invoke' not declared", st.Message()) }) - t.Run("Succeeds", func(t *testing.T) { + t.Run("UsesDefault", func(t *testing.T) { d := newTestDataHost(manifest.Capabilities{AgentInvoke: true}) - d.utilAgents.agent = &UtilityAgent{Name: "summarizer", AgentID: "claude-acp", Model: "claude-opus-4-8", AgentProfileID: "profile-42", ProfileBindingState: "explicit", Enabled: true} + d.defaultProfile.profileID = "profile-default" + d.profiles.profilesByID = map[string]*AgentProfile{ + "profile-default": {Enabled: true, InferenceCapable: 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, "profile-42", d.utilRun.gotProfileID) + require.Equal(t, "profile-default", d.utilRun.gotProfileID) + require.Equal(t, 1, d.defaultProfile.calls) + }) + + t.Run("UsesExplicitOverride", func(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{AgentInvoke: true}) + d.defaultProfile.err = status.Error(codes.Unavailable, "default unavailable") + d.profiles.profilesByID = map[string]*AgentProfile{ + "profile-override": {Enabled: true, InferenceCapable: true}, + } + d.utilRun.text = "override text" + host := dialPluginHostOverWire(t, d.host) + + got, err := host.InvokeUtilityAgent(context.Background(), "summarize", pluginsdk.UtilityAgentOptions{ProfileID: "profile-override"}) + require.NoError(t, err) + require.Equal(t, "override text", got) + require.Equal(t, "profile-override", d.utilRun.gotProfileID) + require.Equal(t, 0, d.defaultProfile.calls) }) } diff --git a/apps/backend/internal/plugins/host_utility.go b/apps/backend/internal/plugins/host_utility.go index 5a2ad43f5d..18b1ea39ad 100644 --- a/apps/backend/internal/plugins/host_utility.go +++ b/apps/backend/internal/plugins/host_utility.go @@ -1,6 +1,6 @@ // host_utility.go implements pluginHost.InvokeUtilityAgent — the agent_invoke -// Host capability (ADR 0048). Plugins delegate one-shot, non-interactive LLM -// steps to the utility agent selected in their own configuration. +// Host capability. Plugins can use the platform default or provide a profile +// override for one invocation. package plugins import ( @@ -8,84 +8,127 @@ import ( "errors" "fmt" + "github.com/kandev/kandev/pkg/pluginsdk" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -const ( - capabilityAgentInvoke = "agent_invoke" - // utilityAgentConfigKey is the manifest config_schema field plugins with - // agent_invoke declare. Its value is the selected utility agent's ID. - utilityAgentConfigKey = "utility_agent" -) +const capabilityAgentInvoke = "agent_invoke" -// ErrUtilityAgentNotFound lets backend adapters identify the one lookup error -// that plugin calls should translate into a configuration failure. -var ErrUtilityAgentNotFound = errors.New("utility agent not found") +// ErrAgentProfileNotFound identifies a deleted profile during host selection +// or final runner validation. +var ErrAgentProfileNotFound = errors.New("agent profile 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 - AgentProfileID string - ProfileBindingState string - Enabled bool +// ErrAgentProfileIneligible identifies a profile that cannot run a utility +// completion during host selection or final runner validation. +var ErrAgentProfileIneligible = errors.New("agent profile is not eligible for utility execution") + +// utilityDefaultProfileSource reads the platform default selected in user +// settings. It is deliberately narrower than the utility-agent service. +type utilityDefaultProfileSource interface { + GetDefaultUtilityAgentProfileID(ctx context.Context) (string, error) } -type utilityAgentSource interface { - GetAgentByID(ctx context.Context, id string) (*UtilityAgent, error) +// AgentProfile is the execution-relevant portion of a profile selection. +// backendapp adapts the agent-settings resolver to this shape. +type AgentProfile struct { + Enabled bool + CLIPassthrough bool + WorkspaceID string + InferenceCapable bool } -// utilityRunner runs a one-shot completion for an agent type + model and -// returns the response text. +type agentProfileSource interface { + GetProfileByID(ctx context.Context, id string) (*AgentProfile, error) +} + +// utilityRunner runs a one-shot completion for a profile and returns the +// response text. type utilityRunner interface { ExecuteProfilePrompt(ctx context.Context, profileID, prompt string) (string, error) } -// InvokeUtilityAgent runs the named utility agent selected in this plugin's -// configuration. Missing, stale, and disabled selections are FailedPrecondition -// so plugins cannot silently fall back to an unrelated model. -func (h *pluginHost) InvokeUtilityAgent(ctx context.Context, prompt string) (string, error) { +// InvokeUtilityAgent runs a one-shot, non-interactive completion. An empty +// profile override resolves the current platform default for this call. +func (h *pluginHost) InvokeUtilityAgent( + ctx context.Context, + prompt string, + options ...pluginsdk.UtilityAgentOptions, +) (string, error) { if !h.capabilities.AgentInvoke { return "", permissionDenied(capabilityAgentInvoke) } - var agents utilityAgentSource + if len(options) > 1 { + return "", status.Error(codes.InvalidArgument, "InvokeUtilityAgent accepts at most one options value") + } + + var defaultProfile utilityDefaultProfileSource + var profiles agentProfileSource var runner utilityRunner if h.utilityDeps != nil { - agents, runner = h.utilityDeps() + defaultProfile, profiles, runner = h.utilityDeps() } - if agents == nil || runner == nil || h.configs == nil { + + profileID := "" + if len(options) == 1 { + profileID = options[0].ProfileID + } + if profileID == "" { + if defaultProfile == nil || profiles == nil || runner == nil { + return h.UnimplementedHostData.InvokeUtilityAgent(ctx, prompt, options...) + } + var err error + profileID, err = defaultProfile.GetDefaultUtilityAgentProfileID(ctx) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return "", status.FromContextError(err).Err() + } + if err != nil { + return "", fmt.Errorf("plugins: load default agent profile: %w", err) + } + if profileID == "" { + return "", status.Error(codes.FailedPrecondition, "no default agent profile configured") + } + } + + return h.invokeAgentProfile(ctx, profileID, profiles, runner, prompt) +} + +func (h *pluginHost) invokeAgentProfile( + ctx context.Context, + profileID string, + profiles agentProfileSource, + runner utilityRunner, + prompt string, +) (string, error) { + if profiles == nil || runner == nil { return h.UnimplementedHostData.InvokeUtilityAgent(ctx, prompt) } - config, err := h.configs.GetConfig(h.pluginID) - if err != nil { - return "", fmt.Errorf("plugins: read plugin config: %w", err) + profile, err := profiles.GetProfileByID(ctx, profileID) + if errors.Is(err, ErrAgentProfileNotFound) { + return "", status.Errorf(codes.FailedPrecondition, "agent profile %q not found", profileID) } - agentID, _ := config[utilityAgentConfigKey].(string) - if agentID == "" { - return "", errNoUtilityAgent() - } - agent, err := agents.GetAgentByID(ctx, agentID) - if errors.Is(err, ErrUtilityAgentNotFound) { - return "", status.Errorf(codes.FailedPrecondition, "configured utility agent %q not found", agentID) + if errors.Is(err, ErrAgentProfileIneligible) { + return "", status.Errorf(codes.FailedPrecondition, "agent profile %q is not eligible for utility execution", profileID) } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return "", status.FromContextError(err).Err() } if err != nil { - return "", fmt.Errorf("plugins: load configured utility agent %q: %w", agentID, err) + return "", fmt.Errorf("plugins: load agent profile %q: %w", profileID, err) } - if !agent.Enabled { - return "", status.Errorf(codes.FailedPrecondition, "configured utility agent %q is disabled", agentID) + if profile == nil { + return "", status.Errorf(codes.FailedPrecondition, "agent profile %q not found", profileID) } - if agent.ProfileBindingState == "unconfigured" || agent.AgentProfileID == "" { - return "", status.Errorf(codes.FailedPrecondition, "configured utility agent %q has no usable agent profile", agentID) + if !profile.Enabled || profile.CLIPassthrough || profile.WorkspaceID != "" || !profile.InferenceCapable { + return "", status.Errorf(codes.FailedPrecondition, "agent profile %q is not eligible for utility execution", profileID) } - return runner.ExecuteProfilePrompt(ctx, agent.AgentProfileID, prompt) -} -func errNoUtilityAgent() error { - return status.Error(codes.FailedPrecondition, "no utility agent configured for this plugin") + response, err := runner.ExecuteProfilePrompt(ctx, profileID, prompt) + if errors.Is(err, ErrAgentProfileNotFound) { + return "", status.Errorf(codes.FailedPrecondition, "agent profile %q not found", profileID) + } + if errors.Is(err, ErrAgentProfileIneligible) { + return "", status.Errorf(codes.FailedPrecondition, "agent profile %q is not eligible for utility execution", profileID) + } + return response, err } diff --git a/apps/backend/internal/plugins/host_utility_test.go b/apps/backend/internal/plugins/host_utility_test.go index fbbe3751a5..0fc3de431e 100644 --- a/apps/backend/internal/plugins/host_utility_test.go +++ b/apps/backend/internal/plugins/host_utility_test.go @@ -6,29 +6,32 @@ import ( "testing" "github.com/kandev/kandev/internal/plugins/manifest" + "github.com/kandev/kandev/pkg/pluginsdk" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) -type fakeUtilityAgentSource struct { - agent *UtilityAgent - err error - calls int - gotSelector string +type fakeDefaultUtilityProfileSource struct { + profileID string + err error + calls int } -func (f *fakeUtilityAgentSource) GetAgentByID(_ context.Context, selector string) (*UtilityAgent, error) { +func (f *fakeDefaultUtilityProfileSource) GetDefaultUtilityAgentProfileID(context.Context) (string, error) { f.calls++ - f.gotSelector = selector - return f.agent, f.err + return f.profileID, f.err } type fakeConfigReader struct { configs map[string]any err error + calls int } -func (f *fakeConfigReader) GetConfig(string) (map[string]any, error) { return f.configs, f.err } +func (f *fakeConfigReader) GetConfig(string) (map[string]any, error) { + f.calls++ + return f.configs, f.err +} type fakeUtilityRunner struct { calls int @@ -47,8 +50,15 @@ func (f *fakeUtilityRunner) ExecuteProfilePrompt(_ context.Context, profileID, p 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", AgentProfileID: "profile-42", ProfileBindingState: "explicit", Enabled: true} + d.host.configs = &fakeConfigReader{configs: map[string]any{ + "utility_agent": "profile-from-plugin-config", + "agent_profile": "profile-from-plugin-config", + }} + d.defaultProfile.profileID = "profile-default" + d.profiles.profilesByID = map[string]*AgentProfile{ + "profile-default": {Enabled: true, InferenceCapable: true}, + "profile-override": {Enabled: true, InferenceCapable: true}, + } d.utilRun.text = "the summary" return d } @@ -57,77 +67,235 @@ func TestPluginHost_InvokeUtilityAgent_DeniedWithoutCapability(t *testing.T) { d := newTestDataHost(manifest.Capabilities{}) _, err := d.host.InvokeUtilityAgent(context.Background(), "hi") assertPermissionDenied(t, err, "agent_invoke") - if d.utilAgents.calls != 0 || d.utilRun.calls != 0 { - t.Fatalf("unauthorized call touched agent lookup %d times and runner %d times", d.utilAgents.calls, d.utilRun.calls) + if d.defaultProfile.calls != 0 || d.profiles.profileCalls != 0 || d.utilRun.calls != 0 { + t.Fatalf("unauthorized call touched default %d times, profiles %d times, runner %d times", d.defaultProfile.calls, d.profiles.profileCalls, d.utilRun.calls) } } -func TestPluginHost_InvokeUtilityAgent_UsesPluginConfiguredUtilityAgent(t *testing.T) { +func TestPluginHost_InvokeUtilityAgent_DefaultAndOverride(t *testing.T) { + t.Run("zero options uses current default", func(t *testing.T) { + d := configuredUtilityHost(t) + + got, err := d.host.InvokeUtilityAgent(context.Background(), "summarize yesterday") + if err != nil || got != "the summary" { + t.Fatalf("InvokeUtilityAgent() = (%q, %v)", got, err) + } + if d.defaultProfile.calls != 1 || d.utilRun.gotProfileID != "profile-default" { + t.Fatalf("default calls = %d, runner profile = %q", d.defaultProfile.calls, d.utilRun.gotProfileID) + } + }) + + t.Run("one empty option uses current default", func(t *testing.T) { + d := configuredUtilityHost(t) + + got, err := d.host.InvokeUtilityAgent(context.Background(), "summarize yesterday", pluginsdk.UtilityAgentOptions{}) + if err != nil || got != "the summary" { + t.Fatalf("InvokeUtilityAgent() = (%q, %v)", got, err) + } + if d.defaultProfile.calls != 1 || d.utilRun.gotProfileID != "profile-default" { + t.Fatalf("default calls = %d, runner profile = %q", d.defaultProfile.calls, d.utilRun.gotProfileID) + } + }) + + t.Run("non-empty option uses exact override", func(t *testing.T) { + d := configuredUtilityHost(t) + + got, err := d.host.InvokeUtilityAgent(context.Background(), "summarize yesterday", pluginsdk.UtilityAgentOptions{ProfileID: "profile-override"}) + if err != nil || got != "the summary" { + t.Fatalf("InvokeUtilityAgent() = (%q, %v)", got, err) + } + if d.defaultProfile.calls != 0 { + t.Fatalf("explicit override read the default %d times", d.defaultProfile.calls) + } + if d.utilRun.gotProfileID != "profile-override" { + t.Fatalf("runner profile = %q, want profile-override", d.utilRun.gotProfileID) + } + }) +} + +func TestPluginHost_InvokeUtilityAgent_IgnoresPluginConfig(t *testing.T) { d := configuredUtilityHost(t) - got, err := d.host.InvokeUtilityAgent(context.Background(), "summarize yesterday") + d.host.configs = &fakeConfigReader{err: errors.New("plugin config must not be read")} + d.host.configSchema = map[string]any{"properties": map[string]any{ + "utility_agent": map[string]any{"type": "string", "format": "utility-agent"}, + }} + + got, err := d.host.InvokeUtilityAgent(context.Background(), "summarize") if err != nil || got != "the summary" { t.Fatalf("InvokeUtilityAgent() = (%q, %v)", got, err) } - if d.utilRun.gotProfileID != "profile-42" { - t.Fatalf("runner got profile %q", d.utilRun.gotProfileID) + if d.host.configs.(*fakeConfigReader).calls != 0 { + t.Fatalf("InvokeUtilityAgent() read plugin config %d times", d.host.configs.(*fakeConfigReader).calls) } - if d.utilAgents.gotSelector != "utility-agent-42" { - t.Fatalf("looked up utility agent %q", d.utilAgents.gotSelector) + if d.utilRun.gotProfileID != "profile-default" { + t.Fatalf("runner profile = %q, want profile-default", d.utilRun.gotProfileID) } } -func TestPluginHost_InvokeUtilityAgent_NotConfigured(t *testing.T) { +func TestPluginHost_InvokeUtilityAgent_DefaultChangesBetweenCalls(t *testing.T) { d := configuredUtilityHost(t) - d.host.configs = &fakeConfigReader{configs: map[string]any{}} - _, err := d.host.InvokeUtilityAgent(context.Background(), "hi") - if status.Code(err) != codes.FailedPrecondition || d.utilRun.calls != 0 { - t.Fatalf("err = %v, calls = %d", err, d.utilRun.calls) + + if _, err := d.host.InvokeUtilityAgent(context.Background(), "first"); err != nil { + t.Fatalf("first InvokeUtilityAgent() error = %v", err) + } + d.defaultProfile.profileID = "profile-override" + if _, err := d.host.InvokeUtilityAgent(context.Background(), "second"); err != nil { + t.Fatalf("second InvokeUtilityAgent() error = %v", err) + } + if d.defaultProfile.calls != 2 { + t.Fatalf("default calls = %d, want 2", d.defaultProfile.calls) + } + if d.utilRun.gotProfileID != "profile-override" { + t.Fatalf("second runner profile = %q, want profile-override", d.utilRun.gotProfileID) } } -func TestPluginHost_InvokeUtilityAgent_MissingOrDisabled(t *testing.T) { - for _, tc := range []struct { - name string - agent *UtilityAgent - err error +func TestPluginHost_InvokeUtilityAgent_ExplicitOverrideDoesNotReadDefault(t *testing.T) { + d := configuredUtilityHost(t) + d.defaultProfile.err = status.Error(codes.Unavailable, "default store unavailable") + + got, err := d.host.InvokeUtilityAgent(context.Background(), "explicit", pluginsdk.UtilityAgentOptions{ProfileID: "profile-override"}) + if err != nil || got != "the summary" { + t.Fatalf("InvokeUtilityAgent() = (%q, %v)", got, err) + } + if d.defaultProfile.calls != 0 { + t.Fatalf("explicit override read the default %d times", d.defaultProfile.calls) + } +} + +func TestPluginHost_InvokeUtilityAgent_RejectsMultipleOptions(t *testing.T) { + d := configuredUtilityHost(t) + + _, err := d.host.InvokeUtilityAgent( + context.Background(), + "summarize", + pluginsdk.UtilityAgentOptions{ProfileID: "profile-default"}, + pluginsdk.UtilityAgentOptions{ProfileID: "profile-override"}, + ) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("InvokeUtilityAgent() status = %s, want %s", status.Code(err), codes.InvalidArgument) + } + if d.defaultProfile.calls != 0 || d.profiles.profileCalls != 0 || d.utilRun.calls != 0 { + t.Fatalf("invalid options touched default %d times, profiles %d times, runner %d times", d.defaultProfile.calls, d.profiles.profileCalls, d.utilRun.calls) + } +} + +func TestPluginHost_InvokeUtilityAgent_SelectionErrors(t *testing.T) { + tests := []struct { + name string + defaultProfile string + options []pluginsdk.UtilityAgentOptions + profiles map[string]*AgentProfile + want string + wantDefaultCalls int }{ - {"missing", nil, ErrUtilityAgentNotFound}, - {"disabled", &UtilityAgent{Name: "summarizer"}, nil}, - } { - t.Run(tc.name, func(t *testing.T) { + { + name: "empty default", + defaultProfile: "", + want: "no default agent profile configured", + wantDefaultCalls: 1, + }, + { + name: "missing default", + defaultProfile: "missing", + profiles: map[string]*AgentProfile{}, + want: `agent profile "missing" not found`, + wantDefaultCalls: 1, + }, + { + name: "ineligible default", + defaultProfile: "disabled", + profiles: map[string]*AgentProfile{"disabled": {Enabled: false, InferenceCapable: true}}, + want: `agent profile "disabled" is not eligible for utility execution`, + wantDefaultCalls: 1, + }, + { + name: "missing explicit override", + defaultProfile: "profile-default", + options: []pluginsdk.UtilityAgentOptions{{ProfileID: "missing"}}, + profiles: map[string]*AgentProfile{}, + want: `agent profile "missing" not found`, + }, + { + name: "ineligible explicit override", + defaultProfile: "profile-default", + options: []pluginsdk.UtilityAgentOptions{{ProfileID: "disabled"}}, + profiles: map[string]*AgentProfile{"disabled": {Enabled: false, InferenceCapable: true}}, + want: `agent profile "disabled" is not eligible for utility execution`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { d := configuredUtilityHost(t) - d.utilAgents.agent, d.utilAgents.err = tc.agent, tc.err - _, err := d.host.InvokeUtilityAgent(context.Background(), "hi") - if status.Code(err) != codes.FailedPrecondition || d.utilRun.calls != 0 { - t.Fatalf("err = %v, calls = %d", err, d.utilRun.calls) + d.defaultProfile.profileID = tt.defaultProfile + if tt.profiles != nil { + d.profiles.profilesByID = tt.profiles + } + + _, err := d.host.InvokeUtilityAgent(context.Background(), "summarize", tt.options...) + if status.Code(err) != codes.FailedPrecondition || status.Convert(err).Message() != tt.want { + t.Fatalf("InvokeUtilityAgent() error = %v, want FailedPrecondition %q", err, tt.want) + } + if d.defaultProfile.calls != tt.wantDefaultCalls || d.utilRun.calls != 0 { + t.Fatalf("default calls = %d, want %d; runner calls = %d", d.defaultProfile.calls, tt.wantDefaultCalls, d.utilRun.calls) } }) } } -func TestPluginHost_InvokeUtilityAgent_PreservesLookupFailure(t *testing.T) { - storeErr := status.Error(codes.Unavailable, "utility agent store unavailable") - d := configuredUtilityHost(t) - d.utilAgents.err = storeErr +func TestPluginHost_InvokeUtilityAgent_PreservesDefaultSourceFailures(t *testing.T) { + for _, tc := range []struct { + name string + err error + code codes.Code + }{ + {name: "storage", err: status.Error(codes.Unavailable, "settings unavailable"), code: codes.Unavailable}, + {name: "cancellation", err: context.Canceled, code: codes.Canceled}, + } { + t.Run(tc.name, func(t *testing.T) { + d := configuredUtilityHost(t) + d.defaultProfile.err = tc.err - _, err := d.host.InvokeUtilityAgent(context.Background(), "hi") - if !errors.Is(err, storeErr) { - t.Fatalf("InvokeUtilityAgent() error = %v, want wrapped store error", err) - } - if status.Code(err) != codes.Unavailable { - t.Fatalf("InvokeUtilityAgent() status = %s, want %s", status.Code(err), codes.Unavailable) - } - if d.utilRun.calls != 0 { - t.Fatalf("runner calls = %d, want 0", d.utilRun.calls) + _, err := d.host.InvokeUtilityAgent(context.Background(), "summarize") + if status.Code(err) != tc.code { + t.Fatalf("InvokeUtilityAgent() status = %s, want %s; err = %v", status.Code(err), tc.code, err) + } + if d.profiles.profileCalls != 0 || d.utilRun.calls != 0 { + t.Fatalf("source failure touched profiles %d times and runner %d times", d.profiles.profileCalls, d.utilRun.calls) + } + }) } } -func TestPluginHost_InvokeUtilityAgent_PreservesLookupCancellation(t *testing.T) { +func TestPluginHost_InvokeUtilityAgent_PreservesRunnerFailure(t *testing.T) { + runnerErr := status.Error(codes.Unavailable, "agentctl unavailable") d := configuredUtilityHost(t) - d.utilAgents.err = context.Canceled + d.utilRun.err = runnerErr - _, err := d.host.InvokeUtilityAgent(context.Background(), "hi") - if status.Code(err) != codes.Canceled { - t.Fatalf("InvokeUtilityAgent() status = %s, want %s", status.Code(err), codes.Canceled) + _, err := d.host.InvokeUtilityAgent(context.Background(), "summarize") + if !errors.Is(err, runnerErr) || status.Code(err) != codes.Unavailable { + t.Fatalf("InvokeUtilityAgent() error = %v, want runner error %v", err, runnerErr) + } +} + +func TestPluginHost_InvokeUtilityAgent_MapsRunnerProfileErrors(t *testing.T) { + for _, tc := range []struct { + name string + err error + want string + }{ + {name: "deleted", err: ErrAgentProfileNotFound, want: `agent profile "profile-default" not found`}, + {name: "ineligible", err: ErrAgentProfileIneligible, want: `agent profile "profile-default" is not eligible for utility execution`}, + } { + t.Run(tc.name, func(t *testing.T) { + d := configuredUtilityHost(t) + d.utilRun.err = tc.err + + _, err := d.host.InvokeUtilityAgent(context.Background(), "summarize") + if status.Code(err) != codes.FailedPrecondition || status.Convert(err).Message() != tc.want { + t.Fatalf("InvokeUtilityAgent() error = %v, want FailedPrecondition %q", err, tc.want) + } + }) } } diff --git a/apps/backend/internal/plugins/manifest/manifest.go b/apps/backend/internal/plugins/manifest/manifest.go index 0ea0f2979e..a58ba2dd70 100644 --- a/apps/backend/internal/plugins/manifest/manifest.go +++ b/apps/backend/internal/plugins/manifest/manifest.go @@ -133,8 +133,8 @@ type Capabilities struct { APIWrite []string `yaml:"api_write,omitempty" json:"api_write,omitempty"` State bool `yaml:"state,omitempty" json:"state,omitempty"` Secrets bool `yaml:"secrets,omitempty" json:"secrets,omitempty"` - // AgentInvoke gates Host.InvokeUtilityAgent (ADR 0048): a one-shot, - // non-interactive completion run by the operator-configured utility agent. + // AgentInvoke gates Host.InvokeUtilityAgent: a one-shot, non-interactive + // completion run by the platform default or an explicitly selected profile. AgentInvoke bool `yaml:"agent_invoke,omitempty" json:"agent_invoke,omitempty"` // Auth gates a plugin's ability to establish an authenticated kandev // browser session for an external identity it has validated against an diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index e1170bdec5..3d9dabf017 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -136,9 +136,10 @@ type Service struct { taskPRs taskPRSource taskWriter taskWriter - // Utility agent invocation (ADR 0048), wired via SetUtilityAgent. - utilityAgents utilityAgentSource - utilityRunner utilityRunner + // Utility agent invocation dependencies, wired via SetUtilityAgent. + utilityDefaultProfile utilityDefaultProfileSource + utilityProfiles agentProfileSource + utilityRunner utilityRunner // Host data API write dependencies wired late via SetWriteDeps (ADR // 0043): the task-message delivery path and the orchestrator task-starter, @@ -575,30 +576,26 @@ func (s *Service) writeDependencies() (taskMessenger, taskStarter) { return s.messenger, s.taskStarter } -// SetUtilityAgent wires the dependencies behind Host.InvokeUtilityAgent -// (ADR 0048): the service that resolves the utility agent selected in plugin -// configuration, and the sessionless runner that executes a one-shot -// completion. Wired by backendapp (not Provide) for the same import-cycle -// reason as SetDataSources. Unlike the data sources, this is wired LATE in boot -// (hostUtilityMgr is only available after agentctl control is healthy, by which -// point StartActivePlugins has already spawned boot-active plugins), so hosts -// read these live via utilityAgentDeps rather than snapshotting them — the -// write here is mutex-guarded against those concurrent reads. -func (s *Service) SetUtilityAgent(agents utilityAgentSource, runner utilityRunner) { +// SetUtilityAgent wires the default-profile source, profile validator, and +// sessionless runner behind Host.InvokeUtilityAgent. Wired by backendapp (not +// Provide) for the same import-cycle reason as SetDataSources. The runner is +// wired late in boot, so hosts read these live via utilityAgentDeps instead of +// snapshotting them. +func (s *Service) SetUtilityAgent(defaultProfile utilityDefaultProfileSource, profiles agentProfileSource, runner utilityRunner) { s.mu.Lock() defer s.mu.Unlock() - s.utilityAgents = agents + s.utilityDefaultProfile = defaultProfile + s.utilityProfiles = profiles s.utilityRunner = runner } -// utilityAgentDeps returns the currently-wired utility-agent dependencies. Read -// live (not snapshotted at hostForPlugin time) so a plugin spawned before -// SetUtilityAgent still resolves them once it is called. Guarded by s.mu against -// the SetUtilityAgent write. -func (s *Service) utilityAgentDeps() (utilityAgentSource, utilityRunner) { +// utilityAgentDeps returns the currently-wired utility invocation +// dependencies. Read live so a plugin spawned before SetUtilityAgent still +// resolves them once it is called. Guarded by s.mu against the write. +func (s *Service) utilityAgentDeps() (utilityDefaultProfileSource, agentProfileSource, utilityRunner) { s.mu.Lock() defer s.mu.Unlock() - return s.utilityAgents, s.utilityRunner + return s.utilityDefaultProfile, s.utilityProfiles, s.utilityRunner } // SetAuthLoginBridge wires the SSO login bridge auth-capable plugins use to diff --git a/apps/backend/internal/plugins/store/fs_store_test.go b/apps/backend/internal/plugins/store/fs_store_test.go index 75c9f13e6f..66e70ad92a 100644 --- a/apps/backend/internal/plugins/store/fs_store_test.go +++ b/apps/backend/internal/plugins/store/fs_store_test.go @@ -261,6 +261,42 @@ func TestFSStore_Get_LegacyRecordDefaultsFailureDiagnostics(t *testing.T) { } } +func TestFSStore_Get_LegacyUtilityMarkerLoadsWithoutAuthority(t *testing.T) { + dir := t.TempDir() + s := NewFSStore(dir) + const id = "kandev-plugin-legacy" + legacyRecord := []byte(`id: kandev-plugin-legacy +api_version: 1 +version: "1.0.0" +display_name: Test Plugin +status: registered +install_path: /tmp/kandev-plugin-legacy +signed: false +legacy_utility_agent_fallback: true +`) + if err := os.WriteFile(filepath.Join(dir, id+".yml"), legacyRecord, 0o600); err != nil { + t.Fatalf("write legacy record: %v", err) + } + + got, err := s.Get(id) + if err != nil { + t.Fatalf("Get() unexpected error: %v", err) + } + if got.ID != id { + t.Fatalf("Get().ID = %q, want %q", got.ID, id) + } + if err := s.Save(got); err != nil { + t.Fatalf("Save() migrated record: %v", err) + } + rewritten, err := os.ReadFile(filepath.Join(dir, id+".yml")) + if err != nil { + t.Fatalf("read migrated record: %v", err) + } + if strings.Contains(string(rewritten), "legacy_utility_agent_fallback") { + t.Fatalf("migrated record retained the retired utility marker: %s", rewritten) + } +} + func TestFSStore_SetConfigThenGetConfig_RoundTrips(t *testing.T) { dir := t.TempDir() s := NewFSStore(dir) diff --git a/apps/backend/pkg/pluginsdk/host.go b/apps/backend/pkg/pluginsdk/host.go index 7310a16b3a..80d3b6d417 100644 --- a/apps/backend/pkg/pluginsdk/host.go +++ b/apps/backend/pkg/pluginsdk/host.go @@ -111,12 +111,19 @@ type Host interface { // prompt to a task session. Messages() MessageReader - // InvokeUtilityAgent runs a one-shot, non-interactive completion using - // the operator-configured "utility agent" (Settings > System) and returns - // its text. Requires the `agent_invoke` capability. Returns a gRPC - // FailedPrecondition error when no utility agent is configured, so a - // plugin needs no API key of its own. - InvokeUtilityAgent(ctx context.Context, prompt string) (string, error) + // InvokeUtilityAgent runs a one-shot, non-interactive completion. With no + // options, or an empty ProfileID, it uses the platform default utility + // profile. A non-empty ProfileID selects that profile for this call only. + // Requires the `agent_invoke` capability and returns gRPC FailedPrecondition + // for a missing or ineligible profile. + InvokeUtilityAgent(ctx context.Context, prompt string, options ...UtilityAgentOptions) (string, error) +} + +// UtilityAgentOptions contains per-call utility completion options. ProfileID +// is an agent-profile ID, not a utility-agent record ID. An empty value uses +// the platform default. +type UtilityAgentOptions struct { + ProfileID string } // TaskReader is the accessor behind Host.Tasks(), mirroring the Host data @@ -381,8 +388,15 @@ func (h *grpcHostClient) Repositories() RepositoryReader { func (h *grpcHostClient) Messages() MessageReader { return grpcMessageReader{client: h.client} } -func (h *grpcHostClient) InvokeUtilityAgent(ctx context.Context, prompt string) (string, error) { - resp, err := h.client.InvokeUtilityAgent(ctx, &pluginv1.InvokeUtilityAgentRequest{Prompt: prompt}) +func (h *grpcHostClient) InvokeUtilityAgent(ctx context.Context, prompt string, options ...UtilityAgentOptions) (string, error) { + if len(options) > 1 { + return "", status.Error(codes.InvalidArgument, "InvokeUtilityAgent accepts at most one options value") + } + req := &pluginv1.InvokeUtilityAgentWithOptionsRequest{Prompt: prompt} + if len(options) == 1 { + req.ProfileId = options[0].ProfileID + } + resp, err := h.client.InvokeUtilityAgentWithOptions(ctx, req) if err != nil { return "", err } @@ -715,7 +729,15 @@ func (s *grpcHostServer) EmitEvent(ctx context.Context, req *pluginv1.EmitEventR } func (s *grpcHostServer) InvokeUtilityAgent(ctx context.Context, req *pluginv1.InvokeUtilityAgentRequest) (*pluginv1.InvokeUtilityAgentResponse, error) { - text, err := s.impl.InvokeUtilityAgent(ctx, req.GetPrompt()) + return s.invokeUtilityAgent(ctx, req.GetPrompt()) +} + +func (s *grpcHostServer) InvokeUtilityAgentWithOptions(ctx context.Context, req *pluginv1.InvokeUtilityAgentWithOptionsRequest) (*pluginv1.InvokeUtilityAgentResponse, error) { + return s.invokeUtilityAgent(ctx, req.GetPrompt(), UtilityAgentOptions{ProfileID: req.GetProfileId()}) +} + +func (s *grpcHostServer) invokeUtilityAgent(ctx context.Context, prompt string, options ...UtilityAgentOptions) (*pluginv1.InvokeUtilityAgentResponse, error) { + text, err := s.impl.InvokeUtilityAgent(ctx, prompt, options...) if err != nil { return nil, err } @@ -995,7 +1017,7 @@ func (UnimplementedHostData) PluginOwnedTaskTrees() PluginOwnedTaskTreeManager { // "unimplemented Host extensions" embed both real Host implementations use — // so a Host that hasn't wired a utility agent (e.g. a test double) still // satisfies the interface, returning gRPC Unimplemented until overridden. -func (UnimplementedHostData) InvokeUtilityAgent(context.Context, string) (string, error) { +func (UnimplementedHostData) InvokeUtilityAgent(context.Context, string, ...UtilityAgentOptions) (string, error) { return "", errUnimplementedHostData("utility_agent") } diff --git a/apps/backend/pkg/pluginsdk/host_data_test.go b/apps/backend/pkg/pluginsdk/host_data_test.go index fcec39b447..04952286b9 100644 --- a/apps/backend/pkg/pluginsdk/host_data_test.go +++ b/apps/backend/pkg/pluginsdk/host_data_test.go @@ -33,12 +33,13 @@ type dataRecordingHost struct { messagePage *PageInfo utilityText string - lastTaskFilter TaskFilter - lastSessionFilter SessionFilter - lastMessageFilter MessageFilter - lastWorkspaceID string - lastWorkflowID string - lastUtilityPrompt string + lastTaskFilter TaskFilter + lastSessionFilter SessionFilter + lastMessageFilter MessageFilter + lastWorkspaceID string + lastWorkflowID string + lastUtilityPrompt string + lastUtilityOptions []UtilityAgentOptions createdTask Task lastCreateInput CreateTaskInput @@ -84,8 +85,9 @@ func (h *dataRecordingHost) Repositories() RepositoryReader { return dataRecordingRepositoryReader{h} } func (h *dataRecordingHost) Messages() MessageReader { return dataRecordingMessageReader{h} } -func (h *dataRecordingHost) InvokeUtilityAgent(_ context.Context, prompt string) (string, error) { +func (h *dataRecordingHost) InvokeUtilityAgent(_ context.Context, prompt string, options ...UtilityAgentOptions) (string, error) { h.lastUtilityPrompt = prompt + h.lastUtilityOptions = append([]UtilityAgentOptions(nil), options...) return h.utilityText, nil } @@ -350,10 +352,11 @@ func TestHostData_InvokeUtilityAgent(t *testing.T) { impl := &dataRecordingHost{utilityText: "the completion"} host := dialHostOverBufconn(t, impl) - text, err := host.InvokeUtilityAgent(context.Background(), "do the thing") + text, err := host.InvokeUtilityAgent(context.Background(), "do the thing", UtilityAgentOptions{ProfileID: "profile-1"}) require.NoError(t, err) require.Equal(t, "the completion", text) require.Equal(t, "do the thing", impl.lastUtilityPrompt) + require.Equal(t, []UtilityAgentOptions{{ProfileID: "profile-1"}}, impl.lastUtilityOptions) } func TestHostData_Messages(t *testing.T) { diff --git a/apps/backend/pkg/pluginsdk/host_utility_wire_test.go b/apps/backend/pkg/pluginsdk/host_utility_wire_test.go new file mode 100644 index 0000000000..0bfee97c8c --- /dev/null +++ b/apps/backend/pkg/pluginsdk/host_utility_wire_test.go @@ -0,0 +1,76 @@ +package pluginsdk + +import ( + "context" + "net" + "testing" + + pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type utilityRPCProbeServer struct { + pluginv1.UnimplementedHostServer + oldCalls int + optionsCalls int +} + +func (s *utilityRPCProbeServer) InvokeUtilityAgent(context.Context, *pluginv1.InvokeUtilityAgentRequest) (*pluginv1.InvokeUtilityAgentResponse, error) { + s.oldCalls++ + return &pluginv1.InvokeUtilityAgentResponse{Text: "old/default"}, nil +} + +func (s *utilityRPCProbeServer) InvokeUtilityAgentWithOptions(context.Context, *pluginv1.InvokeUtilityAgentWithOptionsRequest) (*pluginv1.InvokeUtilityAgentResponse, error) { + s.optionsCalls++ + return nil, status.Error(codes.Unimplemented, "new rpc unavailable") +} + +func dialRawHostClient(t *testing.T, register func(*grpc.Server)) (pluginv1.HostClient, Host) { + t.Helper() + + lis := bufconn.Listen(1024 * 1024) + srv := grpc.NewServer() + register(srv) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return lis.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + return pluginv1.NewHostClient(conn), newHostClient(conn) +} + +func TestInvokeUtilityAgent_ExplicitOverrideDoesNotDowngradeOnUnimplemented(t *testing.T) { + probe := &utilityRPCProbeServer{} + _, host := dialRawHostClient(t, func(server *grpc.Server) { + pluginv1.RegisterHostServer(server, probe) + }) + + _, err := host.InvokeUtilityAgent(context.Background(), "prompt", UtilityAgentOptions{ProfileID: "profile-1"}) + require.Equal(t, codes.Unimplemented, status.Code(err)) + require.Equal(t, 1, probe.optionsCalls) + require.Zero(t, probe.oldCalls) +} + +func TestInvokeUtilityAgent_LegacyWireUsesDefault(t *testing.T) { + impl := &dataRecordingHost{utilityText: "default completion"} + client, _ := dialRawHostClient(t, func(server *grpc.Server) { + registerHostServer(server, impl) + }) + + response, err := client.InvokeUtilityAgent(context.Background(), &pluginv1.InvokeUtilityAgentRequest{Prompt: "prompt"}) + require.NoError(t, err) + require.Equal(t, "default completion", response.GetText()) + require.Empty(t, impl.lastUtilityOptions) +} diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index d6ec4a4d7d..6012a3b2c1 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -6100,9 +6100,7 @@ func (x *CancelClarificationResponse) GetInteraction() *Interaction { } // ── Utility agent invocation (agent_invoke) ────────────────────────────────── -// A one-shot completion delegated to the operator-configured utility agent. -// Adding fields here (e.g. a future system_prompt or max_tokens) is the -// forward-compatible extension path — the SDK method stays source-stable. +// A one-shot completion delegated to the platform utility profile. type InvokeUtilityAgentRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Prompt string `protobuf:"bytes,1,opt,name=prompt,proto3" json:"prompt,omitempty"` @@ -6147,6 +6145,58 @@ func (x *InvokeUtilityAgentRequest) GetPrompt() string { return "" } +type InvokeUtilityAgentWithOptionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Prompt string `protobuf:"bytes,1,opt,name=prompt,proto3" json:"prompt,omitempty"` + ProfileId string `protobuf:"bytes,2,opt,name=profile_id,json=profileId,proto3" json:"profile_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InvokeUtilityAgentWithOptionsRequest) Reset() { + *x = InvokeUtilityAgentWithOptionsRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[95] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InvokeUtilityAgentWithOptionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InvokeUtilityAgentWithOptionsRequest) ProtoMessage() {} + +func (x *InvokeUtilityAgentWithOptionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[95] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InvokeUtilityAgentWithOptionsRequest.ProtoReflect.Descriptor instead. +func (*InvokeUtilityAgentWithOptionsRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{95} +} + +func (x *InvokeUtilityAgentWithOptionsRequest) GetPrompt() string { + if x != nil { + return x.Prompt + } + return "" +} + +func (x *InvokeUtilityAgentWithOptionsRequest) GetProfileId() string { + if x != nil { + return x.ProfileId + } + return "" +} + type InvokeUtilityAgentResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` @@ -6156,7 +6206,7 @@ type InvokeUtilityAgentResponse struct { func (x *InvokeUtilityAgentResponse) Reset() { *x = InvokeUtilityAgentResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[95] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6168,7 +6218,7 @@ func (x *InvokeUtilityAgentResponse) String() string { func (*InvokeUtilityAgentResponse) ProtoMessage() {} func (x *InvokeUtilityAgentResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[95] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6181,7 +6231,7 @@ func (x *InvokeUtilityAgentResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InvokeUtilityAgentResponse.ProtoReflect.Descriptor instead. func (*InvokeUtilityAgentResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{95} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{96} } func (x *InvokeUtilityAgentResponse) GetText() string { @@ -6213,7 +6263,7 @@ type CreateTaskRequest struct { func (x *CreateTaskRequest) Reset() { *x = CreateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[96] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[97] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6225,7 +6275,7 @@ func (x *CreateTaskRequest) String() string { func (*CreateTaskRequest) ProtoMessage() {} func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[96] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[97] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6238,7 +6288,7 @@ func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskRequest.ProtoReflect.Descriptor instead. func (*CreateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{96} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{97} } func (x *CreateTaskRequest) GetWorkspaceId() string { @@ -6331,7 +6381,7 @@ type PluginTaskRepository struct { func (x *PluginTaskRepository) Reset() { *x = PluginTaskRepository{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[97] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[98] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6343,7 +6393,7 @@ func (x *PluginTaskRepository) String() string { func (*PluginTaskRepository) ProtoMessage() {} func (x *PluginTaskRepository) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[97] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[98] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6356,7 +6406,7 @@ func (x *PluginTaskRepository) ProtoReflect() protoreflect.Message { // Deprecated: Use PluginTaskRepository.ProtoReflect.Descriptor instead. func (*PluginTaskRepository) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{97} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{98} } func (x *PluginTaskRepository) GetRepositoryId() string { @@ -6415,7 +6465,7 @@ type RemoteRepositoryDescriptor struct { func (x *RemoteRepositoryDescriptor) Reset() { *x = RemoteRepositoryDescriptor{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[98] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[99] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6427,7 +6477,7 @@ func (x *RemoteRepositoryDescriptor) String() string { func (*RemoteRepositoryDescriptor) ProtoMessage() {} func (x *RemoteRepositoryDescriptor) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[98] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[99] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6440,7 +6490,7 @@ func (x *RemoteRepositoryDescriptor) ProtoReflect() protoreflect.Message { // Deprecated: Use RemoteRepositoryDescriptor.ProtoReflect.Descriptor instead. func (*RemoteRepositoryDescriptor) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{98} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{99} } func (x *RemoteRepositoryDescriptor) GetProviderId() string { @@ -6532,7 +6582,7 @@ type PluginTaskLaunchOptions struct { func (x *PluginTaskLaunchOptions) Reset() { *x = PluginTaskLaunchOptions{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[99] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[100] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6544,7 +6594,7 @@ func (x *PluginTaskLaunchOptions) String() string { func (*PluginTaskLaunchOptions) ProtoMessage() {} func (x *PluginTaskLaunchOptions) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[99] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[100] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6557,7 +6607,7 @@ func (x *PluginTaskLaunchOptions) ProtoReflect() protoreflect.Message { // Deprecated: Use PluginTaskLaunchOptions.ProtoReflect.Descriptor instead. func (*PluginTaskLaunchOptions) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{99} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{100} } func (x *PluginTaskLaunchOptions) GetAgentProfileId() string { @@ -6597,7 +6647,7 @@ type CreateTaskResponse struct { func (x *CreateTaskResponse) Reset() { *x = CreateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[100] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[101] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6609,7 +6659,7 @@ func (x *CreateTaskResponse) String() string { func (*CreateTaskResponse) ProtoMessage() {} func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[100] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[101] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6622,7 +6672,7 @@ func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateTaskResponse.ProtoReflect.Descriptor instead. func (*CreateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{100} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{101} } func (x *CreateTaskResponse) GetTask() *Task { @@ -6646,7 +6696,7 @@ type UpdateTaskRequest struct { func (x *UpdateTaskRequest) Reset() { *x = UpdateTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[101] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[102] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6658,7 +6708,7 @@ func (x *UpdateTaskRequest) String() string { func (*UpdateTaskRequest) ProtoMessage() {} func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[101] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[102] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6671,7 +6721,7 @@ func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskRequest.ProtoReflect.Descriptor instead. func (*UpdateTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{101} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{102} } func (x *UpdateTaskRequest) GetId() string { @@ -6725,7 +6775,7 @@ type UpdateTaskResponse struct { func (x *UpdateTaskResponse) Reset() { *x = UpdateTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[102] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[103] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6737,7 +6787,7 @@ func (x *UpdateTaskResponse) String() string { func (*UpdateTaskResponse) ProtoMessage() {} func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[102] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[103] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6750,7 +6800,7 @@ func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateTaskResponse.ProtoReflect.Descriptor instead. func (*UpdateTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{102} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{103} } func (x *UpdateTaskResponse) GetTask() *Task { @@ -6779,7 +6829,7 @@ type MoveTaskRequest struct { func (x *MoveTaskRequest) Reset() { *x = MoveTaskRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[103] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[104] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6791,7 +6841,7 @@ func (x *MoveTaskRequest) String() string { func (*MoveTaskRequest) ProtoMessage() {} func (x *MoveTaskRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[103] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[104] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6804,7 +6854,7 @@ func (x *MoveTaskRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MoveTaskRequest.ProtoReflect.Descriptor instead. func (*MoveTaskRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{103} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{104} } func (x *MoveTaskRequest) GetTaskId() string { @@ -6853,7 +6903,7 @@ type MoveTaskResponse struct { func (x *MoveTaskResponse) Reset() { *x = MoveTaskResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[104] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[105] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6865,7 +6915,7 @@ func (x *MoveTaskResponse) String() string { func (*MoveTaskResponse) ProtoMessage() {} func (x *MoveTaskResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[104] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[105] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6878,7 +6928,7 @@ func (x *MoveTaskResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MoveTaskResponse.ProtoReflect.Descriptor instead. func (*MoveTaskResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{104} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{105} } func (x *MoveTaskResponse) GetTask() *Task { @@ -6926,7 +6976,7 @@ type SendMessageRequest struct { func (x *SendMessageRequest) Reset() { *x = SendMessageRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[105] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[106] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6938,7 +6988,7 @@ func (x *SendMessageRequest) String() string { func (*SendMessageRequest) ProtoMessage() {} func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[105] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[106] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6951,7 +7001,7 @@ func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageRequest.ProtoReflect.Descriptor instead. func (*SendMessageRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{105} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{106} } func (x *SendMessageRequest) GetTaskId() string { @@ -6985,7 +7035,7 @@ type SendMessageResponse struct { func (x *SendMessageResponse) Reset() { *x = SendMessageResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[106] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[107] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6997,7 +7047,7 @@ func (x *SendMessageResponse) String() string { func (*SendMessageResponse) ProtoMessage() {} func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[106] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[107] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7010,7 +7060,7 @@ func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendMessageResponse.ProtoReflect.Descriptor instead. func (*SendMessageResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{106} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{107} } func (x *SendMessageResponse) GetSessionId() string { @@ -7036,7 +7086,7 @@ type PreviewPluginOwnedTaskTreeRequest struct { func (x *PreviewPluginOwnedTaskTreeRequest) Reset() { *x = PreviewPluginOwnedTaskTreeRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[107] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[108] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7048,7 +7098,7 @@ func (x *PreviewPluginOwnedTaskTreeRequest) String() string { func (*PreviewPluginOwnedTaskTreeRequest) ProtoMessage() {} func (x *PreviewPluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[107] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[108] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7061,7 +7111,7 @@ func (x *PreviewPluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message // Deprecated: Use PreviewPluginOwnedTaskTreeRequest.ProtoReflect.Descriptor instead. func (*PreviewPluginOwnedTaskTreeRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{107} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{108} } func (x *PreviewPluginOwnedTaskTreeRequest) GetRootTaskId() string { @@ -7080,7 +7130,7 @@ type PreviewPluginOwnedTaskTreeResponse struct { func (x *PreviewPluginOwnedTaskTreeResponse) Reset() { *x = PreviewPluginOwnedTaskTreeResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[108] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[109] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7092,7 +7142,7 @@ func (x *PreviewPluginOwnedTaskTreeResponse) String() string { func (*PreviewPluginOwnedTaskTreeResponse) ProtoMessage() {} func (x *PreviewPluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[108] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[109] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7105,7 +7155,7 @@ func (x *PreviewPluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message // Deprecated: Use PreviewPluginOwnedTaskTreeResponse.ProtoReflect.Descriptor instead. func (*PreviewPluginOwnedTaskTreeResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{108} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{109} } func (x *PreviewPluginOwnedTaskTreeResponse) GetTasks() []*Task { @@ -7124,7 +7174,7 @@ type DeletePluginOwnedTaskTreeRequest struct { func (x *DeletePluginOwnedTaskTreeRequest) Reset() { *x = DeletePluginOwnedTaskTreeRequest{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[109] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[110] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7136,7 +7186,7 @@ func (x *DeletePluginOwnedTaskTreeRequest) String() string { func (*DeletePluginOwnedTaskTreeRequest) ProtoMessage() {} func (x *DeletePluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[109] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[110] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7149,7 +7199,7 @@ func (x *DeletePluginOwnedTaskTreeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletePluginOwnedTaskTreeRequest.ProtoReflect.Descriptor instead. func (*DeletePluginOwnedTaskTreeRequest) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{109} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{110} } func (x *DeletePluginOwnedTaskTreeRequest) GetRootTaskId() string { @@ -7168,7 +7218,7 @@ type DeletePluginOwnedTaskTreeResponse struct { func (x *DeletePluginOwnedTaskTreeResponse) Reset() { *x = DeletePluginOwnedTaskTreeResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[110] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[111] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7180,7 +7230,7 @@ func (x *DeletePluginOwnedTaskTreeResponse) String() string { func (*DeletePluginOwnedTaskTreeResponse) ProtoMessage() {} func (x *DeletePluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[110] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[111] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7193,7 +7243,7 @@ func (x *DeletePluginOwnedTaskTreeResponse) ProtoReflect() protoreflect.Message // Deprecated: Use DeletePluginOwnedTaskTreeResponse.ProtoReflect.Descriptor instead. func (*DeletePluginOwnedTaskTreeResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{110} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{111} } func (x *DeletePluginOwnedTaskTreeResponse) GetDeletedTaskIds() []string { @@ -7215,7 +7265,7 @@ type DeletePluginOwnedTaskTreeProgress struct { func (x *DeletePluginOwnedTaskTreeProgress) Reset() { *x = DeletePluginOwnedTaskTreeProgress{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[111] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[112] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -7227,7 +7277,7 @@ func (x *DeletePluginOwnedTaskTreeProgress) String() string { func (*DeletePluginOwnedTaskTreeProgress) ProtoMessage() {} func (x *DeletePluginOwnedTaskTreeProgress) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[111] + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[112] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -7240,7 +7290,7 @@ func (x *DeletePluginOwnedTaskTreeProgress) ProtoReflect() protoreflect.Message // Deprecated: Use DeletePluginOwnedTaskTreeProgress.ProtoReflect.Descriptor instead. func (*DeletePluginOwnedTaskTreeProgress) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{111} + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{112} } func (x *DeletePluginOwnedTaskTreeProgress) GetDeletedTaskIds() []string { @@ -7771,7 +7821,11 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x1bCancelClarificationResponse\x12?\n" + "\vinteraction\x18\x01 \x01(\v2\x1d.kandev.plugin.v1.InteractionR\vinteraction\"3\n" + "\x19InvokeUtilityAgentRequest\x12\x16\n" + - "\x06prompt\x18\x01 \x01(\tR\x06prompt\"0\n" + + "\x06prompt\x18\x01 \x01(\tR\x06prompt\"]\n" + + "$InvokeUtilityAgentWithOptionsRequest\x12\x16\n" + + "\x06prompt\x18\x01 \x01(\tR\x06prompt\x12\x1d\n" + + "\n" + + "profile_id\x18\x02 \x01(\tR\tprofileId\"0\n" + "\x1aInvokeUtilityAgentResponse\x12\x12\n" + "\x04text\x18\x01 \x01(\tR\x04text\"\x94\x04\n" + "\x11CreateTaskRequest\x12!\n" + @@ -7893,7 +7947,7 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x18AuthorizeEntityReference\x121.kandev.plugin.v1.AuthorizeEntityReferenceRequest\x1a2.kandev.plugin.v1.AuthorizeEntityReferenceResponse\x12u\n" + "\x14ResolveGitCredential\x12-.kandev.plugin.v1.ResolveGitCredentialRequest\x1a..kandev.plugin.v1.ResolveGitCredentialResponse\x12x\n" + "\x17GetGitCredentialBinding\x12-.kandev.plugin.v1.GitCredentialBindingRequest\x1a..kandev.plugin.v1.GitCredentialBindingResponse\x12Z\n" + - "\x0fInvokeAgentTool\x12\".kandev.plugin.v1.AgentToolRequest\x1a#.kandev.plugin.v1.AgentToolResponse2\xf8\x19\n" + + "\x0fInvokeAgentTool\x12\".kandev.plugin.v1.AgentToolRequest\x1a#.kandev.plugin.v1.AgentToolResponse2\x80\x1b\n" + "\x04Host\x12Q\n" + "\bGetState\x12!.kandev.plugin.v1.GetStateRequest\x1a\".kandev.plugin.v1.GetStateResponse\x12Q\n" + "\bSetState\x12!.kandev.plugin.v1.SetStateRequest\x1a\".kandev.plugin.v1.SetStateResponse\x12Z\n" + @@ -7918,7 +7972,8 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\fListMessages\x12%.kandev.plugin.v1.ListMessagesRequest\x1a&.kandev.plugin.v1.ListMessagesResponse\x12~\n" + "\x17ListPendingInteractions\x120.kandev.plugin.v1.ListPendingInteractionsRequest\x1a1.kandev.plugin.v1.ListPendingInteractionsResponse\x12c\n" + "\x0eGetInteraction\x12'.kandev.plugin.v1.GetInteractionRequest\x1a(.kandev.plugin.v1.GetInteractionResponse\x12o\n" + - "\x12InvokeUtilityAgent\x12+.kandev.plugin.v1.InvokeUtilityAgentRequest\x1a,.kandev.plugin.v1.InvokeUtilityAgentResponse\x12W\n" + + "\x12InvokeUtilityAgent\x12+.kandev.plugin.v1.InvokeUtilityAgentRequest\x1a,.kandev.plugin.v1.InvokeUtilityAgentResponse\x12\x85\x01\n" + + "\x1dInvokeUtilityAgentWithOptions\x126.kandev.plugin.v1.InvokeUtilityAgentWithOptionsRequest\x1a,.kandev.plugin.v1.InvokeUtilityAgentResponse\x12W\n" + "\n" + "CreateTask\x12#.kandev.plugin.v1.CreateTaskRequest\x1a$.kandev.plugin.v1.CreateTaskResponse\x12W\n" + "\n" + @@ -7943,145 +7998,146 @@ func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { return file_kandev_plugin_v1_plugin_proto_rawDescData } -var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 115) +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 116) var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ - (*Event)(nil), // 0: kandev.plugin.v1.Event - (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck - (*AgentToolRequest)(nil), // 2: kandev.plugin.v1.AgentToolRequest - (*AgentToolContext)(nil), // 3: kandev.plugin.v1.AgentToolContext - (*AgentToolResponse)(nil), // 4: kandev.plugin.v1.AgentToolResponse - (*WebhookRequest)(nil), // 5: kandev.plugin.v1.WebhookRequest - (*WebhookResponse)(nil), // 6: kandev.plugin.v1.WebhookResponse - (*PluginActionRequest)(nil), // 7: kandev.plugin.v1.PluginActionRequest - (*VerifiedActionContext)(nil), // 8: kandev.plugin.v1.VerifiedActionContext - (*PluginActionResponse)(nil), // 9: kandev.plugin.v1.PluginActionResponse - (*SearchEntityReferencesRequest)(nil), // 10: kandev.plugin.v1.SearchEntityReferencesRequest - (*SearchEntityReferencesResponse)(nil), // 11: kandev.plugin.v1.SearchEntityReferencesResponse - (*EntityReferenceCandidate)(nil), // 12: kandev.plugin.v1.EntityReferenceCandidate - (*AuthorizeEntityReferenceRequest)(nil), // 13: kandev.plugin.v1.AuthorizeEntityReferenceRequest - (*AuthorizeEntityReferenceResponse)(nil), // 14: kandev.plugin.v1.AuthorizeEntityReferenceResponse - (*ResolveGitCredentialRequest)(nil), // 15: kandev.plugin.v1.ResolveGitCredentialRequest - (*ResolveGitCredentialResponse)(nil), // 16: kandev.plugin.v1.ResolveGitCredentialResponse - (*GitCredentialBindingRequest)(nil), // 17: kandev.plugin.v1.GitCredentialBindingRequest - (*GitCredentialBindingResponse)(nil), // 18: kandev.plugin.v1.GitCredentialBindingResponse - (*GetStateRequest)(nil), // 19: kandev.plugin.v1.GetStateRequest - (*GetStateResponse)(nil), // 20: kandev.plugin.v1.GetStateResponse - (*SetStateRequest)(nil), // 21: kandev.plugin.v1.SetStateRequest - (*SetStateResponse)(nil), // 22: kandev.plugin.v1.SetStateResponse - (*DeleteStateRequest)(nil), // 23: kandev.plugin.v1.DeleteStateRequest - (*DeleteStateResponse)(nil), // 24: kandev.plugin.v1.DeleteStateResponse - (*ListStateRequest)(nil), // 25: kandev.plugin.v1.ListStateRequest - (*ListStateResponse)(nil), // 26: kandev.plugin.v1.ListStateResponse - (*StateEntry)(nil), // 27: kandev.plugin.v1.StateEntry - (*GetConfigRequest)(nil), // 28: kandev.plugin.v1.GetConfigRequest - (*GetConfigResponse)(nil), // 29: kandev.plugin.v1.GetConfigResponse - (*GetSecretRequest)(nil), // 30: kandev.plugin.v1.GetSecretRequest - (*GetSecretResponse)(nil), // 31: kandev.plugin.v1.GetSecretResponse - (*SetSecretRequest)(nil), // 32: kandev.plugin.v1.SetSecretRequest - (*SetSecretResponse)(nil), // 33: kandev.plugin.v1.SetSecretResponse - (*DeleteSecretRequest)(nil), // 34: kandev.plugin.v1.DeleteSecretRequest - (*DeleteSecretResponse)(nil), // 35: kandev.plugin.v1.DeleteSecretResponse - (*RevealSecretRequest)(nil), // 36: kandev.plugin.v1.RevealSecretRequest - (*RevealSecretResponse)(nil), // 37: kandev.plugin.v1.RevealSecretResponse - (*EmitEventRequest)(nil), // 38: kandev.plugin.v1.EmitEventRequest - (*EmitEventResponse)(nil), // 39: kandev.plugin.v1.EmitEventResponse - (*Page)(nil), // 40: kandev.plugin.v1.Page - (*PageInfo)(nil), // 41: kandev.plugin.v1.PageInfo - (*Task)(nil), // 42: kandev.plugin.v1.Task - (*TaskRepository)(nil), // 43: kandev.plugin.v1.TaskRepository - (*TaskPullRequest)(nil), // 44: kandev.plugin.v1.TaskPullRequest - (*TaskFilter)(nil), // 45: kandev.plugin.v1.TaskFilter - (*ListTasksRequest)(nil), // 46: kandev.plugin.v1.ListTasksRequest - (*ListTasksResponse)(nil), // 47: kandev.plugin.v1.ListTasksResponse - (*GetTaskRequest)(nil), // 48: kandev.plugin.v1.GetTaskRequest - (*GetTaskResponse)(nil), // 49: kandev.plugin.v1.GetTaskResponse - (*Workspace)(nil), // 50: kandev.plugin.v1.Workspace - (*ListWorkspacesRequest)(nil), // 51: kandev.plugin.v1.ListWorkspacesRequest - (*ListWorkspacesResponse)(nil), // 52: kandev.plugin.v1.ListWorkspacesResponse - (*Workflow)(nil), // 53: kandev.plugin.v1.Workflow - (*ListWorkflowsRequest)(nil), // 54: kandev.plugin.v1.ListWorkflowsRequest - (*ListWorkflowsResponse)(nil), // 55: kandev.plugin.v1.ListWorkflowsResponse - (*WorkflowStep)(nil), // 56: kandev.plugin.v1.WorkflowStep - (*ListWorkflowStepsRequest)(nil), // 57: kandev.plugin.v1.ListWorkflowStepsRequest - (*ListWorkflowStepsResponse)(nil), // 58: kandev.plugin.v1.ListWorkflowStepsResponse - (*AgentProfile)(nil), // 59: kandev.plugin.v1.AgentProfile - (*ListAgentProfilesRequest)(nil), // 60: kandev.plugin.v1.ListAgentProfilesRequest - (*ListAgentProfilesResponse)(nil), // 61: kandev.plugin.v1.ListAgentProfilesResponse - (*ExecutorProfile)(nil), // 62: kandev.plugin.v1.ExecutorProfile - (*ListExecutorProfilesRequest)(nil), // 63: kandev.plugin.v1.ListExecutorProfilesRequest - (*ListExecutorProfilesResponse)(nil), // 64: kandev.plugin.v1.ListExecutorProfilesResponse - (*Repository)(nil), // 65: kandev.plugin.v1.Repository - (*ListRepositoriesRequest)(nil), // 66: kandev.plugin.v1.ListRepositoriesRequest - (*ListRepositoriesResponse)(nil), // 67: kandev.plugin.v1.ListRepositoriesResponse - (*Session)(nil), // 68: kandev.plugin.v1.Session - (*SessionFilter)(nil), // 69: kandev.plugin.v1.SessionFilter - (*ListSessionsRequest)(nil), // 70: kandev.plugin.v1.ListSessionsRequest - (*ListSessionsResponse)(nil), // 71: kandev.plugin.v1.ListSessionsResponse - (*SessionCodeStats)(nil), // 72: kandev.plugin.v1.SessionCodeStats - (*ListSessionCodeStatsRequest)(nil), // 73: kandev.plugin.v1.ListSessionCodeStatsRequest - (*ListSessionCodeStatsResponse)(nil), // 74: kandev.plugin.v1.ListSessionCodeStatsResponse - (*Message)(nil), // 75: kandev.plugin.v1.Message - (*MessageFilter)(nil), // 76: kandev.plugin.v1.MessageFilter - (*ListMessagesRequest)(nil), // 77: kandev.plugin.v1.ListMessagesRequest - (*ListMessagesResponse)(nil), // 78: kandev.plugin.v1.ListMessagesResponse - (*InteractionOption)(nil), // 79: kandev.plugin.v1.InteractionOption - (*InteractionQuestion)(nil), // 80: kandev.plugin.v1.InteractionQuestion - (*Interaction)(nil), // 81: kandev.plugin.v1.Interaction - (*InteractionFilter)(nil), // 82: kandev.plugin.v1.InteractionFilter - (*ListPendingInteractionsRequest)(nil), // 83: kandev.plugin.v1.ListPendingInteractionsRequest - (*ListPendingInteractionsResponse)(nil), // 84: kandev.plugin.v1.ListPendingInteractionsResponse - (*GetInteractionRequest)(nil), // 85: kandev.plugin.v1.GetInteractionRequest - (*GetInteractionResponse)(nil), // 86: kandev.plugin.v1.GetInteractionResponse - (*RespondToPermissionRequest)(nil), // 87: kandev.plugin.v1.RespondToPermissionRequest - (*RespondToPermissionResponse)(nil), // 88: kandev.plugin.v1.RespondToPermissionResponse - (*ClarificationAnswer)(nil), // 89: kandev.plugin.v1.ClarificationAnswer - (*AnswerClarificationRequest)(nil), // 90: kandev.plugin.v1.AnswerClarificationRequest - (*AnswerClarificationResponse)(nil), // 91: kandev.plugin.v1.AnswerClarificationResponse - (*CancelClarificationRequest)(nil), // 92: kandev.plugin.v1.CancelClarificationRequest - (*CancelClarificationResponse)(nil), // 93: kandev.plugin.v1.CancelClarificationResponse - (*InvokeUtilityAgentRequest)(nil), // 94: kandev.plugin.v1.InvokeUtilityAgentRequest - (*InvokeUtilityAgentResponse)(nil), // 95: kandev.plugin.v1.InvokeUtilityAgentResponse - (*CreateTaskRequest)(nil), // 96: kandev.plugin.v1.CreateTaskRequest - (*PluginTaskRepository)(nil), // 97: kandev.plugin.v1.PluginTaskRepository - (*RemoteRepositoryDescriptor)(nil), // 98: kandev.plugin.v1.RemoteRepositoryDescriptor - (*PluginTaskLaunchOptions)(nil), // 99: kandev.plugin.v1.PluginTaskLaunchOptions - (*CreateTaskResponse)(nil), // 100: kandev.plugin.v1.CreateTaskResponse - (*UpdateTaskRequest)(nil), // 101: kandev.plugin.v1.UpdateTaskRequest - (*UpdateTaskResponse)(nil), // 102: kandev.plugin.v1.UpdateTaskResponse - (*MoveTaskRequest)(nil), // 103: kandev.plugin.v1.MoveTaskRequest - (*MoveTaskResponse)(nil), // 104: kandev.plugin.v1.MoveTaskResponse - (*SendMessageRequest)(nil), // 105: kandev.plugin.v1.SendMessageRequest - (*SendMessageResponse)(nil), // 106: kandev.plugin.v1.SendMessageResponse - (*PreviewPluginOwnedTaskTreeRequest)(nil), // 107: kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest - (*PreviewPluginOwnedTaskTreeResponse)(nil), // 108: kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse - (*DeletePluginOwnedTaskTreeRequest)(nil), // 109: kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest - (*DeletePluginOwnedTaskTreeResponse)(nil), // 110: kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse - (*DeletePluginOwnedTaskTreeProgress)(nil), // 111: kandev.plugin.v1.DeletePluginOwnedTaskTreeProgress - nil, // 112: kandev.plugin.v1.WebhookRequest.HeadersEntry - nil, // 113: kandev.plugin.v1.WebhookResponse.HeadersEntry - nil, // 114: kandev.plugin.v1.PluginActionResponse.HeadersEntry - (*structpb.Struct)(nil), // 115: google.protobuf.Struct + (*Event)(nil), // 0: kandev.plugin.v1.Event + (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck + (*AgentToolRequest)(nil), // 2: kandev.plugin.v1.AgentToolRequest + (*AgentToolContext)(nil), // 3: kandev.plugin.v1.AgentToolContext + (*AgentToolResponse)(nil), // 4: kandev.plugin.v1.AgentToolResponse + (*WebhookRequest)(nil), // 5: kandev.plugin.v1.WebhookRequest + (*WebhookResponse)(nil), // 6: kandev.plugin.v1.WebhookResponse + (*PluginActionRequest)(nil), // 7: kandev.plugin.v1.PluginActionRequest + (*VerifiedActionContext)(nil), // 8: kandev.plugin.v1.VerifiedActionContext + (*PluginActionResponse)(nil), // 9: kandev.plugin.v1.PluginActionResponse + (*SearchEntityReferencesRequest)(nil), // 10: kandev.plugin.v1.SearchEntityReferencesRequest + (*SearchEntityReferencesResponse)(nil), // 11: kandev.plugin.v1.SearchEntityReferencesResponse + (*EntityReferenceCandidate)(nil), // 12: kandev.plugin.v1.EntityReferenceCandidate + (*AuthorizeEntityReferenceRequest)(nil), // 13: kandev.plugin.v1.AuthorizeEntityReferenceRequest + (*AuthorizeEntityReferenceResponse)(nil), // 14: kandev.plugin.v1.AuthorizeEntityReferenceResponse + (*ResolveGitCredentialRequest)(nil), // 15: kandev.plugin.v1.ResolveGitCredentialRequest + (*ResolveGitCredentialResponse)(nil), // 16: kandev.plugin.v1.ResolveGitCredentialResponse + (*GitCredentialBindingRequest)(nil), // 17: kandev.plugin.v1.GitCredentialBindingRequest + (*GitCredentialBindingResponse)(nil), // 18: kandev.plugin.v1.GitCredentialBindingResponse + (*GetStateRequest)(nil), // 19: kandev.plugin.v1.GetStateRequest + (*GetStateResponse)(nil), // 20: kandev.plugin.v1.GetStateResponse + (*SetStateRequest)(nil), // 21: kandev.plugin.v1.SetStateRequest + (*SetStateResponse)(nil), // 22: kandev.plugin.v1.SetStateResponse + (*DeleteStateRequest)(nil), // 23: kandev.plugin.v1.DeleteStateRequest + (*DeleteStateResponse)(nil), // 24: kandev.plugin.v1.DeleteStateResponse + (*ListStateRequest)(nil), // 25: kandev.plugin.v1.ListStateRequest + (*ListStateResponse)(nil), // 26: kandev.plugin.v1.ListStateResponse + (*StateEntry)(nil), // 27: kandev.plugin.v1.StateEntry + (*GetConfigRequest)(nil), // 28: kandev.plugin.v1.GetConfigRequest + (*GetConfigResponse)(nil), // 29: kandev.plugin.v1.GetConfigResponse + (*GetSecretRequest)(nil), // 30: kandev.plugin.v1.GetSecretRequest + (*GetSecretResponse)(nil), // 31: kandev.plugin.v1.GetSecretResponse + (*SetSecretRequest)(nil), // 32: kandev.plugin.v1.SetSecretRequest + (*SetSecretResponse)(nil), // 33: kandev.plugin.v1.SetSecretResponse + (*DeleteSecretRequest)(nil), // 34: kandev.plugin.v1.DeleteSecretRequest + (*DeleteSecretResponse)(nil), // 35: kandev.plugin.v1.DeleteSecretResponse + (*RevealSecretRequest)(nil), // 36: kandev.plugin.v1.RevealSecretRequest + (*RevealSecretResponse)(nil), // 37: kandev.plugin.v1.RevealSecretResponse + (*EmitEventRequest)(nil), // 38: kandev.plugin.v1.EmitEventRequest + (*EmitEventResponse)(nil), // 39: kandev.plugin.v1.EmitEventResponse + (*Page)(nil), // 40: kandev.plugin.v1.Page + (*PageInfo)(nil), // 41: kandev.plugin.v1.PageInfo + (*Task)(nil), // 42: kandev.plugin.v1.Task + (*TaskRepository)(nil), // 43: kandev.plugin.v1.TaskRepository + (*TaskPullRequest)(nil), // 44: kandev.plugin.v1.TaskPullRequest + (*TaskFilter)(nil), // 45: kandev.plugin.v1.TaskFilter + (*ListTasksRequest)(nil), // 46: kandev.plugin.v1.ListTasksRequest + (*ListTasksResponse)(nil), // 47: kandev.plugin.v1.ListTasksResponse + (*GetTaskRequest)(nil), // 48: kandev.plugin.v1.GetTaskRequest + (*GetTaskResponse)(nil), // 49: kandev.plugin.v1.GetTaskResponse + (*Workspace)(nil), // 50: kandev.plugin.v1.Workspace + (*ListWorkspacesRequest)(nil), // 51: kandev.plugin.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 52: kandev.plugin.v1.ListWorkspacesResponse + (*Workflow)(nil), // 53: kandev.plugin.v1.Workflow + (*ListWorkflowsRequest)(nil), // 54: kandev.plugin.v1.ListWorkflowsRequest + (*ListWorkflowsResponse)(nil), // 55: kandev.plugin.v1.ListWorkflowsResponse + (*WorkflowStep)(nil), // 56: kandev.plugin.v1.WorkflowStep + (*ListWorkflowStepsRequest)(nil), // 57: kandev.plugin.v1.ListWorkflowStepsRequest + (*ListWorkflowStepsResponse)(nil), // 58: kandev.plugin.v1.ListWorkflowStepsResponse + (*AgentProfile)(nil), // 59: kandev.plugin.v1.AgentProfile + (*ListAgentProfilesRequest)(nil), // 60: kandev.plugin.v1.ListAgentProfilesRequest + (*ListAgentProfilesResponse)(nil), // 61: kandev.plugin.v1.ListAgentProfilesResponse + (*ExecutorProfile)(nil), // 62: kandev.plugin.v1.ExecutorProfile + (*ListExecutorProfilesRequest)(nil), // 63: kandev.plugin.v1.ListExecutorProfilesRequest + (*ListExecutorProfilesResponse)(nil), // 64: kandev.plugin.v1.ListExecutorProfilesResponse + (*Repository)(nil), // 65: kandev.plugin.v1.Repository + (*ListRepositoriesRequest)(nil), // 66: kandev.plugin.v1.ListRepositoriesRequest + (*ListRepositoriesResponse)(nil), // 67: kandev.plugin.v1.ListRepositoriesResponse + (*Session)(nil), // 68: kandev.plugin.v1.Session + (*SessionFilter)(nil), // 69: kandev.plugin.v1.SessionFilter + (*ListSessionsRequest)(nil), // 70: kandev.plugin.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 71: kandev.plugin.v1.ListSessionsResponse + (*SessionCodeStats)(nil), // 72: kandev.plugin.v1.SessionCodeStats + (*ListSessionCodeStatsRequest)(nil), // 73: kandev.plugin.v1.ListSessionCodeStatsRequest + (*ListSessionCodeStatsResponse)(nil), // 74: kandev.plugin.v1.ListSessionCodeStatsResponse + (*Message)(nil), // 75: kandev.plugin.v1.Message + (*MessageFilter)(nil), // 76: kandev.plugin.v1.MessageFilter + (*ListMessagesRequest)(nil), // 77: kandev.plugin.v1.ListMessagesRequest + (*ListMessagesResponse)(nil), // 78: kandev.plugin.v1.ListMessagesResponse + (*InteractionOption)(nil), // 79: kandev.plugin.v1.InteractionOption + (*InteractionQuestion)(nil), // 80: kandev.plugin.v1.InteractionQuestion + (*Interaction)(nil), // 81: kandev.plugin.v1.Interaction + (*InteractionFilter)(nil), // 82: kandev.plugin.v1.InteractionFilter + (*ListPendingInteractionsRequest)(nil), // 83: kandev.plugin.v1.ListPendingInteractionsRequest + (*ListPendingInteractionsResponse)(nil), // 84: kandev.plugin.v1.ListPendingInteractionsResponse + (*GetInteractionRequest)(nil), // 85: kandev.plugin.v1.GetInteractionRequest + (*GetInteractionResponse)(nil), // 86: kandev.plugin.v1.GetInteractionResponse + (*RespondToPermissionRequest)(nil), // 87: kandev.plugin.v1.RespondToPermissionRequest + (*RespondToPermissionResponse)(nil), // 88: kandev.plugin.v1.RespondToPermissionResponse + (*ClarificationAnswer)(nil), // 89: kandev.plugin.v1.ClarificationAnswer + (*AnswerClarificationRequest)(nil), // 90: kandev.plugin.v1.AnswerClarificationRequest + (*AnswerClarificationResponse)(nil), // 91: kandev.plugin.v1.AnswerClarificationResponse + (*CancelClarificationRequest)(nil), // 92: kandev.plugin.v1.CancelClarificationRequest + (*CancelClarificationResponse)(nil), // 93: kandev.plugin.v1.CancelClarificationResponse + (*InvokeUtilityAgentRequest)(nil), // 94: kandev.plugin.v1.InvokeUtilityAgentRequest + (*InvokeUtilityAgentWithOptionsRequest)(nil), // 95: kandev.plugin.v1.InvokeUtilityAgentWithOptionsRequest + (*InvokeUtilityAgentResponse)(nil), // 96: kandev.plugin.v1.InvokeUtilityAgentResponse + (*CreateTaskRequest)(nil), // 97: kandev.plugin.v1.CreateTaskRequest + (*PluginTaskRepository)(nil), // 98: kandev.plugin.v1.PluginTaskRepository + (*RemoteRepositoryDescriptor)(nil), // 99: kandev.plugin.v1.RemoteRepositoryDescriptor + (*PluginTaskLaunchOptions)(nil), // 100: kandev.plugin.v1.PluginTaskLaunchOptions + (*CreateTaskResponse)(nil), // 101: kandev.plugin.v1.CreateTaskResponse + (*UpdateTaskRequest)(nil), // 102: kandev.plugin.v1.UpdateTaskRequest + (*UpdateTaskResponse)(nil), // 103: kandev.plugin.v1.UpdateTaskResponse + (*MoveTaskRequest)(nil), // 104: kandev.plugin.v1.MoveTaskRequest + (*MoveTaskResponse)(nil), // 105: kandev.plugin.v1.MoveTaskResponse + (*SendMessageRequest)(nil), // 106: kandev.plugin.v1.SendMessageRequest + (*SendMessageResponse)(nil), // 107: kandev.plugin.v1.SendMessageResponse + (*PreviewPluginOwnedTaskTreeRequest)(nil), // 108: kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest + (*PreviewPluginOwnedTaskTreeResponse)(nil), // 109: kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse + (*DeletePluginOwnedTaskTreeRequest)(nil), // 110: kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest + (*DeletePluginOwnedTaskTreeResponse)(nil), // 111: kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse + (*DeletePluginOwnedTaskTreeProgress)(nil), // 112: kandev.plugin.v1.DeletePluginOwnedTaskTreeProgress + nil, // 113: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 114: kandev.plugin.v1.WebhookResponse.HeadersEntry + nil, // 115: kandev.plugin.v1.PluginActionResponse.HeadersEntry + (*structpb.Struct)(nil), // 116: google.protobuf.Struct } var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ - 115, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct - 115, // 1: kandev.plugin.v1.AgentToolRequest.arguments:type_name -> google.protobuf.Struct + 116, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 116, // 1: kandev.plugin.v1.AgentToolRequest.arguments:type_name -> google.protobuf.Struct 3, // 2: kandev.plugin.v1.AgentToolRequest.context:type_name -> kandev.plugin.v1.AgentToolContext - 115, // 3: kandev.plugin.v1.AgentToolResponse.structured_content:type_name -> google.protobuf.Struct - 112, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry - 113, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 116, // 3: kandev.plugin.v1.AgentToolResponse.structured_content:type_name -> google.protobuf.Struct + 113, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 114, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry 8, // 6: kandev.plugin.v1.PluginActionRequest.context:type_name -> kandev.plugin.v1.VerifiedActionContext - 114, // 7: kandev.plugin.v1.PluginActionResponse.headers:type_name -> kandev.plugin.v1.PluginActionResponse.HeadersEntry + 115, // 7: kandev.plugin.v1.PluginActionResponse.headers:type_name -> kandev.plugin.v1.PluginActionResponse.HeadersEntry 12, // 8: kandev.plugin.v1.SearchEntityReferencesResponse.candidates:type_name -> kandev.plugin.v1.EntityReferenceCandidate - 115, // 9: kandev.plugin.v1.EntityReferenceCandidate.attributes:type_name -> google.protobuf.Struct - 115, // 10: kandev.plugin.v1.AuthorizeEntityReferenceRequest.reference:type_name -> google.protobuf.Struct - 115, // 11: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct - 115, // 12: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 116, // 9: kandev.plugin.v1.EntityReferenceCandidate.attributes:type_name -> google.protobuf.Struct + 116, // 10: kandev.plugin.v1.AuthorizeEntityReferenceRequest.reference:type_name -> google.protobuf.Struct + 116, // 11: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 116, // 12: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct 27, // 13: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry - 115, // 14: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct - 115, // 15: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct - 115, // 16: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 116, // 14: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 116, // 15: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct + 116, // 16: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct 43, // 17: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository - 115, // 18: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct + 116, // 18: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct 44, // 19: kandev.plugin.v1.Task.pull_requests:type_name -> kandev.plugin.v1.TaskPullRequest 45, // 20: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter 40, // 21: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page @@ -8128,10 +8184,10 @@ var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ 89, // 62: kandev.plugin.v1.AnswerClarificationRequest.answers:type_name -> kandev.plugin.v1.ClarificationAnswer 81, // 63: kandev.plugin.v1.AnswerClarificationResponse.interaction:type_name -> kandev.plugin.v1.Interaction 81, // 64: kandev.plugin.v1.CancelClarificationResponse.interaction:type_name -> kandev.plugin.v1.Interaction - 97, // 65: kandev.plugin.v1.CreateTaskRequest.repositories:type_name -> kandev.plugin.v1.PluginTaskRepository - 99, // 66: kandev.plugin.v1.CreateTaskRequest.launch:type_name -> kandev.plugin.v1.PluginTaskLaunchOptions - 115, // 67: kandev.plugin.v1.CreateTaskRequest.metadata:type_name -> google.protobuf.Struct - 98, // 68: kandev.plugin.v1.PluginTaskRepository.remote:type_name -> kandev.plugin.v1.RemoteRepositoryDescriptor + 98, // 65: kandev.plugin.v1.CreateTaskRequest.repositories:type_name -> kandev.plugin.v1.PluginTaskRepository + 100, // 66: kandev.plugin.v1.CreateTaskRequest.launch:type_name -> kandev.plugin.v1.PluginTaskLaunchOptions + 116, // 67: kandev.plugin.v1.CreateTaskRequest.metadata:type_name -> google.protobuf.Struct + 99, // 68: kandev.plugin.v1.PluginTaskRepository.remote:type_name -> kandev.plugin.v1.RemoteRepositoryDescriptor 42, // 69: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task 42, // 70: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task 42, // 71: kandev.plugin.v1.MoveTaskResponse.task:type_name -> kandev.plugin.v1.Task @@ -8168,58 +8224,60 @@ var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ 83, // 102: kandev.plugin.v1.Host.ListPendingInteractions:input_type -> kandev.plugin.v1.ListPendingInteractionsRequest 85, // 103: kandev.plugin.v1.Host.GetInteraction:input_type -> kandev.plugin.v1.GetInteractionRequest 94, // 104: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest - 96, // 105: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest - 101, // 106: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest - 103, // 107: kandev.plugin.v1.Host.MoveTask:input_type -> kandev.plugin.v1.MoveTaskRequest - 105, // 108: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest - 107, // 109: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:input_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest - 109, // 110: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:input_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest - 87, // 111: kandev.plugin.v1.Host.RespondToPermission:input_type -> kandev.plugin.v1.RespondToPermissionRequest - 90, // 112: kandev.plugin.v1.Host.AnswerClarification:input_type -> kandev.plugin.v1.AnswerClarificationRequest - 92, // 113: kandev.plugin.v1.Host.CancelClarification:input_type -> kandev.plugin.v1.CancelClarificationRequest - 1, // 114: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck - 6, // 115: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse - 9, // 116: kandev.plugin.v1.Plugin.HandleAction:output_type -> kandev.plugin.v1.PluginActionResponse - 11, // 117: kandev.plugin.v1.Plugin.SearchEntityReferences:output_type -> kandev.plugin.v1.SearchEntityReferencesResponse - 14, // 118: kandev.plugin.v1.Plugin.AuthorizeEntityReference:output_type -> kandev.plugin.v1.AuthorizeEntityReferenceResponse - 16, // 119: kandev.plugin.v1.Plugin.ResolveGitCredential:output_type -> kandev.plugin.v1.ResolveGitCredentialResponse - 18, // 120: kandev.plugin.v1.Plugin.GetGitCredentialBinding:output_type -> kandev.plugin.v1.GitCredentialBindingResponse - 4, // 121: kandev.plugin.v1.Plugin.InvokeAgentTool:output_type -> kandev.plugin.v1.AgentToolResponse - 20, // 122: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse - 22, // 123: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse - 24, // 124: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse - 26, // 125: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse - 37, // 126: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse - 39, // 127: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse - 31, // 128: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse - 33, // 129: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse - 35, // 130: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse - 29, // 131: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse - 47, // 132: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse - 49, // 133: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse - 52, // 134: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse - 55, // 135: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse - 58, // 136: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse - 61, // 137: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse - 64, // 138: kandev.plugin.v1.Host.ListExecutorProfiles:output_type -> kandev.plugin.v1.ListExecutorProfilesResponse - 67, // 139: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse - 71, // 140: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse - 74, // 141: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse - 78, // 142: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse - 84, // 143: kandev.plugin.v1.Host.ListPendingInteractions:output_type -> kandev.plugin.v1.ListPendingInteractionsResponse - 86, // 144: kandev.plugin.v1.Host.GetInteraction:output_type -> kandev.plugin.v1.GetInteractionResponse - 95, // 145: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse - 100, // 146: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse - 102, // 147: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse - 104, // 148: kandev.plugin.v1.Host.MoveTask:output_type -> kandev.plugin.v1.MoveTaskResponse - 106, // 149: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse - 108, // 150: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:output_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse - 110, // 151: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:output_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse - 88, // 152: kandev.plugin.v1.Host.RespondToPermission:output_type -> kandev.plugin.v1.RespondToPermissionResponse - 91, // 153: kandev.plugin.v1.Host.AnswerClarification:output_type -> kandev.plugin.v1.AnswerClarificationResponse - 93, // 154: kandev.plugin.v1.Host.CancelClarification:output_type -> kandev.plugin.v1.CancelClarificationResponse - 114, // [114:155] is the sub-list for method output_type - 73, // [73:114] is the sub-list for method input_type + 95, // 105: kandev.plugin.v1.Host.InvokeUtilityAgentWithOptions:input_type -> kandev.plugin.v1.InvokeUtilityAgentWithOptionsRequest + 97, // 106: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest + 102, // 107: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest + 104, // 108: kandev.plugin.v1.Host.MoveTask:input_type -> kandev.plugin.v1.MoveTaskRequest + 106, // 109: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest + 108, // 110: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:input_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeRequest + 110, // 111: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:input_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeRequest + 87, // 112: kandev.plugin.v1.Host.RespondToPermission:input_type -> kandev.plugin.v1.RespondToPermissionRequest + 90, // 113: kandev.plugin.v1.Host.AnswerClarification:input_type -> kandev.plugin.v1.AnswerClarificationRequest + 92, // 114: kandev.plugin.v1.Host.CancelClarification:input_type -> kandev.plugin.v1.CancelClarificationRequest + 1, // 115: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 6, // 116: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 9, // 117: kandev.plugin.v1.Plugin.HandleAction:output_type -> kandev.plugin.v1.PluginActionResponse + 11, // 118: kandev.plugin.v1.Plugin.SearchEntityReferences:output_type -> kandev.plugin.v1.SearchEntityReferencesResponse + 14, // 119: kandev.plugin.v1.Plugin.AuthorizeEntityReference:output_type -> kandev.plugin.v1.AuthorizeEntityReferenceResponse + 16, // 120: kandev.plugin.v1.Plugin.ResolveGitCredential:output_type -> kandev.plugin.v1.ResolveGitCredentialResponse + 18, // 121: kandev.plugin.v1.Plugin.GetGitCredentialBinding:output_type -> kandev.plugin.v1.GitCredentialBindingResponse + 4, // 122: kandev.plugin.v1.Plugin.InvokeAgentTool:output_type -> kandev.plugin.v1.AgentToolResponse + 20, // 123: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 22, // 124: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 24, // 125: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 26, // 126: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 37, // 127: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 39, // 128: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 31, // 129: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse + 33, // 130: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse + 35, // 131: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse + 29, // 132: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse + 47, // 133: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse + 49, // 134: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse + 52, // 135: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse + 55, // 136: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse + 58, // 137: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse + 61, // 138: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse + 64, // 139: kandev.plugin.v1.Host.ListExecutorProfiles:output_type -> kandev.plugin.v1.ListExecutorProfilesResponse + 67, // 140: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse + 71, // 141: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse + 74, // 142: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse + 78, // 143: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse + 84, // 144: kandev.plugin.v1.Host.ListPendingInteractions:output_type -> kandev.plugin.v1.ListPendingInteractionsResponse + 86, // 145: kandev.plugin.v1.Host.GetInteraction:output_type -> kandev.plugin.v1.GetInteractionResponse + 96, // 146: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse + 96, // 147: kandev.plugin.v1.Host.InvokeUtilityAgentWithOptions:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse + 101, // 148: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse + 103, // 149: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse + 105, // 150: kandev.plugin.v1.Host.MoveTask:output_type -> kandev.plugin.v1.MoveTaskResponse + 107, // 151: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse + 109, // 152: kandev.plugin.v1.Host.PreviewPluginOwnedTaskTree:output_type -> kandev.plugin.v1.PreviewPluginOwnedTaskTreeResponse + 111, // 153: kandev.plugin.v1.Host.DeletePluginOwnedTaskTree:output_type -> kandev.plugin.v1.DeletePluginOwnedTaskTreeResponse + 88, // 154: kandev.plugin.v1.Host.RespondToPermission:output_type -> kandev.plugin.v1.RespondToPermissionResponse + 91, // 155: kandev.plugin.v1.Host.AnswerClarification:output_type -> kandev.plugin.v1.AnswerClarificationResponse + 93, // 156: kandev.plugin.v1.Host.CancelClarification:output_type -> kandev.plugin.v1.CancelClarificationResponse + 115, // [115:157] is the sub-list for method output_type + 73, // [73:115] is the sub-list for method input_type 73, // [73:73] is the sub-list for extension type_name 73, // [73:73] is the sub-list for extension extendee 0, // [0:73] is the sub-list for field type_name @@ -8238,20 +8296,20 @@ func file_kandev_plugin_v1_plugin_proto_init() { file_kandev_plugin_v1_plugin_proto_msgTypes[65].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[68].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[76].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[96].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[97].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[98].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[99].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[101].OneofWrappers = []any{} - file_kandev_plugin_v1_plugin_proto_msgTypes[103].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[100].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[102].OneofWrappers = []any{} file_kandev_plugin_v1_plugin_proto_msgTypes[104].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[105].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 115, + NumMessages: 116, NumExtensions: 0, NumServices: 2, }, diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index a7b5b056e8..d3a473ce38 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -115,10 +115,14 @@ service Host { rpc GetInteraction(GetInteractionRequest) returns (GetInteractionResponse); // Utility agent — capability agent_invoke. Runs a one-shot, non-interactive - // completion using the operator-configured "utility agent" profile (Settings - // > System), so a plugin can delegate a lightweight LLM step without holding - // its own API key. FailedPrecondition when no utility agent is configured. + // completion using the platform default utility profile when no explicit + // profile is supplied. Existing prompt-only clients continue to use this + // method and therefore receive the platform default. rpc InvokeUtilityAgent(InvokeUtilityAgentRequest) returns (InvokeUtilityAgentResponse); + // Explicit utility profile selection. This separate method prevents an old + // host from silently ignoring a profile override and executing a different + // profile. + rpc InvokeUtilityAgentWithOptions(InvokeUtilityAgentWithOptionsRequest) returns (InvokeUtilityAgentResponse); // Writes — capability api_write:. Route through the first-party // service layer so events fire and WS clients update — the whole reason not @@ -801,12 +805,14 @@ message CancelClarificationResponse { } // ── Utility agent invocation (agent_invoke) ────────────────────────────────── -// A one-shot completion delegated to the operator-configured utility agent. -// Adding fields here (e.g. a future system_prompt or max_tokens) is the -// forward-compatible extension path — the SDK method stays source-stable. +// A one-shot completion delegated to the platform utility profile. message InvokeUtilityAgentRequest { string prompt = 1; } +message InvokeUtilityAgentWithOptionsRequest { + string prompt = 1; + string profile_id = 2; +} message InvokeUtilityAgentResponse { string text = 1; } diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go index 49f37f7f33..f509313a3d 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -413,39 +413,40 @@ var Plugin_ServiceDesc = grpc.ServiceDesc{ } const ( - Host_GetState_FullMethodName = "/kandev.plugin.v1.Host/GetState" - Host_SetState_FullMethodName = "/kandev.plugin.v1.Host/SetState" - Host_DeleteState_FullMethodName = "/kandev.plugin.v1.Host/DeleteState" - Host_ListState_FullMethodName = "/kandev.plugin.v1.Host/ListState" - Host_RevealSecret_FullMethodName = "/kandev.plugin.v1.Host/RevealSecret" - Host_EmitEvent_FullMethodName = "/kandev.plugin.v1.Host/EmitEvent" - Host_GetSecret_FullMethodName = "/kandev.plugin.v1.Host/GetSecret" - Host_SetSecret_FullMethodName = "/kandev.plugin.v1.Host/SetSecret" - Host_DeleteSecret_FullMethodName = "/kandev.plugin.v1.Host/DeleteSecret" - Host_GetConfig_FullMethodName = "/kandev.plugin.v1.Host/GetConfig" - Host_ListTasks_FullMethodName = "/kandev.plugin.v1.Host/ListTasks" - Host_GetTask_FullMethodName = "/kandev.plugin.v1.Host/GetTask" - Host_ListWorkspaces_FullMethodName = "/kandev.plugin.v1.Host/ListWorkspaces" - Host_ListWorkflows_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflows" - Host_ListWorkflowSteps_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflowSteps" - Host_ListAgentProfiles_FullMethodName = "/kandev.plugin.v1.Host/ListAgentProfiles" - Host_ListExecutorProfiles_FullMethodName = "/kandev.plugin.v1.Host/ListExecutorProfiles" - Host_ListRepositories_FullMethodName = "/kandev.plugin.v1.Host/ListRepositories" - Host_ListSessions_FullMethodName = "/kandev.plugin.v1.Host/ListSessions" - Host_ListSessionCodeStats_FullMethodName = "/kandev.plugin.v1.Host/ListSessionCodeStats" - Host_ListMessages_FullMethodName = "/kandev.plugin.v1.Host/ListMessages" - Host_ListPendingInteractions_FullMethodName = "/kandev.plugin.v1.Host/ListPendingInteractions" - Host_GetInteraction_FullMethodName = "/kandev.plugin.v1.Host/GetInteraction" - Host_InvokeUtilityAgent_FullMethodName = "/kandev.plugin.v1.Host/InvokeUtilityAgent" - Host_CreateTask_FullMethodName = "/kandev.plugin.v1.Host/CreateTask" - Host_UpdateTask_FullMethodName = "/kandev.plugin.v1.Host/UpdateTask" - Host_MoveTask_FullMethodName = "/kandev.plugin.v1.Host/MoveTask" - Host_SendMessage_FullMethodName = "/kandev.plugin.v1.Host/SendMessage" - Host_PreviewPluginOwnedTaskTree_FullMethodName = "/kandev.plugin.v1.Host/PreviewPluginOwnedTaskTree" - Host_DeletePluginOwnedTaskTree_FullMethodName = "/kandev.plugin.v1.Host/DeletePluginOwnedTaskTree" - Host_RespondToPermission_FullMethodName = "/kandev.plugin.v1.Host/RespondToPermission" - Host_AnswerClarification_FullMethodName = "/kandev.plugin.v1.Host/AnswerClarification" - Host_CancelClarification_FullMethodName = "/kandev.plugin.v1.Host/CancelClarification" + Host_GetState_FullMethodName = "/kandev.plugin.v1.Host/GetState" + Host_SetState_FullMethodName = "/kandev.plugin.v1.Host/SetState" + Host_DeleteState_FullMethodName = "/kandev.plugin.v1.Host/DeleteState" + Host_ListState_FullMethodName = "/kandev.plugin.v1.Host/ListState" + Host_RevealSecret_FullMethodName = "/kandev.plugin.v1.Host/RevealSecret" + Host_EmitEvent_FullMethodName = "/kandev.plugin.v1.Host/EmitEvent" + Host_GetSecret_FullMethodName = "/kandev.plugin.v1.Host/GetSecret" + Host_SetSecret_FullMethodName = "/kandev.plugin.v1.Host/SetSecret" + Host_DeleteSecret_FullMethodName = "/kandev.plugin.v1.Host/DeleteSecret" + Host_GetConfig_FullMethodName = "/kandev.plugin.v1.Host/GetConfig" + Host_ListTasks_FullMethodName = "/kandev.plugin.v1.Host/ListTasks" + Host_GetTask_FullMethodName = "/kandev.plugin.v1.Host/GetTask" + Host_ListWorkspaces_FullMethodName = "/kandev.plugin.v1.Host/ListWorkspaces" + Host_ListWorkflows_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflows" + Host_ListWorkflowSteps_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflowSteps" + Host_ListAgentProfiles_FullMethodName = "/kandev.plugin.v1.Host/ListAgentProfiles" + Host_ListExecutorProfiles_FullMethodName = "/kandev.plugin.v1.Host/ListExecutorProfiles" + Host_ListRepositories_FullMethodName = "/kandev.plugin.v1.Host/ListRepositories" + Host_ListSessions_FullMethodName = "/kandev.plugin.v1.Host/ListSessions" + Host_ListSessionCodeStats_FullMethodName = "/kandev.plugin.v1.Host/ListSessionCodeStats" + Host_ListMessages_FullMethodName = "/kandev.plugin.v1.Host/ListMessages" + Host_ListPendingInteractions_FullMethodName = "/kandev.plugin.v1.Host/ListPendingInteractions" + Host_GetInteraction_FullMethodName = "/kandev.plugin.v1.Host/GetInteraction" + Host_InvokeUtilityAgent_FullMethodName = "/kandev.plugin.v1.Host/InvokeUtilityAgent" + Host_InvokeUtilityAgentWithOptions_FullMethodName = "/kandev.plugin.v1.Host/InvokeUtilityAgentWithOptions" + Host_CreateTask_FullMethodName = "/kandev.plugin.v1.Host/CreateTask" + Host_UpdateTask_FullMethodName = "/kandev.plugin.v1.Host/UpdateTask" + Host_MoveTask_FullMethodName = "/kandev.plugin.v1.Host/MoveTask" + Host_SendMessage_FullMethodName = "/kandev.plugin.v1.Host/SendMessage" + Host_PreviewPluginOwnedTaskTree_FullMethodName = "/kandev.plugin.v1.Host/PreviewPluginOwnedTaskTree" + Host_DeletePluginOwnedTaskTree_FullMethodName = "/kandev.plugin.v1.Host/DeletePluginOwnedTaskTree" + Host_RespondToPermission_FullMethodName = "/kandev.plugin.v1.Host/RespondToPermission" + Host_AnswerClarification_FullMethodName = "/kandev.plugin.v1.Host/AnswerClarification" + Host_CancelClarification_FullMethodName = "/kandev.plugin.v1.Host/CancelClarification" ) // HostClient is the client API for Host service. @@ -531,10 +532,14 @@ type HostClient interface { ListPendingInteractions(ctx context.Context, in *ListPendingInteractionsRequest, opts ...grpc.CallOption) (*ListPendingInteractionsResponse, error) GetInteraction(ctx context.Context, in *GetInteractionRequest, opts ...grpc.CallOption) (*GetInteractionResponse, error) // Utility agent — capability agent_invoke. Runs a one-shot, non-interactive - // completion using the operator-configured "utility agent" profile (Settings - // > System), so a plugin can delegate a lightweight LLM step without holding - // its own API key. FailedPrecondition when no utility agent is configured. + // completion using the platform default utility profile when no explicit + // profile is supplied. Existing prompt-only clients continue to use this + // method and therefore receive the platform default. InvokeUtilityAgent(ctx context.Context, in *InvokeUtilityAgentRequest, opts ...grpc.CallOption) (*InvokeUtilityAgentResponse, error) + // Explicit utility profile selection. This separate method prevents an old + // host from silently ignoring a profile override and executing a different + // profile. + InvokeUtilityAgentWithOptions(ctx context.Context, in *InvokeUtilityAgentWithOptionsRequest, opts ...grpc.CallOption) (*InvokeUtilityAgentResponse, error) // Writes — capability api_write:. Route through the first-party // service layer so events fire and WS clients update — the whole reason not // to let plugins write the DB. CreateTask/UpdateTask require api_write:tasks @@ -814,6 +819,16 @@ func (c *hostClient) InvokeUtilityAgent(ctx context.Context, in *InvokeUtilityAg return out, nil } +func (c *hostClient) InvokeUtilityAgentWithOptions(ctx context.Context, in *InvokeUtilityAgentWithOptionsRequest, opts ...grpc.CallOption) (*InvokeUtilityAgentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(InvokeUtilityAgentResponse) + err := c.cc.Invoke(ctx, Host_InvokeUtilityAgentWithOptions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *hostClient) CreateTask(ctx context.Context, in *CreateTaskRequest, opts ...grpc.CallOption) (*CreateTaskResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(CreateTaskResponse) @@ -987,10 +1002,14 @@ type HostServer interface { ListPendingInteractions(context.Context, *ListPendingInteractionsRequest) (*ListPendingInteractionsResponse, error) GetInteraction(context.Context, *GetInteractionRequest) (*GetInteractionResponse, error) // Utility agent — capability agent_invoke. Runs a one-shot, non-interactive - // completion using the operator-configured "utility agent" profile (Settings - // > System), so a plugin can delegate a lightweight LLM step without holding - // its own API key. FailedPrecondition when no utility agent is configured. + // completion using the platform default utility profile when no explicit + // profile is supplied. Existing prompt-only clients continue to use this + // method and therefore receive the platform default. InvokeUtilityAgent(context.Context, *InvokeUtilityAgentRequest) (*InvokeUtilityAgentResponse, error) + // Explicit utility profile selection. This separate method prevents an old + // host from silently ignoring a profile override and executing a different + // profile. + InvokeUtilityAgentWithOptions(context.Context, *InvokeUtilityAgentWithOptionsRequest) (*InvokeUtilityAgentResponse, error) // Writes — capability api_write:. Route through the first-party // service layer so events fire and WS clients update — the whole reason not // to let plugins write the DB. CreateTask/UpdateTask require api_write:tasks @@ -1102,6 +1121,9 @@ func (UnimplementedHostServer) GetInteraction(context.Context, *GetInteractionRe func (UnimplementedHostServer) InvokeUtilityAgent(context.Context, *InvokeUtilityAgentRequest) (*InvokeUtilityAgentResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method InvokeUtilityAgent not implemented") } +func (UnimplementedHostServer) InvokeUtilityAgentWithOptions(context.Context, *InvokeUtilityAgentWithOptionsRequest) (*InvokeUtilityAgentResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method InvokeUtilityAgentWithOptions not implemented") +} func (UnimplementedHostServer) CreateTask(context.Context, *CreateTaskRequest) (*CreateTaskResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method CreateTask not implemented") } @@ -1582,6 +1604,24 @@ func _Host_InvokeUtilityAgent_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _Host_InvokeUtilityAgentWithOptions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(InvokeUtilityAgentWithOptionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).InvokeUtilityAgentWithOptions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_InvokeUtilityAgentWithOptions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).InvokeUtilityAgentWithOptions(ctx, req.(*InvokeUtilityAgentWithOptionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Host_CreateTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(CreateTaskRequest) if err := dec(in); err != nil { @@ -1847,6 +1887,10 @@ var Host_ServiceDesc = grpc.ServiceDesc{ MethodName: "InvokeUtilityAgent", Handler: _Host_InvokeUtilityAgent_Handler, }, + { + MethodName: "InvokeUtilityAgentWithOptions", + Handler: _Host_InvokeUtilityAgentWithOptions_Handler, + }, { MethodName: "CreateTask", Handler: _Host_CreateTask_Handler, diff --git a/apps/web/components/settings/plugins/plugin-config-form.agent-profile.test.tsx b/apps/web/components/settings/plugins/plugin-config-form.agent-profile.test.tsx new file mode 100644 index 0000000000..97d17ee6c7 --- /dev/null +++ b/apps/web/components/settings/plugins/plugin-config-form.agent-profile.test.tsx @@ -0,0 +1,100 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { PluginConfigField } from "@/lib/plugins/config-schema"; +import { PluginConfigForm } from "./plugin-config-form"; + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ useSettingsData: vi.fn() })); + +const AGENT_PROFILE_FIELD = "agent_profile"; +const ELIGIBLE_PROFILE_LABEL = "Claude · Notes"; + +const field: PluginConfigField = { + name: AGENT_PROFILE_FIELD, + label: "Notes agent", + type: "agent_profile", + required: false, + secret: false, +}; + +const profile = (overrides: Partial): AgentProfileOption => ({ + id: "profile-1", + label: ELIGIBLE_PROFILE_LABEL, + agent_id: "claude", + agent_name: "claude", + cli_passthrough: false, + enabled: true, + inference_capable: true, + ...overrides, +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +function renderField( + profiles: AgentProfileOption[], + value = "", + fieldOverride: Partial = {}, +) { + const onChange = vi.fn<(name: string, value: string | boolean) => void>(); + render( + + + , + ); + return onChange; +} + +describe("PluginConfigForm agent profile field", () => { + it("offers only enabled global non-CLI inference profiles and stores the stable ID", () => { + const onChange = renderField([ + profile({ id: "eligible", label: ELIGIBLE_PROFILE_LABEL }), + profile({ id: "disabled", label: "Disabled", enabled: false }), + profile({ id: "workspace", label: "Workspace", workspace_id: "workspace-1" }), + profile({ id: "cli", label: "CLI", cli_passthrough: true }), + profile({ id: "non-inference", label: "Non-inference", inference_capable: false }), + ]); + + fireEvent.click(screen.getByRole("combobox")); + + expect(screen.getByRole("option", { name: ELIGIBLE_PROFILE_LABEL })).not.toBeNull(); + expect(screen.queryByRole("option", { name: "Disabled" })).toBeNull(); + expect(screen.queryByRole("option", { name: "Workspace" })).toBeNull(); + expect(screen.queryByRole("option", { name: "CLI" })).toBeNull(); + expect(screen.queryByRole("option", { name: "Non-inference" })).toBeNull(); + + fireEvent.click(screen.getByRole("option", { name: ELIGIBLE_PROFILE_LABEL })); + expect(onChange).toHaveBeenCalledWith(AGENT_PROFILE_FIELD, "eligible"); + }); + + it("shows a stale saved profile without making it selectable", () => { + renderField([], "deleted-profile"); + + expect(screen.getByText("Unavailable: deleted-profile")).not.toBeNull(); + }); + + it("marks an ineligible saved profile as unavailable", () => { + renderField([profile({ id: "disabled", label: "Disabled", enabled: false })], "disabled"); + + expect(screen.getByText("Unavailable: Disabled")).not.toBeNull(); + }); + + it("hides the Not set option for required fields", () => { + renderField([profile({ id: "eligible", label: ELIGIBLE_PROFILE_LABEL })], "", { + required: true, + }); + + fireEvent.click(screen.getByRole("combobox")); + + expect(screen.queryByRole("option", { name: /not set/i })).toBeNull(); + }); +}); diff --git a/apps/web/components/settings/plugins/plugin-config-form.tsx b/apps/web/components/settings/plugins/plugin-config-form.tsx index be5b4652fe..cc06e246bb 100644 --- a/apps/web/components/settings/plugins/plugin-config-form.tsx +++ b/apps/web/components/settings/plugins/plugin-config-form.tsx @@ -5,6 +5,9 @@ import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Switch } from "@kandev/ui/switch"; +import { useAppStore } from "@/components/state-provider"; +import { utilityProfileEligibility } from "@/components/settings/utility-agent-profile-picker"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useUtilityAgents } from "@/hooks/domains/settings/use-utility-agents"; import type { PluginConfigField } from "@/lib/plugins/config-schema"; @@ -12,6 +15,7 @@ import type { PluginConfigField } from "@/lib/plugins/config-schema"; * collide with it, and Radix rejects value="" items. */ const ENUM_UNSET_SENTINEL = "__kandev_enum_unset__"; const UTILITY_AGENT_UNSET_SENTINEL = "__kandev_utility_agent_unset__"; +const AGENT_PROFILE_UNSET_SENTINEL = "__kandev_agent_profile_unset__"; type PluginConfigFormProps = { fields: PluginConfigField[]; @@ -155,6 +159,19 @@ function ConfigFieldControl({ ); } + if (field.type === "agent_profile") { + return ( + + ); + } + return ( state.agentProfiles.items); + const selectedID = typeof value === "string" ? value : ""; + const selectedProfile = profiles.find((profile) => profile.id === selectedID); + const eligibleProfiles = profiles.filter((profile) => utilityProfileEligibility(profile)); + const isStale = + selectedID !== "" && !eligibleProfiles.some((profile) => profile.id === selectedID); + const selectedProfileLabel = + selectedProfile?.label || selectedProfile?.agent_name || selectedProfile?.id || selectedID; + const selectedLabel = isStale + ? t("settings:utilityUnavailableProfile", { name: selectedProfileLabel }) + : selectedProfileLabel || undefined; + + return ( + + ); +} + // These two return catalog keys rather than messages: a plain function that // returns copy is invisible to the guard, and resolving at the call site keeps // the text following a locale switch. diff --git a/apps/web/components/settings/utility-agent-profile-picker.test.ts b/apps/web/components/settings/utility-agent-profile-picker.test.ts index 6e13e06419..ec093e372a 100644 --- a/apps/web/components/settings/utility-agent-profile-picker.test.ts +++ b/apps/web/components/settings/utility-agent-profile-picker.test.ts @@ -9,6 +9,7 @@ const profile = (overrides: Partial = {}): AgentProfileOptio agent_name: "Mock", cli_passthrough: false, enabled: true, + inference_capable: true, ...overrides, }); @@ -19,6 +20,8 @@ describe("utilityProfileEligibility", () => { ["disabled profile", profile({ enabled: false }), false], ["CLI passthrough profile", profile({ cli_passthrough: true }), false], ["workspace profile", profile({ workspace_id: "workspace-1" }), false], + ["non-inference profile", profile({ inference_capable: false }), false], + ["profile with unknown inference support", profile({ inference_capable: undefined }), false], ])("returns %s = %s", (_label, candidate, expected) => { expect(utilityProfileEligibility(candidate)).toBe(expected); }); @@ -28,4 +31,42 @@ describe("utilityProfileEligibility", () => { true, ); }); + + describe("with includeWorkspaceProfiles=true (Config Chat context)", () => { + it.each([ + [ + "non-inference workspace profile", + profile({ workspace_id: "ws-1", inference_capable: false }), + true, + ], + ["non-inference global profile", profile({ inference_capable: false }), true], + [ + "unknown inference workspace profile", + profile({ workspace_id: "ws-1", inference_capable: undefined }), + true, + ], + ])("returns %s = %s", (_label, candidate, expected) => { + expect(utilityProfileEligibility(candidate, true, true)).toBe(expected); + }); + + it("allows CLI passthrough profiles", () => { + expect( + utilityProfileEligibility( + profile({ workspace_id: "ws-1", cli_passthrough: true }), + true, + true, + ), + ).toBe(true); + }); + + it.each([["disabled", false]])("still rejects %s profile", (_label, enabled) => { + expect( + utilityProfileEligibility( + profile({ workspace_id: "ws-1", cli_passthrough: true, enabled }), + true, + true, + ), + ).toBe(false); + }); + }); }); diff --git a/apps/web/components/settings/utility-agent-profile-picker.tsx b/apps/web/components/settings/utility-agent-profile-picker.tsx index 08af5c481b..b7acad35aa 100644 --- a/apps/web/components/settings/utility-agent-profile-picker.tsx +++ b/apps/web/components/settings/utility-agent-profile-picker.tsx @@ -10,8 +10,10 @@ export const utilityProfileEligibility = ( includeWorkspaceProfiles = false, ) => profile.enabled !== false && + (includeWorkspaceProfiles || !profile.cli_passthrough) && + (includeWorkspaceProfiles || profile.inference_capable === true) && (dynamicRoutingEnabled || profile.kind !== "dynamic") && - (includeWorkspaceProfiles || (!profile.cli_passthrough && !profile.workspace_id)); + (includeWorkspaceProfiles || !profile.workspace_id); type UtilityAgentProfilePickerProps = { profiles: AgentProfileOption[]; diff --git a/apps/web/e2e/tests/plugins/plugin-utility-invocation.spec.ts b/apps/web/e2e/tests/plugins/plugin-utility-invocation.spec.ts new file mode 100644 index 0000000000..d2f66983bd --- /dev/null +++ b/apps/web/e2e/tests/plugins/plugin-utility-invocation.spec.ts @@ -0,0 +1,154 @@ +import { expect, test } from "../../fixtures/test-base"; +import { installFixturePlugin, PLUGIN_ID } from "../../helpers/plugin-fixture"; + +async function updateFixtureConfig( + apiClient: import("../../helpers/api-client").ApiClient, + profileID: string, +): Promise { + const response = await apiClient.rawRequest("PATCH", `/api/plugins/${PLUGIN_ID}`, { + config: { + api_token: "utility-invocation-e2e-token", + agent_profile: profileID, + }, + }); + const body = await response.text(); + expect(response.ok, body).toBe(true); +} + +async function invokeUtilityAction( + apiClient: import("../../helpers/api-client").ApiClient, + action: "utility-default" | "utility-preference", + workspaceID: string, +): Promise<{ status: number; body: string }> { + const response = await apiClient.rawRequest( + "POST", + `/api/plugins/${PLUGIN_ID}/actions/${action}`, + { workspaceId: workspaceID }, + ); + return { status: response.status, body: await response.text() }; +} + +function utilityResponse(body: string): string { + return (JSON.parse(body) as { response: string }).response; +} + +test.describe("Packaged plugin utility invocation", () => { + test.afterEach(async ({ apiClient }) => { + await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); + await apiClient + .saveUserSettings({ default_utility_agent_profile_id: "" }) + .catch(() => undefined); + }); + + test("uses the default or the caller's explicit profile across the real plugin transport", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(120_000); + const createdProfileIDs: string[] = []; + + try { + await installFixturePlugin(testPage); + + const { agents } = await apiClient.listAgents(); + const owner = + agents.find((agent) => + agent.profiles?.some((profile) => profile.id === seedData.agentProfileId), + ) ?? agents.find((agent) => agent.id !== "dynamic"); + if (!owner) throw new Error("mock agent owner was not available for utility profiles"); + + const profileFast = await apiClient.createAgentProfile(owner.id, "E2E utility fast", { + model: "mock-fast", + }); + const profileSmart = await apiClient.createAgentProfile(owner.id, "E2E utility smart", { + model: "mock-smart", + }); + const profileSlow = await apiClient.createAgentProfile(owner.id, "E2E utility slow", { + model: "mock-slow", + }); + createdProfileIDs.push(profileFast.id, profileSmart.id, profileSlow.id); + + await apiClient.saveUserSettings({ + default_utility_agent_profile_id: profileFast.id, + }); + await updateFixtureConfig(apiClient, profileSmart.id); + + const defaultCall = await invokeUtilityAction( + apiClient, + "utility-default", + seedData.workspaceId, + ); + expect(defaultCall.status, defaultCall.body).toBe(200); + expect(utilityResponse(defaultCall.body)).toBe("utility profile model: mock-fast"); + + const explicitCall = await invokeUtilityAction( + apiClient, + "utility-preference", + seedData.workspaceId, + ); + expect(explicitCall.status, explicitCall.body).toBe(200); + expect(utilityResponse(explicitCall.body)).toBe("utility profile model: mock-smart"); + + await updateFixtureConfig(apiClient, ""); + const clearedPreferenceCall = await invokeUtilityAction( + apiClient, + "utility-preference", + seedData.workspaceId, + ); + expect(clearedPreferenceCall.status, clearedPreferenceCall.body).toBe(200); + expect(utilityResponse(clearedPreferenceCall.body)).toBe("utility profile model: mock-fast"); + + await apiClient.saveUserSettings({ + default_utility_agent_profile_id: profileSlow.id, + }); + const changedDefaultCall = await invokeUtilityAction( + apiClient, + "utility-preference", + seedData.workspaceId, + ); + expect(changedDefaultCall.status, changedDefaultCall.body).toBe(200); + expect(utilityResponse(changedDefaultCall.body)).toBe("utility profile model: mock-slow"); + + await updateFixtureConfig(apiClient, profileSmart.id); + await apiClient.saveUserSettings({ default_utility_agent_profile_id: "" }); + const explicitWithoutDefaultCall = await invokeUtilityAction( + apiClient, + "utility-preference", + seedData.workspaceId, + ); + expect(explicitWithoutDefaultCall.status, explicitWithoutDefaultCall.body).toBe(200); + expect(utilityResponse(explicitWithoutDefaultCall.body)).toBe( + "utility profile model: mock-smart", + ); + + await apiClient.updateAgentProfile(profileSmart.id, { enabled: false }); + await apiClient.saveUserSettings({ + default_utility_agent_profile_id: profileSlow.id, + }); + const invalidExplicitCall = await invokeUtilityAction( + apiClient, + "utility-preference", + seedData.workspaceId, + ); + expect(invalidExplicitCall.status).toBeGreaterThanOrEqual(400); + + const defaultAfterInvalidOverride = await invokeUtilityAction( + apiClient, + "utility-default", + seedData.workspaceId, + ); + expect(defaultAfterInvalidOverride.status, defaultAfterInvalidOverride.body).toBe(200); + expect(utilityResponse(defaultAfterInvalidOverride.body)).toBe( + "utility profile model: mock-slow", + ); + } finally { + await apiClient + .saveUserSettings({ default_utility_agent_profile_id: "" }) + .catch(() => undefined); + for (const profileID of createdProfileIDs) { + await apiClient.deleteAgentProfile(profileID, true).catch(() => undefined); + } + } + }); +}); diff --git a/apps/web/e2e/tests/task/plan-mode-followup.spec.ts b/apps/web/e2e/tests/task/plan-mode-followup.spec.ts index 355dd80821..af7a7f999b 100644 --- a/apps/web/e2e/tests/task/plan-mode-followup.spec.ts +++ b/apps/web/e2e/tests/task/plan-mode-followup.spec.ts @@ -176,7 +176,8 @@ test.describe("Plan mode follow-up messages", () => { // Fill in the comment textarea. const textarea = testPage.locator('textarea[placeholder="Add your comment or instruction..."]'); await expect(textarea).toBeVisible({ timeout: 5_000 }); - await textarea.fill("Split step 2 into smaller sub-steps"); + const planCommentText = "Split step 2 into smaller sub-steps"; + await textarea.fill(planCommentText); // Click the "Run" button (use exact match to avoid matching sidebar task button). const runBtn = testPage.getByRole("button", { name: "Run", exact: true }); @@ -187,9 +188,13 @@ test.describe("Plan mode follow-up messages", () => { await expect(session.chat.getByText("Plan Comments", { exact: false })).toBeVisible({ timeout: 15_000, }); - await expect( - session.chat.getByText("Split step 2 into smaller sub-steps", { exact: false }), - ).toBeVisible({ timeout: 5_000 }); + const commentBubble = session.chat + .getByTestId("user-message-bubble") + .filter({ hasText: planCommentText }); + await expect(commentBubble).toHaveCount(1, { timeout: 5_000 }); + await expect(commentBubble.getByText(planCommentText, { exact: true })).toBeVisible({ + timeout: 5_000, + }); // Plan mode badge should be visible on the comment message. const planBadges = session.chat.getByText("Plan mode", { exact: true }); diff --git a/apps/web/hooks/domains/settings/use-settings-data.test.tsx b/apps/web/hooks/domains/settings/use-settings-data.test.tsx index 0241720d23..0ed38083e8 100644 --- a/apps/web/hooks/domains/settings/use-settings-data.test.tsx +++ b/apps/web/hooks/domains/settings/use-settings-data.test.tsx @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { createElement, type ReactNode } from "react"; import { act, cleanup, renderHook } from "@testing-library/react"; import { StateProvider, useAppStore } from "@/components/state-provider"; @@ -17,6 +17,8 @@ vi.mock("@/lib/api", () => ({ import { useSettingsData } from "./use-settings-data"; +const MOCK_AGENT_NAME = "Mock Agent"; + function wrapper({ children }: { children: ReactNode }) { return createElement(StateProvider, null, children); } @@ -25,7 +27,9 @@ function useSettingsSnapshot() { useSettingsData(); const agentsLoaded = useAppStore((state) => state.settingsData.agentsLoaded); const agentProfiles = useAppStore((state) => state.agentProfiles.items); - return { agentsLoaded, agentProfiles }; + const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); + const bumpAgentProfilesVersion = useAppStore((state) => state.bumpAgentProfilesVersion); + return { agentsLoaded, agentProfiles, setAgentProfiles, bumpAgentProfilesVersion }; } beforeEach(() => { @@ -40,43 +44,167 @@ afterEach(() => { vi.useRealTimers(); }); -describe("useSettingsData", () => { - it("retries an empty agent list before declaring that no profiles exist", async () => { - const profile = { - id: "profile-1", - agentDisplayName: "Mock Agent", - name: "Default", - cliPassthrough: false, - }; - mocks.listAgents.mockResolvedValueOnce({ agents: [], total: 0 }).mockResolvedValueOnce({ - agents: [{ id: "agent-1", name: "Mock Agent", profiles: [profile] }], +it("retries an empty agent list before declaring that no profiles exist", async () => { + const profile = { + id: "profile-1", + agentDisplayName: MOCK_AGENT_NAME, + name: "Default", + cliPassthrough: false, + }; + mocks.listAgents.mockResolvedValueOnce({ agents: [], total: 0 }).mockResolvedValueOnce({ + agents: [{ id: "agent-1", name: MOCK_AGENT_NAME, profiles: [profile] }], + total: 1, + }); + + const { result } = renderHook(() => useSettingsSnapshot(), { wrapper }); + + await act(async () => { + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(100); + await Promise.resolve(); + }); + + expect(mocks.listAgents).toHaveBeenCalledTimes(2); + expect(result.current.agentsLoaded).toBe(true); + expect(result.current.agentProfiles[0]?.id).toBe("profile-1"); +}); + +it("uses the final attempt after exhausting every retry delay", async () => { + mocks.listAgents.mockResolvedValue({ agents: [], total: 0 }); + + const { result } = renderHook(() => useSettingsSnapshot(), { wrapper }); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(mocks.listAgents).toHaveBeenCalledTimes(5); + expect(result.current.agentsLoaded).toBe(true); + expect(result.current.agentProfiles).toEqual([]); +}); + +it("keeps a profile created by WebSocket while the initial list request is pending", async () => { + let resolveAgents: (response: { agents: unknown[]; total: number }) => void; + mocks.listAgents + .mockReturnValueOnce( + new Promise((resolve) => { + resolveAgents = resolve; + }), + ) + .mockResolvedValue({ + agents: [ + { + id: "agent-1", + name: MOCK_AGENT_NAME, + profiles: [ + { + id: "profile-live", + agentDisplayName: MOCK_AGENT_NAME, + name: "Live profile", + cliPassthrough: false, + }, + ], + }, + ], total: 1, }); + const { result } = renderHook(() => useSettingsSnapshot(), { wrapper }); - const { result } = renderHook(() => useSettingsSnapshot(), { wrapper }); - - await act(async () => { - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(100); - await Promise.resolve(); + await act(async () => { + await Promise.resolve(); + }); + act(() => { + result.current.setAgentProfiles([ + { + id: "profile-live", + label: `${MOCK_AGENT_NAME} • Live profile`, + agent_id: "agent-1", + agent_name: MOCK_AGENT_NAME, + cli_passthrough: false, + inference_capable: true, + updatedAt: "2026-08-26T18:00:01Z", + }, + ]); + result.current.bumpAgentProfilesVersion(); + }); + await act(async () => { + resolveAgents!({ + agents: [ + { + id: "agent-1", + name: MOCK_AGENT_NAME, + profiles: [ + { + id: "profile-stale", + agentDisplayName: MOCK_AGENT_NAME, + name: "Stale profile", + cliPassthrough: false, + }, + ], + }, + ], + total: 1, }); - - expect(mocks.listAgents).toHaveBeenCalledTimes(2); - expect(result.current.agentsLoaded).toBe(true); - expect(result.current.agentProfiles[0]?.id).toBe("profile-1"); + await Promise.resolve(); }); - it("uses the final attempt after exhausting every retry delay", async () => { - mocks.listAgents.mockResolvedValue({ agents: [], total: 0 }); - - const { result } = renderHook(() => useSettingsSnapshot(), { wrapper }); - - await act(async () => { - await vi.runAllTimersAsync(); - }); - - expect(mocks.listAgents).toHaveBeenCalledTimes(5); - expect(result.current.agentsLoaded).toBe(true); - expect(result.current.agentProfiles).toEqual([]); - }); + expect(result.current.agentProfiles.map((profile) => profile.id)).toEqual(["profile-live"]); +}); + +it("drops a profile deleted by WebSocket while the initial list request is pending", async () => { + let resolveAgents: (response: { agents: unknown[]; total: number }) => void; + mocks.listAgents + .mockReturnValueOnce( + new Promise((resolve) => { + resolveAgents = resolve; + }), + ) + .mockResolvedValue({ + agents: [ + { + id: "agent-1", + name: MOCK_AGENT_NAME, + profiles: [ + { + id: "profile-current", + agentDisplayName: MOCK_AGENT_NAME, + name: "Current profile", + cliPassthrough: false, + }, + ], + }, + ], + total: 1, + }); + const { result } = renderHook(() => useSettingsSnapshot(), { wrapper }); + + await act(async () => { + await Promise.resolve(); + }); + act(() => { + result.current.setAgentProfiles([]); + result.current.bumpAgentProfilesVersion(); + }); + await act(async () => { + resolveAgents!({ + agents: [ + { + id: "agent-1", + name: MOCK_AGENT_NAME, + profiles: [ + { + id: "profile-deleted", + agentDisplayName: MOCK_AGENT_NAME, + name: "Deleted profile", + cliPassthrough: false, + }, + ], + }, + ], + total: 1, + }); + await Promise.resolve(); + }); + + expect(result.current.agentProfiles.map((profile) => profile.id)).toEqual(["profile-current"]); }); diff --git a/apps/web/hooks/domains/settings/use-settings-data.ts b/apps/web/hooks/domains/settings/use-settings-data.ts index 528d8cc431..5ca5af502a 100644 --- a/apps/web/hooks/domains/settings/use-settings-data.ts +++ b/apps/web/hooks/domains/settings/use-settings-data.ts @@ -1,7 +1,9 @@ import { useEffect, useRef } from "react"; -import { useAppStore } from "@/components/state-provider"; +import type { StoreApi } from "zustand"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { listAgents, listAvailableAgents, listExecutors } from "@/lib/api"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import { toAgentProfileOption, type AgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { AppState } from "@/lib/state/store"; const AGENT_LIST_RETRY_DELAYS_MS = [100, 250, 500, 1_000] as const; @@ -40,14 +42,26 @@ export async function listAgentsUntilSettled(): Promise { function applyAgentList( response: AgentListResponse, setSettingsAgents: (agents: AgentListResponse["agents"]) => void, - setAgentProfiles: (profiles: ReturnType[]) => void, + setAgentProfiles: (profiles: AgentProfileOption[]) => void, ): void { setSettingsAgents(response.agents); - setAgentProfiles( - response.agents.flatMap((agent) => - agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), - ), + const rebuiltProfiles = response.agents.flatMap((agent) => + agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), ); + setAgentProfiles(rebuiltProfiles); +} + +async function refreshAgentList( + storeApi: StoreApi, + setSettingsAgents: (agents: AgentListResponse["agents"]) => void, + setAgentProfiles: (profiles: AgentProfileOption[]) => void, +): Promise { + const profileVersion = storeApi.getState().agentProfiles.version; + const response = await listAgentsUntilSettled(); + if (storeApi.getState().agentProfiles.version !== profileVersion) { + return refreshAgentList(storeApi, setSettingsAgents, setAgentProfiles); + } + applyAgentList(response, setSettingsAgents, setAgentProfiles); } export function useSettingsData(enabled = true) { @@ -61,6 +75,7 @@ export function useSettingsData(enabled = true) { const setAvailableAgents = useAppStore((state) => state.setAvailableAgents); const setAvailableAgentsLoading = useAppStore((state) => state.setAvailableAgentsLoading); const setSettingsData = useAppStore((state) => state.setSettingsData); + const storeApi = useAppStoreApi(); useEffect(() => { if (!enabled) return; @@ -79,8 +94,7 @@ export function useSettingsData(enabled = true) { if (!enabled) return; if (settingsData.agentsLoaded) return; if (settingsAgents.length === 0) { - listAgentsUntilSettled() - .then((response) => applyAgentList(response, setSettingsAgents, setAgentProfiles)) + refreshAgentList(storeApi, setSettingsAgents, setAgentProfiles) .catch(() => { setSettingsAgents([]); setAgentProfiles([]); @@ -130,17 +144,16 @@ export function useSettingsData(enabled = true) { if (!settingsData.agentsLoaded) return; // Wait for the initial agents fetch first. if (reconciledRef.current) return; reconciledRef.current = true; - listAgentsUntilSettled() - .then((response) => applyAgentList(response, setSettingsAgents, setAgentProfiles)) - .catch(() => { - // Best-effort reconcile; keep prior (possibly stale) profiles rather - // than wiping the dialog state on a transient error. - }); + refreshAgentList(storeApi, setSettingsAgents, setAgentProfiles).catch(() => { + // Best-effort reconcile; keep prior (possibly stale) profiles rather + // than wiping the dialog state on a transient error. + }); }, [ enabled, availableAgents.loaded, settingsData.agentsLoaded, setAgentProfiles, setSettingsAgents, + storeApi, ]); } diff --git a/apps/web/lib/plugins/config-schema.test.ts b/apps/web/lib/plugins/config-schema.test.ts index 3cd14a12c1..4c50a9f27d 100644 --- a/apps/web/lib/plugins/config-schema.test.ts +++ b/apps/web/lib/plugins/config-schema.test.ts @@ -73,6 +73,29 @@ describe("parseConfigSchema", () => { }), ]); }); + + it("maps the agent-profile format to a direct profile picker field", () => { + const fields = parseConfigSchema({ + type: "object", + required: ["agent_profile"], + properties: { + agent_profile: { + type: "string", + format: "agent-profile", + title: "Notes agent", + }, + }, + }); + + expect(fields).toEqual([ + expect.objectContaining({ + name: "agent_profile", + type: "agent_profile", + label: "Notes agent", + required: true, + }), + ]); + }); }); describe("buildInitialValues", () => { diff --git a/apps/web/lib/plugins/config-schema.ts b/apps/web/lib/plugins/config-schema.ts index 1c5782e05f..d365cdfcf6 100644 --- a/apps/web/lib/plugins/config-schema.ts +++ b/apps/web/lib/plugins/config-schema.ts @@ -18,7 +18,8 @@ export type PluginConfigFieldType = | "number" | "integer" | "enum" - | "utility_agent"; + | "utility_agent" + | "agent_profile"; export interface PluginConfigField { name: string; @@ -46,6 +47,7 @@ function asObject(value: unknown): SchemaObject | null { function fieldType(prop: SchemaObject): PluginConfigFieldType { if (prop.format === "utility-agent") return "utility_agent"; + if (prop.format === "agent-profile") return "agent_profile"; const enumValues = prop.enum; if (Array.isArray(enumValues) && enumValues.length > 0) return "enum"; const type = prop.type; diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts index bfb939e6b6..3c09d6bc97 100644 --- a/apps/web/lib/state/hydration/hydrator.test.ts +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -468,6 +468,85 @@ describe("hydrateState — user settings revisions", () => { }); }); +describe("hydrateState — agent profile revisions", () => { + it("keeps a newer websocket profile snapshot over route bootstrap", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.agentProfiles = { + version: 1, + items: [ + { + id: "live-profile", + label: "Live profile", + agent_id: "agent-1", + agent_name: "Agent", + cli_passthrough: false, + inference_capable: true, + }, + ], + }; + draft.settingsAgents.items = [ + { + id: "agent-1", + name: "Agent", + profiles: [], + } as never, + ]; + hydrateState(draft, { + settingsAgents: { items: [] }, + agentProfiles: { + version: 0, + items: [], + }, + settingsData: { agentsLoaded: true }, + } as unknown as Partial); + }); + + expect(result.agentProfiles).toMatchObject({ + version: 1, + items: [{ id: "live-profile" }], + }); + expect(result.settingsAgents.items).toHaveLength(1); + expect(result.settingsData.agentsLoaded).toBe(false); + }); + + it("applies a fresh snapshot captured at the current generation", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.agentProfiles = { + version: 1, + items: [ + { + id: "stale-profile", + label: "Stale profile", + agent_id: "agent-1", + agent_name: "Agent", + cli_passthrough: false, + inference_capable: true, + }, + ], + }; + draft.settingsAgents.items = [ + { + id: "agent-1", + name: "Agent", + profiles: [{ id: "stale-profile" }], + } as never, + ]; + hydrateState(draft, { + settingsAgents: { items: [] }, + agentProfiles: { + version: 1, + items: [], + }, + settingsData: { agentsLoaded: true }, + } as unknown as Partial); + }); + + expect(result.agentProfiles).toEqual({ version: 1, items: [] }); + expect(result.settingsAgents.items).toEqual([]); + expect(result.settingsData.agentsLoaded).toBe(true); + }); +}); + describe("hydrateState — sidebar views from user settings", () => { it("hydrates active view and draft from backend user settings", () => { const result = produce(makeAppDraft(), (draft: Draft) => { diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index df42834ed9..34359a56d5 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -164,14 +164,31 @@ function hydrateKanbanAndWorkspace(draft: Draft, state: HydrationState /** Hydrate settings slices, preserving loading states. */ function hydrateSettings(draft: Draft, state: HydrationState): void { if (state.executors) deepMerge(draft.executors, state.executors); - if (state.settingsAgents) deepMerge(draft.settingsAgents, state.settingsAgents); if (state.agentDiscovery) deepMerge(draft.agentDiscovery, state.agentDiscovery); mergeWithLoading(draft.availableAgents, state.availableAgents); - if (state.agentProfiles) deepMerge(draft.agentProfiles, state.agentProfiles); + const preserveLiveAgentProfiles = + (state.agentProfiles?.version ?? 0) < draft.agentProfiles.version; + if (state.settingsAgents && !preserveLiveAgentProfiles) { + deepMerge(draft.settingsAgents, state.settingsAgents); + } + if (state.agentProfiles) { + // Preserve a newer profile mutation delivered over WebSocket while this + // snapshot was in flight; otherwise the stale response can erase it. + if (!preserveLiveAgentProfiles) { + deepMerge(draft.agentProfiles, state.agentProfiles); + } + } mergeWithLoading(draft.editors, state.editors); mergeWithLoading(draft.prompts, state.prompts); mergeWithLoading(draft.notificationProviders, state.notificationProviders); - if (state.settingsData) deepMerge(draft.settingsData, state.settingsData); + if (state.settingsData) { + // A rejected agent snapshot is incomplete. Leave the loading marker false + // so the settings data hook can retry the complete list. + const settingsData = preserveLiveAgentProfiles + ? { ...state.settingsData, agentsLoaded: false } + : state.settingsData; + deepMerge(draft.settingsData, settingsData); + } if (state.sleepInhibition) deepMerge(draft.sleepInhibition, state.sleepInhibition); if (state.agentProfileRecentUse) { draft.agentProfileRecentUse = mergeAgentProfileRecentUseState( diff --git a/apps/web/lib/state/slices/settings/types.test.ts b/apps/web/lib/state/slices/settings/types.test.ts index 6328660fc3..7ba9ea2487 100644 --- a/apps/web/lib/state/slices/settings/types.test.ts +++ b/apps/web/lib/state/slices/settings/types.test.ts @@ -24,6 +24,7 @@ describe("toAgentProfileOption", () => { name: "claude-acp", capability_status: undefined, capability_error: undefined, + inference_capable: true, }; it("maps enabled from the profile and defaults to true when absent", () => { @@ -34,6 +35,7 @@ describe("toAgentProfileOption", () => { enabled: false, }); expect(enabled.enabled).toBe(false); + expect(enabled.inference_capable).toBe(true); const legacy = toAgentProfileOption(agent, { id: agentProfileId("p2"), diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index d0465321ba..85e3896f33 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -66,6 +66,8 @@ export type AgentProfileOption = { agent_name: string; kind?: AgentProfileKind; cli_passthrough: boolean; + /** Whether the profile's agent supports sessionless inference. */ + inference_capable?: boolean; /** Configured start model (ACP model ID). Empty = agent default. */ model?: string; /** Optional explicit fallback model; ignored when auto_fallback is on. */ @@ -270,7 +272,10 @@ export function refreshSettingsAgentsCapabilities( /** Single source of truth for mapping an API Agent+Profile to a store AgentProfileOption. */ export function toAgentProfileOption( - agent: Pick, + agent: Pick< + Agent, + "id" | "name" | "capability_status" | "capability_error" | "inference_capable" + >, profile: Pick & { updatedAt?: string; kind?: AgentProfileKind; @@ -288,6 +293,7 @@ export function toAgentProfileOption( agent_name: agent.name, kind: profile.kind, cli_passthrough: profile.cliPassthrough ?? false, + inference_capable: agent.inference_capable, model: profile.model ?? undefined, fallback_model: profile.fallbackModel ?? undefined, auto_fallback: profile.autoFallback ?? undefined, diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index f28c6f2731..4b37dcb532 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -380,6 +380,8 @@ export type AgentProfileDeletedPayload = { export type AgentProfileChangedPayload = { profile: AgentProfilePayload; + /** Sessionless-inference capability for profile events received before agent hydration. */ + inference_capable?: boolean; }; export type UserSettingsUpdatedPayload = Omit< diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 8a44fd8f50..de01ec89c0 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -58,6 +58,8 @@ export type Agent = { */ capability_status?: CapabilityStatus; capability_error?: string; + /** Whether this agent supports sessionless host-utility inference. */ + inference_capable?: boolean; created_at: string; updated_at: string; }; diff --git a/apps/web/lib/ws/handlers/agents.test.ts b/apps/web/lib/ws/handlers/agents.test.ts index b081d3ecc1..a47b7e27e4 100644 --- a/apps/web/lib/ws/handlers/agents.test.ts +++ b/apps/web/lib/ws/handlers/agents.test.ts @@ -188,6 +188,22 @@ describe("agent profile events", () => { expect(stored?.model).toBe("new-model"); }); + it("preserves event capability before the owning settings agent is hydrated", () => { + const store = makeStore(); + const handlers = handlersFor(store); + + handlers[PROFILE_CREATED]( + message(PROFILE_CREATED, { + profile: profilePayload(), + inference_capable: true, + }), + ); + + expect(store.getState().settingsAgents.items).toHaveLength(0); + expect(store.getState().agentProfiles.items[0]?.inference_capable).toBe(true); + expect(store.getState().agentProfiles.version).toBe(1); + }); + it("does not resurrect a deleted profile from a delayed create event", () => { const store = makeStore(); const handlers = handlersFor(store); diff --git a/apps/web/lib/ws/handlers/agents.ts b/apps/web/lib/ws/handlers/agents.ts index 4086508aee..dc28d91f94 100644 --- a/apps/web/lib/ws/handlers/agents.ts +++ b/apps/web/lib/ws/handlers/agents.ts @@ -1,6 +1,7 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { WsHandlers } from "@/lib/ws/handlers/types"; +import type { BackendMessageMap } from "@/lib/types/backend"; import { compareTimestamps, isStaleAvailableAgentsSnapshot, @@ -21,6 +22,26 @@ function getAgentId(raw: unknown): string { return typeof value === "string" ? value : ""; } +function getInferenceCapable(raw: unknown): boolean | undefined { + const value = (raw as { inference_capable?: unknown } | undefined)?.inference_capable; + return typeof value === "boolean" ? value : undefined; +} + +function profileEventAgent( + state: AppState, + agentID: string, + inferenceCapable: boolean | undefined, +) { + const agent = state.settingsAgents.items.find((item) => item.id === agentID); + return { + id: agentID, + name: agent?.name ?? "", + capability_status: agent?.capability_status, + capability_error: agent?.capability_error, + inference_capable: agent?.inference_capable ?? inferenceCapable, + }; +} + /** * Office-scoped profiles are owned by the office channels, never the kanban * settings surface (the HTTP agent list hides them via filterGlobalProfiles). @@ -88,19 +109,14 @@ function handleProfileCreated( state: AppState, profile: unknown, eventTimestamp: string | undefined, + inferenceCapable: boolean | undefined, ): Partial { const normalized = normalizeAgentProfile(profile); if (isOfficeScoped(normalized)) return {}; if (isStaleProfileEvent(state, normalized, eventTimestamp)) return {}; deletionTombstones.delete(normalized.id); // a genuinely newer create wins const agentId = getAgentId(profile); - const agent = state.settingsAgents.items.find((a) => a.id === agentId); - const agentStub = { - id: agentId, - name: agent?.name ?? "", - capability_status: agent?.capability_status, - capability_error: agent?.capability_error, - }; + const agentStub = profileEventAgent(state, agentId, inferenceCapable); const nextProfiles = [ ...state.agentProfiles.items.filter((p) => p.id !== normalized.id), toAgentProfileOption(agentStub, normalized), @@ -117,7 +133,11 @@ function handleProfileCreated( : item, ); return { - agentProfiles: { ...state.agentProfiles, items: nextProfiles }, + agentProfiles: { + ...state.agentProfiles, + items: nextProfiles, + version: state.agentProfiles.version + 1, + }, settingsAgents: { items: nextAgents }, }; } @@ -130,18 +150,13 @@ function handleProfileUpdated( state: AppState, profile: unknown, eventTimestamp: string | undefined, + inferenceCapable: boolean | undefined, ): Partial { const normalized = normalizeAgentProfile(profile); if (isOfficeScoped(normalized)) return {}; if (isStaleProfileEvent(state, normalized, eventTimestamp)) return {}; const agentId = getAgentId(profile); - const agent = state.settingsAgents.items.find((a) => a.id === agentId); - const agentStub = { - id: agentId, - name: agent?.name ?? "", - capability_status: agent?.capability_status, - capability_error: agent?.capability_error, - }; + const agentStub = profileEventAgent(state, agentId, inferenceCapable); const nextProfiles = state.agentProfiles.items.map((p) => p.id === normalized.id ? toAgentProfileOption(agentStub, normalized) : p, ); @@ -154,7 +169,11 @@ function handleProfileUpdated( : item, ); return { - agentProfiles: { ...state.agentProfiles, items: nextProfiles }, + agentProfiles: { + ...state.agentProfiles, + items: nextProfiles, + version: state.agentProfiles.version + 1, + }, settingsAgents: { items: nextAgents }, }; } @@ -189,11 +208,42 @@ function handleProfileDeleted( agentProfiles: { ...state.agentProfiles, items: state.agentProfiles.items.filter((p) => p.id !== normalized.id), + version: state.agentProfiles.version + 1, }, settingsAgents: { items: nextAgents }, }; } +function applyProfileCreatedEvent( + store: StoreApi, + message: BackendMessageMap["agent.profile.created"], +) { + store.setState((state) => ({ + ...state, + ...handleProfileCreated( + state, + message.payload.profile, + message.timestamp, + getInferenceCapable(message.payload), + ), + })); +} + +function applyProfileUpdatedEvent( + store: StoreApi, + message: BackendMessageMap["agent.profile.updated"], +) { + store.setState((state) => ({ + ...state, + ...handleProfileUpdated( + state, + message.payload.profile, + message.timestamp, + getInferenceCapable(message.payload), + ), + })); +} + export function registerAgentsHandlers(store: StoreApi): WsHandlers { return { "agent.available.updated": (message) => { @@ -279,18 +329,8 @@ export function registerAgentsHandlers(store: StoreApi): WsHandlers { }; }); }, - "agent.profile.created": (message) => { - store.setState((state) => ({ - ...state, - ...handleProfileCreated(state, message.payload.profile, message.timestamp), - })); - }, - "agent.profile.updated": (message) => { - store.setState((state) => ({ - ...state, - ...handleProfileUpdated(state, message.payload.profile, message.timestamp), - })); - }, + "agent.profile.created": (message) => applyProfileCreatedEvent(store, message), + "agent.profile.updated": (message) => applyProfileUpdatedEvent(store, message), "agent.profile.deleted": (message) => { store.setState((state) => ({ ...state, diff --git a/apps/web/src/settings-routes.bootstrap.test.ts b/apps/web/src/settings-routes.bootstrap.test.ts new file mode 100644 index 0000000000..4b149fab83 --- /dev/null +++ b/apps/web/src/settings-routes.bootstrap.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Agent, ListAgentsResponse, ListWorkspacesResponse } from "@/lib/types/http"; +import { loadSettingsInitialState } from "./settings-routes"; + +const mocks = vi.hoisted(() => ({ + fetchUserSettings: vi.fn(), + listAgentDiscovery: vi.fn(), + listAgents: vi.fn(), + listAvailableAgents: vi.fn(), + listExecutors: vi.fn(), + listWorkspaces: vi.fn(), +})); + +vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({ + ...(await importOriginal()), + fetchUserSettings: mocks.fetchUserSettings, + listAgentDiscovery: mocks.listAgentDiscovery, + listAgents: mocks.listAgents, + listAvailableAgents: mocks.listAvailableAgents, + listExecutors: mocks.listExecutors, +})); + +vi.mock("@/lib/api/domains/workspace-api", async (importOriginal) => ({ + ...(await importOriginal()), + listWorkspaces: mocks.listWorkspaces, +})); + +const TEST_AGENT = { + id: "agent-1", + name: "Agent", + supports_mcp: false, + profiles: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +} as unknown as Agent; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function agentResponse(agents: Agent[]): ListAgentsResponse { + return { agents, total: agents.length }; +} + +function emptyWorkspaceResponse(): ListWorkspacesResponse { + return { workspaces: [], total: 0 }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.fetchUserSettings.mockRejectedValue(new Error("settings unavailable")); + mocks.listAgentDiscovery.mockResolvedValue({ agents: [], total: 0 }); + mocks.listAvailableAgents.mockResolvedValue({ agents: [], tools: [], total: 0 }); + mocks.listExecutors.mockResolvedValue({ executors: [], total: 0 }); + mocks.listWorkspaces.mockResolvedValue(emptyWorkspaceResponse()); + mocks.listAgents.mockResolvedValue(agentResponse([])); +}); + +describe("loadSettingsInitialState", () => { + it("retries the full snapshot when a profile event races the request", async () => { + const firstAgentsRead = deferred(); + mocks.listAgents + .mockImplementationOnce(() => firstAgentsRead.promise) + .mockResolvedValueOnce(agentResponse([TEST_AGENT])); + let version = 0; + + const initialStatePromise = loadSettingsInitialState(() => version); + version = 1; + firstAgentsRead.resolve(agentResponse([])); + + const initialState = await initialStatePromise; + + expect(mocks.listAgents).toHaveBeenCalledTimes(2); + expect(initialState.agentProfiles?.version).toBe(1); + expect(initialState.settingsAgents?.items).toEqual([TEST_AGENT]); + }); + + it("uses the current generation when the request starts after a profile event", async () => { + mocks.listAgents.mockResolvedValue(agentResponse([TEST_AGENT])); + + const initialState = await loadSettingsInitialState(() => 2); + + expect(initialState.agentProfiles?.version).toBe(2); + expect(initialState.settingsAgents?.items).toEqual([TEST_AGENT]); + }); +}); diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index 0394f7f86e..b327b5d2a2 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -150,6 +150,12 @@ describe("buildSettingsInitialStateForRoute", () => { expect(state.system).toBeUndefined(); }); + + it("stamps the agent snapshot with the request generation", () => { + const state = buildState({ agentProfilesVersion: 4 }); + + expect(state.agentProfiles?.version).toBe(4); + }); }); describe("message queue settings route", () => { diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index 9b9eafbe50..38f65ea008 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -120,6 +120,7 @@ type SettingsInitialStateData = { availableAgents: Awaited>["agents"]; availableTools: NonNullable>["tools"]>; userSettingsResponse: UserSettingsResponse | null; + agentProfilesVersion?: number; }; const licenseEntries = licenses as LicenseEntry[]; @@ -564,7 +565,9 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) { let cancelled = false; async function bootstrap() { - const initialState = await loadSettingsInitialState(); + const initialState = await loadSettingsInitialState( + () => store.getState().agentProfiles.version, + ); if (!cancelled && Object.keys(initialState).length > 0) { store.getState().hydrate(initialState); } @@ -580,26 +583,36 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) { return null; } -async function loadSettingsInitialState(): Promise { - const [workspaces, executors, agents, discovery, available, userSettingsResponse] = - await Promise.all([ - listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), - listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })), - listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })), - listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })), - listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })), - fetchUserSettings({ cache: "no-store" }).catch(() => null), - ]); +export async function loadSettingsInitialState( + getAgentProfilesVersion: () => number, +): Promise { + // A profile event can arrive while any of these requests are in flight. Do + // not publish a partial snapshot as loaded; repeat the complete read until + // it was captured at one stable local generation. + for (;;) { + const agentProfilesVersion = getAgentProfilesVersion(); + const [workspaces, executors, agents, discovery, available, userSettingsResponse] = + await Promise.all([ + listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), + listExecutors({ cache: "no-store" }).catch(() => ({ executors: [] })), + listAgents({ cache: "no-store" }).catch(() => ({ agents: [] })), + listAgentDiscovery({ cache: "no-store" }).catch(() => ({ agents: [] })), + listAvailableAgents({ cache: "no-store" }).catch(() => ({ agents: [], tools: [] })), + fetchUserSettings({ cache: "no-store" }).catch(() => null), + ]); - return buildSettingsInitialStateForRoute({ - workspaces: workspaces.workspaces, - executors: executors.executors, - agents: agents.agents, - discoveryAgents: discovery.agents, - availableAgents: available.agents, - availableTools: available.tools ?? [], - userSettingsResponse, - }); + const initialState = buildSettingsInitialStateForRoute({ + workspaces: workspaces.workspaces, + executors: executors.executors, + agents: agents.agents, + discoveryAgents: discovery.agents, + availableAgents: available.agents, + availableTools: available.tools ?? [], + userSettingsResponse, + agentProfilesVersion, + }); + if (getAgentProfilesVersion() === agentProfilesVersion) return initialState; + } } export function buildSettingsInitialStateForRoute({ @@ -610,6 +623,7 @@ export function buildSettingsInitialStateForRoute({ availableAgents, availableTools, userSettingsResponse, + agentProfilesVersion = 0, }: SettingsInitialStateData): HydrationState { const workspaceItems = workspaces.map(mapWorkspaceItem); promoteLegacyWorkspaceSelection(workspaceItems); @@ -627,7 +641,7 @@ export function buildSettingsInitialStateForRoute({ items: agents.flatMap((agent) => agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), ), - version: 0, + version: agentProfilesVersion, }, settingsAgents: { items: agents }, agentDiscovery: { items: discoveryAgents, loading: false, loaded: true }, diff --git a/docs/decisions/0048-plugin-host-utility-agent-invoke.md b/docs/decisions/0048-plugin-host-utility-agent-invoke.md index 9492afeb52..04c4f611ea 100644 --- a/docs/decisions/0048-plugin-host-utility-agent-invoke.md +++ b/docs/decisions/0048-plugin-host-utility-agent-invoke.md @@ -1,6 +1,6 @@ -# 0048 — Plugins invoke a plugin-selected utility agent +# 0048 — Plugins invoke a selected direct profile or utility agent -- Status: accepted +- Status: superseded by [explicit plugin utility selection](2026-09-14-explicit-plugin-utility-selection.md) - Date: 2026-07-21 - Area: backend, frontend, protocol - Related: [0043 — Plugin host data API](0043-plugin-host-data-api.md), @@ -28,14 +28,21 @@ streaming, stateful, and requires an executor + workspace + worktree. Add a capability-gated `Host.InvokeUtilityAgent`, backed by each plugin's configuration and the existing host-utility tier. -1. **Plugin config: `utility_agent`.** Plugins declaring `agent_invoke` declare - a `config_schema` field named `utility_agent`, with `type: string` and - `format: utility-agent`. Settings > Plugins renders it as a picker of the - existing built-in and custom utility agents. The picker displays the name - but stores the selected utility agent's stable ID, scoped to the plugin. The - plugin does not select an execution profile directly; after - ADR-2026-08-08-utility-agent-profile-execution, the selected utility agent - resolves its own effective profile. +1. **Plugin config: direct profile first.** Plugins declaring `agent_invoke` + can declare an `agent_profile` field with `type: string` and + `format: agent-profile`. Settings > Plugins renders enabled, global, + non-CLI profiles whose agent supports sessionless inference and stores the + selected stable ID. A missing, deleted, disabled, CLI-passthrough, + workspace-scoped, or non-inference profile is a + `FailedPrecondition`. A non-empty declared `agent_profile` is the direct + execution selection and takes precedence over `utility_agent`; when it is + unset, the legacy selection remains available if the active manifest + declares that selector, or if a verified manifest transition recorded + `LegacyUtilityAgentFallback` for a retained value. Arbitrary undeclared + submitted `utility_agent` values are ignored. + Existing plugins may continue declaring `utility_agent` with + `format: utility-agent`; that legacy selector resolves the utility agent's + effective profile as before. 2. **New capability `agent_invoke`.** A boolean `Capabilities.AgentInvoke`, enforced exactly like `state`/`secrets`: `Host.InvokeUtilityAgent` returns @@ -50,21 +57,22 @@ configuration and the existing host-utility tier. or `max_tokens` is an added proto field, no SDK signature change). 4. **Reuse the host-utility tier (ADR 0002).** The kandev-side handler: - gate `agent_invoke` → read the calling plugin's `utility_agent` config → - resolve its ID through the established utility-agent configuration → resolve - that utility agent's effective profile → call the narrow profile-aware - utility runner and return the text. `utilityRunner` is a thin + gate `agent_invoke` → use a non-empty declared direct profile, otherwise + resolve the legacy utility agent → validate direct-profile + eligibility → call the narrow profile-aware utility runner and return the + text. `utilityRunner` is a thin `pluginsHostUtilityAdapter` over `hostutility.Manager` wired in `backendapp`, so `internal/plugins` never imports the agent runtime (the same cycle-avoidance as the Slack assistant's adapter and ADR 0043's data sources). No task, session, workspace, or worktree is involved. -5. **Typed "not configured" failure.** If no utility agent is selected — or the - selected utility agent has since been deleted or disabled — the handler returns gRPC - `FailedPrecondition` (`no utility agent configured` / - `configured utility agent "" not found`), never a silent empty - completion. A stale selection is treated as "unconfigured", not an internal - error. +5. **Typed configuration failures.** An absent selection with no usable + legacy selector, or a deleted/ineligible non-empty direct profile, returns + gRPC `FailedPrecondition` (`no agent profile configured` / `configured + agent profile "" not found`). Legacy utility-agent failures retain their + existing messages. + Capability denial is evaluated before either lookup; runner failures remain + operational errors rather than configuration failures. ## Consequences @@ -72,8 +80,8 @@ configuration and the existing host-utility tier. `host.InvokeUtilityAgent(ctx, prompt)` — no API key, no provider wiring. This is what unblocks the "My Daily Standup" plugin's summarization step. - The operator stays in control: nothing runs until each plugin selects a - configured utility agent, including the effective profile's cost, availability, - launcher, and permission characteristics. + configured direct profile or utility agent, including the effective + profile's cost, availability, launcher, and permission characteristics. - We reused the sessionless inference path instead of building an agent loop; the only net-new machinery is a plugin config picker and one gated RPC handler. - The `InvokeUtilityAgentRequest`/`Response` proto is a public contract, extended diff --git a/docs/decisions/2026-09-14-explicit-plugin-utility-selection.md b/docs/decisions/2026-09-14-explicit-plugin-utility-selection.md new file mode 100644 index 0000000000..5af72b5e58 --- /dev/null +++ b/docs/decisions/2026-09-14-explicit-plugin-utility-selection.md @@ -0,0 +1,38 @@ +# ADR-2026-09-14-explicit-plugin-utility-selection: Explicit plugin utility selection + +**Status:** accepted +**Date:** 2026-09-14 +**Area:** protocol + +## Context + +PR #2870 makes Host.InvokeUtilityAgent read the calling plugin's configuration to choose an execution profile. +The method arguments do not expose that selection. Settings > Utility Agents already provides a default profile. +The user rejected implicit configuration routing and requested an explicit override with default behavior when omitted. + +## Decision + +Plugins own persistence of their preferred profile and pass any override with the invocation. +The host uses the platform default only when the request omits a profile. It never reads plugin configuration for execution selection. +An invalid override fails without fallback. The existing agent_invoke permission remains required. +This supersedes ADR 0048's configuration-based selection and compatibility rules. Its sessionless execution boundary remains applicable. +The transport must reject clients requesting unsupported selection semantics before execution. Silent downgrade is forbidden. + +## Consequences + +Callers can reason about execution from arguments and the platform default. Settings and execution ownership are separate. +Old prompt-only clients use the default on the revised host. Clients that need their saved selection must send it explicitly. +The SDK interface changes for implementers. Updated clients require a host supporting the new wire method. +General agent execution with cwd remains outside this utility completion contract. + +## Alternatives considered + +- Implicit plugin configuration routing: rejected because the call hides its execution selection. +- Always use the default: rejected because plugins need explicit per-call overrides. +- Keep legacy configuration fallback: rejected because it preserves the hidden behavior the user removed. +- Add only an optional field to the old RPC: rejected because old servers can ignore that field and execute the wrong profile. + +## Specifications + +- [Requirements](../specs/plugins/requirements/plugin-explicit-utility-invocation.md) +- [System design](../specs/plugins/system-design/plugin-explicit-utility-invocation.md) diff --git a/docs/plans/plugin-direct-profile-invocation/plan.md b/docs/plans/plugin-direct-profile-invocation/plan.md new file mode 100644 index 0000000000..fb2380056a --- /dev/null +++ b/docs/plans/plugin-direct-profile-invocation/plan.md @@ -0,0 +1,126 @@ +--- +created: 2026-09-14 +status: complete +requirements: + - REQ-PLUGINS-DIRECT-PROFILE-INVOCATION-001 +system_design: + - ../../specs/plugins/system-design/plugin-direct-profile-invocation.md +legacy_specs: [] +--- + +# Implementation Plan: Plugin Direct Agent Profile Invocation + +> Historical delivery record. [The replacement plan](../plugin-explicit-utility-invocation/plan.md) supersedes implicit host selection. +> Previous completed checks do not verify the replacement API. + + +## Overview + +Plugins that delegate a one-shot LLM step currently select execution identity +through the legacy `utility_agent` config field, which models the choice as a +custom utility prompt and hides the agent profile that actually executes. +This plan adds a direct `agent_profile` selection to the Host invocation +contract, wires the host to execute the selected profile via +`ExecuteProfilePrompt`, and keeps declaration-gated legacy behavior +untouched. The companion [kandev-plugin-notes PR 7](https://github.com/yattdev/kandev-plugin-notes/pull/7) +consumes the new `format: agent-profile` field at exact Notes head +`f8548f9d7a1eaabf19d27bb0b0af8f75d8eaeec4`. + +The smallest reproduction of the addressed defect is: + +1. Open Settings > Plugins > Notes. The Utility Agent field lists rows from + the utility/custom-prompt catalog, not the platform's agent profiles. +2. Selecting a row persists a utility-prompt ID that fails at invocation with + a generic error rather than executing the configured agent profile. + +## Scope + +### In scope + +- The `agent-profile` config renderer format, shared-profile-picker reuse, + and eligibility filtering in the web app. +- Direct profile routing, typed `FailedPrecondition` translation, and legacy + fallback bounding in the backend plugins host. +- Hydration merging in the plugin config form and store profile-list + hydration revision checks. +- Accessibility and display-label reload for the picker. +- The companion Notes manifest, webhook error translation, tests, docs, and + packaging evidence for the Notes half of the delivery. + +### Out of scope + +- Changing the public `Host.InvokeUtilityAgent` signature or `agent_invoke` + gate semantics. +- Changing utility-agent resolution for plugins that declare only + `format: utility-agent`. +- New database or config migration for the dual selector formats. + +## Technical approach + +### Settings rendering + +Extend `config-schema.ts` with the `agent-profile` format renderer and route +it through `utility-agent-profile-picker.tsx` in `agentRegistration` mode. +Eligibility requires enabled, non-deleted, global, non-CLI-passthrough, +inference-capable profiles; unavailable saved IDs render replaceably. +`plugin-config-form.tsx` merges hydration results so concurrent profile +snapshot and config updates do not overwrite each other, and +`use-settings-data.ts`/`hydrator.ts` guard store revisions against stale +profile snapshots with WebSocket-triggered retry. + +### Host execution + +`host_utility.go` gates on `agent_invoke` first, validates the declared +format, resolves the profile once at call start, and executes with +`ExecuteProfilePrompt(profileID, prompt)`. Missing/ineligible selections +return typed `FailedPrecondition` before runner start; non-not-found store +errors pass through unchanged in `services.go`. Legacy `utility_agent` +routing runs only when no direct selection exists and the active manifest +declares that selector, per +[ADR 0048](../../decisions/0048-plugin-host-utility-agent-invoke.md). + +### Notes companion + +The plugin uses the daemonized target server, refreshes +`Host.InvokeUtilityAgent` enhancement to send the selected profile ID, +translates `FailedPrecondition` to an actionable HTTP 412 setup message, +updates its manifest contract test, README, packaging, and test-tooling +pins, and carries final visual QA evidence for the picker, mobile settings, +and enhancement preview. + +## Tests + +- Backend focused Go tests for direct routing, eligibility/error classes, + capability gating, and legacy fallback preservation. +- Frontend unit tests for config-schema formats, picker eligibility and + required fields, hydration merge, store snapshot revision, and WS retry. +- Notes Go tests for the manifest contract, webhook error mapping, and + enhancement payload, plus `node --test ui/bundle.test.mjs` wiring smoke. + +## Work orders + +- [x] [Task 01: Direct profile invocation and settings rendering](task-01-direct-profile-invocation.md) +- [x] [Task 02: Notes companion manifest, errors, and evidence](task-02-notes-companion-delivery.md) + +## Verification results + +- Backend: focused `internal/plugins` and `internal/backendapp` Go tests + passed, including the direct-profile routing, eligibility, cancellation, + and store-failure cases; static checks passed. +- Frontend: config-schema and picker tests, plugin-config-form hydration + tests, settings hydration and WS agent-handler tests, web typecheck, and + lint all passed at CI for the delivery head. +- Notes: Go test suite, `node --test ui/bundle.test.mjs`, package smoke, doc + validation, and packaging metadata checks passed. +- Notes PR evidence and committed screenshots confirmed the picker, mobile + layout, and enhancement preview behavior. + +## Risks + +- Concurrent profile-list hydration and plugin-config hydration can clobber + state; merge and revision guards prevent lossy overwrites. +- Legacy stored `utility_agent` values must remain executable for trusted + pre-transition manifests, so fallback gating is explicitly bounded in ADR + 0048 and regression-tested. +- Config failures must not be misclassified as operational failures, or web + hooks will mislead operators with wrong HTTP status messages. diff --git a/docs/plans/plugin-direct-profile-invocation/task-01-direct-profile-invocation.md b/docs/plans/plugin-direct-profile-invocation/task-01-direct-profile-invocation.md new file mode 100644 index 0000000000..8e97f1d5a7 --- /dev/null +++ b/docs/plans/plugin-direct-profile-invocation/task-01-direct-profile-invocation.md @@ -0,0 +1,156 @@ +--- +id: "01-direct-profile-invocation" +title: "Direct profile invocation and settings rendering" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLUGINS-DIRECT-PROFILE-INVOCATION-001 +acceptance_criteria: + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.1 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.2 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.3 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.4 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.5 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.6 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.7 +system_design: + - ../../specs/plugins/system-design/plugin-direct-profile-invocation.md +--- + +# Task 01: Direct Profile Invocation and Settings Rendering + +> Historical delivery record. [The replacement plan](../plugin-explicit-utility-invocation/plan.md) supersedes implicit host selection. +> Previous completed checks do not verify the replacement API. + + +## Summary + +Add the direct `agent-profile` config format to Settings > Plugins, render +only eligible platform profiles through the shared picker, and route +`Host.InvokeUtilityAgent` to `ExecuteProfilePrompt` on the stored profile ID +with typed configuration errors and bounded legacy fallback. + +## In scope + +- Add the `agent-profile` renderer, picker-mode eligibility filtering, and + unavailable-selection replaceable rendering. +- Register the companion Notes settings picker via the daemonized target + server and the platform agent registry. +- Keep the plugin config form's hydration merge and the store profile-list + revision guard so profile/config updates do not overwrite each other. +- Route direct selections to `ExecuteProfilePrompt`; return typed + `FailedPrecondition` for missing/ineligible selections before any runner + starts. +- Adapt host-utility manager errors to gRPC codes with not-found/config + classes; pass non-not-found store failures through unchanged. +- Bound legacy `utility_agent` fallback to trusted declared-selector + configurations per ADR 0048. +- Cover the paths with backend and frontend focused tests, docs updates, and + PR validation evidence. + +## Out of scope + +- Notes-side manifest, webhook translation, packaging, and QA evidence (Task + 02). +- `Host.InvokeUtilityAgent` signature or gate semantic changes. +- Utility-agent behavior for manifests that declare only the legacy format. + +## Acceptance + +- Notes's Utility Agent settings field lists eligible profiles, not utility + or custom-prompt rows, on desktop and mobile widths. +- Selecting a profile persists its stable ID, marks the shared settings + contributor dirty, saves, and reloads showing the same display label. +- Disabled, deleted, workspace-scoped, and CLI-passthrough profiles cannot be + newly selected; previously saved unavailable selections render replaceably. +- `Host.InvokeUtilityAgent` with a declared, selected profile executes that + profile directly with no utility/custom-prompt lookup; missing or + ineligible selections return typed `FailedPrecondition`, and + non-`agent_invoke` plugins are denied before profile lookup. +- Direct selection takes precedence over a coexisting legacy value; + legacy-only behavior is unchanged. + +## Verification + +```bash +cd apps/backend +go test ./internal/plugins/... ./internal/backendapp/... +cd ../apps && pnpm --filter @kandev/web test -- --run \ + lib/plugins/config-schema.test.ts \ + components/settings/utility-agent-profile-picker.test.ts \ + components/settings/plugins/plugin-config-form.agent-profile.test.tsx \ + hooks/domains/settings/use-settings-data.test.tsx \ + lib/state/hydration/hydrator.test.ts \ + lib/state/slices/settings/types.test.ts \ + lib/ws/handlers/agents.test.ts +pnpm --filter @kandev/web typecheck +pnpm --filter @kandev/web lint +``` + +## Files likely touched + +- `apps/web/lib/plugins/config-schema.ts` +- `apps/web/components/settings/utility-agent-profile-picker.tsx` +- `apps/web/components/settings/plugins/plugin-config-form.tsx` +- `apps/web/hooks/domains/settings/use-settings-data.ts` +- `apps/web/lib/state/hydration/hydrator.ts` +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/types/backend.ts` +- `apps/web/lib/types/http-agents.ts` +- `apps/web/lib/ws/handlers/agents.ts` +- `apps/backend/internal/plugins/host_utility.go` +- `apps/backend/internal/plugins/host.go` +- `apps/backend/internal/plugins/service.go` +- `apps/backend/internal/backendapp/services.go` +- `apps/backend/internal/agent/settings/controller/profile_crud.go` +- `apps/backend/pkg/pluginsdk/host.go` +- `docs/decisions/0048-plugin-host-utility-agent-invoke.md` +- `docs/public/plugins-authoring.md` +- `docs/public/plugins-manifest.md` +- `docs/screenshots/pr-validation/2870/*` + +## Dependencies + +None. + +## Risks + +- Legacy fallback must remain available for stored configs that still expect + it; explicit bounds and tests prevent silent removal. +- Concurrent hydration of config and profile snapshots can cause lossy + overwrites without merge and revision guards. +- Eligibility criteria must match the agents system's rules, so the shared + registration provider is anchored to the same store used by the settings + API. + +## Parallelism + +`sequential` + +## Inputs + +- `docs/specs/plugins/requirements/plugin-direct-profile-invocation.md` +- `docs/specs/plugins/system-design/plugin-direct-profile-invocation.md` +- `docs/specs/agents/requirements/utility-agent-profiles.md` +- `docs/decisions/0048-plugin-host-utility-agent-invoke.md` +- `apps/web/lib/plugins/config-schema.ts` +- `apps/web/components/settings/utility-agent-profile-picker.tsx` +- `apps/backend/internal/plugins/host_utility.go` + +## Results + +- Added the `agent-profile` renderer and shared-picker `agentRegistration` + mode with utility-execution eligibility (enabled, global, non-CLI, + inference-capable) and replaceable unavailable-selection rendering. +- Registered the daemonized target server so Notes renders platform profiles + with correct labels, persisted the saved stable ID, and surfaced the + profile name in the enhancement preview. +- Preserved legacy `utility_agent` fallback for trusted declared-selector + configs, bounded per ADR 0048 and regression-tested. +- Wired hydration merges and store revision guards so profile snapshot and + plugin-config updates do not clobber each other; profile WS updates retry + when a render would go stale. +- Verification: focused backend `go test` and web vitest/lint/typecheck all + passing at the delivery head, with PR validation screenshots added. diff --git a/docs/plans/plugin-direct-profile-invocation/task-02-notes-companion-delivery.md b/docs/plans/plugin-direct-profile-invocation/task-02-notes-companion-delivery.md new file mode 100644 index 0000000000..82966e845e --- /dev/null +++ b/docs/plans/plugin-direct-profile-invocation/task-02-notes-companion-delivery.md @@ -0,0 +1,104 @@ +--- +id: "02-notes-companion-delivery" +title: "Notes companion manifest, errors, and evidence" +status: done +wave: 1 +depends_on: + - "01-direct-profile-invocation" +plan: "plan.md" +requirements: + - REQ-PLUGINS-DIRECT-PROFILE-INVOCATION-001 +acceptance_criteria: + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.1 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.4 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.5 + - AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.8 +system_design: + - ../../specs/plugins/system-design/plugin-direct-profile-invocation.md +--- + +# Task 02: Notes Companion Manifest, Errors, and Evidence + +> Historical delivery record. [The replacement plan](../plugin-explicit-utility-invocation/plan.md) supersedes implicit host selection. +> Previous completed checks do not verify the replacement API. + + +## Summary + +Update the Notes plugin (yattdev/kandev-plugin-notes at +`f8548f9d7a1eaabf19d27bb0b0af8f75d8eaeec4`, PR 7) to declare the +agent-profile-backed setting, translate `FailedPrecondition` into an +actionable setup response, align packaging, and record final delivery +evidence. + +## In scope + +- Notes manifest declaration and contract-pinning tests for the + agent-profile field name and format. +- Refresh the enhancement/proofread path to send the selected stable profile + ID in the `Host.InvokeUtilityAgent` payload. +- Translate `FailedPrecondition` webhook responses to HTTP 412 with an + actionable profile-selection/setup message; keep operational failures as + 502-class execution errors. +- SDK target output update, README/usage-copy alignment to a single direct + profile selection, packaging metadata consistency, and test tooling pin + alignment (Kandev SDK v0.2.4). +- Final PR body, visual evidence, and Notes-side read-me screenshots. + +## Out of scope + +- Host-side routing, eligibility, translation, and UI rendering, covered by + Task 01. +- Legacy `utility_agent` semantics for other plugins. + +## Acceptance + +- Notes declares and pins the agent-profile settings field, its payload + carries the stable profile ID, and its UI shows the selected profile label. +- A missing or ineligible profile yields HTTP 412 with actionable copy, not + a generic 500/502. +- Notes packaging, version metadata, doc links, and screenshots are + consistent with the shipped manifest and SDK. + +## Verification + +```bash +cd kandev-plugin-notes +go test ./... +node --test ui/bundle.test.mjs +go vet ./... && test -z "$(gofmt -l .)" +``` + +## Files likely touched + +- kandev-plugin-notes `internal/server`, `manifest`, `webhook`, `go.mod`, + `go.sum`, README, docs links, packaging, UI bundle smoke tests. + +## Dependencies + +- `01-direct-profile-invocation` + +## Risks + +- Full-replacement plugin config updates can drop fields unless the plugin's + hydration writes the complete record. +- Committing PR screenshots requires binary assets on the Notes fork head. + +## Parallelism + +`sequential` + +## Inputs + +- `docs/specs/plugins/requirements/plugin-direct-profile-invocation.md` +- `docs/specs/plugins/system-design/plugin-direct-profile-invocation.md` +- kandev-plugin-notes Task 01 implementation and Kandev SDK contract. + +## Results + +- Notes PR 7 CI green (`Build plugin packages`, + `Tidy, format, vet, and test`, `GitGuardian`). +- Notes PR body corrected to reference the commands actually run in QA. +- Draft statuses cleared by marking Notes PR 7 (`f8548f9d7...`), Notes PR 7, + and Kandev PR 2870 (`0f36c0a6b...`) ready for review; both are CLEAN and + MERGEABLE at their exact delivery heads. diff --git a/docs/plans/plugin-explicit-utility-invocation/plan.md b/docs/plans/plugin-explicit-utility-invocation/plan.md new file mode 100644 index 0000000000..351d184f14 --- /dev/null +++ b/docs/plans/plugin-explicit-utility-invocation/plan.md @@ -0,0 +1,134 @@ +--- +created: 2026-09-14 +status: active +requirements: + - REQ-PLUGINS-EXPLICIT-UTILITY-001 +system_design: + - ../../specs/plugins/system-design/plugin-explicit-utility-invocation.md +legacy_specs: [] +--- + +# Implementation Plan: Explicit Plugin Utility Invocation + +## Overview + +Replace implicit plugin-configuration routing in PR #2870 with the user-approved default-plus-override contract. +Implement the host contract first. Then add a real plugin caller fixture, public documentation, and end-to-end evidence. +Finally migrate Notes in its dedicated repository. The companion task has a separate repository and delivery boundary. + +The plugins system owns the Host contract. Agent settings continue to own profile records and the platform default. +The user explicitly confirmed default selection when no override is supplied and forbade host reads of plugin configuration for invocation. +The options struct and separate wire RPC are design choices that implement that direction without silent downgrade on older hosts. + +## Starting point and handoff + +- Repository: kdlbs/kandev; contributor PR: https://github.com/kdlbs/kandev/pull/2870. +- Reviewed branch: feature/implement-host-agent-oav in yattdev/kandev. +- Last verified head: 4af514930a417809eddd7ab2df76e8ec146ba773. Check the live head before editing or pushing. +- Prior fixes 5d620928 and 4af514930 preserve profile hydration correctness and Windows-safe Git test paths. Keep them. +- Last recorded CI: 56 passed. Those checks cover the old API, not this package. +- Companion Notes reference: yattdev/kandev-plugin-notes PR #7, source f8548f9d7a1eaabf19d27bb0b0af8f75d8eaeec4. Recheck its live state. +- These files are intentionally uncommitted. This turn creates no production or permanent test changes and no Kandev task/session objects. + +## Scope + +### In scope + +- Public SDK options, safe wire evolution, platform default resolution, profile validation, and permission/error behavior. +- Removal of invocation-specific implicit configuration routing and legacy transition machinery. +- Explicit plugin-owned preference loading, fixture coverage, authoring documentation, and Notes caller migration. + +### Out of scope + +- A general InvokeAgentProfile API, cwd, session/worktree creation, streaming, and changes to profile eligibility. +- New settings UI, new storage, changes to ordinary utility actions, or automatic utility-record-to-profile migration. +- Merging either PR, publishing a release, or changing unrelated CI infrastructure. + +## Technical approach + +Follow the [design](../../specs/plugins/system-design/plugin-explicit-utility-invocation.md) and [requirements](../../specs/plugins/requirements/plugin-explicit-utility-invocation.md). +Task 01 changes SDK, proto adapters, host dispatch, backend composition, and obsolete legacy routing as one compilable contract slice. +Task 02 adds an explicit caller to the in-tree fixture and documents default/override usage. It proves settings and invocation cross real transport. +Task 03 migrates Notes in its own checkout; do not vendor the plugin into this repository. +No rendered UI change is planned. The existing optional profile picker, unavailable selection state, Save/Discard, and hydration remain in use. + +## Tests + +These are required new or extended tests, not claims that named cases already exist. + +| Criteria | Required evidence | +| --- | --- | +| .1, .2, .3, .8 | host_utility_test.go: TestInvokeUtilityAgent_DefaultAndOverride, TestInvokeUtilityAgent_IgnoresPluginConfig, TestInvokeUtilityAgent_DefaultChangesBetweenCalls | +| .4, .5, .6, .7, .12 | host_utility_test.go: TestInvokeUtilityAgent_SelectionErrors and capability, cancellation, and runner tests; assert runner calls and absence of fallback | +| .1, .5, .8 | services_plugin_utility_test.go: TestPluginDefaultUtilityProfileSource; read errors, latest default, selected ID | +| .10, .11 | host_data_test.go and host_data_wire_test.go: TestInvokeUtilityAgent_WireOptions, TestInvokeUtilityAgent_OldHostRejected, TestInvokeUtilityAgent_LegacyWireUsesDefault | +| .3 | service_install_test.go and config_test.go: reload old marker/config without execution authority | +| .9 | cmd/plugin-fixture tests and Notes server/plugin_test.go: GetConfig preference explicitly reaches options; read/type error stops invocation | + +All criteria use prefix AC-PLUGINS-EXPLICIT-UTILITY-001. Test names can be refined during implementation if the work orders record the final names. +SDK tests must cover zero, one-empty, one-nonempty, and multiple options values, plus no downgrade after Unimplemented. + +## E2E tests + +Add e2e/tests/plugins/plugin-utility-invocation.spec.ts to the chromium project using the real plugin-fixture subprocess. +Map .1, .2, .3, .4, .8, and .9 through these flows: + +1. Configure default A, save plugin preference B, invoke the fixture's default action, and prove profile A executed. +2. Invoke its preference action, which reads GetConfig and sends B; prove profile B executed across gRPC and the runner. +3. Clear preference and reload; the preference action now executes A. Change the default to C and invoke again; prove C executed. +4. Restore B, then disable/delete B. The explicit call fails and A/C never executes. An unset default does not prevent valid B execution. + +Use isolated mock inference profiles with distinct execution evidence. Observing the plugin's outgoing option alone is insufficient. +Do not call real paid agents. Reuse existing package installation and API helpers. Keep fixture probes separate from production APIs. +This exercises existing settings controls; no new UI structure or mobile-only behavior is introduced. + +## Work orders + +- [x] [Task 01: Explicit host invocation contract](task-01-host-contract.md) +- [x] [Task 02: Plugin caller integration and public contract](task-02-caller-integration.md) +- [ ] [Task 03: Notes companion migration](task-03-notes-companion.md) (implemented locally; delivery blocked by repository permissions) + +Execute 01 -> 02 -> 03. This ordering does not authorize subagents. +Task 03 belongs to the separate Notes repository. Host readiness and companion readiness must be reported separately. + +## Verification results + +Implementation checks passed. Host and companion results are recorded in the +work orders. Planning checks also passed: catalog validation (268 decisions, +899 specifications), all 36 specification-linter tests, full specification +lint, and diff whitespace checks. + +Host checks passed: + +- `make -C apps/backend proto` +- `go test ./pkg/pluginsdk ./internal/plugins/... ./internal/backendapp ./internal/agent/hostutility ./internal/utility/profilebinding` +- `go test ./cmd/mock-agent` +- `go test ./cmd/plugin-fixture ./pkg/pluginsdk ./internal/plugins` +- `make -C apps/backend e2e-plugin-package` +- Chromium E2E: one test passed in `e2e/tests/plugins/plugin-utility-invocation.spec.ts` +- Public docs, document catalog, specification tests, full specification lint, and `git diff --check` + +Host delivery commit: `eb9cfceda75cdc51c40efa289018e1a6f008d04a`, pushed to PR +#2870 branch `feature/implement-host-agent-oav`. Notes is a separate repository +and PR; its local implementation and delivery blocker are recorded in Task 03. + +## Delivery instructions for the implementation agent + +Read this package and local AGENTS.md before implementation. Follow TDD for new logic and run normal commit hooks. +Recheck PR head, draft state, conflicts, review threads, and merge queue before delivery. Preserve user edits in this checkout. +When host tasks pass, commit the host implementation and its documentation under Conventional Commits and push over SSH to the contributor PR branch. +Use the existing pr-head remote only after confirming its URL. Never force-push or overwrite contributor commits. +Post the authorized concise explanation of the new default/override contract and resolve only review threads actually addressed. +Wait for CI with scripts/pr-await, fix attributable failures, and record final head/check results. Do not merge the PR. +The user requested no commits in the planning turn; the later implementation turn owns commits and pushes. +For Notes, inspect its own repository instructions and current PR before any delivery. Do not push Notes changes onto host PR #2870. +If companion access or release coordination blocks delivery, record that in Task 03; do not mark the whole package complete. + +## Risks + +- Old prompt-only clients intentionally change from plugin configuration to the platform default on the new host. +- New clients must receive Unimplemented from old hosts; a fallback would silently change execution identity. +- Changing a Go interface requires updating all implementations, embedded defaults, wire adapters, and test doubles together. +- Existing global default identity rules must be reused; do not bind invocation to an unrelated browser user. +- Removing legacy helper code must not break ordinary utility-agent pickers or loading old serialized plugin records. +- The companion plugin uses a separate SDK dependency and needs coordinated packaging after the host change exists. diff --git a/docs/plans/plugin-explicit-utility-invocation/task-01-host-contract.md b/docs/plans/plugin-explicit-utility-invocation/task-01-host-contract.md new file mode 100644 index 0000000000..27138610dd --- /dev/null +++ b/docs/plans/plugin-explicit-utility-invocation/task-01-host-contract.md @@ -0,0 +1,110 @@ +--- +id: "01-host-contract" +title: "Explicit host invocation contract" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-PLUGINS-EXPLICIT-UTILITY-001 +acceptance_criteria: + - AC-PLUGINS-EXPLICIT-UTILITY-001.1 + - AC-PLUGINS-EXPLICIT-UTILITY-001.2 + - AC-PLUGINS-EXPLICIT-UTILITY-001.3 + - AC-PLUGINS-EXPLICIT-UTILITY-001.4 + - AC-PLUGINS-EXPLICIT-UTILITY-001.5 + - AC-PLUGINS-EXPLICIT-UTILITY-001.6 + - AC-PLUGINS-EXPLICIT-UTILITY-001.7 + - AC-PLUGINS-EXPLICIT-UTILITY-001.8 + - AC-PLUGINS-EXPLICIT-UTILITY-001.10 + - AC-PLUGINS-EXPLICIT-UTILITY-001.11 + - AC-PLUGINS-EXPLICIT-UTILITY-001.12 +system_design: + - ../../specs/plugins/system-design/plugin-explicit-utility-invocation.md +--- + +# Task 01: Explicit host invocation contract + +## Summary + +Implement the options API and safe wire method through the real SDK, host, and backend adapters. +Remove implicit configuration routing and its obsolete transition machinery in the same compilable slice. + +## In scope + +- Add UtilityAgentOptions, update Host implementations and test doubles, and regenerate proto stubs with make proto. +- Preserve old prompt-only wire calls as default requests. Never downgrade the revised SDK to the old RPC. +- Wire the existing default getter through a narrow interface; retain profile eligibility, late wiring, and final runner checks. +- Remove invocation-only utility lookup, legacy markers, install stamping, and hidden configuration preservation. Keep old files loadable. +- Add transport, selection, permissions, storage-error, cancellation, default-change, and reload regressions using TDD. + +## Out of scope + +- General agent execution, cwd, session APIs, profile eligibility changes, and unrelated UI redesign. +- Work owned by other tasks. Do not revert their changes or the prior hydration and Windows CI fixes. + +## Acceptance + +- Every default/override and legacy-wire branch selects the prescribed profile without reading plugin configuration. +- Unsupported hosts and invalid selections cannot execute a fallback profile. +- The backend compiles, targeted tests pass, and old serialized records load without retaining routing authority. + +## Verification + +Use TDD for changed logic. Commands below run from the host repository root unless stated otherwise. + +```bash +make -C apps/backend proto +(cd apps/backend && go test ./pkg/pluginsdk ./internal/plugins/... ./internal/backendapp ./internal/agent/hostutility ./internal/utility/profilebinding) +git diff --check +``` + +## Files likely touched + +- `apps/backend/pkg/pluginsdk/host.go` +- `apps/backend/pkg/pluginsdk/host_data_test.go` +- `apps/backend/proto/kandev/plugin/v1/plugin.proto` +- `apps/backend/proto/kandev/plugin/v1/plugin.pb.go` +- `apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go` +- `apps/backend/internal/plugins/host_utility.go` +- `apps/backend/internal/plugins/host_utility_test.go` +- `apps/backend/internal/plugins/host_data_wire_test.go` +- `apps/backend/internal/plugins/host.go` +- `apps/backend/internal/plugins/service.go` +- `apps/backend/internal/plugins/service_install.go` +- `apps/backend/internal/plugins/service_install_test.go` +- `apps/backend/internal/plugins/service_config.go` +- `apps/backend/internal/plugins/config_test.go` +- `apps/backend/internal/plugins/store/store.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/services.go` +- `apps/backend/internal/backendapp/services_plugin_utility_test.go` + +## Dependencies + +None. + +## Risks + +- Changing SDK interfaces and wire semantics together requires all in-tree implementations to be migrated. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirement](../../specs/plugins/requirements/plugin-explicit-utility-invocation.md) and [design](../../specs/plugins/system-design/plugin-explicit-utility-invocation.md). +- [Plan](plan.md), including compatibility rules, evidence matrix, and delivery instructions. +- Existing SDK wire tests, host utility tests, and plugin-fixture patterns. Read changed source and local AGENTS.md before editing. + +## Results + +Implemented. `make -C apps/backend proto` passed. The focused Go command passed +for `pkg/pluginsdk`, `internal/plugins/...`, `internal/backendapp`, +`internal/agent/hostutility`, and `internal/utility/profilebinding`. The +`cmd/mock-agent` suite also passed, including +`TestPromptUsesCurrentSessionModel`, which proves that ACP session model +selection reaches prompt execution. `git diff --check` passed. + +Host commit: `eb9cfceda75cdc51c40efa289018e1a6f008d04a`, pushed to PR #2870. diff --git a/docs/plans/plugin-explicit-utility-invocation/task-02-caller-integration.md b/docs/plans/plugin-explicit-utility-invocation/task-02-caller-integration.md new file mode 100644 index 0000000000..121e1f3fa5 --- /dev/null +++ b/docs/plans/plugin-explicit-utility-invocation/task-02-caller-integration.md @@ -0,0 +1,105 @@ +--- +id: "02-caller-integration" +title: "Plugin caller integration and public contract" +status: done +wave: 2 +depends_on: ['01-host-contract'] +plan: "plan.md" +requirements: + - REQ-PLUGINS-EXPLICIT-UTILITY-001 +acceptance_criteria: + - AC-PLUGINS-EXPLICIT-UTILITY-001.1 + - AC-PLUGINS-EXPLICIT-UTILITY-001.2 + - AC-PLUGINS-EXPLICIT-UTILITY-001.3 + - AC-PLUGINS-EXPLICIT-UTILITY-001.4 + - AC-PLUGINS-EXPLICIT-UTILITY-001.8 + - AC-PLUGINS-EXPLICIT-UTILITY-001.9 + - AC-PLUGINS-EXPLICIT-UTILITY-001.10 + - AC-PLUGINS-EXPLICIT-UTILITY-001.11 +system_design: + - ../../specs/plugins/system-design/plugin-explicit-utility-invocation.md +--- + +# Task 02: Plugin caller integration and public contract + +## Summary + +Add a real plugin caller that reads its own preference and sends an explicit override. +Document the public default/override API and prove the settings-to-execution flow through the packaged fixture. + +## In scope + +- Add two test-only fixture actions: one omits options; the other reads its saved profile and passes it explicitly. +- Add package/Go tests and the E2E flows in the plan, including cleared preference and invalid saved profile. +- Retain the existing generic picker and hydration behavior. Do not add new production UI controls. +- Update public authoring and manifest documentation, Host interface examples, and the gRPC contract. +- Explain old-client default semantics, old-host rejection, error handling, and plugin-owned preference loading. + +## Out of scope + +- General agent execution, cwd, session APIs, profile eligibility changes, and unrelated UI redesign. +- Work owned by other tasks. Do not revert their changes or the prior hydration and Windows CI fixes. + +## Acceptance + +- End-to-end evidence identifies the executed profile, rather than merely the outgoing option. +- Save/reload, clear-to-default, changed default, and invalid override satisfy the mapped criteria. +- Public examples contain no implicit host configuration routing or stale legacy fallback instructions. + +## Verification + +Use TDD for changed logic. Commands below run from the host repository root unless stated otherwise. + +```bash +(cd apps && pnpm install --frozen-lockfile) +(cd apps/backend && go test ./cmd/plugin-fixture ./pkg/pluginsdk ./internal/plugins) +make -C apps/backend e2e-plugin-package +(cd apps/web && pnpm e2e:run --project chromium -- e2e/tests/plugins/plugin-utility-invocation.spec.ts) +node scripts/validate-public-docs.mjs +python3 scripts/list-docs.py validate +python3 scripts/lint-spec-files.test.py +python3 scripts/lint-spec-files.py --all +git diff --check +``` + +## Files likely touched + +- `apps/backend/cmd/plugin-fixture/plugin.go` +- `apps/backend/cmd/plugin-fixture/main_test.go` +- `apps/backend/cmd/plugin-fixture/fixture_package_test.go` +- `apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml` +- `apps/web/e2e/tests/plugins/plugin-utility-invocation.spec.ts (new)` +- `apps/web/e2e/tests/plugins/plugin-test-helpers.ts` +- `docs/public/plugins-authoring.md` +- `docs/public/plugins-manifest.md` +- `docs/plans/plugins/GRPC-CONTRACT.md` + +## Dependencies + +01-host-contract. + +## Risks + +- Fixture evidence must cover real host selection; an echo of requested options would miss dispatch regressions. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirement](../../specs/plugins/requirements/plugin-explicit-utility-invocation.md) and [design](../../specs/plugins/system-design/plugin-explicit-utility-invocation.md). +- [Plan](plan.md), including compatibility rules, evidence matrix, and delivery instructions. +- Existing SDK wire tests, host utility tests, and plugin-fixture patterns. Read changed source and local AGENTS.md before editing. + +## Results + +Implemented. Frozen workspace dependencies, fixture Go tests, and +`make -C apps/backend e2e-plugin-package` passed. The Chromium E2E test passed +with real fixture transport and execution evidence for default, explicit, +changed-default, cleared-preference, unset-default, and invalid-override +flows. Public-doc validation, document-catalog validation, all 36 +specification-linter tests, full specification lint, and `git diff --check` +passed. + +Host commit: `eb9cfceda75cdc51c40efa289018e1a6f008d04a`, pushed to PR #2870. diff --git a/docs/plans/plugin-explicit-utility-invocation/task-03-notes-companion.md b/docs/plans/plugin-explicit-utility-invocation/task-03-notes-companion.md new file mode 100644 index 0000000000..acaaa50007 --- /dev/null +++ b/docs/plans/plugin-explicit-utility-invocation/task-03-notes-companion.md @@ -0,0 +1,104 @@ +--- +id: "03-notes-companion" +title: "Notes companion migration" +status: blocked +wave: 3 +depends_on: ['02-caller-integration'] +plan: "plan.md" +requirements: + - REQ-PLUGINS-EXPLICIT-UTILITY-001 +acceptance_criteria: + - AC-PLUGINS-EXPLICIT-UTILITY-001.2 + - AC-PLUGINS-EXPLICIT-UTILITY-001.4 + - AC-PLUGINS-EXPLICIT-UTILITY-001.7 + - AC-PLUGINS-EXPLICIT-UTILITY-001.9 + - AC-PLUGINS-EXPLICIT-UTILITY-001.10 + - AC-PLUGINS-EXPLICIT-UTILITY-001.11 +system_design: + - ../../specs/plugins/system-design/plugin-explicit-utility-invocation.md +--- + +# Task 03: Notes companion migration + +## Summary + +Migrate the Notes plugin in yattdev/kandev-plugin-notes to load its saved selection and send the override explicitly. +Keep the host PR and companion delivery records separate. + +## In scope + +- Inspect current Notes source and repository instructions; reference files were verified at f8548f9d7a1eaabf19d27bb0b0af8f75d8eaeec4. +- Use the implemented host SDK through the existing sibling-checkout replace in go.mod. Record the host revision and refresh checksums only if needed. +- Read agent_profile through GetConfig, pass the selection explicitly, and omit it when empty. Stop on configuration read/type errors. +- Keep the existing optional agent_profile field and control; the inspected manifest has no required list. +- Update host fakes, manifest tests, README, and existing enhancement tests for default, explicit, invalid selection, and unsupported host. +- Package and check the companion against the revised host. Record the exact SDK pin and companion revision. +- Recheck the live companion PR before delivery; do not assume historical PR #7 is still open or the latest delivery target. + +## Out of scope + +- General agent execution, cwd, session APIs, profile eligibility changes, and unrelated UI redesign. +- Work owned by other tasks. Do not revert their changes or the prior hydration and Windows CI fixes. + +## Acceptance + +- Notes sends a persisted override with its enhancement prompt, while empty preference uses the platform default. +- Invalid selection and configuration errors never silently switch profiles. +- Companion tests and package smoke pass against the recorded host revision; deployment dependencies are explicit. + +## Verification + +Use TDD for changed logic. Commands below run from the host repository root unless stated otherwise. + +```bash +# Run from the Notes repository root, not the Kandev monorepo. +go test ./... +node --test ui/bundle.test.mjs +make vet +make package-host +git diff --check +``` + +The verified Notes README expects a sibling checkout named kandev for its local SDK replace. +Use a separate clean checkout at the implemented host revision for packaging if the current task path cannot satisfy that layout. +For the package smoke, install the resulting host-platform tarball into an isolated revised host. +Exercise Enhance with AI using default A, explicit B, and deleted B. Record host/plugin revisions and outcomes; never use real notes or paid agents. + +## Files likely touched + +- `yattdev/kandev-plugin-notes: server/plugin.go` +- `yattdev/kandev-plugin-notes: server/plugin_test.go` +- `yattdev/kandev-plugin-notes: server/manifest_test.go` +- `yattdev/kandev-plugin-notes: manifest.yaml` +- `yattdev/kandev-plugin-notes: go.mod` +- `yattdev/kandev-plugin-notes: go.sum` +- `yattdev/kandev-plugin-notes: README.md` + +## Dependencies + +02-caller-integration. + +## Risks + +- This task requires a separate repository and a buildable SDK pin. It must not be represented as completed by host CI. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirement](../../specs/plugins/requirements/plugin-explicit-utility-invocation.md) and [design](../../specs/plugins/system-design/plugin-explicit-utility-invocation.md). +- [Plan](plan.md), including compatibility rules, evidence matrix, and delivery instructions. +- Existing SDK wire tests, host utility tests, and plugin-fixture patterns. Read changed source and local AGENTS.md before editing. + +## Results + +Implemented in the separate `yattdev/kandev-plugin-notes` checkout. The Notes +Go suite passed, all 96 UI tests passed, `make vet` passed, `make package-host` +passed, and `git diff --check` passed. Companion commit `fcb1ab2f6d126e91a0e8dd7a823c1b9a3977f07f` +is ready locally and was built against host commit +`eb9cfceda75cdc51c40efa289018e1a6f008d04a`. Delivery to PR #7 is blocked: +the PR has `maintainerCanModify: false`, and the SSH push was rejected because +the authenticated account `carlosflorencio` has no write access to +`yattdev/kandev-plugin-notes`. diff --git a/docs/plans/plugins/GRPC-CONTRACT.md b/docs/plans/plugins/GRPC-CONTRACT.md index 1588e98278..4b2aefecd7 100644 --- a/docs/plans/plugins/GRPC-CONTRACT.md +++ b/docs/plans/plugins/GRPC-CONTRACT.md @@ -62,6 +62,11 @@ service Host { rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse); rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse); + // Empty profile_id delegates to the platform default. A non-empty value + // selects that exact eligible profile. This separate method prevents an + // older host from silently ignoring an explicit selection. + rpc InvokeUtilityAgentWithOptions(InvokeUtilityAgentWithOptionsRequest) returns (InvokeUtilityAgentResponse); + // The plugin's own operator-editable config (Settings > Plugins > , // driven by the manifest's config_schema). Ungated; secret values arrive // in cleartext — this RPC is how an operator-configured credential (e.g. a @@ -184,12 +189,29 @@ message RevealSecretResponse { string value = 1; } message EmitEventRequest { string event_name = 1; google.protobuf.Struct payload = 2; } message EmitEventResponse {} +message InvokeUtilityAgentRequest { string prompt = 1; } +message InvokeUtilityAgentWithOptionsRequest { + string prompt = 1; + string profile_id = 2; +} +message InvokeUtilityAgentResponse { string text = 1; } ``` Notes: scope ∈ instance|workspace|task|agent (empty scope_id for instance — matches the state store). The plugin never passes its own id; the Host service instance is bound to the plugin's record at spawn time. +`InvokeUtilityAgent` remains the prompt-only compatibility method. On a revised +host it uses the platform default profile from Settings > Utility Agents. +`InvokeUtilityAgentWithOptions` accepts the same prompt plus an optional +`profile_id`; a non-empty ID selects that exact eligible profile and an invalid +explicit ID returns `FailedPrecondition` without fallback. The revised SDK +uses the options RPC for every call, including calls without an override. An +older host returns `Unimplemented` for that method, and the SDK does not retry +through the prompt-only RPC. Plugins own saved preferences and pass them in +the request. The host does not read plugin configuration, utility-agent +records, or transition metadata for invocation selection. + `DeletePluginOwnedTaskTree` is partial-progress aware. A successful response carries every deleted task ID. If deletion stops after removing descendants, the non-OK status includes a `DeletePluginOwnedTaskTreeProgress` detail with diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index f97719ee4b..7df218b6d0 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -312,7 +312,7 @@ of truth and must be updated together when the contract changes: - Wire contract: apps/backend/proto/kandev/plugin/v1/plugin.proto. - Manifest model and semantic validation: apps/backend/internal/plugins/manifest. - Package integrity and installation: apps/backend/internal/plugins/pkgtar. -- Durable decisions: [ADR 0043](../decisions/0043-plugin-host-data-api.md), [ADR 0047](../decisions/0047-plugin-host-conversation-reads.md), [ADR 0048](../decisions/0048-plugin-host-utility-agent-invoke.md), and [ADR 0050](../decisions/0050-plugin-external-auth-capability.md). +- Durable decisions: [ADR 0043](../decisions/0043-plugin-host-data-api.md), [ADR 0047](../decisions/0047-plugin-host-conversation-reads.md), [explicit plugin utility selection](../decisions/2026-09-14-explicit-plugin-utility-selection.md), and [ADR 0050](../decisions/0050-plugin-external-auth-capability.md). ## Frontend contract @@ -625,7 +625,7 @@ subscription vocabulary and wildcard rules are in the | Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin: author | | Interactions | Interactions().ListPending, Interactions().Get | api_read: interactions | Durable record of agent requests still owed a human answer; Get resolves resolved ones too | | Interaction responses | Interactions().RespondToPermission, .AnswerClarification, .CancelClarification | api_write: interactions | Routed through the services the native UI drives; first terminal response wins | -| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition | +| Agent invocation | InvokeUtilityAgent(ctx, prompt, options...) | agent_invoke: true | One-shot completion. No options, or an empty ProfileID, uses the current platform default. A non-empty ProfileID selects that exact eligible profile. An invalid explicit profile returns FailedPrecondition without fallback. The host does not read plugin configuration for selection. | The Go signatures, filters, DTOs, and pagination types live in apps/backend/pkg/pluginsdk/host.go and data_types.go. api_write task/message @@ -732,6 +732,10 @@ side effect. A plugin calls back into kandev through the injected `Host`: ```go +type UtilityAgentOptions struct { + ProfileID string +} + type Host interface { GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error) SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error @@ -772,10 +776,10 @@ type Host interface { // delivers a user prompt through the task session (api_write:messages). Messages() MessageReader - // InvokeUtilityAgent runs a one-shot completion using this plugin's - // selected utility agent (capability agent_invoke). No API key of your - // own; FailedPrecondition when no valid enabled agent is selected. - InvokeUtilityAgent(ctx context.Context, prompt string) (string, error) + // InvokeUtilityAgent runs a one-shot completion. No options uses the + // platform default. UtilityAgentOptions.ProfileID selects one eligible + // profile for this call (capability agent_invoke). + InvokeUtilityAgent(ctx context.Context, prompt string, options ...UtilityAgentOptions) (string, error) } ``` @@ -894,9 +898,20 @@ first" apart from "my cached id is stale". Do not retry a `FailedPrecondition`. `host.InvokeUtilityAgent(ctx, prompt)` runs a one-shot, non-interactive LLM -completion using the utility agent selected for this plugin in **Settings > -Plugins > ``** (capability `agent_invoke`), and returns its text. Declare -the selector in `manifest.yaml`: +completion using the platform default profile configured in **Settings > +Utility Agents** (capability `agent_invoke`), and returns its text. A plugin can +pass an explicit profile for one call: + +```go +text, err := host.InvokeUtilityAgent(ctx, prompt, pluginsdk.UtilityAgentOptions{ + ProfileID: savedProfileID, +}) +``` + +The plugin owns persistence of `savedProfileID`. An empty or unset preference +should omit the override or pass an empty ProfileID. The host does not inspect +the plugin's configuration to choose a profile. Declare an optional profile +field when the plugin wants the standard Settings picker: ```yaml capabilities: @@ -905,23 +920,26 @@ capabilities: config_schema: type: object properties: - utility_agent: + agent_profile: type: string - format: utility-agent - title: Utility Agent - description: Agent used for this plugin's LLM calls - required: ["utility_agent"] + format: agent-profile + title: Utility agent profile + description: Optional profile used for this plugin's LLM calls ``` -The picker displays configured built-in and custom agent names but stores the -selected agent's stable ID. Omit `utility_agent` from `required` only when the -plugin supports operating without LLM delegation; optional selectors include a -**Not set** choice. The plugin needs no provider API key because it delegates to -a kandev-configured agent. A missing, deleted, or disabled selection returns -gRPC `FailedPrecondition`, so handle that as "ask the operator to configure -one" rather than a transient failure. This is the LLM step behind, e.g., a -"summarize yesterday" plugin: read the conversation with `host.Messages()`, -then summarize it with `host.InvokeUtilityAgent(...)`. +The picker displays eligible global agent profiles and stores the selected +profile's stable ID. The field is ordinary plugin configuration; read it with +`host.GetConfig` and pass the value explicitly. A missing, deleted, disabled, +CLI-passthrough, workspace-scoped, or non-inference explicit profile returns +gRPC `FailedPrecondition`. The host does not switch to the default after an +invalid explicit value. A revised SDK calls a separate wire method for the +override, so an older host returns `Unimplemented` instead of silently +ignoring the selection. Existing prompt-only wire callers continue to use the +platform default on a revised host. The plugin needs no provider API key +because it delegates to a kandev-configured profile. This is the LLM step +behind, for example, a "summarize yesterday" plugin: read the conversation +with `host.Messages()`, then summarize it with +`host.InvokeUtilityAgent(ctx, prompt, options...)`. **Capability gating.** Every Host RPC except `GetConfig` and `EmitEvent` is checked against your manifest's `capabilities` before the handler runs: diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index ff11ef219a..a3c46bf922 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -100,14 +100,14 @@ config_schema: default_channel: { type: string, description: "Default channel for notifications" } notify_on_task_created: { type: boolean, default: true } - utility_agent: + agent_profile: { type: string, - format: utility-agent, - title: "Utility Agent", - description: "Agent used for plugin LLM calls", + format: agent-profile, + title: "Utility agent profile", + description: "Optional profile used for plugin LLM calls", } - required: ["bot_token", "default_channel", "utility_agent"] + required: ["bot_token", "default_channel"] agent_tools: - name: add_tag @@ -206,7 +206,7 @@ for a complete authoring path. | `capabilities.api_write` | no | string[] | Gates Host writes independently of `api_read`. `tasks` permits `Host.Tasks().Create` and `.Update`; `messages` permits `Host.Messages().Send`. Undeclared writes return gRPC `PermissionDenied`. | | `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | | `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | -| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent`: a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. The selected utility agent resolves its own enabled ACP profile. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid profile-backed selection returns gRPC `FailedPrecondition`. See ADR 0048. | +| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent`, a one-shot completion. No options, or an empty `UtilityAgentOptions.ProfileID`, uses the current default profile from Settings > Utility Agents. A non-empty profile ID selects that exact eligible global, enabled, non-CLI profile whose agent supports sessionless inference. The host does not read plugin configuration or utility-agent records for selection. Calling without this capability returns gRPC `PermissionDenied`; a missing, deleted, disabled, CLI-passthrough, workspace-scoped, or non-inference explicit profile returns gRPC `FailedPrecondition` without fallback. See [explicit plugin utility selection](../decisions/2026-09-14-explicit-plugin-utility-selection.md). | | `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie, so the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject; a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. | | `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required; every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. | | `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. | @@ -361,6 +361,13 @@ persisting: but persists the selected agent's stable ID. Add the property to `required` when the plugin must always have a selection; optional fields include a **Not set** choice. +- A string property with `format: agent-profile` is rendered as a picker of + enabled, global, non-CLI agent profiles whose agent supports sessionless + inference. The UI stores the stable profile ID and keeps a stale saved value + visible but unavailable. The property is ordinary plugin configuration. The + plugin must pass a selected value through `UtilityAgentOptions.ProfileID`; an + empty value delegates to the platform default. The host does not infer + execution from the property's name or from any `utility-agent` property. - A property with `secret: true`, or `format: "password"`, is treated as a **secret field** and must be `type: string` (or untyped); a non-string secret is rejected. Secret values are moved into kandev's encrypted vault; diff --git a/docs/screenshots/pr-validation/2870/agent-profile-picker-desktop.png b/docs/screenshots/pr-validation/2870/agent-profile-picker-desktop.png new file mode 100644 index 0000000000..d815941372 Binary files /dev/null and b/docs/screenshots/pr-validation/2870/agent-profile-picker-desktop.png differ diff --git a/docs/screenshots/pr-validation/2870/agent-profile-settings-mobile.png b/docs/screenshots/pr-validation/2870/agent-profile-settings-mobile.png new file mode 100644 index 0000000000..8cfae9cd23 Binary files /dev/null and b/docs/screenshots/pr-validation/2870/agent-profile-settings-mobile.png differ diff --git a/docs/screenshots/pr-validation/2870/notes-enhance-preview-desktop.png b/docs/screenshots/pr-validation/2870/notes-enhance-preview-desktop.png new file mode 100644 index 0000000000..62a9b38064 Binary files /dev/null and b/docs/screenshots/pr-validation/2870/notes-enhance-preview-desktop.png differ diff --git a/docs/specs/agents/system-design/utility-agent-profiles.md b/docs/specs/agents/system-design/utility-agent-profiles.md index d942aa8805..6a7de70cea 100644 --- a/docs/specs/agents/system-design/utility-agent-profiles.md +++ b/docs/specs/agents/system-design/utility-agent-profiles.md @@ -133,8 +133,10 @@ authoritative after the migration. - `POST /api/v1/utility/execute` keeps its request shape: callers select a utility-agent ID, and the backend resolves the effective profile. Successful responses and call-history responses keep the resolved model and expose logical and concrete profile attribution in call history. -- Plugin `Host.InvokeUtilityAgent` and its plugin manifest selector keep selecting a utility-agent - ID; there is no plugin-facing profile-ID field. +- Plugin `Host.InvokeUtilityAgent` accepts an optional profile ID per call. An empty ID uses the + platform default. The plugin owns any saved preference and passes it explicitly; plugin + configuration is not read by the host for execution selection. Ordinary + `/utility/execute` callers continue to select a utility-agent ID. ## Permissions diff --git a/docs/specs/plugins/requirements/plugin-direct-profile-invocation.md b/docs/specs/plugins/requirements/plugin-direct-profile-invocation.md new file mode 100644 index 0000000000..a5ccbabcde --- /dev/null +++ b/docs/specs/plugins/requirements/plugin-direct-profile-invocation.md @@ -0,0 +1,102 @@ +--- +status: deprecated +system: plugins +created: 2026-09-14 +owners: + - kandev +--- +# Plugin Direct Agent Profile Invocation Requirements + +> Superseded by [explicit invocation requirements](plugin-explicit-utility-invocation.md). +> This document records the previous contract; do not implement its implicit routing. + +## Overview + +Plugins performing a one-shot LLM step previously chose execution identity by +selecting a utility agent, which forced them to route through a secondary +configurable object and hid the actual agent profile that would run. Users +need to select the platform agent profile directly from a plugin's settings so +the profile that executes is the profile they configured, while existing +plugins that still declare the legacy `utility_agent` selector keep working. +The plugin system owns the Host invocation contract and its configuration +state; the agents system continues to own profile eligibility and utility +execution resolution. + +## Terminology + +- **Direct profile selection:** a plugin `config_schema` field with + `type: string` and `format: agent-profile` whose stored value is a stable + agent-profile ID executed directly by the host. +- **Legacy utility-agent selection:** an existing `config_schema` field with + `format: utility-agent` whose stored value resolves through the utility + agent catalog, unchanged for plugins that still declare it. +- **Eligibility:** enabled, non-deleted, global, non-CLI-passthrough profiles + whose agent supports sessionless inference; the backend remains + authoritative. + +## Requirements + +### REQ-PLUGINS-DIRECT-PROFILE-INVOCATION-001: Direct agent profile invocation for plugins + +**Intent:** Let a plugin invoke one executable agent profile of the +operator's choosing without inventing a custom utility prompt, while keeping +the public Host RPC, SDK, and capability gate source-compatible and +preserving installed legacy configurations. + +**User story:** As a Kandev operator configuring a plugin such as Notes, I +want to pick the agent profile that performs the plugin's LLM step directly +in the plugin's settings, so that the profile I configured is the profile +that executes and I do not have to maintain a separate custom utility +prompt. + +#### Acceptance criteria + +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.1:** When a plugin manifest + declares a `config_schema` field with `format: agent-profile`, Settings > + Plugins shall list only eligible platform agent profiles and shall not list + utility or custom-prompt rows, so built-in and custom utility prompts with + the same or different IDs cannot be confused with executable profiles. +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.2:** When the user selects a + profile, the plugin configuration shall persist that profile's stable ID + through the shared settings save path, the contributor shall become dirty + and save successfully, and a reload shall again show the selected profile's + display label. +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.3:** When a profile is disabled, + deleted, workspace-scoped, or CLI-passthrough, the picker shall exclude it + from new selections and the settings surface shall represent a previously + saved unavailable selection safely and replaceably rather than failing + render. +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.4:** When a plugin with + `capabilities.agent_invoke: true` invokes `Host.InvokeUtilityAgent` with a + non-empty direct `agent_profile` configuration, the host shall route the + prompt to `ExecuteProfilePrompt` using that exact profile ID and shall not + resolve or execute a utility or custom-prompt definition. +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.5:** When the direct profile + selection is missing, or the selected profile is missing or ineligible at + execution time, the host shall return a typed gRPC `FailedPrecondition` + that plugins can translate into an actionable configuration message rather + than a generic execution failure. +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.6:** When a plugin's + configuration holds both a direct profile and a legacy `utility_agent` + value, the direct profile shall take precedence; when only the legacy value + exists and the active manifest declares that selector, the existing + utility-agent resolution and execution behavior shall remain unchanged. +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.7:** A plugin that does not + declare `capabilities.agent_invoke` shall be denied before any profile or + configuration lookup, and operational runner failures that occur after + successful configuration validation shall remain execution errors instead + of being misreported as configuration failures. +- **AC-PLUGINS-DIRECT-PROFILE-INVOCATION-001.8:** The plugin manifest and its + contract tests shall declare the agent-profile-backed setting with a + pinned field name and format, and the plugin's package metadata shall stay + internally consistent. + +## Out of scope + +- Changing the public `Host.InvokeUtilityAgent(ctx, prompt)` RPC or SDK + signature, or the `agent_invoke` capability-gate semantics. +- Adding new config migration; a database or schema migration is not + required for the dual-format selection. +- Changing utility-agent resolution for plugins that continue to declare + only the legacy `utility_agent` selector. +- Changing agent-profile eligibility rules owned by the agents system. diff --git a/docs/specs/plugins/requirements/plugin-explicit-utility-invocation.md b/docs/specs/plugins/requirements/plugin-explicit-utility-invocation.md new file mode 100644 index 0000000000..52a25044b2 --- /dev/null +++ b/docs/specs/plugins/requirements/plugin-explicit-utility-invocation.md @@ -0,0 +1,50 @@ +--- +status: active +system: plugins +created: 2026-09-14 +owners: + - kandev +--- + +# Explicit Plugin Utility Invocation Requirements + +## Overview + +The plugin system owns the public Host invocation contract. Agent settings own profiles and the platform default utility profile. +A plugin can save a preferred profile in its configuration. It must pass that preference explicitly when it requests a completion. +These requirements replace REQ-PLUGINS-DIRECT-PROFILE-INVOCATION-001. The user accepted this direction after review of PR #2870. +The implementation and verification are recorded in the [implementation plan](../../../plans/plugin-explicit-utility-invocation/plan.md). + +## Terminology + +- **Default profile:** the profile selected in Settings > Utility Agents under Default utility agent model. +- **Override:** an agent-profile ID supplied in a single invocation. It is not a utility-agent record ID. +- **Plugin preference:** a profile ID saved by the plugin through the existing configuration service. +- **Utility completion:** one prompt and one text response, without a task session or caller-selected working directory. + +## Requirements + +### REQ-PLUGINS-EXPLICIT-UTILITY-001: Explicit utility execution selection + +**Intent:** Make execution selection visible in the invocation contract and keep plugin configuration ownership with the caller. + +#### Acceptance criteria + +- **AC-PLUGINS-EXPLICIT-UTILITY-001.1:** When the caller omits an override or supplies an empty profile ID, the host shall use the current default profile. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.2:** When the caller supplies an eligible profile ID, the host shall use that exact profile, independent of the default. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.3:** Plugin configuration, field names, manifest selectors, and legacy transition metadata shall not determine invocation selection. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.4:** A missing or ineligible explicit override shall return FailedPrecondition without executing a different profile. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.5:** An unset, missing, or ineligible default shall return FailedPrecondition when the caller omits an override. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.6:** Without agent_invoke permission, the call shall fail before settings access, profile lookup, or execution. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.7:** Cancellation and operational failures shall remain distinct from configuration failures. No failure shall trigger implicit profile fallback. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.8:** Changes to the default shall affect subsequent default invocations. A default change shall not redirect an explicit override or an invocation already dispatched. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.9:** The plugin shall read its saved preference and pass it explicitly. Saving, clearing, and reloading a preference shall preserve that behavior. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.10:** A host that cannot honor the explicit selection contract shall reject the new client call before execution. It shall not silently ignore an override. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.11:** Existing prompt-only clients on the revised host shall use the platform default. Migration documentation shall state this behavior change. +- **AC-PLUGINS-EXPLICIT-UTILITY-001.12:** Invocation shall remain a single text completion. It shall not create a task, session, or worktree, or accept a caller-selected working directory. + +## Out of scope + +- General agent execution, cwd, streaming, tools, session lifecycle, and arbitrary execution options. +- Changes to eligibility: profiles remain enabled, global, non-CLI-passthrough, and explicitly inference-capable. +- UI redesign, new configuration storage, automatic conversion of utility-agent record IDs into profile IDs, or new capability grants. diff --git a/docs/specs/plugins/system-design/plugin-direct-profile-invocation.md b/docs/specs/plugins/system-design/plugin-direct-profile-invocation.md new file mode 100644 index 0000000000..6ba2c87668 --- /dev/null +++ b/docs/specs/plugins/system-design/plugin-direct-profile-invocation.md @@ -0,0 +1,110 @@ +--- +status: superseded +system: plugins +requirements: + - REQ-PLUGINS-DIRECT-PROFILE-INVOCATION-001 +created: 2026-09-14 +owners: + - kandev +--- +# Plugin Direct Agent Profile Invocation System Design + +> Superseded by [explicit invocation design](plugin-explicit-utility-invocation.md). +> Retained as historical context for PR #2870. + +## Purpose and boundaries + +The plugin system owns the `Host.InvokeUtilityAgent` contract and the +plugin-configuration state that selects its execution identity. This design +adds a direct agent-profile selection path beside the legacy utility-agent +selection, without changing the public RPC, SDK, or capability gate. The +agents system continues to own profile eligibility and the utility execution +resolution described by +[Profile-backed Utility Agents](../../agents/system-design/utility-agent-profiles.md). +The UI system's shared settings-save coordinator and hydration contracts +remain independent dependencies, as does +[ADR 0048](../../../decisions/0048-plugin-host-utility-agent-invoke.md) for +the invocation precedence rule. + +## Requirement mapping + +| Requirement | Design sections | +| --- | --- | +| `REQ-PLUGINS-DIRECT-PROFILE-INVOCATION-001` | [Configuration selection](#configuration-selection), [Host execution routing](#host-execution-routing), [Eligibility and error translation](#eligibility-and-error-translation), [Legacy compatibility](#legacy-compatibility) | + +## Configuration selection + +`apps/web/lib/plugins/config-schema.ts` adds the `agent-profile` renderer +format beside the existing `utility-agent` format. Settings > Plugins renders +the field through the shared utility-agent profile picker component with +`agentRegistration` data, which exposes each platform profile's name, label, +workspace scope, CLI passthrough, and `inference_capable` flag. + +Eligibility filtering is `utility-execution` scoped: a profile is selectable +only when it is enabled, non-deleted, global (not workspace- or +Office-scoped), not CLI passthrough, and its agent declares +`inference_capable === true`. The picker does not fall back to matching +platform profiles against utility or custom-prompt catalog rows: rows from +`/api/v1/utility/agents` are never shown in the `agent-profile` field. +Unknown or ineligible saved IDs render through the picker's unavailable +option so the stored value stays visible-and-replaceable. + +File-field hydration (`plugin-config-form.tsx`) merges its backend profile +response with the current store snapshot instead of replacing the record, so +plugin config hydration and independent profile-list updates do not clobber +each other. Store-side profile-list hydration in +`use-settings-data.ts`/`hydrator.ts` compares an incoming profile snapshot +against what is already loaded and does not overwrite a higher store +revision; a WebSocket profile update triggers a retry when the list render +would otherwise go stale. + +## Host execution routing + +`internal/plugins/host_utility.go` resolves the invocation identity at the +start of each call, before any execution: + +1. The `agent_invoke` capability gate runs first; a plugin without it is + denied before configuration or profile lookup. +2. If the manifest's `config_schema` declares a field with + `format: agent-profile` and the stored value is non-empty, that value is + the direct profile ID. The host validates the declared format first, then + resolves the profile through the agent registry: missing or ineligible + profiles return typed `FailedPrecondition` before any runner starts, so + configuration failure and operational failure remain distinct classes. +3. With a resolved eligible profile, the host calls + `ExecuteProfilePrompt(profileID, prompt)` directly on the host-utility + tier. No utility or custom-prompt definition is looked up or executed in + this path. +4. Legacy `utility_agent` routing is consulted only when no direct profile is + selected, the active manifest declares the `utility-agent` format, and the + stored legacy value passes the existing trusted-resolution checks. Its + behavior is otherwise unchanged. + +`internal/backendapp/services.go` adapts the host-utility manager's typed +revalidation errors to gRPC codes: not-found/missing selection and +ineligibility surface as `FailedPrecondition`; store failures other than +not-found are returned unchanged so they remain server-side errors rather +than misleading the plugin's configuration messaging. + +## Eligibility and error translation + +The Notes webhook and other plugin callers translate `FailedPrecondition` +into an actionable "select an agent profile" setup message (HTTP 412) and +leave other codes (for example `Internal`) as execution failures (HTTP 502). +Service wiring in `services.go` builds the agent-profile provider from the +same registration store used by the settings API, so the picker and the +execution path judge eligibility with one source of truth. Renaming or +reflagging a profile revalidates at the next invocation; an in-flight call +keeps the configuration resolved when it started. + +## Legacy compatibility + +Manifests that keep only `format: utility-agent` continue to render the +utility-agent picker and persist utility prompt IDs; their stored values +resolve and execute exactly as before this change. A plugin updating its +manifest from `utility-agent` to `agent-profile` keeps its legacy value +until the operator saves a direct selection, and +`docs/decisions/0048-plugin-host-utility-agent-invoke.md` documents the +verified-transition rule that bounds the legacy fallback to trusted +pre-transition configurations. No config migration runs; both selector +formats coexist in stored plugin configuration. diff --git a/docs/specs/plugins/system-design/plugin-explicit-utility-invocation.md b/docs/specs/plugins/system-design/plugin-explicit-utility-invocation.md new file mode 100644 index 0000000000..35ff1875fc --- /dev/null +++ b/docs/specs/plugins/system-design/plugin-explicit-utility-invocation.md @@ -0,0 +1,128 @@ +--- +status: current +system: plugins +requirements: + - REQ-PLUGINS-EXPLICIT-UTILITY-001 +created: 2026-09-14 +owners: + - kandev +--- + +# Explicit Plugin Utility Invocation System Design + +## Purpose and boundaries + +This document defines the replacement for the configuration-driven routing in PR #2870 and its implementation boundary. +The plugin Host boundary owns dispatch. The user settings service owns the default. The plugin owns its saved preference. +The prior [design](plugin-direct-profile-invocation.md) is superseded. + +## Requirement mapping + +| Criteria | Design section | +| --- | --- | +| .1, .2, .3, .8 | Resolution and ownership | +| .4, .5, .6, .7 | Errors and permissions | +| .9 | Plugin configuration lifecycle | +| .10, .11 | SDK and wire compatibility | +| .12 | Execution boundary | + +All suffixes refer to REQ-PLUGINS-EXPLICIT-UTILITY-001 and its acceptance criteria. + +## SDK and wire compatibility + +Use this public Go shape. Names are prescribed for this package. + +```go +type UtilityAgentOptions struct { + ProfileID string +} + +InvokeUtilityAgent(ctx context.Context, prompt string, options ...UtilityAgentOptions) (string, error) +``` + +Zero option values and one value with an empty ProfileID select the default. One non-empty ProfileID selects an override. +Reject more than one options value with InvalidArgument. Do not implement last-value-wins behavior or normalize non-empty IDs to empty. +An existing two-argument call still compiles. Implementations of the Host interface and test doubles must adopt the new signature. +Keep prompt and text response behavior unchanged. + +Add the wire RPC `InvokeUtilityAgentWithOptions` with a new `InvokeUtilityAgentWithOptionsRequest` message. +Use `string prompt = 1` and `string profile_id = 2`. Reuse `InvokeUtilityAgentResponse`. +Every call from the revised SDK uses this RPC, including calls without options. +An older host returns Unimplemented; the SDK must not retry with the old RPC. +Adding only a field to the existing request is unsafe: an old host can ignore it and execute its configured profile. +The public SDK still has one method named InvokeUtilityAgent; the separate RPC establishes transport compatibility, not a second product API. + +Retain the old prompt-only RPC for existing binaries. On the revised host it delegates to the new implementation without an override. +It uses the platform default even if the plugin has saved agent_profile, utility_agent, or legacy transition metadata. +This preserves wire availability but intentionally changes old configuration-based semantics. Document that change explicitly. +Use the existing proto generation target; never hand-edit generated Go files. + +## Resolution and ownership + +1. Check agent_invoke before any dependency lookup with side effects. +2. Take ProfileID from the invocation options. +3. If it is empty, call a narrow default-profile source backed by userservice.Service.GetDefaultUtilityAgentProfileID(ctx). +4. Validate the chosen ID with the existing profilebinding resolver through pluginsAgentProfileAdapter. +5. Call pluginsHostUtilityAdapter.ExecuteProfilePrompt with that ID and the unchanged prompt. +6. Return the response text or the classified error. + +Do not read plugin configuration, config_schema, utility-agent catalog records, or legacy fallback markers in this flow. +Do not read the default source at all for an explicit override. Read the current default once for each default invocation. +Use the existing settings service identity rules and request context; do not invent a plugin-specific default or a browser-user cache. +A later default change affects the next call. The hostutility manager resolves the selected profile before dispatch and revalidates eligibility. + +Keep the narrow interfaces in internal/plugins. Wire the default source, existing profile source, and runner in backendapp/main.go and services.go. +Preserve the mutex-protected late dependency accessor in Service: boot-active plugin hosts must see dependencies wired after startup. +Do not import internal/agent/runtime or the user service implementation into internal/plugins. + +## Errors and permissions + +PermissionDenied precedes settings and profile access. More than one options value returns InvalidArgument without execution. +An empty default or missing/ineligible selected profile returns FailedPrecondition. Do not substitute any other profile. +An explicit valid override works even when the default is unset or its storage read would fail. +Preserve cancellation, deadlines, storage failures, and runner failures as operational errors through both gRPC adapters. +The default getter must propagate storage failures. It must not convert them into an empty default. +The runner retains the existing final profile eligibility check. No retry changes execution identity. + +## Plugin configuration lifecycle + +Keep the generic format: agent-profile picker and its Save/Discard flow. The field is ordinary plugin data. +It must work regardless of the property name used by a plugin. A manifest selector is not an execution grant. +A plugin that supports the default makes its selection optional. An absent or empty preference means no override. +For Notes, read agent_profile through Host.GetConfig, validate its type, and pass it as UtilityAgentOptions.ProfileID. +A configuration read or type error must stop the request, not silently use the default. +The existing plugin restart after Save provides refreshed configuration. Reading GetConfig on each invocation is the simplest implementation. +Keep the existing unavailable-selection display and the profile hydration fixes from this PR. +No new host UI controls or copy are required for the monorepo change. + +## Persistence and legacy removal + +There is no new database, default setting, or plugin configuration key owned by the host invocation API. +Remove the invocation-only UtilityAgent source and backend adapter once no caller needs them. +Remove LegacyUtilityAgentFallback production routing, installation stamping, host capture, and hidden-value preservation introduced for this PR. +Old serialized marker fields must not cause a store reload failure. Ignore them without restoring routing authority. +Do not delete arbitrary user configuration files or migrate stored utility IDs into profile IDs. +Retain generic utility-agent schema rendering for other consumers. Remove only obsolete invocation-specific helpers and tests. +Inspect existing schemas and serialized fixtures before removing fields so the normal store decoder remains compatible. + +## Execution boundary + +Reuse hostutility.Manager.ExecuteProfilePrompt. It owns the existing inference runtime and its working directory. +Profile settings still control the model and execution configuration. The plugin supplies neither cwd nor a task/session identifier. +General profile execution is a separate future API design. Do not add speculative fields for it here. + +## Delivery and diagnostics + +Update SDK comments, public authoring examples, manifest reference, and protocol documentation together with implementation. +Explain default, override, invalid override, and old-host rejection. Do not log prompts or complete plugin configuration. +Tests must distinguish selected profiles through runner arguments or test-only execution evidence, not only echoed request values. +The dedicated Notes repository is a separate delivery dependency. Host E2E coverage must use the in-tree fixture and must not depend on Notes installation. + +## Related decisions + +- [Explicit invocation decision](../../../decisions/2026-09-14-explicit-plugin-utility-selection.md) +- [Host utility execution tier](../../../decisions/0002-host-utility-agentctl-for-sessionless-flows.md) + +## Implementation plans + +- [Replacement work package](../../../plans/plugin-explicit-utility-invocation/plan.md) diff --git a/docs/specs/plugins/system-design/plugins-02.md b/docs/specs/plugins/system-design/plugins-02.md index 6341ab3b7a..9288639b76 100644 --- a/docs/specs/plugins/system-design/plugins-02.md +++ b/docs/specs/plugins/system-design/plugins-02.md @@ -197,6 +197,7 @@ service Host { rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse); rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse); rpc InvokeUtilityAgent(InvokeUtilityAgentRequest) returns (InvokeUtilityAgentResponse); + rpc InvokeUtilityAgentWithOptions(InvokeUtilityAgentWithOptionsRequest) returns (InvokeUtilityAgentResponse); } ``` @@ -210,21 +211,17 @@ service Host { - `EmitEvent(event_name, payload)` publishes `plugin..` on the internal event bus for delivery to subscribers (replaces the old `POST /api/plugins/{plugin_id}/events/emit` HTTP endpoint). -- `InvokeUtilityAgent(prompt)` runs a one-shot, non-interactive completion using - the utility agent selected in the plugin's `utility_agent` config field and - returns its text. Plugins declaring `capabilities.agent_invoke: true` must - declare that field in `config_schema` with `type: string` and - `format: utility-agent`; Settings > Plugins then renders a picker containing - the configured built-in and custom utility agents. The picker displays the - agent name and persists its stable ID. It reuses kandev's - sessionless host-utility inference tier (ADR 0002) — no task, session, or - workspace — so a plugin can delegate a lightweight LLM step without holding a - provider API key. Returns gRPC `FailedPrecondition` when no utility agent is - configured, selected agent was deleted, or it is disabled. See - [ADR 0048](../../../decisions/0048-plugin-host-utility-agent-invoke.md). The plugin does not select - an execution profile directly; the selected utility agent resolves its effective profile and - complete launch/permission policy according to - [Profile-backed Utility Agents](../../agents/requirements/utility-agent-profiles.md). +- `InvokeUtilityAgent(prompt)` runs a one-shot, non-interactive completion + using the platform default profile from Settings > Utility Agents. + `InvokeUtilityAgentWithOptions(prompt, profile_id)` uses the supplied + eligible profile when the ID is non-empty. An invalid explicit profile + returns `FailedPrecondition` without fallback. Plugin configuration is + caller-owned and is never read by the host for execution selection. Both + methods reuse the sessionless host-utility inference tier from ADR 0002. + The revised SDK uses the options method for every call, including default + calls, and returns `Unimplemented` from an older host without retrying the + prompt-only method. Existing prompt-only wire callers use the platform + default on a revised host. See [explicit plugin utility selection](../../../decisions/2026-09-14-explicit-plugin-utility-selection.md). Every Host RPC is capability-gated: `GetState`/`SetState`/`DeleteState`/`ListState` check `capabilities.state`, `RevealSecret` checks `capabilities.secrets`,