diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 5d4fa6d4ce..5d150cde49 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -38,6 +38,8 @@ import ( taskservice "github.com/kandev/kandev/internal/task/service" usercontroller "github.com/kandev/kandev/internal/user/controller" userhandlers "github.com/kandev/kandev/internal/user/handlers" + utilitycontroller "github.com/kandev/kandev/internal/utility/controller" + utilityhandlers "github.com/kandev/kandev/internal/utility/handlers" workflowcontroller "github.com/kandev/kandev/internal/workflow/controller" workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers" ws "github.com/kandev/kandev/pkg/websocket" @@ -260,6 +262,7 @@ type routeParams struct { notificationCtrl *notificationcontroller.Controller editorCtrl *editorcontroller.Controller promptCtrl *promptcontroller.Controller + utilityCtrl *utilitycontroller.Controller msgCreator *messageCreatorAdapter secretsSvc *secrets.Service secretStore secrets.SecretStore @@ -326,6 +329,9 @@ func registerSecondaryRoutes( prompthandlers.RegisterRoutes(p.router, p.promptCtrl, p.log) p.log.Debug("Registered Prompts handlers (HTTP)") + utilityhandlers.RegisterRoutes(p.router, p.utilityCtrl, p.lifecycleMgr, p.services.User, p.log) + p.log.Debug("Registered Utility Agents handlers (HTTP)") + clarification.RegisterRoutes(p.router, clarificationStore, p.gateway.Hub, p.msgCreator, p.taskRepo, p.log) p.log.Debug("Registered Clarification handlers (HTTP)") diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index fd57880196..4ee17b40e2 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -39,6 +39,7 @@ import ( notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" usercontroller "github.com/kandev/kandev/internal/user/controller" + utilitycontroller "github.com/kandev/kandev/internal/utility/controller" // Orchestrator "github.com/kandev/kandev/internal/orchestrator" @@ -426,6 +427,7 @@ func buildHTTPServer( notificationCtrl: notificationCtrl, editorCtrl: editorcontroller.NewController(services.Editor), promptCtrl: promptcontroller.NewController(services.Prompts), + utilityCtrl: utilitycontroller.NewController(services.Utility), msgCreator: msgCreator, secretsSvc: secrets.NewService(repos.Secrets, log), secretStore: repos.Secrets, diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index a3db9710fc..764cdbc7bd 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -19,6 +19,7 @@ import ( taskmodels "github.com/kandev/kandev/internal/task/models" taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" + utilityservice "github.com/kandev/kandev/internal/utility/service" wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" ) @@ -36,6 +37,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories userSvc := userservice.NewService(repos.User, eventBus, log) editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc) promptSvc := promptservice.NewService(repos.Prompts) + utilitySvc := utilityservice.NewService(repos.Utility) workflowSvc := workflowservice.NewService(repos.Workflow, log) taskSvc := taskservice.NewService( taskservice.Repos{ @@ -84,6 +86,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories User: userSvc, Editor: editorSvc, Prompts: promptSvc, + Utility: utilitySvc, Workflow: workflowSvc, GitHub: githubSvc, // Notification service is initialized after gateway is available. diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index 9764d9c83e..c5b3a15add 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/persistence" "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/task/repository" + utilitystore "github.com/kandev/kandev/internal/utility/store" workflowrepository "github.com/kandev/kandev/internal/workflow/repository" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" @@ -81,6 +82,12 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep } cleanups = append(cleanups, cleanup) + utilityRepo, cleanup, err := utilitystore.Provide(writer, reader) + if err != nil { + return nil, nil, nil, err + } + cleanups = append(cleanups, cleanup) + // Initialize master key and secrets store kandevDir := filepath.Join(os.Getenv("HOME"), ".kandev") masterKeyProvider, err := secrets.NewMasterKeyProvider(kandevDir) @@ -102,6 +109,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*db.Pool, *Rep Notification: notificationRepo, Editor: editorRepo, Prompts: promptRepo, + Utility: utilityRepo, Workflow: workflowRepo, Secrets: secretStore, } diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 0a1a31dbf8..229972f3da 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -15,6 +15,8 @@ import ( taskservice "github.com/kandev/kandev/internal/task/service" userservice "github.com/kandev/kandev/internal/user/service" userstore "github.com/kandev/kandev/internal/user/store" + utilityservice "github.com/kandev/kandev/internal/utility/service" + utilitystore "github.com/kandev/kandev/internal/utility/store" workflowrepository "github.com/kandev/kandev/internal/workflow/repository" workflowservice "github.com/kandev/kandev/internal/workflow/service" ) @@ -27,6 +29,7 @@ type Repositories struct { Notification notificationstore.Repository Editor editorstore.Repository Prompts promptstore.Repository + Utility utilitystore.Repository Workflow *workflowrepository.Repository Secrets secrets.SecretStore } @@ -37,6 +40,7 @@ type Services struct { Editor *editorservice.Service Notification *notificationservice.Service Prompts *promptservice.Service + Utility *utilityservice.Service Workflow *workflowservice.Service GitHub *github.Service } diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index d353bfb3f9..75b393bfbf 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -50,9 +50,14 @@ type Agent interface { RemoteAuth() *RemoteAuth } -// InferenceAgent is an optional capability for agents that support direct LLM inference. +// InferenceAgent is an optional capability for agents that support one-shot LLM inference. +// Agents implementing this interface can execute single prompts without a persistent session. type InferenceAgent interface { - GenerateText(ctx context.Context, req InferenceRequest) (*InferenceResponse, error) + // InferenceConfig returns the configuration for one-shot inference. + InferenceConfig() *InferenceConfig + + // InferenceModels returns models available for inference (may be a subset of full models). + InferenceModels() []InferenceModel } // PassthroughAgent is an optional capability for agents that support CLI passthrough mode. @@ -80,6 +85,25 @@ type Model struct { Source string `json:"source,omitempty"` // "static" or "dynamic" } +// ToInferenceModel converts a Model to an InferenceModel. +func (m Model) ToInferenceModel() InferenceModel { + return InferenceModel{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + IsDefault: m.IsDefault, + } +} + +// ModelsToInferenceModels converts a slice of Models to InferenceModels. +func ModelsToInferenceModels(models []Model) []InferenceModel { + result := make([]InferenceModel, 0, len(models)) + for _, m := range models { + result = append(result, m.ToInferenceModel()) + } + return result +} + // ModelList is the result of listing models for an agent. type ModelList struct { Models []Model @@ -214,20 +238,27 @@ var DefaultResourceLimits = ResourceLimits{ MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour, } -// InferenceRequest for direct model inference. -type InferenceRequest struct { - Prompt string - Model string - MaxTokens int - Credentials map[string]string +// InferenceConfig describes how an agent executes one-shot prompts. +type InferenceConfig struct { + // Supported indicates the agent can do one-shot inference. + Supported bool + // Command is the CLI command for one-shot inference (e.g., ["claude", "--print"]). + // Uses the local CLI directly since agents must be installed/authenticated to work. + Command Command + // ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]). + ModelFlag Param + // OutputFormat describes how to parse the output: "text", "stream-json". + OutputFormat string + // StdinInput if true, prompt is sent via stdin; otherwise as a positional argument. + StdinInput bool } -// InferenceResponse from direct model inference. -type InferenceResponse struct { - Text string - Model string - TokensUsed int - FinishReason string +// InferenceModel describes a model available for inference. +type InferenceModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsDefault bool `json:"is_default"` } // RemoteAuth describes all auth methods an agent supports for remote environments. diff --git a/apps/backend/internal/agent/agents/amp.go b/apps/backend/internal/agent/agents/amp.go index 8799e1a79b..88f1f6313e 100644 --- a/apps/backend/internal/agent/agents/amp.go +++ b/apps/backend/internal/agent/agents/amp.go @@ -17,6 +17,7 @@ var ampLogoDark []byte var ( _ Agent = (*Amp)(nil) _ PassthroughAgent = (*Amp)(nil) + _ InferenceAgent = (*Amp)(nil) ) type Amp struct { @@ -116,6 +117,22 @@ func (a *Amp) PermissionSettings() map[string]PermissionSetting { return ampPermSettings } +// InferenceConfig returns configuration for one-shot inference. +func (a *Amp) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("amp", "--execute", "--stream-json", "--stream-json-input"), + ModelFlag: NewParam("-m", "{model}"), + OutputFormat: "stream-json", + StdinInput: true, + } +} + +// InferenceModels returns models available for one-shot inference. +func (a *Amp) InferenceModels() []InferenceModel { + return ModelsToInferenceModels(ampStaticModels()) +} + var ampPermSettings = map[string]PermissionSetting{ "auto_approve": { Supported: true, Default: true, Label: "Auto-approve (Dangerously Allow All)", Description: "Automatically approve all tool calls including shell commands", diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go index 0c63d4368f..794609cf40 100644 --- a/apps/backend/internal/agent/agents/auggie.go +++ b/apps/backend/internal/agent/agents/auggie.go @@ -20,6 +20,7 @@ var auggieLogoDark []byte var ( _ Agent = (*Auggie)(nil) _ PassthroughAgent = (*Auggie)(nil) + _ InferenceAgent = (*Auggie)(nil) ) // Auggie implements Agent for the Augment Coding Agent. @@ -147,6 +148,22 @@ func (a *Auggie) PermissionSettings() map[string]PermissionSetting { return auggiePermSettings } +// InferenceConfig returns configuration for one-shot inference. +func (a *Auggie) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("auggie", "--print", "--output-format", "json"), + ModelFlag: NewParam("--model", "{model}"), + OutputFormat: "auggie-json", + StdinInput: true, + } +} + +// InferenceModels returns models available for one-shot inference. +func (a *Auggie) InferenceModels() []InferenceModel { + return ModelsToInferenceModels(auggieStaticModels()) +} + // --- Private --- var auggiePermTools = []string{"launch-process", "save-file", "str-replace-editor", "remove-files"} diff --git a/apps/backend/internal/agent/agents/claude_code.go b/apps/backend/internal/agent/agents/claude_code.go index 435ba41c42..a2c19a9cfd 100644 --- a/apps/backend/internal/agent/agents/claude_code.go +++ b/apps/backend/internal/agent/agents/claude_code.go @@ -17,6 +17,7 @@ var claudeCodeLogoDark []byte var ( _ Agent = (*ClaudeCode)(nil) _ PassthroughAgent = (*ClaudeCode)(nil) + _ InferenceAgent = (*ClaudeCode)(nil) ) type ClaudeCode struct { @@ -150,6 +151,22 @@ func (a *ClaudeCode) PermissionSettings() map[string]PermissionSetting { return claudeCodePermSettings } +// InferenceConfig returns configuration for one-shot inference. +func (a *ClaudeCode) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("claude", "--print"), + ModelFlag: NewParam("--model", "{model}"), + OutputFormat: "text", + StdinInput: true, + } +} + +// InferenceModels returns models available for one-shot inference tasks. +func (a *ClaudeCode) InferenceModels() []InferenceModel { + return ModelsToInferenceModels(claudeCodeStaticModels()) +} + var claudeCodePermSettings = map[string]PermissionSetting{ "auto_approve": { Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls via stdio protocol", diff --git a/apps/backend/internal/agent/agents/codex.go b/apps/backend/internal/agent/agents/codex.go index 4dbe95292f..b371bfea9f 100644 --- a/apps/backend/internal/agent/agents/codex.go +++ b/apps/backend/internal/agent/agents/codex.go @@ -17,6 +17,7 @@ var codexLogoDark []byte var ( _ Agent = (*Codex)(nil) _ PassthroughAgent = (*Codex)(nil) + _ InferenceAgent = (*Codex)(nil) ) type Codex struct { @@ -125,6 +126,23 @@ func (a *Codex) PermissionSettings() map[string]PermissionSetting { return codexPermSettings } +// InferenceConfig returns configuration for one-shot inference. +// Uses `exec` subcommand for non-interactive execution. +func (a *Codex) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("codex", "exec"), + ModelFlag: NewParam("-m", "{model}"), + OutputFormat: "text", + StdinInput: true, + } +} + +// InferenceModels returns models available for one-shot inference. +func (a *Codex) InferenceModels() []InferenceModel { + return ModelsToInferenceModels(codexStaticModels()) +} + var codexPermSettings = map[string]PermissionSetting{ "auto_approve": { Supported: true, Default: true, Label: "Auto-approve", Description: "Skip approval requests from Codex", diff --git a/apps/backend/internal/agent/agents/copilot.go b/apps/backend/internal/agent/agents/copilot.go index ecec44eb31..324bfded76 100644 --- a/apps/backend/internal/agent/agents/copilot.go +++ b/apps/backend/internal/agent/agents/copilot.go @@ -17,6 +17,7 @@ var copilotLogoDark []byte var ( _ Agent = (*Copilot)(nil) _ PassthroughAgent = (*Copilot)(nil) + _ InferenceAgent = (*Copilot)(nil) ) type Copilot struct { @@ -116,6 +117,22 @@ func (a *Copilot) PermissionSettings() map[string]PermissionSetting { return copilotPermSettings } +// InferenceConfig returns configuration for one-shot inference. +func (a *Copilot) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("github-copilot", "-p"), + ModelFlag: NewParam("--model", "{model}"), + OutputFormat: "text", + StdinInput: true, + } +} + +// InferenceModels returns models available for one-shot inference. +func (a *Copilot) InferenceModels() []InferenceModel { + return ModelsToInferenceModels(copilotStaticModels()) +} + var copilotPermSettings = map[string]PermissionSetting{ "auto_approve": { Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Enable all tools, folders and urls without confirmation.", diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go index d9df9593a6..05c19861d7 100644 --- a/apps/backend/internal/agent/agents/gemini.go +++ b/apps/backend/internal/agent/agents/gemini.go @@ -17,6 +17,7 @@ var geminiLogoDark []byte var ( _ Agent = (*Gemini)(nil) _ PassthroughAgent = (*Gemini)(nil) + _ InferenceAgent = (*Gemini)(nil) ) type Gemini struct { @@ -134,6 +135,22 @@ func (a *Gemini) PermissionSettings() map[string]PermissionSetting { return geminiPermSettings } +// InferenceConfig returns configuration for one-shot inference. +func (a *Gemini) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("gemini", "-p"), + ModelFlag: NewParam("--model", "{model}"), + OutputFormat: "text", + StdinInput: true, + } +} + +// InferenceModels returns models available for one-shot inference. +func (a *Gemini) InferenceModels() []InferenceModel { + return ModelsToInferenceModels(geminiStaticModels()) +} + var geminiPermSettings = map[string]PermissionSetting{ "auto_approve": { Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Automatically approve all tool calls", diff --git a/apps/backend/internal/agent/agents/opencode.go b/apps/backend/internal/agent/agents/opencode.go index b8f7884a31..1217e35428 100644 --- a/apps/backend/internal/agent/agents/opencode.go +++ b/apps/backend/internal/agent/agents/opencode.go @@ -18,6 +18,7 @@ var opencodeLogoDark []byte var ( _ Agent = (*OpenCode)(nil) _ PassthroughAgent = (*OpenCode)(nil) + _ InferenceAgent = (*OpenCode)(nil) ) type OpenCode struct { @@ -118,6 +119,22 @@ func (a *OpenCode) PermissionSettings() map[string]PermissionSetting { return opencodePermSettings } +// InferenceConfig returns configuration for one-shot inference. +func (a *OpenCode) InferenceConfig() *InferenceConfig { + return &InferenceConfig{ + Supported: true, + Command: NewCommand("opencode", "ask"), + ModelFlag: NewParam("--model", "{model}"), + OutputFormat: "text", + StdinInput: true, + } +} + +// InferenceModels returns models available for one-shot inference. +func (a *OpenCode) InferenceModels() []InferenceModel { + return ModelsToInferenceModels(opencodeStaticModels()) +} + var opencodePermSettings = map[string]PermissionSetting{ "auto_approve": { Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls", diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index 596e1dbd0a..542e57ef62 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -7,7 +7,10 @@ import ( "github.com/kandev/kandev/pkg/agent" ) -var _ Agent = (*OpenCodeACP)(nil) +var ( + _ Agent = (*OpenCodeACP)(nil) + _ InferenceAgent = (*OpenCodeACP)(nil) +) // OpenCodeACP is the ACP protocol variant of OpenCode. // Uses JSON-RPC 2.0 over stdin/stdout via "opencode acp" instead of REST/SSE. @@ -81,3 +84,14 @@ func (a *OpenCodeACP) RemoteAuth() *RemoteAuth { return nil } func (a *OpenCodeACP) PermissionSettings() map[string]PermissionSetting { return opencodePermSettings } + +// InferenceConfig returns configuration for one-shot inference. +// Uses the same "opencode ask" command as the SSE variant. +func (a *OpenCodeACP) InferenceConfig() *InferenceConfig { + return (&OpenCode{}).InferenceConfig() +} + +// InferenceModels returns models available for one-shot inference. +func (a *OpenCodeACP) InferenceModels() []InferenceModel { + return (&OpenCode{}).InferenceModels() +} diff --git a/apps/backend/internal/agent/lifecycle/utility.go b/apps/backend/internal/agent/lifecycle/utility.go new file mode 100644 index 0000000000..ccd5950138 --- /dev/null +++ b/apps/backend/internal/agent/lifecycle/utility.go @@ -0,0 +1,100 @@ +package lifecycle + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agentctl/server/utility" +) + +// ExecuteInferencePrompt executes an inference prompt via an active session's agentctl. +// It looks up the inference config from the agent registry and passes it to agentctl. +func (m *Manager) ExecuteInferencePrompt(ctx context.Context, sessionID, agentID, model, prompt string) (*utility.PromptResponse, error) { + if sessionID == "" { + return nil, fmt.Errorf("session_id is required") + } + if agentID == "" { + return nil, fmt.Errorf("agent_id is required") + } + + // Get inference agent from registry + ia, ok := m.registry.GetInferenceAgent(agentID) + if !ok { + return nil, fmt.Errorf("agent %q does not support inference", agentID) + } + + cfg := ia.InferenceConfig() + if cfg == nil || !cfg.Supported { + return nil, fmt.Errorf("agent %q inference not supported", agentID) + } + + // Get the agentctl client + execution, exists := m.executionStore.GetBySessionID(sessionID) + if !exists { + return nil, fmt.Errorf("no execution found for session %s", sessionID) + } + + client := execution.GetAgentCtlClient() + if client == nil { + return nil, fmt.Errorf("agentctl client not available for session %s", sessionID) + } + + // Build request with inference config + req := &utility.PromptRequest{ + Prompt: prompt, + AgentID: agentID, + Model: model, + InferenceConfig: &utility.InferenceConfigDTO{ + Command: cfg.Command.Args(), + ModelFlag: cfg.ModelFlag.Args(), + OutputFormat: cfg.OutputFormat, + StdinInput: cfg.StdinInput, + }, + } + + return client.InferencePrompt(ctx, req) +} + +// ListInferenceAgents returns agents that support inference with their models. +// Only returns agents that are actually installed on the system. +func (m *Manager) ListInferenceAgents() []InferenceAgentInfo { + return m.ListInferenceAgentsWithContext(context.Background()) +} + +// ListInferenceAgentsWithContext returns installed inference agents using the provided context. +func (m *Manager) ListInferenceAgentsWithContext(ctx context.Context) []InferenceAgentInfo { + inferenceAgents := m.registry.ListInferenceAgents() + result := make([]InferenceAgentInfo, 0, len(inferenceAgents)) + + for _, ia := range inferenceAgents { + // Get base agent for metadata + ag, ok := ia.(agents.Agent) + if !ok { + continue + } + + // Only include agents that are installed + installed, err := ag.IsInstalled(ctx) + if err != nil || installed == nil || !installed.Available { + continue + } + + result = append(result, InferenceAgentInfo{ + ID: ag.ID(), + Name: ag.Name(), + DisplayName: ag.DisplayName(), + Models: ia.InferenceModels(), + }) + } + + return result +} + +// InferenceAgentInfo contains info about an inference-capable agent. +type InferenceAgentInfo struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Models []agents.InferenceModel `json:"models"` +} diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index c79323d6b7..72c6ef4fc3 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -133,6 +133,59 @@ func (r *Registry) ListEnabled() []agents.Agent { return result } +// ListInferenceAgents returns enabled agents that implement InferenceAgent. +func (r *Registry) ListInferenceAgents() []agents.InferenceAgent { + r.mu.RLock() + defer r.mu.RUnlock() + + var result []agents.InferenceAgent + var forSort []agents.Agent + for _, ag := range r.agents { + if !ag.Enabled() { + continue + } + if ia, ok := ag.(agents.InferenceAgent); ok { + if cfg := ia.InferenceConfig(); cfg != nil && cfg.Supported { + result = append(result, ia) + forSort = append(forSort, ag) + } + } + } + // Sort by DisplayOrder + slices.SortStableFunc(result, func(a, b agents.InferenceAgent) int { + aIdx, bIdx := -1, -1 + for i, ag := range forSort { + if ag.(agents.InferenceAgent) == a { + aIdx = i + } + if ag.(agents.InferenceAgent) == b { + bIdx = i + } + } + return cmp.Compare(forSort[aIdx].DisplayOrder(), forSort[bIdx].DisplayOrder()) + }) + return result +} + +// GetInferenceAgent returns an inference-capable agent by ID. +func (r *Registry) GetInferenceAgent(id string) (agents.InferenceAgent, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + ag, exists := r.agents[id] + if !exists || !ag.Enabled() { + return nil, false + } + ia, ok := ag.(agents.InferenceAgent) + if !ok { + return nil, false + } + if cfg := ia.InferenceConfig(); cfg == nil || !cfg.Supported { + return nil, false + } + return ia, true +} + // Exists checks if an agent exists func (r *Registry) Exists(id string) bool { r.mu.RLock() diff --git a/apps/backend/internal/agentctl/client/utility.go b/apps/backend/internal/agentctl/client/utility.go new file mode 100644 index 0000000000..171b922ca0 --- /dev/null +++ b/apps/backend/internal/agentctl/client/utility.go @@ -0,0 +1,47 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/kandev/kandev/internal/agentctl/server/utility" +) + +// InferencePrompt executes a one-shot inference prompt via agentctl. +func (c *Client) InferencePrompt(ctx context.Context, req *utility.PromptRequest) (*utility.PromptResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/inference/prompt", bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := readResponseBody(resp) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("utility prompt failed with status %d: %s", resp.StatusCode, truncateBody(respBody)) + } + + var result utility.PromptResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response (status %d, body: %s): %w", resp.StatusCode, truncateBody(respBody), err) + } + + return &result, nil +} diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 2ede61d15a..7c97f8ac6a 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -10,6 +10,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/server/config" "github.com/kandev/kandev/internal/agentctl/server/mcp" "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/agentctl/server/utility" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" @@ -123,6 +124,10 @@ func (s *Server) setupRoutes() { api.GET("/git/commit/:sha", s.handleGitShowCommit) } + // Utility agent routes + auxHandler := utility.NewHandler(s.cfg.WorkDir, s.logger.Zap()) + auxHandler.RegisterRoutes(api) + // MCP routes (if MCP server is configured) if s.mcpServer != nil { s.mcpServer.RegisterRoutes(s.router) diff --git a/apps/backend/internal/agentctl/server/utility/agent_provider.go b/apps/backend/internal/agentctl/server/utility/agent_provider.go new file mode 100644 index 0000000000..2b06d4d3eb --- /dev/null +++ b/apps/backend/internal/agentctl/server/utility/agent_provider.go @@ -0,0 +1,231 @@ +package utility + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strings" + "time" +) + +// modelNameRegex validates model names - alphanumeric, hyphens, underscores, dots, slashes, colons. +var modelNameRegex = regexp.MustCompile(`^[a-zA-Z0-9._:/-]*$`) + +// validateModelName ensures the model name doesn't contain shell metacharacters. +func validateModelName(model string) error { + if model == "" { + return nil // Empty model is allowed (uses default) + } + if len(model) > 256 { + return fmt.Errorf("model name too long") + } + if !modelNameRegex.MatchString(model) { + return fmt.Errorf("invalid model name: contains disallowed characters") + } + return nil +} + +// InferenceExecutor executes one-shot prompts using the agent's inference config. +type InferenceExecutor struct { + workDir string +} + +// NewInferenceExecutor creates a new inference executor. +func NewInferenceExecutor(workDir string) *InferenceExecutor { + return &InferenceExecutor{workDir: workDir} +} + +// Execute runs a one-shot prompt using the agent's inference configuration. +func (e *InferenceExecutor) Execute(ctx context.Context, req *PromptRequest) (*PromptResponse, error) { + if req.InferenceConfig == nil { + return &PromptResponse{Success: false, Error: "inference config is required"}, nil + } + + cfg := req.InferenceConfig + if len(cfg.Command) == 0 { + return &PromptResponse{Success: false, Error: "inference command is empty"}, nil + } + + // Validate model name to prevent command injection. + // Model is user-provided and substituted into command arguments. + if err := validateModelName(req.Model); err != nil { + return &PromptResponse{Success: false, Error: err.Error()}, nil + } + + startTime := time.Now() + + // Build command from inference config. + // Note: Command and ModelFlag come from hardcoded agent definitions in the registry, + // not from user input. Only the model name (validated above) and prompt are user-provided. + args := e.buildCommand(cfg, req.Model) + + // #nosec G204 -- Command comes from agent registry (hardcoded), model is validated above + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = e.workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := e.startCommand(cmd, cfg.StdinInput, req.Prompt); err != nil { + return &PromptResponse{Success: false, Error: err.Error()}, nil + } + + if err := cmd.Wait(); err != nil { + return &PromptResponse{ + Success: false, + Error: fmt.Sprintf("process failed: %v, stderr: %s", err, stderr.String()), + DurationMs: int(time.Since(startTime).Milliseconds()), + }, nil + } + + // Parse response based on output format + response := e.parseResponse(stdout.String(), cfg.OutputFormat) + + return &PromptResponse{ + Success: true, + Response: response, + Model: req.Model, + DurationMs: int(time.Since(startTime).Milliseconds()), + }, nil +} + +// startCommand starts the command with optional stdin input. +func (e *InferenceExecutor) startCommand(cmd *exec.Cmd, stdinInput bool, prompt string) error { + if !stdinInput { + cmd.Args = append(cmd.Args, prompt) + return cmd.Start() + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + + if _, err := stdin.Write([]byte(prompt)); err != nil { + _ = cmd.Process.Kill() + return fmt.Errorf("write: %w", err) + } + + if err := stdin.Close(); err != nil { + return fmt.Errorf("close stdin: %w", err) + } + + return nil +} + +// buildCommand builds the command arguments from inference config. +func (e *InferenceExecutor) buildCommand(cfg *InferenceConfigDTO, model string) []string { + args := make([]string, len(cfg.Command)) + copy(args, cfg.Command) + + if model != "" && len(cfg.ModelFlag) > 0 { + for _, part := range cfg.ModelFlag { + args = append(args, strings.ReplaceAll(part, "{model}", model)) + } + } + + return args +} + +// parseResponse extracts text from the output based on format. +func (e *InferenceExecutor) parseResponse(output, format string) string { + switch format { + case "stream-json": + return parseStreamJSON(output) + case "auggie-json": + return parseAuggieJSON(output) + default: + return strings.TrimSpace(output) + } +} + +// parseStreamJSON parses stream-json output format (used by Amp). +func parseStreamJSON(output string) string { + var result strings.Builder + + scanner := bufio.NewScanner(strings.NewReader(output)) + // Increase buffer for large outputs (8MB max) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + text := extractAssistantText(line) + if text != "" { + result.WriteString(text) + } + } + + // Ignore scanner errors - best effort parsing + _ = scanner.Err() + + return strings.TrimSpace(result.String()) +} + +// extractAssistantText extracts text content from a JSON line if it's an assistant message. +func extractAssistantText(line string) string { + var msg map[string]interface{} + if err := json.Unmarshal([]byte(line), &msg); err != nil { + return "" + } + + msgType, ok := msg["type"].(string) + if !ok || msgType != "assistant" { + return "" + } + + message, ok := msg["message"].(map[string]interface{}) + if !ok { + return "" + } + + content, ok := message["content"].([]interface{}) + if !ok { + return "" + } + + return extractTextFromContent(content) +} + +// extractTextFromContent extracts text from content blocks. +func extractTextFromContent(content []interface{}) string { + var result strings.Builder + for _, block := range content { + b, ok := block.(map[string]interface{}) + if !ok { + continue + } + blockType, ok := b["type"].(string) + if !ok || blockType != "text" { + continue + } + if text, ok := b["text"].(string); ok { + result.WriteString(text) + } + } + return result.String() +} + +// parseAuggieJSON extracts the result field from auggie JSON output. +func parseAuggieJSON(output string) string { + var response struct { + Result string `json:"result"` + IsError bool `json:"is_error"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &response); err != nil { + return strings.TrimSpace(output) + } + return strings.TrimSpace(response.Result) +} diff --git a/apps/backend/internal/agentctl/server/utility/handler.go b/apps/backend/internal/agentctl/server/utility/handler.go new file mode 100644 index 0000000000..8423102179 --- /dev/null +++ b/apps/backend/internal/agentctl/server/utility/handler.go @@ -0,0 +1,63 @@ +package utility + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// Handler provides HTTP handlers for inference operations. +type Handler struct { + executor *InferenceExecutor + logger *zap.Logger +} + +// NewHandler creates a new inference handler. +func NewHandler(workDir string, logger *zap.Logger) *Handler { + return &Handler{ + executor: NewInferenceExecutor(workDir), + logger: logger, + } +} + +// RegisterRoutes registers the inference routes on the given router group. +func (h *Handler) RegisterRoutes(api *gin.RouterGroup) { + aux := api.Group("/inference") + aux.POST("/prompt", h.handlePrompt) +} + +// handlePrompt handles POST /api/v1/inference/prompt +func (h *Handler) handlePrompt(c *gin.Context) { + var req PromptRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, PromptResponse{ + Success: false, + Error: "invalid request: " + err.Error(), + }) + return + } + + h.logger.Info("executing inference prompt", + zap.String("agent_id", req.AgentID), + zap.String("model", req.Model), + ) + + resp, err := h.executor.Execute(c.Request.Context(), &req) + if err != nil { + h.logger.Error("inference prompt failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, PromptResponse{ + Success: false, + Error: "inference execution failed", + }) + return + } + + if !resp.Success { + h.logger.Warn("inference prompt unsuccessful", zap.String("error", resp.Error)) + } else { + h.logger.Info("inference prompt completed", zap.Int("duration_ms", resp.DurationMs)) + } + + c.JSON(http.StatusOK, resp) +} diff --git a/apps/backend/internal/agentctl/server/utility/types.go b/apps/backend/internal/agentctl/server/utility/types.go new file mode 100644 index 0000000000..d42b0ab712 --- /dev/null +++ b/apps/backend/internal/agentctl/server/utility/types.go @@ -0,0 +1,71 @@ +// Package utility provides one-shot prompt execution via inference-capable agents. +// This is a simplified interface compared to the full session-based adapters, +// designed for quick tasks like generating commit messages or PR descriptions. +package utility + +// PromptRequest is the request for executing an inference prompt. +type PromptRequest struct { + // Prompt is the fully resolved prompt text to send to the LLM. + Prompt string `json:"prompt" binding:"required"` + + // AgentID is the agent to use (e.g., "claude-code", "amp"). + AgentID string `json:"agent_id" binding:"required"` + + // Model is the model to use (e.g., "claude-haiku-4-5"). + Model string `json:"model,omitempty"` + + // InferenceConfig is the agent's inference configuration. + // This is passed from the backend which has access to the agent registry. + InferenceConfig *InferenceConfigDTO `json:"inference_config,omitempty"` + + // MaxTokens is the maximum tokens for the response (default: 1024). + MaxTokens int `json:"max_tokens,omitempty"` +} + +// InferenceConfigDTO is the inference configuration passed from backend to agentctl. +type InferenceConfigDTO struct { + // Command is the CLI command for one-shot inference (e.g., ["claude", "--print"]). + Command []string `json:"command"` + // ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]). + ModelFlag []string `json:"model_flag,omitempty"` + // OutputFormat describes how to parse the output: "text", "stream-json". + OutputFormat string `json:"output_format"` + // StdinInput if true, prompt is sent via stdin; otherwise as a positional argument. + StdinInput bool `json:"stdin_input"` +} + +// PromptResponse is the response from executing a utility prompt. +type PromptResponse struct { + // Success indicates if the prompt completed successfully. + Success bool `json:"success"` + + // Response is the generated text. + Response string `json:"response,omitempty"` + + // Model is the model that was used. + Model string `json:"model,omitempty"` + + // PromptTokens is the number of input tokens. + PromptTokens int `json:"prompt_tokens,omitempty"` + + // ResponseTokens is the number of output tokens. + ResponseTokens int `json:"response_tokens,omitempty"` + + // DurationMs is the execution duration in milliseconds. + DurationMs int `json:"duration_ms,omitempty"` + + // Error is the error message if the prompt failed. + Error string `json:"error,omitempty"` +} + +// ModelInfo represents an available model. +type ModelInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ModelsResponse is the response for listing available models. +type ModelsResponse struct { + Models []ModelInfo `json:"models"` +} diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 3cd8fa138b..dfc2b8e064 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -60,6 +60,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS LspAutoInstallLanguages: req.LspAutoInstallLanguages, LspServerConfigs: req.LspServerConfigs, SavedLayouts: req.SavedLayouts, + DefaultUtilityAgentID: req.DefaultUtilityAgentID, + DefaultUtilityModel: req.DefaultUtilityModel, }) if err != nil { return dto.UserSettingsResponse{}, err diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 888805671d..7c45e9fe08 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -31,6 +31,8 @@ type UserSettingsDTO struct { LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` + DefaultUtilityAgentID string `json:"default_utility_agent_id"` + DefaultUtilityModel string `json:"default_utility_model"` UpdatedAt string `json:"updated_at"` } @@ -66,6 +68,8 @@ type UpdateUserSettingsRequest struct { LspAutoInstallLanguages *[]string `json:"lsp_auto_install_languages,omitempty"` LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` SavedLayouts *[]models.SavedLayout `json:"saved_layouts,omitempty"` + DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` + DefaultUtilityModel *string `json:"default_utility_model,omitempty"` } func FromUser(user *models.User) UserDTO { @@ -96,6 +100,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { LspAutoInstallLanguages: settings.LspAutoInstallLanguages, LspServerConfigs: settings.LspServerConfigs, SavedLayouts: settings.SavedLayouts, + DefaultUtilityAgentID: settings.DefaultUtilityAgentID, + DefaultUtilityModel: settings.DefaultUtilityModel, UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index d3fc6f9f5e..18796b950b 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -30,6 +30,8 @@ type UserSettings struct { LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` SavedLayouts []SavedLayout `json:"saved_layouts"` + DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents + DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 8fa4f74edf..1709b70392 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -42,6 +42,8 @@ type UpdateUserSettingsRequest struct { LspAutoInstallLanguages *[]string LspServerConfigs *map[string]map[string]interface{} SavedLayouts *[]models.SavedLayout + DefaultUtilityAgentID *string + DefaultUtilityModel *string } func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger) *Service { @@ -77,6 +79,15 @@ func (s *Service) PreferredShell(ctx context.Context) (string, error) { return settings.PreferredShell, nil } +// GetDefaultUtilitySettings returns the user's default utility agent/model settings. +func (s *Service) GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) { + settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + if err != nil { + return "", "", err + } + return settings.DefaultUtilityAgentID, settings.DefaultUtilityModel, nil +} + func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) { settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) if err != nil { @@ -137,6 +148,12 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe if req.ReleaseNotesLastSeenVersion != nil { settings.ReleaseNotesLastSeenVersion = *req.ReleaseNotesLastSeenVersion } + if req.DefaultUtilityAgentID != nil { + settings.DefaultUtilityAgentID = strings.TrimSpace(*req.DefaultUtilityAgentID) + } + if req.DefaultUtilityModel != nil { + settings.DefaultUtilityModel = strings.TrimSpace(*req.DefaultUtilityModel) + } return nil } @@ -216,6 +233,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "lsp_auto_install_languages": settings.LspAutoInstallLanguages, "lsp_server_configs": settings.LspServerConfigs, "saved_layouts": settings.SavedLayouts, + "default_utility_agent_id": settings.DefaultUtilityAgentID, + "default_utility_model": settings.DefaultUtilityModel, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 6a15df5fbd..09deac4ffa 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -146,6 +146,8 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod "lsp_auto_install_languages": lspAutoInstall, "lsp_server_configs": lspServerConfigs, "saved_layouts": savedLayouts, + "default_utility_agent_id": settings.DefaultUtilityAgentID, + "default_utility_model": settings.DefaultUtilityModel, }) if err != nil { return err @@ -207,6 +209,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` + DefaultUtilityAgentID string `json:"default_utility_agent_id"` + DefaultUtilityModel string `json:"default_utility_model"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { return nil, err @@ -249,5 +253,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if settings.SavedLayouts == nil { settings.SavedLayouts = []models.SavedLayout{} } + settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID + settings.DefaultUtilityModel = payload.DefaultUtilityModel return settings, nil } diff --git a/apps/backend/internal/utility/controller/controller.go b/apps/backend/internal/utility/controller/controller.go new file mode 100644 index 0000000000..9468fc347c --- /dev/null +++ b/apps/backend/internal/utility/controller/controller.go @@ -0,0 +1,144 @@ +package controller + +import ( + "context" + + "github.com/kandev/kandev/internal/utility/dto" + "github.com/kandev/kandev/internal/utility/service" + "github.com/kandev/kandev/internal/utility/template" +) + +// Controller handles utility agent operations. +type Controller struct { + svc *service.Service +} + +// NewController creates a new utility agent controller. +func NewController(svc *service.Service) *Controller { + return &Controller{svc: svc} +} + +// ListAgents returns all utility agents. +func (c *Controller) ListAgents(ctx context.Context) (*dto.UtilityAgentsResponse, error) { + agents, err := c.svc.ListAgents(ctx) + if err != nil { + return nil, err + } + + dtos := make([]dto.UtilityAgentDTO, 0, len(agents)) + for _, agent := range agents { + dtos = append(dtos, dto.FromUtilityAgent(agent)) + } + + return &dto.UtilityAgentsResponse{Agents: dtos}, nil +} + +// GetAgent returns a utility agent by ID. +func (c *Controller) GetAgent(ctx context.Context, id string) (*dto.UtilityAgentDTO, error) { + agent, err := c.svc.GetAgentByID(ctx, id) + if err != nil { + return nil, err + } + result := dto.FromUtilityAgent(agent) + return &result, nil +} + +// CreateAgent creates a new utility agent. +func (c *Controller) CreateAgent(ctx context.Context, req dto.CreateUtilityAgentRequest) (*dto.UtilityAgentDTO, error) { + agent, err := c.svc.CreateAgent( + ctx, + req.Name, + req.Description, + req.Prompt, + req.AgentID, + req.Model, + ) + if err != nil { + return nil, err + } + result := dto.FromUtilityAgent(agent) + return &result, nil +} + +// UpdateAgent updates an existing utility agent. +func (c *Controller) UpdateAgent(ctx context.Context, id string, req dto.UpdateUtilityAgentRequest) (*dto.UtilityAgentDTO, error) { + agent, err := c.svc.UpdateAgent( + ctx, + id, + req.Name, + req.Description, + req.Prompt, + req.AgentID, + req.Model, + req.Enabled, + ) + if err != nil { + return nil, err + } + result := dto.FromUtilityAgent(agent) + return &result, nil +} + +// DeleteAgent deletes a utility agent. +func (c *Controller) DeleteAgent(ctx context.Context, id string) error { + return c.svc.DeleteAgent(ctx, id) +} + +// GetTemplateVariables returns the available template variables. +func (c *Controller) GetTemplateVariables(ctx context.Context) *dto.TemplateVariablesResponse { + return &dto.TemplateVariablesResponse{ + Variables: c.svc.GetAvailableVariables(), + } +} + +// PreparePromptRequest prepares a prompt request by resolving the template. +// If defaults is provided and the utility agent has empty agent_id/model, the defaults are used. +func (c *Controller) PreparePromptRequest(ctx context.Context, req dto.ExecutePromptRequest, defaults *service.DefaultUtilitySettings) (*service.PromptRequest, error) { + tmplCtx := &template.Context{ + GitDiff: req.GitDiff, + CommitLog: req.CommitLog, + ChangedFiles: req.ChangedFiles, + DiffSummary: req.DiffSummary, + BranchName: req.BranchName, + BaseBranch: req.BaseBranch, + TaskTitle: req.TaskTitle, + TaskDescription: req.TaskDescription, + SessionID: req.SessionID, + UserPrompt: req.UserPrompt, + } + return c.svc.PreparePromptRequest(ctx, req.UtilityAgentID, tmplCtx, defaults) +} + +// CreateCall creates a call record for tracking. +func (c *Controller) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model string) (string, error) { + call, err := c.svc.CreateCall(ctx, utilityID, sessionID, resolvedPrompt, model) + if err != nil { + return "", err + } + return call.ID, nil +} + +// CompleteCall marks a call as completed. +func (c *Controller) CompleteCall(ctx context.Context, callID, response string, promptTokens, responseTokens, durationMs int) error { + return c.svc.CompleteCall(ctx, callID, response, promptTokens, responseTokens, durationMs) +} + +// FailCall marks a call as failed. +func (c *Controller) FailCall(ctx context.Context, callID, errorMessage string, durationMs int) error { + return c.svc.FailCall(ctx, callID, errorMessage, durationMs) +} + +// ListCalls returns the call history for a utility agent. +func (c *Controller) ListCalls(ctx context.Context, utilityID string, limit int) (*dto.UtilityAgentCallsResponse, error) { + calls, err := c.svc.ListCalls(ctx, utilityID, limit) + if err != nil { + return nil, err + } + + dtos := make([]dto.UtilityAgentCallDTO, 0, len(calls)) + for _, call := range calls { + dtos = append(dtos, dto.FromUtilityAgentCall(call)) + } + + return &dto.UtilityAgentCallsResponse{Calls: dtos}, nil +} diff --git a/apps/backend/internal/utility/dto/dto.go b/apps/backend/internal/utility/dto/dto.go new file mode 100644 index 0000000000..4719bb5940 --- /dev/null +++ b/apps/backend/internal/utility/dto/dto.go @@ -0,0 +1,177 @@ +package dto + +import ( + "time" + + "github.com/kandev/kandev/internal/utility/models" + "github.com/kandev/kandev/internal/utility/template" +) + +// UtilityAgentDTO represents a utility agent for API responses. +type UtilityAgentDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Prompt string `json:"prompt"` + AgentID string `json:"agent_id"` + Model string `json:"model"` + Builtin bool `json:"builtin"` + Enabled bool `json:"enabled"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// UtilityAgentsResponse is the response for listing utility agents. +type UtilityAgentsResponse struct { + Agents []UtilityAgentDTO `json:"agents"` +} + +// CreateUtilityAgentRequest is the request for creating a utility agent. +type CreateUtilityAgentRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Prompt string `json:"prompt" binding:"required"` + AgentID string `json:"agent_id" binding:"required"` + Model string `json:"model" binding:"required"` +} + +// UpdateUtilityAgentRequest is the request for updating a utility agent. +type UpdateUtilityAgentRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Prompt *string `json:"prompt,omitempty"` + AgentID *string `json:"agent_id,omitempty"` + Model *string `json:"model,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +// UtilityAgentCallDTO represents a call for API responses. +type UtilityAgentCallDTO struct { + ID string `json:"id"` + UtilityID string `json:"utility_id"` + SessionID string `json:"session_id"` + ResolvedPrompt string `json:"resolved_prompt"` + Response string `json:"response"` + Model string `json:"model"` + PromptTokens int `json:"prompt_tokens"` + ResponseTokens int `json:"response_tokens"` + DurationMs int `json:"duration_ms"` + Status string `json:"status"` + ErrorMessage string `json:"error_message"` + CreatedAt string `json:"created_at"` + CompletedAt *string `json:"completed_at,omitempty"` +} + +// UtilityAgentCallsResponse is the response for listing calls. +type UtilityAgentCallsResponse struct { + Calls []UtilityAgentCallDTO `json:"calls"` +} + +// TemplateVariablesResponse is the response for listing available template variables. +type TemplateVariablesResponse struct { + Variables []template.VariableInfo `json:"variables"` +} + +// FromUtilityAgent converts a model to a DTO. +func FromUtilityAgent(agent *models.UtilityAgent) UtilityAgentDTO { + return UtilityAgentDTO{ + ID: agent.ID, + Name: agent.Name, + Description: agent.Description, + Prompt: agent.Prompt, + AgentID: agent.AgentID, + Model: agent.Model, + Builtin: agent.Builtin, + Enabled: agent.Enabled, + CreatedAt: agent.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: agent.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +// FromUtilityAgentCall converts a call model to a DTO. +func FromUtilityAgentCall(call *models.UtilityAgentCall) UtilityAgentCallDTO { + dto := UtilityAgentCallDTO{ + ID: call.ID, + UtilityID: call.UtilityID, + SessionID: call.SessionID, + ResolvedPrompt: call.ResolvedPrompt, + Response: call.Response, + Model: call.Model, + PromptTokens: call.PromptTokens, + ResponseTokens: call.ResponseTokens, + DurationMs: call.DurationMs, + Status: call.Status, + ErrorMessage: call.ErrorMessage, + CreatedAt: call.CreatedAt.UTC().Format(time.RFC3339), + } + if call.CompletedAt != nil { + formatted := call.CompletedAt.UTC().Format(time.RFC3339) + dto.CompletedAt = &formatted + } + return dto +} + +// ExecutePromptRequest is the request for executing a utility prompt. +type ExecutePromptRequest struct { + // UtilityAgentID is the ID of the utility agent to execute. + UtilityAgentID string `json:"utility_agent_id" binding:"required"` + + // SessionID is the active session to piggyback on. + SessionID string `json:"session_id"` + + // Context variables for template resolution. + GitDiff string `json:"git_diff,omitempty"` + CommitLog string `json:"commit_log,omitempty"` + ChangedFiles string `json:"changed_files,omitempty"` + DiffSummary string `json:"diff_summary,omitempty"` + BranchName string `json:"branch_name,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + TaskTitle string `json:"task_title,omitempty"` + TaskDescription string `json:"task_description,omitempty"` + UserPrompt string `json:"user_prompt,omitempty"` +} + +// ExecutePromptResponse is the response from executing a utility prompt. +type ExecutePromptResponse struct { + Success bool `json:"success"` + CallID string `json:"call_id,omitempty"` + Response string `json:"response,omitempty"` + Model string `json:"model,omitempty"` + PromptTokens int `json:"prompt_tokens,omitempty"` + ResponseTokens int `json:"response_tokens,omitempty"` + DurationMs int `json:"duration_ms,omitempty"` + Error string `json:"error,omitempty"` +} + +// ModelInfoDTO represents a model for API responses. +type ModelInfoDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// ModelsResponse is the response for listing models. +type ModelsResponse struct { + Models []ModelInfoDTO `json:"models"` +} + +// InferenceModelDTO represents a model available for inference. +type InferenceModelDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsDefault bool `json:"is_default"` +} + +// InferenceAgentDTO represents an agent that supports inference. +type InferenceAgentDTO struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Models []InferenceModelDTO `json:"models"` +} + +// InferenceAgentsResponse is the response for listing inference agents. +type InferenceAgentsResponse struct { + Agents []InferenceAgentDTO `json:"agents"` +} diff --git a/apps/backend/internal/utility/handlers/handlers.go b/apps/backend/internal/utility/handlers/handlers.go new file mode 100644 index 0000000000..502cc5caea --- /dev/null +++ b/apps/backend/internal/utility/handlers/handlers.go @@ -0,0 +1,272 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/lifecycle" + agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/utility/controller" + "github.com/kandev/kandev/internal/utility/dto" + "github.com/kandev/kandev/internal/utility/service" +) + +// InferenceExecutor executes inference prompts via agentctl. +type InferenceExecutor interface { + // ExecuteInferencePrompt executes an inference prompt via an active session's agentctl. + ExecuteInferencePrompt(ctx context.Context, sessionID, agentID, model, prompt string) (*agentctlutil.PromptResponse, error) + // ListInferenceAgentsWithContext returns installed agents that support inference. + ListInferenceAgentsWithContext(ctx context.Context) []lifecycle.InferenceAgentInfo +} + +// UserSettingsProvider provides user settings for default utility agent/model. +type UserSettingsProvider interface { + // GetDefaultUtilitySettings returns the user's default utility agent/model settings. + GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) +} + +// Handlers provides HTTP handlers for utility agents. +type Handlers struct { + controller *controller.Controller + executor InferenceExecutor + userSettings UserSettingsProvider + logger *logger.Logger +} + +// NewHandlers creates new utility agent handlers. +func NewHandlers(ctrl *controller.Controller, executor InferenceExecutor, userSettings UserSettingsProvider, log *logger.Logger) *Handlers { + return &Handlers{ + controller: ctrl, + executor: executor, + userSettings: userSettings, + logger: log.WithFields(zap.String("component", "utility-handlers")), + } +} + +// RegisterRoutes registers the utility agent routes. +func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, executor InferenceExecutor, userSettings UserSettingsProvider, log *logger.Logger) { + handlers := NewHandlers(ctrl, executor, userSettings, log) + api := router.Group("/api/v1/utility") + api.GET("/agents", handlers.httpListAgents) + api.GET("/agents/:id", handlers.httpGetAgent) + api.POST("/agents", handlers.httpCreateAgent) + api.PATCH("/agents/:id", handlers.httpUpdateAgent) + api.DELETE("/agents/:id", handlers.httpDeleteAgent) + api.GET("/template-variables", handlers.httpGetTemplateVariables) + api.POST("/execute", handlers.httpExecutePrompt) + api.GET("/agents/:id/calls", handlers.httpListCalls) + api.GET("/inference-agents", handlers.httpListInferenceAgents) +} + +func (h *Handlers) httpListAgents(c *gin.Context) { + resp, err := h.controller.ListAgents(c.Request.Context()) + if err != nil { + h.logger.Error("failed to list utility agents", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list utility agents"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpGetAgent(c *gin.Context) { + resp, err := h.controller.GetAgent(c.Request.Context(), c.Param("id")) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, service.ErrAgentNotFound) { + status = http.StatusNotFound + } + h.logger.Error("failed to get utility agent", zap.Error(err)) + c.JSON(status, gin.H{"error": "failed to get utility agent"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpCreateAgent(c *gin.Context) { + var req dto.CreateUtilityAgentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.CreateAgent(c.Request.Context(), req) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, service.ErrInvalidAgent) { + status = http.StatusBadRequest + } + h.logger.Error("failed to create utility agent", zap.Error(err)) + c.JSON(status, gin.H{"error": "failed to create utility agent"}) + return + } + c.JSON(http.StatusCreated, resp) +} + +func (h *Handlers) httpUpdateAgent(c *gin.Context) { + var req dto.UpdateUtilityAgentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + resp, err := h.controller.UpdateAgent(c.Request.Context(), c.Param("id"), req) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, service.ErrAgentNotFound): + status = http.StatusNotFound + case errors.Is(err, service.ErrInvalidAgent): + status = http.StatusBadRequest + case errors.Is(err, service.ErrBuiltinAgent): + status = http.StatusForbidden + } + h.logger.Error("failed to update utility agent", zap.Error(err)) + c.JSON(status, gin.H{"error": "failed to update utility agent"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpDeleteAgent(c *gin.Context) { + if err := h.controller.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, service.ErrAgentNotFound): + status = http.StatusNotFound + case errors.Is(err, service.ErrBuiltinAgent): + status = http.StatusForbidden + } + h.logger.Error("failed to delete utility agent", zap.Error(err)) + c.JSON(status, gin.H{"error": "failed to delete utility agent"}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func (h *Handlers) httpGetTemplateVariables(c *gin.Context) { + resp := h.controller.GetTemplateVariables(c.Request.Context()) + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpExecutePrompt(c *gin.Context) { + var req dto.ExecutePromptRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, dto.ExecutePromptResponse{Error: "invalid payload"}) + return + } + + ctx := c.Request.Context() + + // Get default utility settings from user settings + var defaults *service.DefaultUtilitySettings + if h.userSettings != nil { + agentID, model, err := h.userSettings.GetDefaultUtilitySettings(ctx) + if err == nil && (agentID != "" || model != "") { + defaults = &service.DefaultUtilitySettings{AgentID: agentID, Model: model} + } + } + + // Prepare the prompt request (resolve template, get agent/model info) + prepared, err := h.controller.PreparePromptRequest(ctx, req, defaults) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, service.ErrAgentNotFound) { + status = http.StatusNotFound + } + h.logger.Error("failed to prepare prompt", zap.Error(err)) + c.JSON(status, dto.ExecutePromptResponse{Error: "failed to prepare prompt"}) + return + } + + // Create call record for tracking + callID, err := h.controller.CreateCall(ctx, req.UtilityAgentID, req.SessionID, prepared.ResolvedPrompt, prepared.Model) + if err != nil { + h.logger.Error("failed to create call record", zap.Error(err)) + c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{Error: "failed to create call record"}) + return + } + + // Execute via agentctl using inference agent + resp, err := h.executor.ExecuteInferencePrompt(ctx, req.SessionID, prepared.AgentCLI, prepared.Model, prepared.ResolvedPrompt) + if err != nil { + h.logger.Error("failed to execute prompt", zap.Error(err), zap.String("call_id", callID)) + _ = h.controller.FailCall(ctx, callID, err.Error(), 0) + c.JSON(http.StatusInternalServerError, dto.ExecutePromptResponse{ + CallID: callID, + Error: "failed to execute prompt: " + err.Error(), + }) + return + } + + if !resp.Success { + _ = h.controller.FailCall(ctx, callID, resp.Error, resp.DurationMs) + c.JSON(http.StatusOK, dto.ExecutePromptResponse{ + CallID: callID, + Error: resp.Error, + DurationMs: resp.DurationMs, + }) + return + } + + // Mark call as completed + if err := h.controller.CompleteCall(ctx, callID, resp.Response, resp.PromptTokens, resp.ResponseTokens, resp.DurationMs); err != nil { + h.logger.Warn("failed to update call record", zap.Error(err), zap.String("call_id", callID)) + } + + c.JSON(http.StatusOK, dto.ExecutePromptResponse{ + Success: true, + CallID: callID, + Response: resp.Response, + Model: resp.Model, + PromptTokens: resp.PromptTokens, + ResponseTokens: resp.ResponseTokens, + DurationMs: resp.DurationMs, + }) +} + +func (h *Handlers) httpListCalls(c *gin.Context) { + utilityID := c.Param("id") + limit := 50 + if limitStr := c.Query("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } + + resp, err := h.controller.ListCalls(c.Request.Context(), utilityID, limit) + if err != nil { + h.logger.Error("failed to list calls", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list calls"}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) httpListInferenceAgents(c *gin.Context) { + inferenceAgents := h.executor.ListInferenceAgentsWithContext(c.Request.Context()) + + // Convert to DTO + result := make([]dto.InferenceAgentDTO, 0, len(inferenceAgents)) + for _, ia := range inferenceAgents { + models := make([]dto.InferenceModelDTO, 0, len(ia.Models)) + for _, m := range ia.Models { + models = append(models, dto.InferenceModelDTO{ + ID: m.ID, + Name: m.Name, + Description: m.Description, + IsDefault: m.IsDefault, + }) + } + result = append(result, dto.InferenceAgentDTO{ + ID: ia.ID, + Name: ia.Name, + DisplayName: ia.DisplayName, + Models: models, + }) + } + c.JSON(http.StatusOK, dto.InferenceAgentsResponse{Agents: result}) +} diff --git a/apps/backend/internal/utility/models/models.go b/apps/backend/internal/utility/models/models.go new file mode 100644 index 0000000000..6d8bc22fb2 --- /dev/null +++ b/apps/backend/internal/utility/models/models.go @@ -0,0 +1,35 @@ +package models + +import "time" + +// UtilityAgent represents a configured utility agent for quick one-shot tasks. +// It references an inference-capable agent (like claude-code, amp) by ID. +type UtilityAgent struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Prompt string `json:"prompt" db:"prompt"` + AgentID string `json:"agent_id" db:"agent_id"` // Reference to inference agent (e.g., "claude-code") + Model string `json:"model" db:"model"` // Model to use (e.g., "claude-haiku-4-5") + Builtin bool `json:"builtin" db:"builtin"` // Built-in agents cannot be deleted + Enabled bool `json:"enabled" db:"enabled"` // Whether agent is enabled + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// UtilityAgentCall represents a single invocation of a utility agent. +type UtilityAgentCall struct { + ID string `json:"id" db:"id"` + UtilityID string `json:"utility_id" db:"utility_id"` + SessionID string `json:"session_id" db:"session_id"` + ResolvedPrompt string `json:"resolved_prompt" db:"resolved_prompt"` + Response string `json:"response" db:"response"` + Model string `json:"model" db:"model"` + PromptTokens int `json:"prompt_tokens" db:"prompt_tokens"` + ResponseTokens int `json:"response_tokens" db:"response_tokens"` + DurationMs int `json:"duration_ms" db:"duration_ms"` + Status string `json:"status" db:"status"` // "pending", "completed", "failed" + ErrorMessage string `json:"error_message" db:"error_message"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CompletedAt *time.Time `json:"completed_at" db:"completed_at"` +} diff --git a/apps/backend/internal/utility/service/service.go b/apps/backend/internal/utility/service/service.go new file mode 100644 index 0000000000..f7c93c3c32 --- /dev/null +++ b/apps/backend/internal/utility/service/service.go @@ -0,0 +1,257 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/kandev/kandev/internal/utility/models" + "github.com/kandev/kandev/internal/utility/store" + "github.com/kandev/kandev/internal/utility/template" +) + +var ( + ErrAgentNotFound = errors.New("utility agent not found") + ErrInvalidAgent = errors.New("invalid utility agent") + ErrCallNotFound = errors.New("utility agent call not found") + ErrBuiltinAgent = errors.New("cannot modify built-in agent") +) + +// Service provides business logic for utility agents. +type Service struct { + repo store.Repository + templateEngine *template.Engine +} + +// NewService creates a new utility agents service. +func NewService(repo store.Repository) *Service { + return &Service{ + repo: repo, + templateEngine: template.NewEngine(), + } +} + +// ListAgents returns all utility agents. +func (s *Service) ListAgents(ctx context.Context) ([]*models.UtilityAgent, error) { + return s.repo.ListAgents(ctx) +} + +// GetAgentByID returns a utility agent by ID. +func (s *Service) GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) { + agent, err := s.repo.GetAgentByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentNotFound + } + return nil, err + } + return agent, nil +} + +// GetAgentByName returns a utility agent by name. +func (s *Service) GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error) { + agent, err := s.repo.GetAgentByName(ctx, name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAgentNotFound + } + return nil, err + } + return agent, nil +} + +// CreateAgent creates a new utility agent. +func (s *Service) CreateAgent(ctx context.Context, name, description, prompt, agentID, model string) (*models.UtilityAgent, error) { + name = strings.TrimSpace(name) + prompt = strings.TrimSpace(prompt) + agentID = strings.TrimSpace(agentID) + model = strings.TrimSpace(model) + + if name == "" || prompt == "" || agentID == "" || model == "" { + return nil, ErrInvalidAgent + } + + agent := &models.UtilityAgent{ + Name: name, + Description: description, + Prompt: prompt, + AgentID: agentID, + Model: model, + Builtin: false, + } + + if err := s.repo.CreateAgent(ctx, agent); err != nil { + return nil, err + } + return agent, nil +} + +// UpdateAgent updates an existing utility agent. +func (s *Service) UpdateAgent(ctx context.Context, id string, name, description, prompt, agentID, model *string, enabled *bool) (*models.UtilityAgent, error) { + agent, err := s.repo.GetAgentByID(ctx, id) + if err != nil { + return nil, ErrAgentNotFound + } + + if name != nil { + trimmed := strings.TrimSpace(*name) + if trimmed == "" { + return nil, ErrInvalidAgent + } + agent.Name = trimmed + } + if description != nil { + agent.Description = strings.TrimSpace(*description) + } + if prompt != nil { + trimmed := strings.TrimSpace(*prompt) + if trimmed == "" { + return nil, ErrInvalidAgent + } + agent.Prompt = trimmed + } + if agentID != nil { + agent.AgentID = strings.TrimSpace(*agentID) + } + if model != nil { + agent.Model = strings.TrimSpace(*model) + } + if enabled != nil { + // Allow enabling even with empty agent_id/model - defaults can be used at execution time. + // For custom (non-builtin) agents, we still require agent_id and model. + if *enabled && !agent.Builtin && (agent.AgentID == "" || agent.Model == "") { + return nil, ErrInvalidAgent + } + agent.Enabled = *enabled + } + + if err := s.repo.UpdateAgent(ctx, agent); err != nil { + return nil, err + } + return agent, nil +} + +// DeleteAgent deletes a utility agent. +func (s *Service) DeleteAgent(ctx context.Context, id string) error { + agent, err := s.repo.GetAgentByID(ctx, id) + if err != nil { + return ErrAgentNotFound + } + if agent.Builtin { + return ErrBuiltinAgent + } + return s.repo.DeleteAgent(ctx, id) +} + +// ResolvePrompt resolves template variables in the agent's prompt. +func (s *Service) ResolvePrompt(ctx context.Context, utilityID string, tmplCtx *template.Context) (string, error) { + agent, err := s.repo.GetAgentByID(ctx, utilityID) + if err != nil { + return "", ErrAgentNotFound + } + return s.templateEngine.Resolve(agent.Prompt, tmplCtx) +} + +// GetAvailableVariables returns the list of available template variables. +func (s *Service) GetAvailableVariables() []template.VariableInfo { + return s.templateEngine.AvailableVariables() +} + +// DefaultUtilitySettings contains the user's default utility agent/model settings. +type DefaultUtilitySettings struct { + AgentID string + Model string +} + +// PreparePromptRequest prepares a prompt request by resolving the template. +// If the utility agent has empty AgentID/Model, the defaults are used. +func (s *Service) PreparePromptRequest(ctx context.Context, utilityID string, tmplCtx *template.Context, defaults *DefaultUtilitySettings) (*PromptRequest, error) { + agent, err := s.repo.GetAgentByID(ctx, utilityID) + if err != nil { + return nil, ErrAgentNotFound + } + + // Resolve template + resolvedPrompt, err := s.templateEngine.Resolve(agent.Prompt, tmplCtx) + if err != nil { + return nil, err + } + + // Use agent's configured values, fall back to defaults if empty + agentCLI := agent.AgentID + model := agent.Model + if agentCLI == "" && defaults != nil { + agentCLI = defaults.AgentID + } + if model == "" && defaults != nil { + model = defaults.Model + } + + return &PromptRequest{ + UtilityID: utilityID, + ResolvedPrompt: resolvedPrompt, + AgentCLI: agentCLI, + Model: model, + }, nil +} + +// PromptRequest contains the prepared request for executing a utility prompt. +type PromptRequest struct { + UtilityID string + ResolvedPrompt string + AgentCLI string // The inference agent ID (e.g., "claude-code", "amp") + Model string // The model to use +} + +// CreateCall creates a new call record (for tracking history). +func (s *Service) CreateCall(ctx context.Context, utilityID, sessionID, resolvedPrompt, model string) (*models.UtilityAgentCall, error) { + call := &models.UtilityAgentCall{ + UtilityID: utilityID, + SessionID: sessionID, + ResolvedPrompt: resolvedPrompt, + Model: model, + Status: "pending", + CreatedAt: time.Now().UTC(), + } + if err := s.repo.CreateCall(ctx, call); err != nil { + return nil, err + } + return call, nil +} + +// CompleteCall marks a call as completed with the response. +func (s *Service) CompleteCall(ctx context.Context, callID, response string, promptTokens, responseTokens, durationMs int) error { + call, err := s.repo.GetCallByID(ctx, callID) + if err != nil { + return ErrCallNotFound + } + now := time.Now().UTC() + call.Response = response + call.PromptTokens = promptTokens + call.ResponseTokens = responseTokens + call.DurationMs = durationMs + call.Status = "completed" + call.CompletedAt = &now + return s.repo.UpdateCall(ctx, call) +} + +// FailCall marks a call as failed with an error message. +func (s *Service) FailCall(ctx context.Context, callID, errorMessage string, durationMs int) error { + call, err := s.repo.GetCallByID(ctx, callID) + if err != nil { + return ErrCallNotFound + } + now := time.Now().UTC() + call.ErrorMessage = errorMessage + call.DurationMs = durationMs + call.Status = "failed" + call.CompletedAt = &now + return s.repo.UpdateCall(ctx, call) +} + +// ListCalls returns the call history for a utility agent. +func (s *Service) ListCalls(ctx context.Context, utilityID string, limit int) ([]*models.UtilityAgentCall, error) { + return s.repo.ListCalls(ctx, utilityID, limit) +} diff --git a/apps/backend/internal/utility/store/builtins.go b/apps/backend/internal/utility/store/builtins.go new file mode 100644 index 0000000000..0bb744b114 --- /dev/null +++ b/apps/backend/internal/utility/store/builtins.go @@ -0,0 +1,182 @@ +package store + +import ( + "database/sql" + "fmt" + "time" + + "github.com/kandev/kandev/internal/utility/models" +) + +// seedBuiltinAgents creates the default built-in utility agents if they don't exist. +func (r *sqliteRepository) seedBuiltinAgents() error { + builtinAgents := r.getBuiltinAgents() + + for _, agent := range builtinAgents { + // Check if agent already exists + var exists bool + err := r.db.QueryRow(r.db.Rebind("SELECT 1 FROM utility_agents WHERE id = ?"), agent.ID).Scan(&exists) + if err != nil && err != sql.ErrNoRows { + return err + } + if exists { + continue + } + + // Insert built-in agent (disabled by default, user must configure agent/model) + _, err = r.db.Exec(r.db.Rebind(` + INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), agent.ID, agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, + 1, 0, agent.CreatedAt, agent.UpdatedAt) + if err != nil { + return fmt.Errorf("failed to insert built-in agent %s: %w", agent.ID, err) + } + } + + return nil +} + +// getBuiltinAgents returns the predefined built-in utility agents. +func (r *sqliteRepository) getBuiltinAgents() []*models.UtilityAgent { + now := time.Now().UTC() + return []*models.UtilityAgent{ + r.builtinCommitMessage(now), + r.builtinBranchName(now), + r.builtinPRDescription(now), + r.builtinEnhancePrompt(now), + } +} + +func (r *sqliteRepository) builtinCommitMessage(now time.Time) *models.UtilityAgent { + return &models.UtilityAgent{ + ID: "builtin-commit-message", + Name: "commit-message", + Description: "Generate a commit message based on staged changes", + AgentID: "", // User must configure + Model: "", // User must configure + Builtin: true, + Enabled: false, // Disabled until user configures + CreatedAt: now, + UpdatedAt: now, + Prompt: `Generate a concise git commit message for the following changes. + +## Staged Changes (git diff --staged): +{{GitDiff}} + +## Instructions: +1. Follow the Conventional Commits format: (): +2. Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore +3. Scope is optional but recommended (e.g., api, ui, db) +4. Description should be imperative mood ("add" not "added") +5. Keep the first line under 72 characters +6. If needed, add a blank line and bullet points for details + +## Output Format: +Return ONLY the commit message, no explanations or markdown.`, + } +} + +func (r *sqliteRepository) builtinBranchName(now time.Time) *models.UtilityAgent { + return &models.UtilityAgent{ + ID: "builtin-branch-name", + Name: "branch-name", + Description: "Generate a branch name based on task description", + AgentID: "", // User must configure + Model: "", // User must configure + Builtin: true, + Enabled: false, // Disabled until user configures + CreatedAt: now, + UpdatedAt: now, + Prompt: `Generate a git branch name for the following task. + +## Task Title: +{{TaskTitle}} + +## Task Description: +{{TaskDescription}} + +## Instructions: +1. Use kebab-case (lowercase with hyphens) +2. Start with a type prefix: feature/, fix/, refactor/, docs/, chore/ +3. Keep it concise but descriptive (max 50 characters total) +4. Include relevant keywords from the task +5. Avoid special characters except hyphens + +## Output Format: +Return ONLY the branch name, no explanations. +Example: feature/add-user-authentication`, + } +} + +func (r *sqliteRepository) builtinPRDescription(now time.Time) *models.UtilityAgent { + return &models.UtilityAgent{ + ID: "builtin-pr-description", + Name: "pr-description", + Description: "Generate a PR description based on commits and changes", + AgentID: "", // User must configure + Model: "", // User must configure + Builtin: true, + Enabled: false, // Disabled until user configures + CreatedAt: now, + UpdatedAt: now, + Prompt: `Generate a Pull Request description based on the following information. + +## Commits: +{{CommitLog}} + +## Changed Files: +{{ChangedFiles}} + +## Diff Summary: +{{DiffSummary}} + +## Instructions: +1. Start with a clear title summarizing the PR +2. Include a "## Summary" section explaining what this PR does +3. Add a "## Changes" section with bullet points of key changes +4. Include a "## Testing" section describing how to test +5. If applicable, mention breaking changes or migration steps +6. Keep it professional and concise + +## Output Format: +Return ONLY the PR description in Markdown format. And nothing else.`, + } +} + +func (r *sqliteRepository) builtinEnhancePrompt(now time.Time) *models.UtilityAgent { + return &models.UtilityAgent{ + ID: "builtin-enhance-prompt", + Name: "enhance-prompt", + Description: "Enhance and expand a user prompt with context and clarity", + AgentID: "", // User must configure + Model: "", // User must configure + Builtin: true, + Enabled: false, // Disabled until user configures + CreatedAt: now, + UpdatedAt: now, + Prompt: `Enhance the following user prompt to be clearer and more actionable for a coding assistant. + +## User's Original Prompt: +{{UserPrompt}} + +## Task Context: +Title: {{TaskTitle}} +Description: {{TaskDescription}} + +## Current Git Changes (if any): +{{GitDiff}} + +## Instructions: +1. Expand vague requests into specific, actionable instructions +2. Add relevant context from the task and git changes +3. Clarify ambiguous terms or requirements +4. Structure the prompt clearly with numbered steps if appropriate +5. Keep the original intent - don't change what the user wants to do +6. Be concise - don't add unnecessary verbosity + +## Output Format: +Return ONLY the enhanced prompt text, ready to be sent to the coding assistant. +Do not include explanations or meta-commentary around the prompt.`, + } +} diff --git a/apps/backend/internal/utility/store/interface.go b/apps/backend/internal/utility/store/interface.go new file mode 100644 index 0000000000..1516471938 --- /dev/null +++ b/apps/backend/internal/utility/store/interface.go @@ -0,0 +1,24 @@ +package store + +import ( + "context" + + "github.com/kandev/kandev/internal/utility/models" +) + +// Repository defines the interface for utility agent storage. +type Repository interface { + // Agents + ListAgents(ctx context.Context) ([]*models.UtilityAgent, error) + GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) + GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error) + CreateAgent(ctx context.Context, agent *models.UtilityAgent) error + UpdateAgent(ctx context.Context, agent *models.UtilityAgent) error + DeleteAgent(ctx context.Context, id string) error + + // Calls (history) + ListCalls(ctx context.Context, utilityID string, limit int) ([]*models.UtilityAgentCall, error) + GetCallByID(ctx context.Context, id string) (*models.UtilityAgentCall, error) + CreateCall(ctx context.Context, call *models.UtilityAgentCall) error + UpdateCall(ctx context.Context, call *models.UtilityAgentCall) error +} diff --git a/apps/backend/internal/utility/store/provider.go b/apps/backend/internal/utility/store/provider.go new file mode 100644 index 0000000000..111fdab75d --- /dev/null +++ b/apps/backend/internal/utility/store/provider.go @@ -0,0 +1,12 @@ +package store + +import "github.com/jmoiron/sqlx" + +// Provide creates the SQLite utility agents store using separate writer and reader pools. +func Provide(writer, reader *sqlx.DB) (*sqliteRepository, func() error, error) { + repo, err := newSQLiteRepositoryWithDB(writer, reader) + if err != nil { + return nil, nil, err + } + return repo, repo.Close, nil +} diff --git a/apps/backend/internal/utility/store/sqlite.go b/apps/backend/internal/utility/store/sqlite.go new file mode 100644 index 0000000000..ea95950524 --- /dev/null +++ b/apps/backend/internal/utility/store/sqlite.go @@ -0,0 +1,334 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/utility/models" +) + +type sqliteRepository struct { + db *sqlx.DB // writer + ro *sqlx.DB // reader + ownsDB bool +} + +var _ Repository = (*sqliteRepository)(nil) + +func newSQLiteRepositoryWithDB(writer, reader *sqlx.DB) (*sqliteRepository, error) { + return newSQLiteRepository(writer, reader, false) +} + +func newSQLiteRepository(writer, reader *sqlx.DB, ownsDB bool) (*sqliteRepository, error) { + repo := &sqliteRepository{db: writer, ro: reader, ownsDB: ownsDB} + if err := repo.initSchema(); err != nil { + if ownsDB { + if closeErr := writer.Close(); closeErr != nil { + return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr) + } + } + return nil, fmt.Errorf("failed to initialize schema: %w", err) + } + return repo, nil +} + +func (r *sqliteRepository) initSchema() error { + // New simplified schema for utility agents + schema := ` + CREATE TABLE IF NOT EXISTS utility_agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + prompt TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT 'claude-code', + model TEXT NOT NULL DEFAULT '', + builtin INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + + CREATE TABLE IF NOT EXISTS utility_agent_calls ( + id TEXT PRIMARY KEY, + utility_id TEXT NOT NULL, + session_id TEXT NOT NULL DEFAULT '', + resolved_prompt TEXT NOT NULL DEFAULT '', + response TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL DEFAULT '', + prompt_tokens INTEGER NOT NULL DEFAULT 0, + response_tokens INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + error_message TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + FOREIGN KEY (utility_id) REFERENCES utility_agents(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_utility_calls_utility_id ON utility_agent_calls(utility_id); + CREATE INDEX IF NOT EXISTS idx_utility_calls_session_id ON utility_agent_calls(session_id); + CREATE INDEX IF NOT EXISTS idx_utility_calls_created_at ON utility_agent_calls(created_at); + ` + if _, err := r.db.Exec(schema); err != nil { + return err + } + + // Add enabled column if it doesn't exist (migration for existing DBs) + _, _ = r.db.Exec(`ALTER TABLE utility_agents ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`) + + // Seed built-in agents + if err := r.seedBuiltinAgents(); err != nil { + return fmt.Errorf("failed to seed built-in agents: %w", err) + } + + // Migration: fix template placeholders from {{.Key}} to {{Key}} format + if err := r.migrateTemplatePlaceholders(); err != nil { + return fmt.Errorf("failed to migrate template placeholders: %w", err) + } + + return nil +} + +// migrateTemplatePlaceholders fixes old {{.Key}} format to {{Key}} format in builtin prompts. +func (r *sqliteRepository) migrateTemplatePlaceholders() error { + placeholders := []string{ + "GitDiff", "CommitLog", "ChangedFiles", "DiffSummary", "BranchName", "BaseBranch", + "TaskTitle", "TaskDescription", "SessionID", "WorkspacePath", "UserPrompt", + } + for _, p := range placeholders { + old := "{{." + p + "}}" + new := "{{" + p + "}}" + _, _ = r.db.Exec(r.db.Rebind(`UPDATE utility_agents SET prompt = REPLACE(prompt, ?, ?) WHERE builtin = 1`), old, new) + } + return nil +} + +func (r *sqliteRepository) Close() error { + if !r.ownsDB { + return nil + } + var errs []error + if err := r.db.Close(); err != nil { + errs = append(errs, err) + } + if r.ro != r.db { + if err := r.ro.Close(); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errs[0] + } + return nil +} + +func (r *sqliteRepository) ListAgents(ctx context.Context) ([]*models.UtilityAgent, error) { + rows, err := r.ro.QueryContext(ctx, ` + SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at + FROM utility_agents + ORDER BY builtin DESC, name ASC + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return r.scanAgentRows(rows) +} + +func (r *sqliteRepository) scanAgentRows(rows *sql.Rows) ([]*models.UtilityAgent, error) { + var agents []*models.UtilityAgent + for rows.Next() { + agent := &models.UtilityAgent{} + var builtinInt, enabledInt int + if err := rows.Scan( + &agent.ID, &agent.Name, &agent.Description, &agent.Prompt, + &agent.AgentID, &agent.Model, &builtinInt, &enabledInt, + &agent.CreatedAt, &agent.UpdatedAt, + ); err != nil { + return nil, err + } + agent.Builtin = builtinInt == 1 + agent.Enabled = enabledInt == 1 + agents = append(agents, agent) + } + return agents, rows.Err() +} + +func (r *sqliteRepository) GetAgentByID(ctx context.Context, id string) (*models.UtilityAgent, error) { + row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at + FROM utility_agents WHERE id = ? + `), id) + return r.scanAgentRow(row) +} + +func (r *sqliteRepository) GetAgentByName(ctx context.Context, name string) (*models.UtilityAgent, error) { + row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at + FROM utility_agents WHERE name = ? + `), name) + return r.scanAgentRow(row) +} + +func (r *sqliteRepository) scanAgentRow(row *sql.Row) (*models.UtilityAgent, error) { + agent := &models.UtilityAgent{} + var builtinInt, enabledInt int + if err := row.Scan( + &agent.ID, &agent.Name, &agent.Description, &agent.Prompt, + &agent.AgentID, &agent.Model, &builtinInt, &enabledInt, + &agent.CreatedAt, &agent.UpdatedAt, + ); err != nil { + return nil, err + } + agent.Builtin = builtinInt == 1 + agent.Enabled = enabledInt == 1 + return agent, nil +} + +func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.UtilityAgent) error { + if agent.ID == "" { + agent.ID = uuid.New().String() + } + agent.Name = strings.TrimSpace(agent.Name) + agent.Description = strings.TrimSpace(agent.Description) + agent.Prompt = strings.TrimSpace(agent.Prompt) + if agent.CreatedAt.IsZero() { + agent.CreatedAt = time.Now().UTC() + } + agent.UpdatedAt = time.Now().UTC() + + builtinInt := 0 + if agent.Builtin { + builtinInt = 1 + } + enabledInt := 0 + if agent.Enabled { + enabledInt = 1 + } + + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO utility_agents (id, name, description, prompt, agent_id, model, builtin, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), agent.ID, agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, + builtinInt, enabledInt, agent.CreatedAt, agent.UpdatedAt) + return err +} + +func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.UtilityAgent) error { + if agent == nil { + return errors.New("agent is nil") + } + agent.Name = strings.TrimSpace(agent.Name) + agent.Description = strings.TrimSpace(agent.Description) + agent.Prompt = strings.TrimSpace(agent.Prompt) + agent.UpdatedAt = time.Now().UTC() + + enabledInt := 0 + if agent.Enabled { + enabledInt = 1 + } + + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE utility_agents + SET name = ?, description = ?, prompt = ?, agent_id = ?, model = ?, enabled = ?, updated_at = ? + WHERE id = ? + `), agent.Name, agent.Description, agent.Prompt, agent.AgentID, agent.Model, enabledInt, agent.UpdatedAt, agent.ID) + return err +} + +func (r *sqliteRepository) DeleteAgent(ctx context.Context, id string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(`DELETE FROM utility_agents WHERE id = ? AND builtin = 0`), id) + return err +} + +func (r *sqliteRepository) ListCalls(ctx context.Context, utilityID string, limit int) ([]*models.UtilityAgentCall, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at + FROM utility_agent_calls + WHERE utility_id = ? + ORDER BY created_at DESC + LIMIT ? + `), utilityID, limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return r.scanCallRows(rows) +} + +func (r *sqliteRepository) scanCallRows(rows *sql.Rows) ([]*models.UtilityAgentCall, error) { + var calls []*models.UtilityAgentCall + for rows.Next() { + call := &models.UtilityAgentCall{} + if err := rows.Scan( + &call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response, + &call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, + &call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt, + ); err != nil { + return nil, err + } + calls = append(calls, call) + } + return calls, rows.Err() +} + +func (r *sqliteRepository) GetCallByID(ctx context.Context, id string) (*models.UtilityAgentCall, error) { + row := r.ro.QueryRowContext(ctx, r.ro.Rebind(` + SELECT id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at + FROM utility_agent_calls WHERE id = ? + `), id) + call := &models.UtilityAgentCall{} + if err := row.Scan( + &call.ID, &call.UtilityID, &call.SessionID, &call.ResolvedPrompt, &call.Response, + &call.Model, &call.PromptTokens, &call.ResponseTokens, &call.DurationMs, + &call.Status, &call.ErrorMessage, &call.CreatedAt, &call.CompletedAt, + ); err != nil { + return nil, err + } + return call, nil +} + +func (r *sqliteRepository) CreateCall(ctx context.Context, call *models.UtilityAgentCall) error { + if call.ID == "" { + call.ID = uuid.New().String() + } + if call.CreatedAt.IsZero() { + call.CreatedAt = time.Now().UTC() + } + if call.Status == "" { + call.Status = "pending" + } + + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + INSERT INTO utility_agent_calls (id, utility_id, session_id, resolved_prompt, response, model, prompt_tokens, response_tokens, duration_ms, status, error_message, created_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), call.ID, call.UtilityID, call.SessionID, call.ResolvedPrompt, call.Response, + call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs, + call.Status, call.ErrorMessage, call.CreatedAt, call.CompletedAt) + return err +} + +func (r *sqliteRepository) UpdateCall(ctx context.Context, call *models.UtilityAgentCall) error { + if call == nil { + return errors.New("call is nil") + } + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE utility_agent_calls + SET response = ?, model = ?, prompt_tokens = ?, response_tokens = ?, duration_ms = ?, status = ?, error_message = ?, completed_at = ? + WHERE id = ? + `), call.Response, call.Model, call.PromptTokens, call.ResponseTokens, call.DurationMs, + call.Status, call.ErrorMessage, call.CompletedAt, call.ID) + return err +} diff --git a/apps/backend/internal/utility/template/engine.go b/apps/backend/internal/utility/template/engine.go new file mode 100644 index 0000000000..5196de9188 --- /dev/null +++ b/apps/backend/internal/utility/template/engine.go @@ -0,0 +1,112 @@ +package template + +import ( + "regexp" + "strings" +) + +// Context holds the data available for template resolution. +type Context struct { + // Git-related + GitDiff string // Output of git diff --staged or git diff + CommitLog string // Output of git log for the branch + ChangedFiles string // List of changed files + DiffSummary string // Summary of changes (insertions, deletions) + BranchName string // Current branch name + BaseBranch string // Base branch (main/master) + + // Task-related + TaskTitle string // Title of the current task + TaskDescription string // Description of the current task + + // Session-related + SessionID string // Current session ID + WorkspacePath string // Path to the workspace/worktree + + // User input + UserPrompt string // User's original prompt text (for enhance-prompt) + + // Custom key-value pairs for extensibility + Custom map[string]string +} + +// Engine resolves template variables in prompts. +type Engine struct{} + +// NewEngine creates a new template engine. +func NewEngine() *Engine { + return &Engine{} +} + +// templatePattern matches {{Key}} placeholders. +var templatePattern = regexp.MustCompile(`\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}`) + +// Resolve resolves template variables in the given prompt using the provided context. +// Uses {{Key}} format (no dot) for consistency with scriptengine. +func (e *Engine) Resolve(promptTemplate string, ctx *Context) (string, error) { + if ctx == nil { + ctx = &Context{} + } + data := e.buildTemplateData(ctx) + + // Use regex replacement for deterministic single-pass substitution + result := templatePattern.ReplaceAllStringFunc(promptTemplate, func(match string) string { + // Extract key from {{Key}} + key := strings.TrimSuffix(strings.TrimPrefix(match, "{{"), "}}") + if val, ok := data[key]; ok { + return val + } + return match // Leave unknown placeholders unchanged + }) + + return result, nil +} + +// buildTemplateData creates the data map for template execution. +func (e *Engine) buildTemplateData(ctx *Context) map[string]string { + data := map[string]string{ + "GitDiff": ctx.GitDiff, + "CommitLog": ctx.CommitLog, + "ChangedFiles": ctx.ChangedFiles, + "DiffSummary": ctx.DiffSummary, + "BranchName": ctx.BranchName, + "BaseBranch": ctx.BaseBranch, + "TaskTitle": ctx.TaskTitle, + "TaskDescription": ctx.TaskDescription, + "SessionID": ctx.SessionID, + "WorkspacePath": ctx.WorkspacePath, + "UserPrompt": ctx.UserPrompt, + } + + // Add custom key-value pairs + for k, v := range ctx.Custom { + data[k] = v + } + + return data +} + +// AvailableVariables returns the list of available template variables. +func (e *Engine) AvailableVariables() []VariableInfo { + return []VariableInfo{ + {Name: "GitDiff", Description: "Output of git diff --staged or git diff", Example: "diff --git a/main.go...", Category: "git"}, + {Name: "CommitLog", Description: "Git log for the current branch", Example: "abc123 Fix bug in handler", Category: "git"}, + {Name: "ChangedFiles", Description: "List of changed files", Example: "src/main.go\nsrc/utils.go", Category: "git"}, + {Name: "DiffSummary", Description: "Summary of changes (files, insertions, deletions)", Example: "3 files, +50 -10", Category: "git"}, + {Name: "BranchName", Description: "Current git branch name", Example: "feature/add-login", Category: "git"}, + {Name: "BaseBranch", Description: "Base branch (main/master/develop)", Example: "main", Category: "git"}, + {Name: "TaskTitle", Description: "Title of the current task", Example: "Add user authentication", Category: "task"}, + {Name: "TaskDescription", Description: "Description of the current task", Example: "Implement OAuth2 login flow", Category: "task"}, + {Name: "SessionID", Description: "Current session ID", Example: "sess_abc123", Category: "session"}, + {Name: "WorkspacePath", Description: "Path to the workspace/worktree", Example: "/home/user/project", Category: "session"}, + {Name: "UserPrompt", Description: "User's original prompt text", Example: "Fix the login bug", Category: "input"}, + } +} + +// VariableInfo describes a template variable. +type VariableInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Example string `json:"example"` + Category string `json:"category"` // "git", "task", "session" +} diff --git a/apps/web/app/settings/utility/page.tsx b/apps/web/app/settings/utility/page.tsx new file mode 100644 index 0000000000..f008f5560e --- /dev/null +++ b/apps/web/app/settings/utility/page.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useState, useCallback, useMemo } from "react"; +import { Separator } from "@kandev/ui/separator"; +import { + listUtilityAgents, + listInferenceAgents, + updateUtilityAgent, + deleteUtilityAgent, + type UtilityAgent, + type InferenceAgent, +} from "@/lib/api/domains/utility-api"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { UtilityAgentDialog } from "@/components/settings/utility-agent-dialog"; +import { + DefaultModelSection, + PerActionOverridesSection, + CustomAgentsSection, + USE_DEFAULT, +} from "@/components/settings/utility-sections"; + +function buildAllModels(inferenceAgents: InferenceAgent[]) { + return inferenceAgents.flatMap((ia) => + ia.models.map((m) => ({ + value: `${ia.id}|${m.id}`, + label: `${ia.display_name} / ${m.name}`, + agentName: ia.display_name, + modelName: m.name, + })), + ); +} + +async function handleBuiltinChange( + agent: UtilityAgent, + value: string, + setAgents: React.Dispatch>, +) { + const isDefault = value === USE_DEFAULT; + const [agentId, model] = isDefault ? ["", ""] : value.split("|"); + await updateUtilityAgent(agent.id, { agent_id: agentId, model, enabled: true }); + setAgents((prev) => + prev.map((a) => (a.id === agent.id ? { ...a, agent_id: agentId, model } : a)), + ); +} + +export default function UtilityAgentsSettingsPage() { + const [agents, setAgents] = useState([]); + const [inferenceAgents, setInferenceAgents] = useState([]); + const [defaultAgentId, setDefaultAgentId] = useState(""); + const [defaultModel, setDefaultModel] = useState(""); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingAgent, setEditingAgent] = useState(null); + + const builtins = useMemo(() => agents.filter((a) => a.builtin), [agents]); + const customAgents = useMemo(() => agents.filter((a) => !a.builtin), [agents]); + const allModels = useMemo(() => buildAllModels(inferenceAgents), [inferenceAgents]); + + const fetchData = useCallback(async () => { + try { + const [agentsRes, inferenceRes, settingsRes] = await Promise.all([ + listUtilityAgents({ cache: "no-store" }), + listInferenceAgents(), + fetchUserSettings({ cache: "no-store" }), + ]); + setAgents(agentsRes.agents); + setInferenceAgents(inferenceRes.agents); + setDefaultAgentId(settingsRes.settings.default_utility_agent_id || ""); + setDefaultModel(settingsRes.settings.default_utility_model || ""); + } catch { + setAgents([]); + setInferenceAgents([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + const handleDefaultChange = async (agentId: string, model: string) => { + setDefaultAgentId(agentId); + setDefaultModel(model); + await updateUserSettings({ default_utility_agent_id: agentId, default_utility_model: model }); + }; + + const openEditDialog = (agent: UtilityAgent | null) => { + setEditingAgent(agent); + setDialogOpen(true); + }; + + const closeDialog = () => { + setDialogOpen(false); + setEditingAgent(null); + fetchData(); + }; + + if (loading) + return
Loading...
; + + return ( +
+
+

Utility Agents

+

+ One-shot AI helpers for commits, PRs, and prompts. +

+
+ + + handleBuiltinChange(agent, value, setAgents)} + onEdit={openEditDialog} + /> + openEditDialog(null)} + onEdit={openEditDialog} + onDelete={async (agent) => { + await deleteUtilityAgent(agent.id); + setAgents((prev) => prev.filter((a) => a.id !== agent.id)); + }} + /> + +
+ ); +} diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 88ac6527a9..2f5db95d20 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -14,6 +14,7 @@ import { IconMessageCircle, IconBrandGithub, IconSparkles, + IconWand, } from "@tabler/icons-react"; import { Sidebar, @@ -298,6 +299,16 @@ export function SettingsAppSidebar() { + {/* Utility Agents */} + + + + + Utility Agents + + + + diff --git a/apps/web/components/settings/utility-agent-dialog.tsx b/apps/web/components/settings/utility-agent-dialog.tsx new file mode 100644 index 0000000000..2ba6f4ff00 --- /dev/null +++ b/apps/web/components/settings/utility-agent-dialog.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { + type UtilityAgent, + createUtilityAgent, + updateUtilityAgent, + getTemplateVariables, + listInferenceAgents, + type TemplateVariable, + type InferenceAgent, + type InferenceModel, +} from "@/lib/api/domains/utility-api"; +import { ScriptEditor } from "./profile-edit/script-editor"; +import type { ScriptPlaceholder } from "./profile-edit/script-editor-completions"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + agent: UtilityAgent | null; + onSuccess: () => void; +}; + +type FormState = { + name: string; + description: string; + prompt: string; + agent_id: string; + model: string; +}; + +const defaultFormState: FormState = { + name: "", + description: "", + prompt: "", + agent_id: "claude-code", + model: "", +}; + +function toScriptPlaceholders(variables: TemplateVariable[]): ScriptPlaceholder[] { + return variables.map((v) => ({ + key: v.name, + description: v.description, + example: v.example, + executor_types: [], + })); +} + +type AgentModelSelectProps = { + agentId: string; + model: string; + inferenceAgents: InferenceAgent[]; + availableModels: InferenceModel[]; + onAgentChange: (agentId: string) => void; + onModelChange: (model: string) => void; +}; + +function AgentModelSelect({ + agentId, + model, + inferenceAgents, + availableModels, + onAgentChange, + onModelChange, +}: AgentModelSelectProps) { + return ( +
+
+ + +
+
+ + +
+
+ ); +} + +type UtilityAgentFormProps = { + form: FormState; + setForm: React.Dispatch>; + isBuiltin: boolean; + inferenceAgents: InferenceAgent[]; + availableModels: InferenceModel[]; + placeholders: ScriptPlaceholder[]; +}; + +function UtilityAgentForm({ + form, + setForm, + isBuiltin, + inferenceAgents, + availableModels, + placeholders, +}: UtilityAgentFormProps) { + return ( +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + placeholder="e.g., commit-message" + disabled={isBuiltin} + /> +
+
+ + setForm((f) => ({ ...f, description: e.target.value }))} + placeholder="Brief description of what this agent does" + /> +
+ setForm((f) => ({ ...f, agent_id: v, model: "" }))} + onModelChange={(v) => setForm((f) => ({ ...f, model: v }))} + /> +
+ +
+ setForm((f) => ({ ...f, prompt: v }))} + language="plaintext" + height="200px" + placeholders={placeholders} + lineNumbers="off" + /> +
+

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

