From cc33b2a813f243e620c4b43933492be321886b8e Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Tue, 15 Sep 2026 04:40:39 -0400 Subject: [PATCH] fix(plugins): support direct agent profile invocation (#2870) Co-authored-by: ayattara Co-authored-by: Carlos Florencio --- apps/backend/cmd/mock-agent/handler.go | 2 + apps/backend/cmd/mock-agent/main.go | 18 +- .../backend/cmd/mock-agent/mock_agent_test.go | 14 + .../cmd/mock-agent/session_config_test.go | 37 ++ .../fixture-package/manifest.yaml | 12 + .../plugin-fixture/fixture_package_test.go | 2 + apps/backend/cmd/plugin-fixture/main_test.go | 67 ++- apps/backend/cmd/plugin-fixture/plugin.go | 47 ++ .../agent/settings/controller/agent_crud.go | 8 +- .../agent/settings/controller/custom_tui.go | 4 +- .../agent/settings/controller/profile_crud.go | 5 +- .../internal/agent/settings/dto/dto.go | 7 +- .../agent/settings/handlers/handlers.go | 24 +- .../handlers/profile_crud_handlers_test.go | 32 + apps/backend/internal/backendapp/main.go | 20 +- apps/backend/internal/backendapp/services.go | 70 ++- .../services_plugin_utility_test.go | 137 +++-- .../mcp/handlers/config_agent_handlers.go | 15 +- .../handlers/config_agent_handlers_test.go | 64 ++ apps/backend/internal/plugins/config_test.go | 14 + apps/backend/internal/plugins/host.go | 20 +- .../internal/plugins/host_data_test.go | 68 ++- .../internal/plugins/host_data_wire_test.go | 30 +- apps/backend/internal/plugins/host_utility.go | 143 +++-- .../internal/plugins/host_utility_test.go | 278 +++++++-- .../internal/plugins/manifest/manifest.go | 4 +- apps/backend/internal/plugins/service.go | 37 +- .../internal/plugins/store/fs_store_test.go | 36 ++ apps/backend/pkg/pluginsdk/host.go | 42 +- apps/backend/pkg/pluginsdk/host_data_test.go | 19 +- .../pkg/pluginsdk/host_utility_wire_test.go | 76 +++ .../proto/kandev/plugin/v1/plugin.pb.go | 554 ++++++++++-------- .../proto/kandev/plugin/v1/plugin.proto | 18 +- .../proto/kandev/plugin/v1/plugin_grpc.pb.go | 122 ++-- .../plugin-config-form.agent-profile.test.tsx | 100 ++++ .../settings/plugins/plugin-config-form.tsx | 76 +++ .../utility-agent-profile-picker.test.ts | 41 ++ .../settings/utility-agent-profile-picker.tsx | 4 +- .../plugins/plugin-utility-invocation.spec.ts | 154 +++++ .../e2e/tests/task/plan-mode-followup.spec.ts | 13 +- .../settings/use-settings-data.test.tsx | 198 +++++-- .../domains/settings/use-settings-data.ts | 43 +- apps/web/lib/plugins/config-schema.test.ts | 23 + apps/web/lib/plugins/config-schema.ts | 4 +- apps/web/lib/state/hydration/hydrator.test.ts | 79 +++ apps/web/lib/state/hydration/hydrator.ts | 23 +- .../lib/state/slices/settings/types.test.ts | 2 + apps/web/lib/state/slices/settings/types.ts | 8 +- apps/web/lib/types/backend.ts | 2 + apps/web/lib/types/http-agents.ts | 2 + apps/web/lib/ws/handlers/agents.test.ts | 16 + apps/web/lib/ws/handlers/agents.ts | 96 ++- .../web/src/settings-routes.bootstrap.test.ts | 91 +++ apps/web/src/settings-routes.test.ts | 6 + apps/web/src/settings-routes.tsx | 56 +- .../0048-plugin-host-utility-agent-invoke.md | 52 +- ...09-14-explicit-plugin-utility-selection.md | 38 ++ .../plugin-direct-profile-invocation/plan.md | 126 ++++ .../task-01-direct-profile-invocation.md | 156 +++++ .../task-02-notes-companion-delivery.md | 104 ++++ .../plan.md | 134 +++++ .../task-01-host-contract.md | 110 ++++ .../task-02-caller-integration.md | 105 ++++ .../task-03-notes-companion.md | 104 ++++ docs/plans/plugins/GRPC-CONTRACT.md | 22 + docs/public/plugins-authoring.md | 64 +- docs/public/plugins-manifest.md | 19 +- .../2870/agent-profile-picker-desktop.png | Bin 0 -> 101404 bytes .../2870/agent-profile-settings-mobile.png | Bin 0 -> 50281 bytes .../2870/notes-enhance-preview-desktop.png | Bin 0 -> 87189 bytes .../system-design/utility-agent-profiles.md | 6 +- .../plugin-direct-profile-invocation.md | 102 ++++ .../plugin-explicit-utility-invocation.md | 50 ++ .../plugin-direct-profile-invocation.md | 110 ++++ .../plugin-explicit-utility-invocation.md | 128 ++++ .../specs/plugins/system-design/plugins-02.md | 27 +- 76 files changed, 3769 insertions(+), 771 deletions(-) create mode 100644 apps/backend/pkg/pluginsdk/host_utility_wire_test.go create mode 100644 apps/web/components/settings/plugins/plugin-config-form.agent-profile.test.tsx create mode 100644 apps/web/e2e/tests/plugins/plugin-utility-invocation.spec.ts create mode 100644 apps/web/src/settings-routes.bootstrap.test.ts create mode 100644 docs/decisions/2026-09-14-explicit-plugin-utility-selection.md create mode 100644 docs/plans/plugin-direct-profile-invocation/plan.md create mode 100644 docs/plans/plugin-direct-profile-invocation/task-01-direct-profile-invocation.md create mode 100644 docs/plans/plugin-direct-profile-invocation/task-02-notes-companion-delivery.md create mode 100644 docs/plans/plugin-explicit-utility-invocation/plan.md create mode 100644 docs/plans/plugin-explicit-utility-invocation/task-01-host-contract.md create mode 100644 docs/plans/plugin-explicit-utility-invocation/task-02-caller-integration.md create mode 100644 docs/plans/plugin-explicit-utility-invocation/task-03-notes-companion.md create mode 100644 docs/screenshots/pr-validation/2870/agent-profile-picker-desktop.png create mode 100644 docs/screenshots/pr-validation/2870/agent-profile-settings-mobile.png create mode 100644 docs/screenshots/pr-validation/2870/notes-enhance-preview-desktop.png create mode 100644 docs/specs/plugins/requirements/plugin-direct-profile-invocation.md create mode 100644 docs/specs/plugins/requirements/plugin-explicit-utility-invocation.md create mode 100644 docs/specs/plugins/system-design/plugin-direct-profile-invocation.md create mode 100644 docs/specs/plugins/system-design/plugin-explicit-utility-invocation.md 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 0000000000000000000000000000000000000000..d815941372396fd4f0621e1c70c44223becffade GIT binary patch literal 101404 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