From 7e43534cd2d15103dd19b7a23b65e427973a9a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 19 Jan 2026 22:41:00 +0000 Subject: [PATCH] refactor: remove auggie-specific code and use standard ACP protocol (#47) - Remove ProtocolAuggie constant and AuggieAdapter - Remove hooks system (HooksRegistry, AgentHooks, AuggieHooks) - Remove PreGeneratedSessionID and SessionDir fields - Change auggie-agent to use standard 'acp' protocol in agents.json - Add model select dropdown to profile settings page - Fix unused variable lint warnings Auggie now uses the standard ACP protocol flow like other agents. The --resume flag was found to not work in ACP mode, so the workaround was unnecessary since session/new returns valid session IDs. --- .../internal/agent/discovery/agents.json | 44 ---- .../internal/agent/discovery/discovery.go | 82 ++++--- .../internal/agent/lifecycle/manager.go | 2 - .../internal/agent/lifecycle/runtime.go | 2 +- .../internal/agent/registry/agents.json | 202 ++++++++++++++++++ .../internal/agent/registry/defaults.go | 184 ---------------- .../internal/agent/registry/registry.go | 107 ++++++++++ .../agent/settings/controller/controller.go | 16 ++ .../internal/agent/settings/dto/dto.go | 14 ++ .../internal/agentctl/client/control.go | 2 +- .../internal/agentctl/server/config/config.go | 8 + .../agentctl/server/instance/instance.go | 2 +- .../orchestrator/controller/controller.go | 7 +- apps/backend/internal/orchestrator/dto/dto.go | 7 +- .../orchestrator/executor/executor.go | 28 +-- .../orchestrator/scheduler/scheduler.go | 7 +- .../orchestrator/scheduler/scheduler_test.go | 40 ++-- apps/backend/internal/orchestrator/service.go | 1 - .../app/settings/agents/[agentId]/page.tsx | 48 ++++- .../[agentId]/profiles/[profileId]/page.tsx | 45 +++- .../settings/settings-app-sidebar.tsx | 2 - apps/web/components/state-hydrator.tsx | 7 +- apps/web/components/task/task-chat-panel.tsx | 30 ++- apps/web/lib/types/http.ts | 14 ++ 24 files changed, 556 insertions(+), 345 deletions(-) delete mode 100644 apps/backend/internal/agent/discovery/agents.json create mode 100644 apps/backend/internal/agent/registry/agents.json delete mode 100644 apps/backend/internal/agent/registry/defaults.go diff --git a/apps/backend/internal/agent/discovery/agents.json b/apps/backend/internal/agent/discovery/agents.json deleted file mode 100644 index f53d2d3c00..0000000000 --- a/apps/backend/internal/agent/discovery/agents.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "agents": [ - { - "name": "codex", - "display_name": "Codex", - "supports_mcp": false, - "mcp_config_path": { - "linux": [], - "windows": [], - "macos": [] - }, - "installation_path": { - "linux": ["~/.codex/auth.json"], - "windows": ["~/.codex/auth.json"], - "macos": ["~/.codex/auth.json"] - }, - "capabilities": { - "supports_session_resume": false, - "supports_shell": false, - "supports_workspace_only": false - } - }, - { - "name": "auggie", - "display_name": "Auggie", - "supports_mcp": false, - "mcp_config_path": { - "linux": [], - "windows": [], - "macos": [] - }, - "installation_path": { - "linux": ["~/.augment/.auggie.json"], - "windows": ["~/.augment/.auggie.json"], - "macos": ["~/.augment/.auggie.json"] - }, - "capabilities": { - "supports_session_resume": false, - "supports_shell": false, - "supports_workspace_only": false - } - } - ] -} diff --git a/apps/backend/internal/agent/discovery/discovery.go b/apps/backend/internal/agent/discovery/discovery.go index 18dbfc7c65..072ed90a9f 100644 --- a/apps/backend/internal/agent/discovery/discovery.go +++ b/apps/backend/internal/agent/discovery/discovery.go @@ -1,32 +1,32 @@ +// Package discovery provides agent installation detection and discovery functionality. +// It reads agent definitions from the unified agents.json configuration in the registry package. package discovery import ( "context" - "embed" - "encoding/json" "fmt" "os" "path/filepath" "runtime" "strings" + + "github.com/kandev/kandev/internal/agent/registry" ) -//go:embed agents.json -var agentsFS embed.FS +// OSPaths defines OS-specific paths for discovery (re-exported from registry for API compatibility) +type OSPaths = registry.OSPaths -type OSPaths struct { - Linux []string `json:"linux"` - Windows []string `json:"windows"` - MacOS []string `json:"macos"` -} +// Capabilities defines discovery-specific capabilities (re-exported from registry) +type Capabilities = registry.DiscoveryCapabilities -type Capabilities struct { - SupportsSessionResume bool `json:"supports_session_resume"` - SupportsShell bool `json:"supports_shell"` - SupportsWorkspaceOnly bool `json:"supports_workspace_only"` -} +// ModelConfig defines the model configuration for an agent (re-exported from registry) +type ModelConfig = registry.ModelConfig -func (p OSPaths) ForOS(goos string) []string { +// ModelEntry defines a single model available for an agent (re-exported from registry) +type ModelEntry = registry.ModelEntry + +// ForOS returns paths for the specified operating system +func ForOS(p OSPaths, goos string) []string { switch goos { case "windows": return p.Windows @@ -37,6 +37,7 @@ func (p OSPaths) ForOS(goos string) []string { } } +// KnownAgent represents an agent definition with discovery metadata type KnownAgent struct { Name string `json:"name"` DisplayName string `json:"display_name"` @@ -44,12 +45,15 @@ type KnownAgent struct { MCPConfigPath OSPaths `json:"mcp_config_path"` InstallationPath OSPaths `json:"installation_path"` Capabilities Capabilities `json:"capabilities"` + ModelConfig ModelConfig `json:"model_config"` } +// Config is the legacy structure for agent discovery configuration type Config struct { Agents []KnownAgent `json:"agents"` } +// Availability represents the result of detecting an agent's installation type Availability struct { Name string `json:"name"` SupportsMCP bool `json:"supports_mcp"` @@ -59,29 +63,53 @@ type Availability struct { MatchedPath string `json:"matched_path,omitempty"` } +// Adapter defines the interface for agent detection strategies type Adapter interface { Detect(ctx context.Context) (Availability, error) } +// Registry manages agent discovery adapters and definitions type Registry struct { adapters []Adapter definitions []KnownAgent } +// LoadRegistry creates a new discovery registry by loading agent definitions +// from the unified agents.json configuration in the registry package. func LoadRegistry() (*Registry, error) { - data, err := agentsFS.ReadFile("agents.json") + agentConfigs, err := registry.GetAgentDefinitions() if err != nil { - return nil, fmt.Errorf("read agents config: %w", err) + return nil, fmt.Errorf("load agent definitions: %w", err) } - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parse agents config: %w", err) + + definitions := make([]KnownAgent, 0, len(agentConfigs)) + adapters := make([]Adapter, 0, len(agentConfigs)) + + for _, agentCfg := range agentConfigs { + // Convert registry.AgentTypeConfig to discovery.KnownAgent + knownAgent := KnownAgent{ + Name: extractAgentName(agentCfg.ID), + DisplayName: agentCfg.DisplayName, + SupportsMCP: agentCfg.Discovery.SupportsMCP, + MCPConfigPath: agentCfg.Discovery.MCPConfigPath, + InstallationPath: agentCfg.Discovery.InstallationPath, + Capabilities: agentCfg.Discovery.DiscoveryCapabilities, + ModelConfig: agentCfg.ModelConfig, + } + if knownAgent.DisplayName == "" { + knownAgent.DisplayName = agentCfg.Name + } + definitions = append(definitions, knownAgent) + adapters = append(adapters, NewFilePresenceAdapter(knownAgent)) } - adapters := make([]Adapter, 0, len(cfg.Agents)) - for _, agent := range cfg.Agents { - adapters = append(adapters, NewFilePresenceAdapter(agent)) - } - return &Registry{adapters: adapters, definitions: cfg.Agents}, nil + + return &Registry{adapters: adapters, definitions: definitions}, nil +} + +// extractAgentName extracts the short name from an agent ID (e.g., "auggie-agent" -> "auggie") +func extractAgentName(id string) string { + name := strings.TrimSuffix(id, "-agent") + return name } func (r *Registry) Definitions() []KnownAgent { @@ -113,8 +141,8 @@ func NewFilePresenceAdapter(def KnownAgent) *FilePresenceAdapter { func (a *FilePresenceAdapter) Detect(ctx context.Context) (Availability, error) { _ = ctx - paths := resolvePaths(a.definition.InstallationPath.ForOS(runtime.GOOS)) - mcpPaths := resolvePaths(a.definition.MCPConfigPath.ForOS(runtime.GOOS)) + paths := resolvePaths(ForOS(a.definition.InstallationPath, runtime.GOOS)) + mcpPaths := resolvePaths(ForOS(a.definition.MCPConfigPath, runtime.GOOS)) available, matched := anyPathExists(paths) mcpPath := "" if len(mcpPaths) > 0 { diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 643174509c..720b7704b8 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -1220,5 +1220,3 @@ func (m *Manager) RespondToPermissionBySessionID(sessionID, pendingID, optionID return m.RespondToPermission(execution.ID, pendingID, optionID, cancelled) } - - diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go index ae3b8f4dc9..b93d27bc9a 100644 --- a/apps/backend/internal/agent/lifecycle/runtime.go +++ b/apps/backend/internal/agent/lifecycle/runtime.go @@ -5,8 +5,8 @@ import ( "context" "time" - agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agent/registry" + agentctl "github.com/kandev/kandev/internal/agentctl/client" v1 "github.com/kandev/kandev/pkg/api/v1" ) diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json new file mode 100644 index 0000000000..7c7a80ea41 --- /dev/null +++ b/apps/backend/internal/agent/registry/agents.json @@ -0,0 +1,202 @@ +{ + "$schema": "./agents.schema.json", + "version": "1.0", + "agents": [ + { + "id": "auggie-agent", + "name": "Augment Coding Agent", + "display_name": "Auggie", + "description": "Auggie CLI-powered autonomous coding agent. Requires AUGMENT_SESSION_AUTH for authentication.", + "enabled": true, + "image": "kandev/augment-agent", + "tag": "latest", + "cmd": ["auggie", "--acp"], + "working_dir": "/workspace", + "required_env": ["AUGMENT_SESSION_AUTH"], + "env": {}, + "mounts": [ + {"source": "{workspace}", "target": "/workspace", "read_only": false} + ], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": ["code_generation", "code_review", "refactoring", "testing", "shell_execution"], + "protocol": "acp", + "protocol_config": {}, + "model_flag": "--model", + "workspace_flag": "--workspace-root", + "session_config": { + "resume_via_acp": false, + "resume_flag": "", + "can_recover": false, + "session_dir_template": "{home}/.augment/sessions", + "session_dir_target": "/root/.augment/sessions" + }, + "permission_config": { + "permission_flag": "--permission", + "tools_requiring_permission": [ + "launch-process", + "save-file", + "str-replace-editor", + "remove-files" + ] + }, + "discovery": { + "supports_mcp": false, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": ["~/.augment/.auggie.json"], + "windows": ["~/.augment/.auggie.json"], + "macos": ["~/.augment/.auggie.json"] + }, + "discovery_capabilities": { + "supports_session_resume": false, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "sonnet4.5", + "available_models": [ + { + "id": "sonnet4.5", + "name": "Sonnet 4.5", + "provider": "anthropic", + "context_window": 200000, + "is_default": true + }, + { + "id": "opus4.5", + "name": "Claude Opus 4.5", + "provider": "anthropic", + "context_window": 200000, + "is_default": false + }, + { + "id": "haiku4.5", + "name": "Haiku 4.5", + "provider": "anthropic", + "context_window": 200000, + "is_default": false + }, + { + "id": "sonnet4", + "name": "Sonnet 4", + "provider": "anthropic", + "context_window": 200000, + "is_default": false + }, + { + "id": "gpt5.1", + "name": "GPT-5.1", + "provider": "openai", + "context_window": 128000, + "is_default": false + }, + { + "id": "gpt5", + "name": "GPT-5", + "provider": "openai", + "context_window": 128000, + "is_default": false + } + ] + } + }, + { + "id": "codex-agent", + "name": "OpenAI Codex Agent", + "display_name": "Codex", + "description": "OpenAI Codex CLI-powered autonomous coding agent using the Codex app-server protocol.", + "enabled": true, + "image": "kandev/codex-agent", + "tag": "latest", + "cmd": ["codex", "app-server"], + "working_dir": "/workspace", + "required_env": [], + "env": {}, + "mounts": [ + {"source": "{workspace}", "target": "/workspace", "read_only": false} + ], + "resource_limits": { + "memory_mb": 4096, + "cpu_cores": 2.0, + "timeout_seconds": 3600 + }, + "capabilities": ["code_generation", "code_review", "refactoring", "testing", "shell_execution"], + "protocol": "codex", + "protocol_config": {}, + "model_flag": "--model", + "workspace_flag": "", + "session_config": { + "resume_via_acp": true, + "resume_flag": "", + "can_recover": true, + "session_dir_template": "{home}/.codex/sessions", + "session_dir_target": "/root/.codex/sessions" + }, + "permission_config": { + "permission_flag": "", + "tools_requiring_permission": [] + }, + "discovery": { + "supports_mcp": false, + "mcp_config_path": { + "linux": [], + "windows": [], + "macos": [] + }, + "installation_path": { + "linux": ["~/.codex/auth.json"], + "windows": ["~/.codex/auth.json"], + "macos": ["~/.codex/auth.json"] + }, + "discovery_capabilities": { + "supports_session_resume": false, + "supports_shell": false, + "supports_workspace_only": false + } + }, + "model_config": { + "default_model": "gpt-5.2-codex", + "available_models": [ + { + "id": "gpt-5.2-codex", + "name": "GPT-5.2 Codex", + "provider": "openai", + "context_window": 200000, + "is_default": true + }, + { + "id": "gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "provider": "openai", + "context_window": 200000, + "is_default": false + }, + { + "id": "gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex Mini", + "provider": "openai", + "context_window": 200000, + "is_default": false + }, + { + "id": "gpt-5.2", + "name": "GPT-5.2", + "provider": "openai", + "context_window": 200000, + "is_default": false + } + ] + } + } + ] +} + diff --git a/apps/backend/internal/agent/registry/defaults.go b/apps/backend/internal/agent/registry/defaults.go deleted file mode 100644 index dea29b6bb0..0000000000 --- a/apps/backend/internal/agent/registry/defaults.go +++ /dev/null @@ -1,184 +0,0 @@ -package registry - -import "github.com/kandev/kandev/pkg/agent" - -// boolPtr returns a pointer to a bool value -func boolPtr(v bool) *bool { - return &v -} - -// DefaultAgents returns the default agent configurations -func DefaultAgents() []*AgentTypeConfig { - return []*AgentTypeConfig{ - { - ID: "auggie-agent", - Name: "Augment Coding Agent", - Description: "Auggie CLI-powered autonomous coding agent. Requires AUGMENT_SESSION_AUTH for authentication.", - Image: "kandev/augment-agent", - Tag: "latest", - Cmd: []string{"auggie", "--acp"}, - WorkingDir: "/workspace", - RequiredEnv: []string{"AUGMENT_SESSION_AUTH"}, - Env: map[string]string{}, - Mounts: []MountTemplate{ - {Source: "{workspace}", Target: "/workspace", ReadOnly: false}, - }, - ResourceLimits: ResourceLimits{ - MemoryMB: 4096, - CPUCores: 2.0, - TimeoutSeconds: 3600, - }, - Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, - Enabled: true, - ModelFlag: "--model", - WorkspaceFlag: "--workspace-root", - Protocol: agent.ProtocolACP, - SessionConfig: SessionConfig{ - ResumeViaACP: false, // Auggie handles resume via CLI flag - ResumeFlag: "--resume", - CanRecover: boolPtr(false), // Auggie cannot recover sessions after backend restart - SessionDirTemplate: "{home}/.augment/sessions", - SessionDirTarget: "/root/.augment/sessions", - }, - PermissionConfig: PermissionConfig{ - PermissionFlag: "--permission", - ToolsRequiringPermission: []string{ - "launch-process", - "save-file", - "str-replace-editor", - "remove-files", - }, - }, - }, - { - ID: "claude-agent", - Name: "Claude Code Agent", - Description: "Claude Code CLI-powered autonomous coding agent.", - Image: "kandev/claude-agent", - Tag: "latest", - Cmd: []string{"claude", "--acp"}, - WorkingDir: "/workspace", - RequiredEnv: []string{}, - Env: map[string]string{}, - Mounts: []MountTemplate{ - {Source: "{workspace}", Target: "/workspace", ReadOnly: false}, - }, - ResourceLimits: ResourceLimits{ - MemoryMB: 4096, - CPUCores: 2.0, - TimeoutSeconds: 3600, - }, - Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, - Enabled: true, - ModelFlag: "--model", - Protocol: agent.ProtocolACP, - SessionConfig: SessionConfig{ - ResumeViaACP: true, // Claude uses ACP session/load for resume - }, - }, - { - ID: "codex-agent", - Name: "OpenAI Codex Agent", - Description: "OpenAI Codex CLI-powered autonomous coding agent using the Codex app-server protocol.", - Image: "kandev/codex-agent", - Tag: "latest", - Cmd: []string{"codex", "app-server"}, - WorkingDir: "/workspace", - RequiredEnv: []string{}, - Env: map[string]string{}, - Mounts: []MountTemplate{ - {Source: "{workspace}", Target: "/workspace", ReadOnly: false}, - }, - ResourceLimits: ResourceLimits{ - MemoryMB: 4096, - CPUCores: 2.0, - TimeoutSeconds: 3600, - }, - Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, - Enabled: true, - ModelFlag: "--model", - Protocol: agent.ProtocolCodex, - SessionConfig: SessionConfig{ - ResumeViaACP: true, // Codex uses thread/resume via ACP - }, - }, - { - ID: "gemini-agent", - Name: "Gemini CLI Agent", - Description: "Google Gemini CLI-powered autonomous coding agent.", - Image: "kandev/gemini-agent", - Tag: "latest", - Cmd: []string{"gemini", "--acp"}, - WorkingDir: "/workspace", - RequiredEnv: []string{}, - Env: map[string]string{}, - Mounts: []MountTemplate{ - {Source: "{workspace}", Target: "/workspace", ReadOnly: false}, - }, - ResourceLimits: ResourceLimits{ - MemoryMB: 4096, - CPUCores: 2.0, - TimeoutSeconds: 3600, - }, - Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, - Enabled: true, - ModelFlag: "--model", - Protocol: agent.ProtocolACP, - SessionConfig: SessionConfig{ - ResumeViaACP: true, // Gemini uses ACP session/load for resume - }, - }, - { - ID: "opencode-agent", - Name: "OpenCode Agent", - Description: "OpenCode CLI-powered autonomous coding agent.", - Image: "kandev/opencode-agent", - Tag: "latest", - Cmd: []string{"opencode", "--acp"}, - WorkingDir: "/workspace", - RequiredEnv: []string{}, - Env: map[string]string{}, - Mounts: []MountTemplate{ - {Source: "{workspace}", Target: "/workspace", ReadOnly: false}, - }, - ResourceLimits: ResourceLimits{ - MemoryMB: 4096, - CPUCores: 2.0, - TimeoutSeconds: 3600, - }, - Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, - Enabled: true, - ModelFlag: "--model", - Protocol: agent.ProtocolACP, - SessionConfig: SessionConfig{ - ResumeViaACP: true, // OpenCode uses ACP session/load for resume - }, - }, - { - ID: "copilot-agent", - Name: "GitHub Copilot Agent", - Description: "GitHub Copilot CLI-powered autonomous coding agent.", - Image: "kandev/copilot-agent", - Tag: "latest", - Cmd: []string{"copilot", "--acp"}, - WorkingDir: "/workspace", - RequiredEnv: []string{}, - Env: map[string]string{}, - Mounts: []MountTemplate{ - {Source: "{workspace}", Target: "/workspace", ReadOnly: false}, - }, - ResourceLimits: ResourceLimits{ - MemoryMB: 4096, - CPUCores: 2.0, - TimeoutSeconds: 3600, - }, - Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, - Enabled: true, - ModelFlag: "--model", - Protocol: agent.ProtocolACP, - SessionConfig: SessionConfig{ - ResumeViaACP: true, // Copilot uses ACP session/load for resume - }, - }, - } -} diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index a50a1bc575..73900c18ad 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -2,6 +2,7 @@ package registry import ( + "embed" "encoding/json" "fmt" "os" @@ -15,6 +16,15 @@ import ( v1 "github.com/kandev/kandev/pkg/api/v1" ) +//go:embed agents.json +var agentsFS embed.FS + +// agentsConfig is the structure of the agents.json file +type agentsConfig struct { + Version string `json:"version"` + Agents []*AgentTypeConfig `json:"agents"` +} + // AgentTypeConfig holds configuration for an agent type type AgentTypeConfig struct { ID string `json:"id"` @@ -43,6 +53,15 @@ type AgentTypeConfig struct { // Permission configuration PermissionConfig PermissionConfig `json:"permission_config,omitempty"` // How to handle tool permissions + + // Discovery configuration (for detecting agent installation) + Discovery DiscoveryConfig `json:"discovery,omitempty"` // Discovery metadata for agent detection + + // Model configuration + ModelConfig ModelConfig `json:"model_config,omitempty"` // Available models and default model + + // Display name for UI (shorter than Name) + DisplayName string `json:"display_name,omitempty"` } // SessionConfig defines how session resumption is handled for an agent type @@ -107,6 +126,43 @@ type ResourceLimits struct { TimeoutSeconds int `json:"timeout_seconds"` } +// ModelConfig defines the model configuration for an agent +type ModelConfig struct { + DefaultModel string `json:"default_model"` + AvailableModels []ModelEntry `json:"available_models"` +} + +// ModelEntry defines a single model available for an agent +type ModelEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + ContextWindow int `json:"context_window"` + IsDefault bool `json:"is_default"` +} + +// OSPaths defines OS-specific paths for discovery +type OSPaths struct { + Linux []string `json:"linux"` + Windows []string `json:"windows"` + MacOS []string `json:"macos"` +} + +// DiscoveryCapabilities defines discovery-specific capabilities +type DiscoveryCapabilities struct { + SupportsSessionResume bool `json:"supports_session_resume"` + SupportsShell bool `json:"supports_shell"` + SupportsWorkspaceOnly bool `json:"supports_workspace_only"` +} + +// DiscoveryConfig holds discovery-related configuration for an agent +type DiscoveryConfig struct { + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPath OSPaths `json:"mcp_config_path"` + InstallationPath OSPaths `json:"installation_path"` + DiscoveryCapabilities DiscoveryCapabilities `json:"discovery_capabilities"` +} + // Registry manages agent type configurations type Registry struct { agents map[string]*AgentTypeConfig @@ -311,3 +367,54 @@ func ValidateConfig(config *AgentTypeConfig) error { return nil } +// DefaultAgents returns the default agent configurations loaded from agents.json +func DefaultAgents() []*AgentTypeConfig { + agents, err := loadAgentsFromJSON() + if err != nil { + // Fall back to empty list if loading fails (should not happen with embedded file) + return []*AgentTypeConfig{} + } + return agents +} + +// loadAgentsFromJSON loads agent configurations from the embedded agents.json file +func loadAgentsFromJSON() ([]*AgentTypeConfig, error) { + data, err := agentsFS.ReadFile("agents.json") + if err != nil { + return nil, fmt.Errorf("read agents config: %w", err) + } + + var cfg agentsConfig + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse agents config: %w", err) + } + + // Post-process to convert protocol strings to agent.Protocol type + for _, agentCfg := range cfg.Agents { + agentCfg.Protocol = parseProtocol(string(agentCfg.Protocol)) + } + + return cfg.Agents, nil +} + +// parseProtocol converts a protocol string to agent.Protocol +func parseProtocol(p string) agent.Protocol { + switch p { + case "acp": + return agent.ProtocolACP + case "rest": + return agent.ProtocolREST + case "mcp": + return agent.ProtocolMCP + case "codex": + return agent.ProtocolCodex + default: + return agent.ProtocolACP // Default to ACP + } +} + +// GetAgentDefinitions returns the raw agent definitions from agents.json for discovery. +// This provides access to discovery metadata that may be needed by the discovery package. +func GetAgentDefinitions() ([]*AgentTypeConfig, error) { + return loadAgentsFromJSON() +} diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 6cc2858f8c..e9728287af 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -89,6 +89,18 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl if displayName == "" { displayName = def.Name } + // Convert model entries + modelEntries := make([]dto.ModelEntryDTO, 0, len(def.ModelConfig.AvailableModels)) + for _, model := range def.ModelConfig.AvailableModels { + modelEntries = append(modelEntries, dto.ModelEntryDTO{ + ID: model.ID, + Name: model.Name, + Provider: model.Provider, + ContextWindow: model.ContextWindow, + IsDefault: model.IsDefault, + }) + } + payload = append(payload, dto.AvailableAgentDTO{ Name: availability.Name, DisplayName: displayName, @@ -102,6 +114,10 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl SupportsShell: def.Capabilities.SupportsShell, SupportsWorkspaceOnly: def.Capabilities.SupportsWorkspaceOnly, }, + ModelConfig: dto.ModelConfigDTO{ + DefaultModel: def.ModelConfig.DefaultModel, + AvailableModels: modelEntries, + }, UpdatedAt: now, }) } diff --git a/apps/backend/internal/agent/settings/dto/dto.go b/apps/backend/internal/agent/settings/dto/dto.go index 6a322d73b2..719cce495d 100644 --- a/apps/backend/internal/agent/settings/dto/dto.go +++ b/apps/backend/internal/agent/settings/dto/dto.go @@ -51,6 +51,19 @@ type AgentCapabilitiesDTO struct { SupportsWorkspaceOnly bool `json:"supports_workspace_only"` } +type ModelEntryDTO struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` + ContextWindow int `json:"context_window"` + IsDefault bool `json:"is_default"` +} + +type ModelConfigDTO struct { + DefaultModel string `json:"default_model"` + AvailableModels []ModelEntryDTO `json:"available_models"` +} + type AvailableAgentDTO struct { Name string `json:"name"` DisplayName string `json:"display_name"` @@ -60,6 +73,7 @@ type AvailableAgentDTO struct { Available bool `json:"available"` MatchedPath string `json:"matched_path,omitempty"` Capabilities AgentCapabilitiesDTO `json:"capabilities"` + ModelConfig ModelConfigDTO `json:"model_config"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/agentctl/client/control.go b/apps/backend/internal/agentctl/client/control.go index 3c2b9bc55f..2d366eb962 100644 --- a/apps/backend/internal/agentctl/client/control.go +++ b/apps/backend/internal/agentctl/client/control.go @@ -28,7 +28,7 @@ type CreateInstanceRequest struct { ID string `json:"id,omitempty"` WorkspacePath string `json:"workspace_path"` AgentCommand string `json:"agent_command,omitempty"` - Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (acp, rest, mcp) + Protocol string `json:"protocol,omitempty"` // Protocol adapter to use (acp, rest, mcp, codex) WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root") Env map[string]string `json:"env,omitempty"` AutoStart bool `json:"auto_start,omitempty"` diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 62bab93c54..ca1bb9c61e 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -109,6 +109,10 @@ type InstanceConfig struct { // LogFormat for this instance LogFormat string + + // AgentType identifies the agent (e.g., "auggie", "codex", "claude") + // Used for agent-specific adapter selection + AgentType string } // Load loads the configuration from environment variables. @@ -168,6 +172,9 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst if overrides.ApprovalPolicy != "" { cfg.ApprovalPolicy = overrides.ApprovalPolicy } + if overrides.AgentType != "" { + cfg.AgentType = overrides.AgentType + } } // Parse agent command into args @@ -189,6 +196,7 @@ type InstanceOverrides struct { AutoStart *bool Env []string ApprovalPolicy string + AgentType string } // ParseCommand splits a command string into arguments diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index bd7dbdaa26..fe59890ca8 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -52,7 +52,7 @@ type CreateRequest struct { // AgentCommand is an optional command to start the agent. If empty, a default is used. AgentCommand string `json:"agent_command,omitempty"` - // Protocol is the protocol adapter to use (acp, codex). If empty, default is used. + // Protocol is the protocol adapter to use (acp, codex, auggie). If empty, default is used. Protocol string `json:"protocol,omitempty"` // WorkspaceFlag is the CLI flag for workspace path (e.g., "--workspace-root"). diff --git a/apps/backend/internal/orchestrator/controller/controller.go b/apps/backend/internal/orchestrator/controller/controller.go index e956e65408..4e4fec1dd4 100644 --- a/apps/backend/internal/orchestrator/controller/controller.go +++ b/apps/backend/internal/orchestrator/controller/controller.go @@ -24,10 +24,9 @@ func NewController(svc *orchestrator.Service) *Controller { func (c *Controller) GetStatus(ctx context.Context, req dto.GetStatusRequest) (dto.StatusResponse, error) { status := c.service.GetStatus() return dto.StatusResponse{ - Running: status.Running, - ActiveAgents: status.ActiveAgents, - QueuedTasks: status.QueuedTasks, - MaxConcurrent: 0, // Not available from service Status; would need scheduler access + Running: status.Running, + ActiveAgents: status.ActiveAgents, + QueuedTasks: status.QueuedTasks, }, nil } diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index 546d7f7bea..576772bbf3 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -6,10 +6,9 @@ type GetStatusRequest struct{} // StatusResponse is the response for orchestrator.status type StatusResponse struct { - Running bool `json:"running"` - ActiveAgents int `json:"active_agents"` - QueuedTasks int `json:"queued_tasks"` - MaxConcurrent int `json:"max_concurrent"` + Running bool `json:"running"` + ActiveAgents int `json:"active_agents"` + QueuedTasks int `json:"queued_tasks"` } // GetQueueRequest is the request for orchestrator.queue diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index 76d587b372..1901a4f094 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -17,7 +17,6 @@ import ( // Common errors var ( - ErrMaxConcurrentReached = errors.New("maximum concurrent executions reached") ErrNoAgentProfileID = errors.New("task has no agent_profile_id configured") ErrExecutionNotFound = errors.New("execution not found") ErrExecutionAlreadyRunning = errors.New("execution already running") @@ -146,7 +145,6 @@ type Executor struct { logger *logger.Logger // Configuration - maxConcurrent int retryLimit int retryDelay time.Duration worktreeEnabled bool // Whether to use Git worktrees for agent isolation @@ -154,7 +152,6 @@ type Executor struct { // ExecutorConfig holds configuration for the Executor type ExecutorConfig struct { - MaxConcurrent int // Maximum concurrent agent executions WorktreeEnabled bool // Whether to use Git worktrees for agent isolation ShellPrefs ShellPreferenceProvider } @@ -165,16 +162,11 @@ type ShellPreferenceProvider interface { // NewExecutor creates a new executor func NewExecutor(agentManager AgentManagerClient, repo repository.Repository, log *logger.Logger, cfg ExecutorConfig) *Executor { - maxConcurrent := cfg.MaxConcurrent - if maxConcurrent <= 0 { - maxConcurrent = 5 // default - } return &Executor{ agentManager: agentManager, repo: repo, shellPrefs: cfg.ShellPrefs, logger: log.WithFields(zap.String("component", "executor")), - maxConcurrent: maxConcurrent, retryLimit: 3, retryDelay: 5 * time.Second, worktreeEnabled: cfg.WorktreeEnabled, @@ -223,14 +215,6 @@ func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, // ExecuteWithProfile starts agent execution for a task using an explicit agent profile. // The prompt parameter is the initial prompt to send to the agent. func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, prompt string) (*TaskExecution, error) { - // Check if max concurrent limit is reached - if !e.CanExecute() { - e.logger.Warn("max concurrent executions reached", - zap.Int("max", e.maxConcurrent), - zap.Int("current", e.ActiveCount())) - return nil, ErrMaxConcurrentReached - } - if agentProfileID == "" { e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID)) return nil, ErrNoAgentProfileID @@ -465,13 +449,6 @@ func (e *Executor) ResumeSession(ctx context.Context, session *models.TaskSessio return nil, ErrExecutionNotFound } - if !e.CanExecute() { - e.logger.Warn("max concurrent executions reached", - zap.Int("max", e.maxConcurrent), - zap.Int("current", e.ActiveCount())) - return nil, ErrMaxConcurrentReached - } - if session.AgentProfileID == "" { e.logger.Error("task session has no agent_profile_id configured", zap.String("task_id", session.TaskID), @@ -896,9 +873,10 @@ func (e *Executor) ActiveCount() int { return len(sessions) } -// CanExecute returns true if there's capacity for another execution +// CanExecute returns true if there's capacity for another execution. +// Currently always returns true as there is no concurrent execution limit. func (e *Executor) CanExecute() bool { - return e.ActiveCount() < e.maxConcurrent + return true } // UpdateProgressBySession updates the progress of an execution by session ID diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler.go b/apps/backend/internal/orchestrator/scheduler/scheduler.go index 0c6d87a2cf..7ceac3791c 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler.go @@ -25,7 +25,6 @@ var ( // SchedulerConfig holds scheduler configuration type SchedulerConfig struct { ProcessInterval time.Duration // How often to process the queue - MaxConcurrent int // Max concurrent agent executions RetryLimit int // Max retries for failed tasks RetryDelay time.Duration // Delay between retries } @@ -34,7 +33,6 @@ type SchedulerConfig struct { func DefaultSchedulerConfig() SchedulerConfig { return SchedulerConfig{ ProcessInterval: 5 * time.Second, - MaxConcurrent: 5, RetryLimit: 3, RetryDelay: 30 * time.Second, } @@ -50,7 +48,6 @@ type TaskRepository interface { type QueueStatus struct { QueuedTasks int ActiveExecutions int - MaxConcurrent int TotalProcessed int64 TotalFailed int64 } @@ -107,8 +104,7 @@ func (s *Scheduler) Start(ctx context.Context) error { s.mu.Unlock() s.logger.Info("scheduler starting", - zap.Duration("process_interval", s.config.ProcessInterval), - zap.Int("max_concurrent", s.config.MaxConcurrent)) + zap.Duration("process_interval", s.config.ProcessInterval)) s.wg.Add(1) go s.processLoop(ctx) @@ -162,7 +158,6 @@ func (s *Scheduler) GetQueueStatus() *QueueStatus { return &QueueStatus{ QueuedTasks: s.queue.Len(), ActiveExecutions: s.executor.ActiveCount(), - MaxConcurrent: s.config.MaxConcurrent, TotalProcessed: atomic.LoadInt64(&s.totalProcessed), TotalFailed: atomic.LoadInt64(&s.totalFailed), } diff --git a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go index 4ee371d7a0..629fcb4d29 100644 --- a/apps/backend/internal/orchestrator/scheduler/scheduler_test.go +++ b/apps/backend/internal/orchestrator/scheduler/scheduler_test.go @@ -130,7 +130,7 @@ func createTestLogger() *logger.Logger { return log } -func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Logger, maxConcurrent int) *executor.Executor { +func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Logger) *executor.Executor { tmpDir := t.TempDir() dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db")) if err != nil { @@ -150,7 +150,6 @@ func createTestExecutor(t *testing.T, agentMgr *mockAgentManager, log *logger.Lo } }) cfg := executor.ExecutorConfig{ - MaxConcurrent: maxConcurrent, WorktreeEnabled: true, } return executor.NewExecutor(agentMgr, repo, log, cfg) @@ -172,7 +171,7 @@ func TestNewScheduler(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -190,9 +189,6 @@ func TestDefaultSchedulerConfig(t *testing.T) { if cfg.ProcessInterval != 5*time.Second { t.Errorf("expected ProcessInterval = 5s, got %v", cfg.ProcessInterval) } - if cfg.MaxConcurrent != 5 { - t.Errorf("expected MaxConcurrent = 5, got %d", cfg.MaxConcurrent) - } if cfg.RetryLimit != 3 { t.Errorf("expected RetryLimit = 3, got %d", cfg.RetryLimit) } @@ -205,7 +201,7 @@ func TestStartStop(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -231,7 +227,7 @@ func TestStartAlreadyRunning(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -251,7 +247,7 @@ func TestStopNotRunning(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -266,7 +262,7 @@ func TestEnqueueTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -286,7 +282,7 @@ func TestRemoveTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -308,7 +304,7 @@ func TestRemoveNonExistentTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -323,11 +319,10 @@ func TestGetQueueStatus(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() - cfg.MaxConcurrent = 10 s := NewScheduler(q, exec, taskRepo, log, cfg) _ = s.EnqueueTask(createTestTask("task-1", 5)) @@ -337,9 +332,6 @@ func TestGetQueueStatus(t *testing.T) { if status.QueuedTasks != 2 { t.Errorf("expected QueuedTasks = 2, got %d", status.QueuedTasks) } - if status.MaxConcurrent != 10 { - t.Errorf("expected MaxConcurrent = 10, got %d", status.MaxConcurrent) - } if status.ActiveExecutions != 0 { t.Errorf("expected ActiveExecutions = 0, got %d", status.ActiveExecutions) } @@ -349,7 +341,7 @@ func TestHandleTaskCompleted(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -378,7 +370,7 @@ func TestPriorityOrdering(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -399,7 +391,7 @@ func TestIsRunning(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -423,7 +415,7 @@ func TestSchedulerWithContextCancellation(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() @@ -452,7 +444,7 @@ func TestEnqueueDuplicateTask(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() s := NewScheduler(q, exec, taskRepo, log, DefaultSchedulerConfig()) @@ -470,7 +462,7 @@ func TestRetryTaskExceedsLimit(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() @@ -504,7 +496,7 @@ func TestRetryTaskNotFound(t *testing.T) { q := queue.NewTaskQueue(100) agentMgr := newMockAgentManager() log := createTestLogger() - exec := createTestExecutor(t, agentMgr, log, 5) + exec := createTestExecutor(t, agentMgr, log) taskRepo := newTestTaskRepository() cfg := DefaultSchedulerConfig() diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 10e5cc7d5f..4d2329cb07 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -114,7 +114,6 @@ func NewService( // Create the executor with the agent manager client and repository for persistent sessions execCfg := executor.ExecutorConfig{ - MaxConcurrent: cfg.Scheduler.MaxConcurrent, WorktreeEnabled: cfg.WorktreeEnabled, ShellPrefs: shellPrefs, } diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 7ddd0028d1..f3a3e53532 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -8,6 +8,13 @@ import { Button } from '@kandev/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; import { Input } from '@kandev/ui/input'; import { Label } from '@kandev/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kandev/ui/select'; import { Separator } from '@kandev/ui/separator'; import { Switch } from '@kandev/ui/switch'; import { Textarea } from '@kandev/ui/textarea'; @@ -82,6 +89,12 @@ function AgentSetupForm({ const resolveDisplayName = (name: string) => availableAgents.find((item) => item.name === name)?.display_name ?? ''; + // Get model config for the current agent + const currentAgentModelConfig = useMemo(() => { + const agent = availableAgents.find((item) => item.name === draftAgent.name); + return agent?.model_config ?? { default_model: '', available_models: [] }; + }, [availableAgents, draftAgent.name]); + const isProfileDirty = (draft: DraftProfile, saved?: AgentProfile) => { if (!saved) return true; return ( @@ -311,13 +324,34 @@ function AgentSetupForm({
- - handleProfileChange(profile.id, { model: event.target.value }) - } - placeholder="model-id" - /> + {currentAgentModelConfig.available_models.length > 0 ? ( + + ) : ( + + handleProfileChange(profile.id, { model: event.target.value }) + } + placeholder="model-id" + /> + )}
diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx index 89349abcb8..bf00de4efa 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/page.tsx @@ -9,21 +9,24 @@ import { Button } from '@kandev/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card'; import { Input } from '@kandev/ui/input'; import { Label } from '@kandev/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kandev/ui/select'; import { Separator } from '@kandev/ui/separator'; import { Switch } from '@kandev/ui/switch'; import { Textarea } from '@kandev/ui/textarea'; import { useToast } from '@/components/toast-provider'; import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator'; import { deleteAgentProfileAction, updateAgentProfileAction } from '@/app/actions/agents'; -import type { Agent, AgentProfile } from '@/lib/types/http'; +import type { Agent, AgentProfile, ModelConfig } from '@/lib/types/http'; import { useAppStore } from '@/components/state-provider'; +import { useAvailableAgents } from '@/hooks/use-available-agents'; type ProfileEditorProps = { agent: Agent; profile: AgentProfile; + modelConfig: ModelConfig; }; -function ProfileEditor({ agent, profile }: ProfileEditorProps) { +function ProfileEditor({ agent, profile, modelConfig }: ProfileEditorProps) { const { toast } = useToast(); const settingsAgents = useAppStore((state) => state.settingsAgents.items); const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); @@ -151,11 +154,32 @@ function ProfileEditor({ agent, profile }: ProfileEditorProps) {
- setDraft({ ...draft, model: event.target.value })} - placeholder="model-id" - /> + {modelConfig.available_models.length > 0 ? ( + + ) : ( + setDraft({ ...draft, model: event.target.value })} + placeholder="model-id" + /> + )}
@@ -225,6 +249,7 @@ export default function AgentProfilePage() { const agentKey = decodeURIComponent(agentParam ?? ''); const profileId = profileParam ?? ''; const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const availableAgents = useAvailableAgents().items; const agent = useMemo(() => { return settingsAgents.find((item) => item.name === agentKey) ?? null; @@ -234,6 +259,11 @@ export default function AgentProfilePage() { return agent?.profiles.find((item) => item.id === profileId) ?? null; }, [agent?.profiles, profileId]); + const modelConfig = useMemo(() => { + const availableAgent = availableAgents.find((item) => item.name === agent?.name); + return availableAgent?.model_config ?? { default_model: '', available_models: [] }; + }, [availableAgents, agent?.name]); + if (!agent || !profile) { return ( @@ -252,6 +282,7 @@ export default function AgentProfilePage() { key={profile.id} agent={agent} profile={profile} + modelConfig={modelConfig} /> ); } diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 8dc6545264..f6f37ecb9d 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -31,7 +31,6 @@ import { } from '@kandev/ui/sidebar'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@kandev/ui/collapsible'; import { useAppStore } from '@/components/state-provider'; -import { useAvailableAgents } from '@/hooks/use-available-agents'; export function SettingsAppSidebar() { const pathname = usePathname(); @@ -39,7 +38,6 @@ export function SettingsAppSidebar() { const environments = useAppStore((state) => state.environments.items); const executors = useAppStore((state) => state.executors.items); const agents = useAppStore((state) => state.settingsAgents.items); - const availableAgents = useAvailableAgents().items; return ( diff --git a/apps/web/components/state-hydrator.tsx b/apps/web/components/state-hydrator.tsx index 2b7d956d04..630f7eb52c 100644 --- a/apps/web/components/state-hydrator.tsx +++ b/apps/web/components/state-hydrator.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useLayoutEffect } from 'react'; import type { AppState } from '@/lib/state/store'; import { useAppStoreApi } from '@/components/state-provider'; @@ -11,7 +11,10 @@ type StateHydratorProps = { export function StateHydrator({ initialState }: StateHydratorProps) { const store = useAppStoreApi(); - useEffect(() => { + // Use useLayoutEffect to hydrate state synchronously before child effects run. + // This ensures SSR-hydrated data is available before hooks like useSettingsData + // decide whether to fetch data. + useLayoutEffect(() => { if (Object.keys(initialState).length) { store.getState().hydrate(initialState); } diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index b986ae9cb8..b87a5f8cd6 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -3,7 +3,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { IconBrain, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react'; +import { IconBrain, IconCpu, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { DropdownMenu, @@ -24,6 +24,7 @@ import { useLazyLoadMessages } from '@/hooks/use-lazy-load-messages'; import { useSession } from '@/hooks/use-session'; import { useTask } from '@/hooks/use-task'; import { useSessionMessages } from '@/hooks/use-session-messages'; +import { useSettingsData } from '@/hooks/use-settings-data'; import { MessageRenderer } from '@/components/task/chat/message-renderer'; import { RunningIndicator } from '@/components/task/chat/messages/running-indicator'; import { TodoSummary } from '@/components/task/chat/todo-summary'; @@ -65,6 +66,22 @@ export function TaskChatPanel({ const isWorking = isStarting || session?.state === 'RUNNING'; const isAgentBusy = session?.state === 'CREATED' || session?.state === 'RUNNING'; + // Ensure agent profile data is loaded (may not be hydrated from SSR in all navigation paths) + useSettingsData(true); + + // Get model from agent profile using agent_profile_id + const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const sessionProfile = useMemo(() => { + if (!session?.agent_profile_id) return null; + for (const agent of settingsAgents) { + const profile = agent.profiles.find((p) => p.id === session.agent_profile_id); + if (profile) return profile; + } + return null; + }, [session?.agent_profile_id, settingsAgents]); + + const sessionModel = sessionProfile?.model ?? null; + // Fetch messages for this session const { messages, isLoading: messagesLoading } = useSessionMessages(resolvedSessionId); const isInitialLoading = messagesLoading && messages.length === 0; @@ -305,10 +322,17 @@ export function TaskChatPanel({ )}
- {/* Running indicator - shows agent state */} + {/* Session info - shows agent state and model */} {session?.state && ( -
+
+ {/* Model indicator */} + {sessionModel && ( +
+ + {sessionModel} +
+ )}
)} diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 8d4a713211..44ca260199 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -376,6 +376,19 @@ export type AgentCapabilities = { supports_workspace_only: boolean; }; +export type ModelEntry = { + id: string; + name: string; + provider: string; + context_window: number; + is_default: boolean; +}; + +export type ModelConfig = { + default_model: string; + available_models: ModelEntry[]; +}; + export type AvailableAgent = { name: string; display_name: string; @@ -385,6 +398,7 @@ export type AvailableAgent = { available: boolean; matched_path?: string | null; capabilities: AgentCapabilities; + model_config: ModelConfig; updated_at: string; };