+
+
+ ); +} + +export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Props) { + const [form, setForm] = useState(defaultFormState); + const [saving, setSaving] = useState(false); + const [placeholders, setPlaceholders] = useState([]); + const [inferenceAgents, setInferenceAgents] = useState([]); + const isEdit = Boolean(agent); + + // Fetch template variables and inference agents + useEffect(() => { + getTemplateVariables() + .then(({ variables }) => setPlaceholders(toScriptPlaceholders(variables))) + .catch(() => setPlaceholders([])); + + listInferenceAgents() + .then(({ agents }) => setInferenceAgents(agents)) + .catch(() => setInferenceAgents([])); + }, []); + + // Get models for the selected agent + const selectedAgent = useMemo( + () => inferenceAgents.find((a) => a.id === form.agent_id), + [inferenceAgents, form.agent_id], + ); + + const availableModels = selectedAgent?.models ?? []; + + // Auto-select default model when agent changes + useEffect(() => { + if (selectedAgent && !form.model) { + const defaultModel = selectedAgent.models.find((m) => m.is_default); + if (defaultModel) { + setForm((f) => ({ ...f, model: defaultModel.id })); + } + } + }, [selectedAgent, form.model]); + + useEffect(() => { + if (agent) { + setForm({ + name: agent.name, + description: agent.description, + prompt: agent.prompt, + agent_id: agent.agent_id || "claude-code", + model: agent.model || "", + }); + } else { + setForm(defaultFormState); + } + }, [agent, open]); + + const handleSubmit = async () => { + setSaving(true); + try { + const data = { + name: form.name, + description: form.description, + prompt: form.prompt, + agent_id: form.agent_id, + model: form.model, + }; + + if (isEdit && agent) { + await updateUtilityAgent(agent.id, data); + } else { + await createUtilityAgent(data); + } + onSuccess(); + } catch (error) { + console.error("Failed to save agent:", error); + } finally { + setSaving(false); + } + }; + + const dialogTitle = isEdit ? "Edit Utility Agent" : "Create Utility Agent"; + const getSubmitLabel = () => { + if (saving) return "Saving..."; + return isEdit ? "Save Changes" : "Create Agent"; + }; + + return ( + + + + {dialogTitle} + + + + + + + + + ); +} diff --git a/apps/web/components/settings/utility-sections.tsx b/apps/web/components/settings/utility-sections.tsx new file mode 100644 index 0000000000..23e1efa73a --- /dev/null +++ b/apps/web/components/settings/utility-sections.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { IconPencil, IconPlus, IconTrash } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Label } from "@kandev/ui/label"; +import { Separator } from "@kandev/ui/separator"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import type { UtilityAgent, InferenceAgent } from "@/lib/api/domains/utility-api"; + +const USE_DEFAULT = "__USE_DEFAULT__"; + +export type ModelOption = { value: string; label: string; agentName: string; modelName: string }; + +// Default model selector section +type DefaultModelSectionProps = { + inferenceAgents: InferenceAgent[]; + defaultAgentId: string; + defaultModel: string; + onDefaultChange: (agentId: string, model: string) => void; +}; + +export function DefaultModelSection({ + inferenceAgents, + defaultAgentId, + defaultModel, + onDefaultChange, +}: DefaultModelSectionProps) { + const selectedAgent = inferenceAgents.find((a) => a.id === defaultAgentId); + const modelOptions = selectedAgent?.models ?? []; + + return ( +
+
+

Default utility agent model

+

+ Select the default model used by all built-in utility actions. +

+
+
+
+ + +
+
+ + +
+
+
+ ); +} + +// Builtin action row +type BuiltinActionRowProps = { + agent: UtilityAgent; + allModels: ModelOption[]; + defaultLabel: string; + onModelChange: (agent: UtilityAgent, value: string) => void; + onEdit: (agent: UtilityAgent) => void; +}; + +export function BuiltinActionRow({ + agent, + allModels, + defaultLabel, + onModelChange, + onEdit, +}: BuiltinActionRowProps) { + const currentValue = + agent.agent_id && agent.model ? `${agent.agent_id}|${agent.model}` : USE_DEFAULT; + + return ( +
+
onEdit(agent)}> +
{agent.name}
+

