feat: add utility agents for one-shot AI tasks (#341)

This commit is contained in:
João Salavisa
2026-03-02 01:55:39 +00:00
committed by GitHub
parent 3656704263
commit ae616136bb
54 changed files with 3709 additions and 145 deletions
+6
View File
@@ -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)")
+2
View File
@@ -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,
+3
View File
@@ -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.
+8
View File
@@ -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,
}
+4
View File
@@ -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
}
+45 -14
View File
@@ -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.
+17
View File
@@ -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",
@@ -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"}
@@ -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",
@@ -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",
@@ -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.",
@@ -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",
@@ -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",
@@ -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()
}
@@ -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"`
}
@@ -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()
@@ -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
}
@@ -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)
@@ -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)
}
@@ -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)
}
@@ -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"`
}
@@ -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
+6
View File
@@ -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),
}
}
@@ -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"`
}
@@ -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 {
@@ -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
}
@@ -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
}
+177
View File
@@ -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"`
}
@@ -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})
}
@@ -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"`
}
@@ -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)
}
@@ -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: <type>(<scope>): <description>
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.`,
}
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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"
}
+141
View File
@@ -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<React.SetStateAction<UtilityAgent[]>>,
) {
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<UtilityAgent[]>([]);
const [inferenceAgents, setInferenceAgents] = useState<InferenceAgent[]>([]);
const [defaultAgentId, setDefaultAgentId] = useState("");
const [defaultModel, setDefaultModel] = useState("");
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingAgent, setEditingAgent] = useState<UtilityAgent | null>(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 <div className="py-8 text-center text-muted-foreground text-sm">Loading...</div>;
return (
<div className="space-y-8">
<header>
<h2 className="text-xl font-semibold">Utility Agents</h2>
<p className="text-sm text-muted-foreground">
One-shot AI helpers for commits, PRs, and prompts.
</p>
</header>
<Separator />
<DefaultModelSection
inferenceAgents={inferenceAgents}
defaultAgentId={defaultAgentId}
defaultModel={defaultModel}
onDefaultChange={handleDefaultChange}
/>
<PerActionOverridesSection
builtins={builtins}
allModels={allModels}
defaultModel={defaultModel}
onModelChange={(agent, value) => handleBuiltinChange(agent, value, setAgents)}
onEdit={openEditDialog}
/>
<CustomAgentsSection
agents={customAgents}
onAdd={() => openEditDialog(null)}
onEdit={openEditDialog}
onDelete={async (agent) => {
await deleteUtilityAgent(agent.id);
setAgents((prev) => prev.filter((a) => a.id !== agent.id));
}}
/>
<UtilityAgentDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
agent={editingAgent}
onSuccess={closeDialog}
/>
</div>
);
}
@@ -14,6 +14,7 @@ import {
IconMessageCircle,
IconBrandGithub,
IconSparkles,
IconWand,
} from "@tabler/icons-react";
import {
Sidebar,
@@ -298,6 +299,16 @@ export function SettingsAppSidebar() {
</SidebarMenuButton>
</SidebarMenuItem>
{/* Utility Agents */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === "/settings/utility"}>
<Link href="/settings/utility">
<IconWand className="h-4 w-4" />
<span>Utility Agents</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<ExecutorsSidebarSection pathname={pathname} executors={executors} />
<SecretsSidebarSection pathname={pathname} />
@@ -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 (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Agent</Label>
<Select value={agentId} onValueChange={onAgentChange}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{inferenceAgents.map((ia) => (
<SelectItem key={ia.id} value={ia.id}>
{ia.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Select value={model} onValueChange={onModelChange} disabled={availableModels.length === 0}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select model..." />
</SelectTrigger>
<SelectContent>
{availableModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
type UtilityAgentFormProps = {
form: FormState;
setForm: React.Dispatch<React.SetStateAction<FormState>>;
isBuiltin: boolean;
inferenceAgents: InferenceAgent[];
availableModels: InferenceModel[];
placeholders: ScriptPlaceholder[];
};
function UtilityAgentForm({
form,
setForm,
isBuiltin,
inferenceAgents,
availableModels,
placeholders,
}: UtilityAgentFormProps) {
return (
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g., commit-message"
disabled={isBuiltin}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder="Brief description of what this agent does"
/>
</div>
<AgentModelSelect
agentId={form.agent_id}
model={form.model}
inferenceAgents={inferenceAgents}
availableModels={availableModels}
onAgentChange={(v) => setForm((f) => ({ ...f, agent_id: v, model: "" }))}
onModelChange={(v) => setForm((f) => ({ ...f, model: v }))}
/>
<div className="space-y-2">
<Label>Prompt Template</Label>
<div className="border rounded-md overflow-hidden">
<ScriptEditor
value={form.prompt}
onChange={(v) => setForm((f) => ({ ...f, prompt: v }))}
language="plaintext"
height="200px"
placeholders={placeholders}
lineNumbers="off"
/>
</div>
<p className="text-xs text-muted-foreground">
Type {"{{"} to see available variables with autocomplete
</p>
</div>
</div>
);
}
export function UtilityAgentDialog({ open, onOpenChange, agent, onSuccess }: Props) {
const [form, setForm] = useState<FormState>(defaultFormState);
const [saving, setSaving] = useState(false);
const [placeholders, setPlaceholders] = useState<ScriptPlaceholder[]>([]);
const [inferenceAgents, setInferenceAgents] = useState<InferenceAgent[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
<UtilityAgentForm
form={form}
setForm={setForm}
isBuiltin={agent?.builtin ?? false}
inferenceAgents={inferenceAgents}
availableModels={availableModels}
placeholders={placeholders}
/>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
</Button>
<Button onClick={handleSubmit} disabled={saving || !form.name} className="cursor-pointer">
{getSubmitLabel()}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className="space-y-3">
<div>
<h3 className="text-base font-medium">Default utility agent model</h3>
<p className="text-sm text-muted-foreground">
Select the default model used by all built-in utility actions.
</p>
</div>
<div className="flex gap-2">
<div className="w-[180px]">
<Label className="text-xs text-muted-foreground mb-1 block">Agent</Label>
<Select value={defaultAgentId} onValueChange={(v) => onDefaultChange(v, "")}>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select agent..." />
</SelectTrigger>
<SelectContent>
{inferenceAgents.map((ia) => (
<SelectItem key={ia.id} value={ia.id}>
{ia.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[180px]">
<Label className="text-xs text-muted-foreground mb-1 block">Model</Label>
<Select
value={defaultModel}
onValueChange={(v) => onDefaultChange(defaultAgentId, v)}
disabled={!defaultAgentId}
>
<SelectTrigger className="cursor-pointer">
<SelectValue placeholder="Select model..." />
</SelectTrigger>
<SelectContent>
{modelOptions.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
// 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 (
<div className="flex items-center gap-4 py-2 px-2 rounded hover:bg-muted/50 group">
<div className="w-[420px] shrink-0 cursor-pointer" onClick={() => onEdit(agent)}>
<div className="text-sm font-medium">{agent.name}</div>
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
</div>
<Select value={currentValue} onValueChange={(v) => onModelChange(agent, v)}>
<SelectTrigger className="w-[240px] cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={USE_DEFAULT}>{defaultLabel}</SelectItem>
{allModels.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.agentName} / {m.modelName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// Custom agent row
type CustomAgentRowProps = {
agent: UtilityAgent;
onEdit: (agent: UtilityAgent) => void;
onDelete: (agent: UtilityAgent) => void;
};
export function CustomAgentRow({ agent, onEdit, onDelete }: CustomAgentRowProps) {
return (
<div className="flex items-center justify-between py-3 px-3 rounded hover:bg-muted/50 group">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{agent.name}</div>
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{agent.model || "Not configured"}</span>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(agent)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 cursor-pointer"
>
<IconPencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(agent)}
className="h-7 w-7 p-0 opacity-0 group-hover:opacity-100 text-destructive cursor-pointer"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
// 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 (
<div className="space-y-2">
<Separator />
<h3 className="text-base font-medium pt-2">Actions</h3>
<div className="space-y-0">
{builtins.map((agent) => (
<BuiltinActionRow
key={agent.id}
agent={agent}
allModels={allModels}
defaultLabel={defaultLabel}
onModelChange={onModelChange}
onEdit={onEdit}
/>
))}
</div>
</div>
);
}
// 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-medium">Custom utility agents</h3>
<p className="text-sm text-muted-foreground">
Create your own utility agents with custom prompts.
</p>
</div>
<Button onClick={onAdd} size="sm" className="cursor-pointer">
<IconPlus className="h-4 w-4 mr-1" />
Add
</Button>
</div>
{agents.length === 0 && (
<p className="text-sm text-muted-foreground py-4">No custom utility agents.</p>
)}
{agents.length > 0 && (
<div className="space-y-2">
{agents.map((agent) => (
<CustomAgentRow key={agent.id} agent={agent} onEdit={onEdit} onDelete={onDelete} />
))}
</div>
)}
</div>
);
}
// Export the USE_DEFAULT constant
export { USE_DEFAULT };
@@ -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}
/>
<Textarea
<Input
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
</div>
@@ -234,7 +233,7 @@ export function PRDialog({
value={prBody}
onChange={(e) => onPrBodyChange(e.target.value)}
rows={6}
className="resize-none"
className="resize-none max-h-[200px] overflow-y-auto"
/>
</div>
<div className="flex items-center space-x-2">
@@ -45,6 +45,8 @@ export type ChatInputEditorAreaProps = {
setContextPopoverOpen: (open: boolean) => void;
contextFiles: ContextFile[];
onImplementPlan?: () => void;
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
};
export function ChatInputEditorArea({
@@ -79,17 +81,27 @@ export function ChatInputEditorArea({
setContextPopoverOpen,
contextFiles,
onImplementPlan,
onEnhancePrompt,
isEnhancingPrompt,
}: ChatInputEditorAreaProps) {
// Block submit while enhancing prompt, but keep editor editable for programmatic updates
const wrappedSubmit = isEnhancingPrompt ? () => {} : handleSubmitWithReset;
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<Tooltip open={showRequestChangesTooltip}>
<TooltipTrigger asChild>
<div className="flex-1 min-h-0">
<div
className={cn(
"flex-1 min-h-0 transition-opacity",
isEnhancingPrompt && "opacity-50 pointer-events-none",
)}
>
<TipTapInput
ref={inputRef}
value={value}
onChange={handleChange}
onSubmit={handleSubmitWithReset}
onSubmit={wrappedSubmit}
placeholder={inputPlaceholder}
disabled={isDisabled || hasClarification}
planModeEnabled={planModeEnabled}
@@ -124,7 +136,7 @@ export function ChatInputEditorArea({
isDisabled={isDisabled}
isSending={isSending}
onCancel={onCancel}
onSubmit={handleSubmitWithReset}
onSubmit={wrappedSubmit}
submitKey={submitKey}
contextCount={contextCount}
contextPopoverOpen={contextPopoverOpen}
@@ -133,6 +145,8 @@ export function ChatInputEditorArea({
contextFiles={contextFiles}
onToggleFile={onToggleContextFile}
onImplementPlan={onImplementPlan}
onEnhancePrompt={onEnhancePrompt}
isEnhancingPrompt={isEnhancingPrompt}
/>
</div>
);
@@ -1,6 +1,6 @@
"use client";
import { forwardRef } from "react";
import { forwardRef, useCallback } from "react";
import { IconAlertTriangle, IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { TaskCreateDialog } from "@/components/task-create-dialog";
@@ -14,6 +14,7 @@ import {
type ChatInputEditorAreaProps,
} from "./chat-input-body";
import type { ContextItem } from "@/lib/types/context";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
// Re-export ImageAttachment type for consumers
export type { ImageAttachment } from "./image-attachment-preview";
@@ -137,9 +138,15 @@ function buildContextAreaProps(
};
}
type EnhancePromptExtras = {
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
};
function buildEditorAreaProps(
s: ContainerState,
p: ChatInputContainerProps,
extras: EnhancePromptExtras = {},
): ChatInputEditorAreaProps {
return {
inputRef: s.inputRef,
@@ -173,6 +180,8 @@ function buildEditorAreaProps(
setContextPopoverOpen: s.setContextPopoverOpen,
contextFiles: p.contextFiles ?? [],
onImplementPlan: p.onImplementPlan,
onEnhancePrompt: extras.onEnhancePrompt,
isEnhancingPrompt: extras.isEnhancingPrompt,
};
}
@@ -223,6 +232,21 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
onSubmit: props.onSubmit,
});
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
sessionId,
taskTitle,
taskDescription,
});
const handleEnhancePrompt = useCallback(() => {
const currentValue = s.value?.trim();
if (!currentValue) return;
enhancePrompt(currentValue, (enhanced) => {
// Use setValue to directly update TipTap editor (handleChange only updates React state)
s.inputRef.current?.setValue(enhanced);
});
}, [s, enhancePrompt]);
if (p.isFailed) {
return (
<FailedSessionBanner
@@ -249,7 +273,10 @@ export const ChatInputContainer = forwardRef<ChatInputContainerHandle, ChatInput
planModeEnabled={props.planModeEnabled}
showFocusHint={s.showFocusHint}
contextAreaProps={buildContextAreaProps(s, p)}
editorAreaProps={buildEditorAreaProps(s, p)}
editorAreaProps={buildEditorAreaProps(s, p, {
onEnhancePrompt: handleEnhancePrompt,
isEnhancingPrompt,
})}
/>
);
},
@@ -9,6 +9,7 @@ import {
IconPlugConnected,
IconPlugConnectedX,
IconRocket,
IconSparkles,
} from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { Button } from "@kandev/ui/button";
@@ -45,6 +46,10 @@ export type ChatInputToolbarProps = {
contextFiles?: ContextFile[];
onToggleFile?: (file: ContextFile) => void;
onImplementPlan?: () => void;
/** Callback to enhance the current prompt with AI */
onEnhancePrompt?: () => void;
/** Whether prompt enhancement is in progress */
isEnhancingPrompt?: boolean;
};
type SubmitButtonProps = {
@@ -157,12 +162,34 @@ function ImplementPlanButton({ onClick }: { onClick: () => void }) {
);
}
function EnhancePromptButton({ onClick, isLoading }: { onClick: () => void; isLoading: boolean }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-slate-400"
onClick={onClick}
disabled={isLoading}
aria-label="Enhance prompt with AI"
aria-busy={isLoading}
>
{isLoading ? <GridSpinner className="h-4 w-4" /> : <IconSparkles className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>Enhance prompt with AI</TooltipContent>
</Tooltip>
);
}
function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
const hasMcp = mcpServers.length > 0;
const tooltipText = hasMcp
? `MCP Servers: ${mcpServers.join(", ")}`
: "Agent does not support MCP";
const Icon = hasMcp ? IconPlugConnected : IconPlugConnectedX;
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -172,11 +199,7 @@ function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
hasMcp ? "text-foreground" : "text-muted-foreground/40",
)}
>
{hasMcp ? (
<IconPlugConnected className="h-4 w-4" />
) : (
<IconPlugConnectedX className="h-4 w-4" />
)}
<Icon className="h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>{tooltipText}</TooltipContent>
@@ -184,6 +207,70 @@ function McpIndicator({ mcpServers }: { mcpServers: string[] }) {
);
}
type ToolbarRightSectionProps = {
taskId: string | null;
sessionId: string | null;
taskTitle?: string;
taskDescription: string;
planModeEnabled: boolean;
isAgentBusy: boolean;
isDisabled: boolean;
isSending: boolean;
onCancel: () => void;
onSubmit: () => void;
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
onEnhancePrompt?: () => void;
isEnhancingPrompt?: boolean;
onImplementPlan?: () => void;
};
function ToolbarRightSection({
taskId,
sessionId,
taskTitle,
taskDescription,
planModeEnabled,
isAgentBusy,
isDisabled,
isSending,
onCancel,
onSubmit,
submitShortcut,
onEnhancePrompt,
isEnhancingPrompt,
onImplementPlan,
}: ToolbarRightSectionProps) {
const showEnhance = onEnhancePrompt && !isAgentBusy;
const showImplement = planModeEnabled && !isAgentBusy && onImplementPlan;
return (
<div className="flex items-center gap-0.5 shrink-0">
<SessionsDropdown
taskId={taskId}
activeSessionId={sessionId}
taskTitle={taskTitle}
taskDescription={taskDescription}
/>
<TokenUsageDisplay sessionId={sessionId} />
<ModelSelector sessionId={sessionId} />
{showEnhance && (
<EnhancePromptButton onClick={onEnhancePrompt} isLoading={isEnhancingPrompt ?? false} />
)}
{showImplement && <ImplementPlanButton onClick={onImplementPlan} />}
<div className="ml-1">
<SubmitButton
isAgentBusy={isAgentBusy}
isDisabled={isDisabled}
isSending={isSending}
planModeEnabled={planModeEnabled}
onCancel={onCancel}
onSubmit={onSubmit}
submitShortcut={submitShortcut}
/>
</div>
</div>
);
}
export const ChatInputToolbar = memo(function ChatInputToolbar({
planModeEnabled,
planModeAvailable = true,
@@ -206,6 +293,8 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({
contextFiles = [],
onToggleFile,
onImplementPlan,
onEnhancePrompt,
isEnhancingPrompt = false,
}: ChatInputToolbarProps) {
const submitShortcut = submitKey === "enter" ? SHORTCUTS.SUBMIT_ENTER : SHORTCUTS.SUBMIT;
@@ -247,30 +336,22 @@ export const ChatInputToolbar = memo(function ChatInputToolbar({
<div className="flex-1" />
<div className="flex items-center gap-0.5 shrink-0">
<SessionsDropdown
taskId={taskId}
activeSessionId={sessionId}
taskTitle={taskTitle}
taskDescription={taskDescription}
/>
<TokenUsageDisplay sessionId={sessionId} />
<ModelSelector sessionId={sessionId} />
{planModeEnabled && !isAgentBusy && onImplementPlan && (
<ImplementPlanButton onClick={onImplementPlan} />
)}
<div className="ml-1">
<SubmitButton
isAgentBusy={isAgentBusy}
isDisabled={isDisabled}
isSending={isSending}
planModeEnabled={planModeEnabled}
onCancel={onCancel}
onSubmit={onSubmit}
submitShortcut={submitShortcut}
/>
</div>
</div>
<ToolbarRightSection
taskId={taskId}
sessionId={sessionId}
taskTitle={taskTitle}
taskDescription={taskDescription}
planModeEnabled={planModeEnabled}
isAgentBusy={isAgentBusy}
isDisabled={isDisabled}
isSending={isSending}
onCancel={onCancel}
onSubmit={onSubmit}
submitShortcut={submitShortcut}
onEnhancePrompt={onEnhancePrompt}
isEnhancingPrompt={isEnhancingPrompt}
onImplementPlan={onImplementPlan}
/>
</div>
);
});
@@ -67,6 +67,12 @@ export function escapeHtml(str: string): string {
.replace(/'/g, "&#039;");
}
/** Convert plain text with newlines to HTML paragraphs for TipTap */
export function textToHtml(text: string): string {
const lines = text.split("\n");
return lines.map((line) => `<p>${escapeHtml(line) || "<br>"}</p>`).join("");
}
// ── Code fence parsing ──────────────────────────────────────────────
export type FenceSegment =
@@ -14,7 +14,7 @@ import { common, createLowlight } from "lowlight";
import { Extension } from "@tiptap/core";
import { cn } from "@/lib/utils";
import { getChatDraftContent, setChatDraftContent } from "@/lib/local-storage";
import { getMarkdownText, escapeHtml, handleEditorPaste } from "./tiptap-helpers";
import { getMarkdownText, textToHtml, handleEditorPaste } from "./tiptap-helpers";
import { CodeBlockView } from "./tiptap-code-block-view";
import { ContextMention } from "./tiptap-mention-extension";
import type { ContextFile } from "@/lib/state/context-files-store";
@@ -279,7 +279,7 @@ function syncEditorValue({
}
isSyncingRef.current = true;
editor.commands.setContent(`<p>${escapeHtml(value)}</p>`);
editor.commands.setContent(textToHtml(value));
isSyncingRef.current = false;
initialSyncDoneRef.current = true;
}
@@ -346,7 +346,7 @@ function useEditorImperativeHandle(
if (v === "") {
editor.commands.clearContent();
} else {
editor.commands.setContent(`<p>${escapeHtml(v)}</p>`);
editor.commands.setContent(textToHtml(v));
}
isSyncingRef.current = false;
onChange(v);
@@ -32,6 +32,7 @@ import {
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from "@kandev/ui/dropdown-menu";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
@@ -135,12 +136,10 @@ export function CommitDialog({
uncommittedDeletions={uncommittedDeletions}
/>
</div>
<Textarea
<Input
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
<div className="flex items-center gap-2">
@@ -249,7 +248,7 @@ export function PRDialog({
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
rows={4}
className="resize-none"
className="resize-none max-h-[200px] overflow-y-auto"
/>
</div>
<div className="flex items-center space-x-2">
@@ -240,7 +240,6 @@ function buildTaskTopBarProps(params: {
taskId: taskProps.taskId,
activeSessionId: params.effectiveSessionId,
taskTitle: taskProps.taskTitle,
taskDescription: taskProps.taskDescription,
baseBranch: taskProps.baseBranch ?? undefined,
onStartAgent: agent.handleStartAgent,
onStopAgent: agent.handleStopAgent,
@@ -38,7 +38,6 @@ type TaskTopBarProps = {
taskId?: string | null;
activeSessionId?: string | null;
taskTitle?: string;
taskDescription?: string;
baseBranch?: string;
onStartAgent?: (agentProfileId: string) => void;
onStopAgent?: () => void;
+213 -82
View File
@@ -1,7 +1,13 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import { IconGitCommit, IconGitPullRequest, IconLoader2, IconCheck } from "@tabler/icons-react";
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from "react";
import {
IconGitCommit,
IconGitPullRequest,
IconLoader2,
IconCheck,
IconSparkles,
} from "@tabler/icons-react";
import {
Dialog,
DialogContent,
@@ -13,10 +19,13 @@ import {
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Label } from "@kandev/ui/label";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useGitOperations } from "@/hooks/use-git-operations";
import { useGitWithFeedback } from "@/hooks/use-git-with-feedback";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import { useToast } from "@/components/toast-provider";
import type { FileInfo } from "@/lib/state/slices";
@@ -41,7 +50,9 @@ type VcsDialogsProviderProps = {
children: ReactNode;
};
function computeFileSummary(files: Record<string, FileInfo> | undefined) {
type FileSummary = { count: number; additions: number; deletions: number };
function computeFileSummary(files: Record<string, FileInfo> | undefined): FileSummary {
const count = files ? Object.keys(files).length : 0;
let additions = 0;
let deletions = 0;
@@ -54,16 +65,78 @@ function computeFileSummary(files: Record<string, FileInfo> | undefined) {
return { count, additions, deletions };
}
function FileSummaryText({ count, additions, deletions }: FileSummary) {
if (count === 0) return <span>No changes to commit</span>;
return (
<span>
<span className="font-medium text-foreground">{count}</span> file{count !== 1 ? "s" : ""}{" "}
changed
{(additions > 0 || deletions > 0) && (
<span className="ml-2">
(<span className="text-green-600">+{additions}</span>
{" / "}
<span className="text-red-600">-{deletions}</span>)
</span>
)}
</span>
);
}
type GenerateButtonProps = {
onClick: () => void;
isGenerating: boolean;
disabled?: boolean;
tooltip: string;
size?: "icon" | "sm";
showLabel?: boolean;
};
function GenerateButton({
onClick,
isGenerating,
disabled,
tooltip,
size = "icon",
showLabel,
}: GenerateButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size={size}
variant="ghost"
className={
size === "icon" ? "h-7 w-7 cursor-pointer" : "h-6 px-2 cursor-pointer gap-1.5 text-xs"
}
onClick={onClick}
disabled={disabled || isGenerating}
>
{isGenerating ? (
<IconLoader2 className="h-4 w-4 animate-spin" />
) : (
<IconSparkles className="h-4 w-4" />
)}
{showLabel && "Generate"}
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
);
}
type CommitDialogProps = {
open: boolean;
onOpenChange: (v: boolean) => void;
fileSummary: { count: number; additions: number; deletions: number };
fileSummary: FileSummary;
commitMessage: string;
onCommitMessageChange: (v: string) => void;
stageAll: boolean;
onStageAllChange: (v: boolean) => void;
isGitLoading: boolean;
onCommit: () => void;
onGenerateMessage?: () => void;
isGenerating?: boolean;
};
function CommitDialog({
@@ -76,8 +149,9 @@ function CommitDialog({
onStageAllChange,
isGitLoading,
onCommit,
onGenerateMessage,
isGenerating,
}: CommitDialogProps) {
const { count, additions, deletions } = fileSummary;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
@@ -89,30 +163,27 @@ function CommitDialog({
</DialogHeader>
<div className="space-y-4 py-2">
<div className="text-sm text-muted-foreground">
{count > 0 ? (
<span>
<span className="font-medium text-foreground">{count}</span> file
{count !== 1 ? "s" : ""} changed
{(additions > 0 || deletions > 0) && (
<span className="ml-2">
(<span className="text-green-600">+{additions}</span>
{" / "}
<span className="text-red-600">-{deletions}</span>)
</span>
)}
</span>
) : (
<span>No changes to commit</span>
<FileSummaryText {...fileSummary} />
</div>
<div className="relative">
<Input
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
className="pr-10"
autoFocus
/>
{onGenerateMessage && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2">
<GenerateButton
onClick={onGenerateMessage}
isGenerating={isGenerating ?? false}
disabled={fileSummary.count === 0}
tooltip="Generate commit message with AI"
/>
</div>
)}
</div>
<Textarea
placeholder="Enter commit message..."
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
rows={4}
className="resize-none"
autoFocus
/>
<div className="flex items-center gap-2">
<Checkbox
id="vcs-stage-all"
@@ -162,6 +233,8 @@ type PRDialogProps = {
onPrDraftChange: (v: boolean) => void;
isGitLoading: boolean;
onCreatePR: () => void;
onGenerateDescription?: () => void;
isGenerating?: boolean;
};
function PRBranchSummary({
@@ -202,6 +275,8 @@ function PRDialog({
onPrDraftChange,
isGitLoading,
onCreatePR,
onGenerateDescription,
isGenerating,
}: PRDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -218,27 +293,36 @@ function PRDialog({
<Label htmlFor="vcs-pr-title" className="text-sm">
Title
</Label>
<input
<Input
id="vcs-pr-title"
type="text"
placeholder="Pull request title..."
value={prTitle}
onChange={(e) => onPrTitleChange(e.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="vcs-pr-body" className="text-sm">
Description
</Label>
<div className="flex items-center justify-between">
<Label htmlFor="vcs-pr-body" className="text-sm">
Description
</Label>
{onGenerateDescription && (
<GenerateButton
onClick={onGenerateDescription}
isGenerating={isGenerating ?? false}
tooltip="Generate PR description with AI"
size="sm"
showLabel
/>
)}
</div>
<Textarea
id="vcs-pr-body"
placeholder="Describe your changes..."
value={prBody}
onChange={(e) => onPrBodyChange(e.target.value)}
rows={6}
className="resize-none"
className="resize-none max-h-[200px] overflow-y-auto"
/>
</div>
<div className="flex items-center space-x-2">
@@ -277,6 +361,53 @@ function PRDialog({
);
}
type UseCommitDialogReturn = {
open: boolean;
setOpen: (v: boolean) => void;
message: string;
setMessage: (v: string) => void;
stageAll: boolean;
setStageAll: (v: boolean) => void;
openDialog: () => void;
};
function useCommitDialogState(): UseCommitDialogReturn {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [stageAll, setStageAll] = useState(true);
const openDialog = useCallback(() => {
setMessage("");
setStageAll(true);
setOpen(true);
}, []);
return { open, setOpen, message, setMessage, stageAll, setStageAll, openDialog };
}
type UsePRDialogReturn = {
open: boolean;
setOpen: (v: boolean) => void;
title: string;
setTitle: (v: string) => void;
body: string;
setBody: (v: string) => void;
draft: boolean;
setDraft: (v: boolean) => void;
openDialog: (taskTitle?: string) => void;
};
function usePRDialogState(): UsePRDialogReturn {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [draft, setDraft] = useState(true);
const openDialog = useCallback((taskTitle?: string) => {
setTitle(taskTitle || "");
setBody("");
setOpen(true);
}, []);
return { open, setOpen, title, setTitle, body, setBody, draft, setDraft, openDialog };
}
export function VcsDialogsProvider({
sessionId,
baseBranch,
@@ -284,48 +415,36 @@ export function VcsDialogsProvider({
displayBranch,
children,
}: VcsDialogsProviderProps) {
const [commitDialogOpen, setCommitDialogOpen] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const [stageAll, setStageAll] = useState(true);
const [prDialogOpen, setPrDialogOpen] = useState(false);
const [prTitle, setPrTitle] = useState("");
const [prBody, setPrBody] = useState("");
const [prDraft, setPrDraft] = useState(true);
const cs = useCommitDialogState();
const ps = usePRDialogState();
const { toast } = useToast();
const gitWithFeedback = useGitWithFeedback();
const gitStatus = useSessionGitStatus(sessionId);
const { commit, createPR, isLoading: isGitLoading } = useGitOperations(sessionId);
const {
isGeneratingCommitMessage,
isGeneratingPRDescription,
generateCommitMessage,
generatePRDescription,
} = useUtilityAgentGenerator({ sessionId, taskTitle });
const fileSummary = computeFileSummary(gitStatus?.files);
const openCommitDialog = useCallback(() => {
setCommitMessage("");
setStageAll(true);
setCommitDialogOpen(true);
}, []);
const handleCommit = useCallback(async () => {
if (!commitMessage.trim()) return;
setCommitDialogOpen(false);
await gitWithFeedback(() => commit(commitMessage.trim(), stageAll), "Commit");
setCommitMessage("");
}, [commitMessage, stageAll, gitWithFeedback, commit]);
const openPRDialog = useCallback(() => {
setPrTitle(taskTitle || "");
setPrBody("");
setPrDialogOpen(true);
}, [taskTitle]);
if (!cs.message.trim()) return;
cs.setOpen(false);
await gitWithFeedback(() => commit(cs.message.trim(), cs.stageAll), "Commit");
cs.setMessage("");
}, [cs, gitWithFeedback, commit]);
const handleCreatePR = useCallback(async () => {
if (!prTitle.trim()) return;
setPrDialogOpen(false);
if (!ps.title.trim()) return;
ps.setOpen(false);
try {
const result = await createPR(prTitle.trim(), prBody.trim(), baseBranch, prDraft);
const result = await createPR(ps.title.trim(), ps.body.trim(), baseBranch, ps.draft);
if (result.success) {
const title = ps.draft ? "Draft PR created" : "PR created";
toast({
title: prDraft ? "Draft PR created" : "PR created",
title,
description: result.pr_url || "PR created successfully",
variant: "success",
});
@@ -344,37 +463,49 @@ export function VcsDialogsProvider({
variant: "error",
});
}
setPrTitle("");
setPrBody("");
}, [prTitle, prBody, baseBranch, prDraft, createPR, toast]);
ps.setTitle("");
ps.setBody("");
}, [ps, baseBranch, createPR, toast]);
const contextValue = useMemo(
() => ({
openCommitDialog: cs.openDialog,
openPRDialog: () => ps.openDialog(taskTitle),
}),
[cs.openDialog, ps, taskTitle],
);
return (
<VcsDialogsContext.Provider value={{ openCommitDialog, openPRDialog }}>
<VcsDialogsContext.Provider value={contextValue}>
{children}
<CommitDialog
open={commitDialogOpen}
onOpenChange={setCommitDialogOpen}
open={cs.open}
onOpenChange={cs.setOpen}
fileSummary={fileSummary}
commitMessage={commitMessage}
onCommitMessageChange={setCommitMessage}
stageAll={stageAll}
onStageAllChange={setStageAll}
commitMessage={cs.message}
onCommitMessageChange={cs.setMessage}
stageAll={cs.stageAll}
onStageAllChange={cs.setStageAll}
isGitLoading={isGitLoading}
onCommit={handleCommit}
onGenerateMessage={() => generateCommitMessage(cs.setMessage)}
isGenerating={isGeneratingCommitMessage}
/>
<PRDialog
open={prDialogOpen}
onOpenChange={setPrDialogOpen}
open={ps.open}
onOpenChange={ps.setOpen}
displayBranch={displayBranch}
baseBranch={baseBranch}
prTitle={prTitle}
onPrTitleChange={setPrTitle}
prBody={prBody}
onPrBodyChange={setPrBody}
prDraft={prDraft}
onPrDraftChange={setPrDraft}
prTitle={ps.title}
onPrTitleChange={ps.setTitle}
prBody={ps.body}
onPrBodyChange={ps.setBody}
prDraft={ps.draft}
onPrDraftChange={ps.setDraft}
isGitLoading={isGitLoading}
onCreatePR={handleCreatePR}
onGenerateDescription={() => generatePRDescription(ps.setBody)}
isGenerating={isGeneratingPRDescription}
/>
</VcsDialogsContext.Provider>
);
@@ -0,0 +1,142 @@
"use client";
import { useState, useCallback } from "react";
import { executeUtilityPrompt, type ExecutePromptRequest } from "@/lib/api/domains/utility-api";
import { useToast } from "@/components/toast-provider";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import type { FileInfo } from "@/lib/state/slices";
type GeneratorType = "commit-message" | "pr-description" | "enhance-prompt";
const UTILITY_AGENT_IDS: Record<GeneratorType, string> = {
"commit-message": "builtin-commit-message",
"pr-description": "builtin-pr-description",
"enhance-prompt": "builtin-enhance-prompt",
};
type UseUtilityAgentGeneratorOptions = {
sessionId: string | null;
taskTitle?: string;
taskDescription?: string;
};
type GenerateOptions = {
onSuccess?: (content: string) => void;
// Additional context for PR description
commitLog?: string;
diffSummary?: string;
// User's original prompt for enhancement
userPrompt?: string;
};
export function useUtilityAgentGenerator({
sessionId,
taskTitle,
taskDescription,
}: UseUtilityAgentGeneratorOptions) {
const [isGenerating, setIsGenerating] = useState<GeneratorType | null>(null);
const { toast } = useToast();
const gitStatus = useSessionGitStatus(sessionId);
const collectGitContext = useCallback(() => {
const changedFiles = gitStatus?.files ? Object.keys(gitStatus.files) : [];
const diffs = gitStatus?.files
? Object.values(gitStatus.files as Record<string, FileInfo>)
.filter((f) => f.diff)
.map((f) => f.diff)
.join("\n\n")
: undefined;
return { changedFiles, diffs };
}, [gitStatus]);
const generate = useCallback(
async (type: GeneratorType, options?: GenerateOptions) => {
if (!sessionId) {
toast({
title: "No active session",
description: "Start a session first to use AI generation",
variant: "error",
});
return;
}
setIsGenerating(type);
try {
const { changedFiles, diffs } = collectGitContext();
const request: ExecutePromptRequest = {
utility_agent_id: UTILITY_AGENT_IDS[type],
session_id: sessionId,
task_title: taskTitle,
task_description: taskDescription,
git_diff: diffs || undefined,
changed_files: changedFiles.join("\n") || undefined,
commit_log: options?.commitLog,
diff_summary: options?.diffSummary,
user_prompt: options?.userPrompt,
};
const resp = await executeUtilityPrompt(request);
// Clear generating state BEFORE calling onSuccess so the editor is re-enabled
setIsGenerating(null);
if (resp.success && resp.response) {
// Use requestAnimationFrame to ensure React has re-rendered with editor enabled
const response = resp.response;
requestAnimationFrame(() => {
options?.onSuccess?.(response);
});
} else {
toast({
title: "Generation failed",
description: resp.error || "Failed to generate content",
variant: "error",
});
}
} catch (error) {
setIsGenerating(null);
toast({
title: "Generation failed",
description: error instanceof Error ? error.message : "Unknown error",
variant: "error",
});
}
},
[sessionId, taskTitle, taskDescription, collectGitContext, toast],
);
const generateCommitMessage = useCallback(
(onSuccess: (message: string) => void) => {
return generate("commit-message", { onSuccess });
},
[generate],
);
const generatePRDescription = useCallback(
(
onSuccess: (description: string) => void,
extra?: { commitLog?: string; diffSummary?: string },
) => {
return generate("pr-description", { onSuccess, ...extra });
},
[generate],
);
const enhancePrompt = useCallback(
(userPrompt: string, onSuccess: (enhanced: string) => void) => {
return generate("enhance-prompt", { onSuccess, userPrompt });
},
[generate],
);
return {
isGenerating,
isGeneratingCommitMessage: isGenerating === "commit-message",
isGeneratingPRDescription: isGenerating === "pr-description",
isEnhancingPrompt: isGenerating === "enhance-prompt",
generateCommitMessage,
generatePRDescription,
enhancePrompt,
};
}
+2
View File
@@ -42,6 +42,8 @@ export async function updateUserSettings(
lsp_auto_install_languages?: string[];
lsp_server_configs?: Record<string, Record<string, unknown>>;
saved_layouts?: SavedLayout[];
default_utility_agent_id?: string;
default_utility_model?: string;
},
options?: ApiRequestOptions,
) {
+155
View File
@@ -0,0 +1,155 @@
import { fetchJson, type ApiRequestOptions } from "../client";
// Types
export type UtilityAgent = {
id: string;
name: string;
description: string;
prompt: string;
agent_id: string;
model: string;
builtin: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
};
export type UtilityAgentCall = {
id: string;
utility_id: string;
session_id: string;
resolved_prompt: string;
response: string;
model: string;
prompt_tokens: number;
response_tokens: number;
duration_ms: number;
status: string;
error_message: string;
created_at: string;
completed_at?: string;
};
export type TemplateVariable = {
name: string;
description: string;
example: string;
category: string;
};
export type InferenceModel = {
id: string;
name: string;
description: string;
is_default: boolean;
};
export type InferenceAgent = {
id: string;
name: string;
display_name: string;
models: InferenceModel[];
};
export type ExecutePromptRequest = {
utility_agent_id: string;
session_id?: string;
git_diff?: string;
commit_log?: string;
changed_files?: string;
diff_summary?: string;
branch_name?: string;
base_branch?: string;
task_title?: string;
task_description?: string;
user_prompt?: string;
};
export type ExecutePromptResponse = {
success: boolean;
call_id?: string;
response?: string;
model?: string;
prompt_tokens?: number;
response_tokens?: number;
duration_ms?: number;
error?: string;
};
// API Functions
export async function listUtilityAgents(
options?: ApiRequestOptions,
): Promise<{ agents: UtilityAgent[] }> {
return fetchJson<{ agents: UtilityAgent[] }>("/api/v1/utility/agents", options);
}
export async function getUtilityAgent(
id: string,
options?: ApiRequestOptions,
): Promise<UtilityAgent> {
return fetchJson<UtilityAgent>(`/api/v1/utility/agents/${id}`, options);
}
export async function createUtilityAgent(
data: Partial<UtilityAgent>,
options?: ApiRequestOptions,
): Promise<UtilityAgent> {
return fetchJson<UtilityAgent>("/api/v1/utility/agents", {
...options,
init: { method: "POST", body: JSON.stringify(data), ...(options?.init ?? {}) },
});
}
export async function updateUtilityAgent(
id: string,
data: Partial<UtilityAgent>,
options?: ApiRequestOptions,
): Promise<UtilityAgent> {
return fetchJson<UtilityAgent>(`/api/v1/utility/agents/${id}`, {
...options,
init: { method: "PATCH", body: JSON.stringify(data), ...(options?.init ?? {}) },
});
}
export async function deleteUtilityAgent(id: string, options?: ApiRequestOptions): Promise<void> {
await fetchJson<{ success: boolean }>(`/api/v1/utility/agents/${id}`, {
...options,
init: { method: "DELETE", ...(options?.init ?? {}) },
});
}
export async function getTemplateVariables(
options?: ApiRequestOptions,
): Promise<{ variables: TemplateVariable[] }> {
return fetchJson<{ variables: TemplateVariable[] }>(
"/api/v1/utility/template-variables",
options,
);
}
export async function listInferenceAgents(
options?: ApiRequestOptions,
): Promise<{ agents: InferenceAgent[] }> {
return fetchJson<{ agents: InferenceAgent[] }>("/api/v1/utility/inference-agents", options);
}
export async function executeUtilityPrompt(
req: ExecutePromptRequest,
options?: ApiRequestOptions,
): Promise<ExecutePromptResponse> {
return fetchJson<ExecutePromptResponse>("/api/v1/utility/execute", {
...options,
init: { method: "POST", body: JSON.stringify(req), ...(options?.init ?? {}) },
});
}
export async function listUtilityCalls(
utilityId: string,
limit = 50,
options?: ApiRequestOptions,
): Promise<{ calls: UtilityAgentCall[] }> {
return fetchJson<{ calls: UtilityAgentCall[] }>(
`/api/v1/utility/agents/${utilityId}/calls?limit=${limit}`,
options,
);
}
+2
View File
@@ -364,6 +364,8 @@ export type UserSettings = {
lsp_auto_install_languages?: string[];
lsp_server_configs?: Record<string, Record<string, unknown>>;
saved_layouts?: SavedLayout[];
default_utility_agent_id?: string;
default_utility_model?: string;
updated_at: string;
};