{agent.description}

+
+ +
+ ); +} + +// Custom agent row +type CustomAgentRowProps = { + agent: UtilityAgent; + onEdit: (agent: UtilityAgent) => void; + onDelete: (agent: UtilityAgent) => void; +}; + +export function CustomAgentRow({ agent, onEdit, onDelete }: CustomAgentRowProps) { + return ( +
+
+
{agent.name}
+

{agent.description}

+
+
+ {agent.model || "Not configured"} + + +
+
+ ); +} + +// Per-action overrides section +type PerActionOverridesSectionProps = { + builtins: UtilityAgent[]; + allModels: ModelOption[]; + defaultModel: string; + onModelChange: (agent: UtilityAgent, value: string) => void; + onEdit: (agent: UtilityAgent) => void; +}; + +export function PerActionOverridesSection({ + builtins, + allModels, + defaultModel, + onModelChange, + onEdit, +}: PerActionOverridesSectionProps) { + if (builtins.length === 0) return null; + + const defaultLabel = defaultModel ? `Default (${defaultModel})` : "Default"; + + return ( +
+ +

Actions

+
+ {builtins.map((agent) => ( + + ))} +
+
+ ); +} + +// Custom agents section +type CustomAgentsSectionProps = { + agents: UtilityAgent[]; + onAdd: () => void; + onEdit: (agent: UtilityAgent) => void; + onDelete: (agent: UtilityAgent) => void; +}; + +export function CustomAgentsSection({ agents, onAdd, onEdit, onDelete }: CustomAgentsSectionProps) { + return ( +
+
+
+

Custom utility agents

+

+ Create your own utility agents with custom prompts. +

+
+ +
+ {agents.length === 0 && ( +

No custom utility agents.

+ )} + {agents.length > 0 && ( +
+ {agents.map((agent) => ( + + ))} +
+ )} +
+ ); +} + +// Export the USE_DEFAULT constant +export { USE_DEFAULT }; diff --git a/apps/web/components/task/changes-panel-dialogs.tsx b/apps/web/components/task/changes-panel-dialogs.tsx index 95251662c8..c3727aa9e3 100644 --- a/apps/web/components/task/changes-panel-dialogs.tsx +++ b/apps/web/components/task/changes-panel-dialogs.tsx @@ -23,6 +23,7 @@ import { } from "@kandev/ui/alert-dialog"; import { Checkbox } from "@kandev/ui/checkbox"; import { Label } from "@kandev/ui/label"; +import { Input } from "@kandev/ui/input"; import { Textarea } from "@kandev/ui/textarea"; // --- Discard Confirmation Dialog --- @@ -104,12 +105,10 @@ export function CommitDialog({ stagedAdditions={stagedAdditions} stagedDeletions={stagedDeletions} /> -