diff --git a/apps/backend/cmd/kandev/adapters.go b/apps/backend/cmd/kandev/adapters.go index 8c8aa91517..24d3960796 100644 --- a/apps/backend/cmd/kandev/adapters.go +++ b/apps/backend/cmd/kandev/adapters.go @@ -167,7 +167,7 @@ func (a *lifecycleAdapter) ListAgentTypes(ctx context.Context) ([]*v1.AgentType, configs := a.registry.List() result := make([]*v1.AgentType, 0, len(configs)) for _, cfg := range configs { - result = append(result, cfg.ToAPIType()) + result = append(result, registry.ToAPIType(cfg)) } return result, nil } diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 675208ceed..e6e6a12667 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -18,11 +18,11 @@ import ( ) func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, eventBus bus.EventBus) (*Services, *agentsettingscontroller.Controller, error) { - discoveryRegistry, err := discovery.LoadRegistry() + agentRegistry, _, err := registry.Provide(log) if err != nil { return nil, nil, err } - agentRegistry, _, err := registry.Provide(log) + discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log) if err != nil { return nil, nil, err } diff --git a/apps/backend/internal/agent/agents/adapter_wrappers.go b/apps/backend/internal/agent/agents/adapter_wrappers.go new file mode 100644 index 0000000000..c2ec6e3558 --- /dev/null +++ b/apps/backend/internal/agent/agents/adapter_wrappers.go @@ -0,0 +1,92 @@ +package agents + +import ( + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/codex" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/opencode" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/streamjson" +) + +// Adapter wrappers convert transport-specific adapters to the common AgentAdapter interface. +// These handle the type conversion between transport-specific AgentInfo and adapter.AgentInfo. + +// --- ACP --- + +type acpAdapterWrapper struct { + *acp.Adapter +} + +func newACPAdapterWrapper(a *acp.Adapter) *acpAdapterWrapper { + return &acpAdapterWrapper{Adapter: a} +} + +func (w *acpAdapterWrapper) GetAgentInfo() *adapter.AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &adapter.AgentInfo{Name: info.Name, Version: info.Version} +} + +// --- Stream JSON (Claude Code, Amp) --- + +type streamJSONAdapterWrapper struct { + *streamjson.Adapter +} + +func newStreamJSONAdapterWrapper(a *streamjson.Adapter) *streamJSONAdapterWrapper { + return &streamJSONAdapterWrapper{Adapter: a} +} + +func (w *streamJSONAdapterWrapper) GetAgentInfo() *adapter.AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &adapter.AgentInfo{Name: info.Name, Version: info.Version} +} + +func (w *streamJSONAdapterWrapper) SetStderrProvider(provider adapter.StderrProvider) { + w.Adapter.SetStderrProvider(provider) +} + +// --- Codex --- + +type codexAdapterWrapper struct { + *codex.Adapter +} + +func newCodexAdapterWrapper(a *codex.Adapter) *codexAdapterWrapper { + return &codexAdapterWrapper{Adapter: a} +} + +func (w *codexAdapterWrapper) GetAgentInfo() *adapter.AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &adapter.AgentInfo{Name: info.Name, Version: info.Version} +} + +func (w *codexAdapterWrapper) SetStderrProvider(provider adapter.StderrProvider) { + w.Adapter.SetStderrProvider(provider) +} + +// --- OpenCode --- + +type openCodeAdapterWrapper struct { + *opencode.Adapter +} + +func newOpenCodeAdapterWrapper(a *opencode.Adapter) *openCodeAdapterWrapper { + return &openCodeAdapterWrapper{Adapter: a} +} + +func (w *openCodeAdapterWrapper) GetAgentInfo() *adapter.AgentInfo { + info := w.Adapter.GetAgentInfo() + if info == nil { + return nil + } + return &adapter.AgentInfo{Name: info.Name, Version: info.Version} +} diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go new file mode 100644 index 0000000000..b303872c7e --- /dev/null +++ b/apps/backend/internal/agent/agents/agent.go @@ -0,0 +1,277 @@ +// Package agents defines the Agent interface and supporting types. +// Each agent (Auggie, Claude Code, Codex, etc.) implements this interface +// in its own file, consolidating identity, discovery, models, protocol, +// execution, and runtime configuration in one place. +package agents + +import ( + "context" + "errors" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +// ErrNotSupported is returned when an agent does not support an operation. +var ErrNotSupported = errors.New("not supported by this agent") + +// Agent is the core interface for all coding agents. +type Agent interface { + // --- Identity --- + ID() string + Name() string + DisplayName() string + Description() string + Enabled() bool + + // --- Assets --- + Logo(variant LogoVariant) []byte // nil if unavailable + + // --- Discovery --- + IsInstalled(ctx context.Context) (*DiscoveryResult, error) + + // --- Models --- + DefaultModel() string + ListModels(ctx context.Context) (*ModelList, error) + + // --- Protocol --- + CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) + + // --- Execution --- + BuildCommand(opts CommandOptions) Command + + // --- Permissions --- + PermissionSettings() map[string]PermissionSetting + + // --- Runtime --- + Runtime() *RuntimeConfig +} + +// InferenceAgent is an optional capability for agents that support direct LLM inference. +type InferenceAgent interface { + GenerateText(ctx context.Context, req InferenceRequest) (*InferenceResponse, error) +} + +// PassthroughAgent is an optional capability for agents that support CLI passthrough mode. +type PassthroughAgent interface { + PassthroughConfig() PassthroughConfig + BuildPassthroughCommand(opts PassthroughOptions) Command +} + +// LogoVariant selects light or dark logo. +type LogoVariant int + +const ( + LogoLight LogoVariant = iota + LogoDark +) + +// Model describes a single model available for an agent. +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Provider string `json:"provider"` + ContextWindow int `json:"context_window"` // 0 = unspecified + IsDefault bool `json:"is_default"` + Source string `json:"source,omitempty"` // "static" or "dynamic" +} + +// ModelList is the result of listing models for an agent. +type ModelList struct { + Models []Model + SupportsDynamic bool // true = UI shows refresh button +} + +// DiscoveryResult is the result of checking if an agent is installed. +type DiscoveryResult struct { + Available bool + MatchedPath string + SupportsMCP bool + MCPConfigPaths []string + InstallationPaths []string + Capabilities DiscoveryCapabilities +} + +// DiscoveryCapabilities describes what the agent supports. +type DiscoveryCapabilities struct { + SupportsSessionResume bool + SupportsShell bool + SupportsWorkspaceOnly bool +} + +// CommandOptions are passed to BuildCommand. +type CommandOptions struct { + Model string + SessionID string // for --resume flag + AutoApprove bool + PermissionValues map[string]bool // e.g. {"allow_indexing": true} +} + +// PassthroughOptions are passed to BuildPassthroughCommand. +type PassthroughOptions struct { + Model string + SessionID string // ACP session ID; resumes a specific session via --resume + Prompt string // initial prompt for new sessions + Resume bool // generic "continue last session" (e.g. -c, --resume latest) + PermissionValues map[string]bool // e.g. {"auto_approve": true} + WorkDir string +} + +// RuntimeConfig holds Docker / standalone runtime settings. +type RuntimeConfig struct { + Image string + Tag string + Cmd Command + Entrypoint Command + WorkingDir string + Env map[string]string + RequiredEnv []string + Mounts []MountTemplate + ResourceLimits ResourceLimits + Capabilities []string + SessionConfig SessionConfig + Protocol agent.Protocol + ModelFlag Param // e.g. NewParam("--model", "{model}") + WorkspaceFlag string // e.g. "--workspace-root" +} + +// MountTemplate defines a mount with template variables. +type MountTemplate struct { + Source string `json:"source"` + Target string `json:"target"` + ReadOnly bool `json:"read_only"` +} + +// ResourceLimits defines resource constraints. +type ResourceLimits struct { + MemoryMB int64 `json:"memory_mb"` + CPUCores float64 `json:"cpu_cores"` + Timeout time.Duration `json:"timeout"` +} + +// SessionConfig defines session resumption behaviour. +type SessionConfig struct { + NativeSessionResume bool + ResumeFlag Param + CanRecover *bool + SessionDirTemplate string + SessionDirTarget string + ForkSessionCmd Command + ContinueSessionCmd Command +} + +// SupportsRecovery returns whether the agent supports session recovery. +// Returns true by default if CanRecover is not explicitly set. +func (c SessionConfig) SupportsRecovery() bool { + if c.CanRecover == nil { + return true + } + return *c.CanRecover +} + +// PermissionSetting defines metadata for a permission setting option. +type PermissionSetting struct { + Supported bool `json:"supported"` + Default bool `json:"default"` + Label string `json:"label"` + Description string `json:"description"` + ApplyMethod string `json:"apply_method,omitempty"` + CLIFlag string `json:"cli_flag,omitempty"` + CLIFlagValue string `json:"cli_flag_value,omitempty"` +} + +// PassthroughConfig defines configuration for CLI passthrough mode. +type PassthroughConfig struct { + Supported bool + Label string + Description string + PassthroughCmd Command + ModelFlag Param + PromptFlag Param + PromptPattern string + IdleTimeout time.Duration + BufferMaxBytes int64 + StatusDetector string + CheckInterval time.Duration + StabilityWindow time.Duration + ResumeFlag Param // generic "continue last session" (e.g. NewParam("-c"), NewParam("--resume", "latest")) + SessionResumeFlag Param // resume a specific session by ID (e.g. NewParam("--resume")) + WaitForTerminal bool +} + +// DefaultBufferMaxBytes is the default maximum buffer size for passthrough mode (2 MB). +const DefaultBufferMaxBytes int64 = 2 * 1024 * 1024 + +// DefaultCapabilities is the standard set of capabilities shared by most agents. +var DefaultCapabilities = []string{ + "code_generation", "code_review", "refactoring", "testing", "shell_execution", +} + +// DefaultResourceLimits is the standard resource limit set shared by most agents. +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 +} + +// InferenceResponse from direct model inference. +type InferenceResponse struct { + Text string + Model string + TokensUsed int + FinishReason string +} + +// Command is a domain value type representing a CLI command with arguments. +// Serialize to []string only at system boundaries (process exec, Docker API, JSON DTOs). +type Command struct { + args []string +} + +// NewCommand creates a Command from the given arguments. +func NewCommand(args ...string) Command { + return Command{args: append([]string{}, args...)} +} + +// Args returns the raw string slice for serialization at system boundaries. +func (c Command) Args() []string { + return c.args +} + +// IsEmpty reports whether the command has no arguments. +func (c Command) IsEmpty() bool { + return len(c.args) == 0 +} + +// With returns a CmdBuilder seeded with this command's arguments, +// allowing fluent extension without mutating the original. +func (c Command) With() *CmdBuilder { + return &CmdBuilder{args: append([]string{}, c.args...)} +} + +// Param is a command fragment — one or more pre-split CLI arguments +// (flags, flag+value pairs, templates with placeholders). +// Composed into a Command via CmdBuilder methods. +type Param struct { + args []string +} + +// NewParam creates a Param from the given arguments. +func NewParam(args ...string) Param { + return Param{args: append([]string{}, args...)} +} + +// Args returns the raw string slice. +func (p Param) Args() []string { return p.args } + +// IsEmpty reports whether the param has no arguments. +func (p Param) IsEmpty() bool { return len(p.args) == 0 } diff --git a/apps/backend/internal/agent/agents/amp.go b/apps/backend/internal/agent/agents/amp.go new file mode 100644 index 0000000000..8b203eab3b --- /dev/null +++ b/apps/backend/internal/agent/agents/amp.go @@ -0,0 +1,134 @@ +package agents + +import ( + "context" + _ "embed" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/amp_light.svg +var ampLogoLight []byte + +//go:embed logos/amp_dark.svg +var ampLogoDark []byte + +var ( + _ Agent = (*Amp)(nil) + _ PassthroughAgent = (*Amp)(nil) +) + +type Amp struct { + StandardPassthrough +} + +func NewAmp() *Amp { + return &Amp{ + StandardPassthrough: StandardPassthrough{ + PermSettings: ampPermSettings, + Cfg: PassthroughConfig{ + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "-y", "@sourcegraph/amp@latest"), + ModelFlag: NewParam("-m", "{model}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + }, + }, + } +} + +func (a *Amp) ID() string { return "amp" } +func (a *Amp) Name() string { return "Sourcegraph Amp Agent" } +func (a *Amp) DisplayName() string { return "Amp" } +func (a *Amp) Description() string { + return "Sourcegraph Amp CLI-powered autonomous coding agent using stream-json protocol." +} +func (a *Amp) Enabled() bool { return true } + +func (a *Amp) Logo(v LogoVariant) []byte { + if v == LogoDark { + return ampLogoDark + } + return ampLogoLight +} + +func (a *Amp) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + install := OSPaths{ + Linux: []string{"~/.amp/bin"}, + MacOS: []string{"~/.amp/bin"}, + } + mcp := OSPaths{ + Linux: []string{"~/.config/amp/settings.json"}, + MacOS: []string{"~/.amp/bin"}, + } + + result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + if err != nil { + return result, err + } + result.SupportsMCP = true + result.InstallationPaths = install.Expanded() + result.MCPConfigPaths = mcp.Expanded() + result.Capabilities = DiscoveryCapabilities{ + SupportsSessionResume: true, + } + return result, nil +} + +func (a *Amp) DefaultModel() string { return "smart" } + +func (a *Amp) ListModels(ctx context.Context) (*ModelList, error) { + return &ModelList{Models: ampStaticModels(), SupportsDynamic: false}, nil +} + +func (a *Amp) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return adapter.NewAmpAdapter(cfg, log), nil +} + +func (a *Amp) BuildCommand(opts CommandOptions) Command { + return Cmd("npx", "-y", "@sourcegraph/amp@latest", "--execute", "--stream-json", "--stream-json-input"). + Model(NewParam("-m", "{model}"), opts.Model). + Settings(ampPermSettings, opts.PermissionValues). + Build() +} + + +func (a *Amp) Runtime() *RuntimeConfig { + canRecover := true + return &RuntimeConfig{ + Cmd: Cmd("npx", "-y", "@sourcegraph/amp@latest", "--execute", "--stream-json", "--stream-json-input").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolAmp, + ModelFlag: NewParam("-m", "{model}"), + SessionConfig: SessionConfig{ + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.config/amp", + ForkSessionCmd: Cmd("npx", "-y", "@sourcegraph/amp@latest", "threads", "fork").Build(), + ContinueSessionCmd: Cmd("npx", "-y", "@sourcegraph/amp@latest", "threads", "continue", "--execute", "--stream-json", "--stream-json-input").Build(), + }, + } +} + +func (a *Amp) PermissionSettings() map[string]PermissionSetting { + return ampPermSettings +} + +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", + ApplyMethod: "cli_flag", CLIFlag: "--dangerously-allow-all"}, +} + +func ampStaticModels() []Model { + return []Model{ + {ID: "smart", Name: "Smart Mode", Description: "State-of-the-art models for maximum capability and autonomy", Provider: "amp", IsDefault: true, Source: "static"}, + {ID: "deep", Name: "Deep Mode", Description: "Deep reasoning with extended thinking on complex problems", Provider: "amp", Source: "static"}, + } +} diff --git a/apps/backend/internal/agent/agents/auggie.go b/apps/backend/internal/agent/agents/auggie.go new file mode 100644 index 0000000000..5cc90a7261 --- /dev/null +++ b/apps/backend/internal/agent/agents/auggie.go @@ -0,0 +1,149 @@ +package agents + +import ( + "context" + _ "embed" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/auggie_light.svg +var auggieLogoLight []byte + +//go:embed logos/auggie_dark.svg +var auggieLogoDark []byte + +var ( + _ Agent = (*Auggie)(nil) + _ PassthroughAgent = (*Auggie)(nil) +) + +// Auggie implements Agent for the Augment Coding Agent. +type Auggie struct { + StandardPassthrough +} + +func NewAuggie() *Auggie { + return &Auggie{ + StandardPassthrough: StandardPassthrough{ + PermSettings: auggiePermSettings, + Cfg: PassthroughConfig{ + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "-y", "@augmentcode/auggie"), + ModelFlag: NewParam("--model", "{model}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + ResumeFlag: NewParam("-c"), + SessionResumeFlag: NewParam("--resume"), + }, + }, + } +} + +func (a *Auggie) ID() string { return "auggie" } +func (a *Auggie) Name() string { return "Augment Coding Agent" } +func (a *Auggie) DisplayName() string { return "Auggie" } +func (a *Auggie) Description() string { return "Auggie CLI-powered autonomous coding agent." } +func (a *Auggie) Enabled() bool { return true } + +func (a *Auggie) Logo(v LogoVariant) []byte { + if v == LogoDark { + return auggieLogoDark + } + return auggieLogoLight +} + +func (a *Auggie) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + result, err := Detect(ctx, + WithFileExists("~/.augment/.auggie.json"), + WithCommand("auggie"), + ) + if err != nil { + return result, err + } + result.SupportsMCP = true + result.InstallationPaths = []string{expandHomePath("~/.augment/.auggie.json")} + result.Capabilities = DiscoveryCapabilities{ + SupportsSessionResume: false, + SupportsShell: false, + SupportsWorkspaceOnly: false, + } + return result, nil +} + +func (a *Auggie) DefaultModel() string { return "sonnet4.5" } + +func (a *Auggie) ListModels(ctx context.Context) (*ModelList, error) { + return &ModelList{Models: auggieStaticModels(), SupportsDynamic: false}, nil +} + +func (a *Auggie) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return newACPAdapterWrapper(acp.NewAdapter(cfg.ToSharedConfig(), log)), nil +} + +func (a *Auggie) BuildCommand(opts CommandOptions) Command { + return Cmd("npx", "-y", "@augmentcode/auggie@0.15.0", "--acp"). + Model(NewParam("--model", "{model}"), opts.Model). + Resume(NewParam("--resume"), opts.SessionID, false). + Permissions("--permission", auggiePermTools, opts). + Settings(auggiePermSettings, opts.PermissionValues). + Build() +} + + +func (a *Auggie) Runtime() *RuntimeConfig { + canRecover := false + return &RuntimeConfig{ + Image: "kandev/multi-agent", + Tag: "latest", + Cmd: Cmd("npx", "-y", "@augmentcode/auggie@0.15.0", "--acp").Build(), + WorkingDir: "/workspace", + RequiredEnv: []string{"AUGMENT_SESSION_AUTH"}, + Env: map[string]string{}, + Mounts: []MountTemplate{ + {Source: "{workspace}", Target: "/workspace"}, + }, + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolACP, + ModelFlag: NewParam("--model", "{model}"), + WorkspaceFlag: "--workspace-root", + SessionConfig: SessionConfig{ + ResumeFlag: NewParam("--resume"), + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.augment/sessions", + SessionDirTarget: "/root/.augment/sessions", + }, + } +} + +func (a *Auggie) PermissionSettings() map[string]PermissionSetting { + return auggiePermSettings +} + +// --- Private --- + +var auggiePermTools = []string{"launch-process", "save-file", "str-replace-editor", "remove-files"} + +var auggiePermSettings = map[string]PermissionSetting{ + "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls"}, + "allow_indexing": {Supported: true, Default: true, Label: "Allow indexing", Description: "Enable workspace indexing without confirmation", + ApplyMethod: "cli_flag", CLIFlag: "--allow-indexing"}, +} + +func auggieStaticModels() []Model { + return []Model{ + {ID: "sonnet4.5", Name: "Sonnet 4.5", Description: "Great for everyday tasks", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "opus4.5", Name: "Claude Opus 4.5", Description: "Best for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "haiku4.5", Name: "Haiku 4.5", Description: "Fast and efficient responses", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "sonnet4", Name: "Sonnet 4", Description: "Legacy model", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "gpt5.1", Name: "GPT-5.1", Description: "Strong reasoning and planning", Provider: "openai", ContextWindow: 128000, Source: "static"}, + {ID: "gpt5", Name: "GPT-5", Description: "OpenAI GPT-5 legacy", Provider: "openai", ContextWindow: 128000, Source: "static"}, + } +} diff --git a/apps/backend/internal/agent/agents/claude_code.go b/apps/backend/internal/agent/agents/claude_code.go new file mode 100644 index 0000000000..4e3489b349 --- /dev/null +++ b/apps/backend/internal/agent/agents/claude_code.go @@ -0,0 +1,138 @@ +package agents + +import ( + "context" + _ "embed" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/streamjson" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/claude_code_light.svg +var claudeCodeLogoLight []byte + +//go:embed logos/claude_code_dark.svg +var claudeCodeLogoDark []byte + +var ( + _ Agent = (*ClaudeCode)(nil) + _ PassthroughAgent = (*ClaudeCode)(nil) +) + +type ClaudeCode struct { + StandardPassthrough +} + +func NewClaudeCode() *ClaudeCode { + return &ClaudeCode{ + StandardPassthrough: StandardPassthrough{ + PermSettings: claudeCodePermSettings, + Cfg: PassthroughConfig{ + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "-y", "@anthropic-ai/claude-code", "--verbose"), + ModelFlag: NewParam("--model", "{model}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + ResumeFlag: NewParam("-c"), + SessionResumeFlag: NewParam("--resume"), + }, + }, + } +} + +func (a *ClaudeCode) ID() string { return "claude-code" } +func (a *ClaudeCode) Name() string { return "Claude Code CLI Agent" } +func (a *ClaudeCode) DisplayName() string { return "Claude" } +func (a *ClaudeCode) Description() string { + return "Anthropic Claude Code CLI-powered autonomous coding agent using the stream-json protocol." +} +func (a *ClaudeCode) Enabled() bool { return true } + +func (a *ClaudeCode) Logo(v LogoVariant) []byte { + if v == LogoDark { + return claudeCodeLogoDark + } + return claudeCodeLogoLight +} + +func (a *ClaudeCode) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + result, err := Detect(ctx, WithFileExists("~/.claude.json")) + if err != nil { + return result, err + } + result.SupportsMCP = true + result.InstallationPaths = []string{expandHomePath("~/.claude.json")} + result.Capabilities = DiscoveryCapabilities{ + SupportsSessionResume: true, + } + return result, nil +} + +func (a *ClaudeCode) DefaultModel() string { return "claude-sonnet-4-5" } + +func (a *ClaudeCode) ListModels(ctx context.Context) (*ModelList, error) { + return &ModelList{Models: claudeCodeStaticModels(), SupportsDynamic: false}, nil +} + +func (a *ClaudeCode) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return newStreamJSONAdapterWrapper(streamjson.NewAdapter(cfg.ToSharedConfig(), log)), nil +} + +func (a *ClaudeCode) BuildCommand(opts CommandOptions) Command { + return Cmd("npx", "-y", "@anthropic-ai/claude-code@2.1.29", + "-p", "--output-format=stream-json", "--input-format=stream-json", + "--permission-prompt-tool=stdio", "--disallowedTools=AskUserQuestion", + "--setting-sources=user,project", "--verbose"). + Model(NewParam("--model", "{model}"), opts.Model). + Resume(NewParam("--resume"), opts.SessionID, false). + Settings(claudeCodePermSettings, opts.PermissionValues). + Build() +} + + +func (a *ClaudeCode) Runtime() *RuntimeConfig { + canRecover := true + return &RuntimeConfig{ + Cmd: Cmd("npx", "-y", "@anthropic-ai/claude-code@2.1.29", + "-p", "--output-format=stream-json", "--input-format=stream-json", + "--permission-prompt-tool=stdio", "--disallowedTools=AskUserQuestion", + "--setting-sources=user,project", "--verbose").Build(), + WorkingDir: "{workspace}", + RequiredEnv: []string{"ANTHROPIC_API_KEY"}, + Env: map[string]string{}, + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolClaudeCode, + ModelFlag: NewParam("--model", "{model}"), + SessionConfig: SessionConfig{ + ResumeFlag: NewParam("--resume"), + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.claude", + }, + } +} + +func (a *ClaudeCode) PermissionSettings() map[string]PermissionSetting { + return claudeCodePermSettings +} + +var claudeCodePermSettings = map[string]PermissionSetting{ + "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls via stdio protocol", + ApplyMethod: "stdio"}, + "dangerously_skip_permissions": {Supported: true, Default: true, Label: "Skip Permissions", Description: "Bypass all permission checks (dangerous but fast for trusted tasks)", + ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions"}, +} + +func claudeCodeStaticModels() []Model { + return []Model{ + {ID: "claude-sonnet-4-5", Name: "Claude Sonnet 4.5", Description: "Latest Sonnet with improved reasoning", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "claude-opus-4-6", Name: "Claude Opus 4.6", Description: "Latest and most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "claude-opus-4-5", Name: "Claude Opus 4.5", Description: "Most capable model for complex tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "claude-haiku-4-5", Name: "Claude Haiku 4.5", Description: "Fast and affordable model for simple tasks", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + } +} diff --git a/apps/backend/internal/agent/agents/codex.go b/apps/backend/internal/agent/agents/codex.go new file mode 100644 index 0000000000..da2ea71a46 --- /dev/null +++ b/apps/backend/internal/agent/agents/codex.go @@ -0,0 +1,128 @@ +package agents + +import ( + "context" + _ "embed" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/codex" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/codex_light.svg +var codexLogoLight []byte + +//go:embed logos/codex_dark.svg +var codexLogoDark []byte + +var ( + _ Agent = (*Codex)(nil) + _ PassthroughAgent = (*Codex)(nil) +) + +type Codex struct { + StandardPassthrough +} + +func NewCodex() *Codex { + return &Codex{ + StandardPassthrough: StandardPassthrough{ + PermSettings: codexPermSettings, + Cfg: PassthroughConfig{ + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "-y", "@openai/codex", "--full-auto"), + ModelFlag: NewParam("--model", "{model}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + }, + }, + } +} + +func (a *Codex) ID() string { return "codex" } +func (a *Codex) Name() string { return "OpenAI Codex Agent" } +func (a *Codex) DisplayName() string { return "Codex" } +func (a *Codex) Description() string { + return "OpenAI Codex CLI-powered autonomous coding agent using the Codex app-server protocol." +} +func (a *Codex) Enabled() bool { return true } + +func (a *Codex) Logo(v LogoVariant) []byte { + if v == LogoDark { + return codexLogoDark + } + return codexLogoLight +} + +func (a *Codex) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + result, err := Detect(ctx, WithFileExists("~/.codex/auth.json")) + if err != nil { + return result, err + } + result.SupportsMCP = true + result.InstallationPaths = []string{expandHomePath("~/.codex/auth.json")} + return result, nil +} + +func (a *Codex) DefaultModel() string { return "gpt-5.2-codex" } + +func (a *Codex) ListModels(ctx context.Context) (*ModelList, error) { + return &ModelList{Models: codexStaticModels(), SupportsDynamic: false}, nil +} + +func (a *Codex) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return newCodexAdapterWrapper(codex.NewAdapter(cfg.ToSharedConfig(), log)), nil +} + +func (a *Codex) BuildCommand(opts CommandOptions) Command { + return Cmd("npx", "-y", "@openai/codex@0.98.0", "app-server"). + Model(NewParam("-c", "model=\"{model}\""), opts.Model). + Settings(codexPermSettings, opts.PermissionValues). + Build() +} + + +func (a *Codex) Runtime() *RuntimeConfig { + canRecover := true + return &RuntimeConfig{ + Image: "kandev/multi-agent", + Tag: "latest", + Cmd: Cmd("npx", "-y", "@openai/codex@0.98.0", "app-server").Build(), + WorkingDir: "/workspace", + Env: map[string]string{}, + Mounts: []MountTemplate{ + {Source: "{workspace}", Target: "/workspace"}, + {Source: "{home}/.codex", Target: "/root/.codex"}, + }, + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolCodex, + ModelFlag: NewParam("-c", "model=\"{model}\""), + SessionConfig: SessionConfig{ + NativeSessionResume: true, + CanRecover: &canRecover, + }, + } +} + +func (a *Codex) PermissionSettings() map[string]PermissionSetting { + return codexPermSettings +} + +var codexPermSettings = map[string]PermissionSetting{ + "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Skip approval requests from Codex", + ApplyMethod: "acp", CLIFlag: "--full-auto"}, +} + +func codexStaticModels() []Model { + return []Model{ + {ID: "gpt-5.2-codex", Name: "GPT-5.2 Codex", Description: "Latest frontier agentic coding model", Provider: "openai", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "gpt-5.1-codex-max", Name: "GPT-5.1 Codex Max", Description: "Codex-optimized flagship for deep and fast reasoning", Provider: "openai", ContextWindow: 200000, Source: "static"}, + {ID: "gpt-5.1-codex-mini", Name: "GPT-5.1 Codex Mini", Description: "Optimized for codex. Cheaper, faster, but less capable", Provider: "openai", ContextWindow: 200000, Source: "static"}, + {ID: "gpt-5.2", Name: "GPT-5.2", Description: "Latest frontier model with improvements across knowledge, reasoning and coding", Provider: "openai", ContextWindow: 200000, Source: "static"}, + } +} diff --git a/apps/backend/internal/agent/agents/copilot.go b/apps/backend/internal/agent/agents/copilot.go new file mode 100644 index 0000000000..5321671719 --- /dev/null +++ b/apps/backend/internal/agent/agents/copilot.go @@ -0,0 +1,148 @@ +package agents + +import ( + "context" + _ "embed" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/copilot_light.svg +var copilotLogoLight []byte + +//go:embed logos/copilot_dark.svg +var copilotLogoDark []byte + +var ( + _ Agent = (*Copilot)(nil) + _ PassthroughAgent = (*Copilot)(nil) +) + +type Copilot struct { + StandardPassthrough +} + +func NewCopilot() *Copilot { + return &Copilot{ + StandardPassthrough: StandardPassthrough{ + PermSettings: copilotPermSettings, + Cfg: PassthroughConfig{ + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "-y", "@github/copilot"), + ModelFlag: NewParam("--model", "{model}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + ResumeFlag: NewParam("--resume"), + }, + }, + } +} + +func (a *Copilot) ID() string { return "copilot" } +func (a *Copilot) Name() string { return "GitHub Copilot Agent" } +func (a *Copilot) DisplayName() string { return "Copilot" } +func (a *Copilot) Description() string { + return "GitHub Copilot CLI-powered autonomous coding agent using the Copilot SDK protocol." +} +func (a *Copilot) Enabled() bool { return true } + +func (a *Copilot) Logo(v LogoVariant) []byte { + if v == LogoDark { + return copilotLogoDark + } + return copilotLogoLight +} + +func (a *Copilot) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + install := OSPaths{ + MacOS: []string{"~/.copilot/pkg"}, + } + mcp := OSPaths{ + Linux: []string{"~/.copilot/mcp-config.json"}, + MacOS: []string{"~/.copilot/mcp-config.json"}, + } + + result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + if err != nil { + return result, err + } + result.SupportsMCP = true + result.InstallationPaths = install.Expanded() + result.MCPConfigPaths = mcp.Expanded() + result.Capabilities = DiscoveryCapabilities{ + SupportsSessionResume: true, + } + return result, nil +} + +func (a *Copilot) DefaultModel() string { return "gpt-4.1" } + +func (a *Copilot) ListModels(ctx context.Context) (*ModelList, error) { + return &ModelList{Models: copilotStaticModels(), SupportsDynamic: false}, nil +} + +func (a *Copilot) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return adapter.NewCopilotAdapter(cfg, log), nil +} + +func (a *Copilot) BuildCommand(opts CommandOptions) Command { + return Cmd("npx", "-y", "@github/copilot@0.0.406", "--server", "--log-level", "error"). + Model(NewParam("--model", "{model}"), opts.Model). + Settings(copilotPermSettings, opts.PermissionValues). + Build() +} + + +func (a *Copilot) Runtime() *RuntimeConfig { + canRecover := true + return &RuntimeConfig{ + Cmd: Cmd("npx", "-y", "@github/copilot@0.0.406", "--server", "--log-level", "error").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolCopilot, + ModelFlag: NewParam("--model", "{model}"), + SessionConfig: SessionConfig{ + NativeSessionResume: true, + ResumeFlag: NewParam("--resume"), + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.copilot", + }, + } +} + +func (a *Copilot) PermissionSettings() map[string]PermissionSetting { + return copilotPermSettings +} + +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.", + ApplyMethod: "cli_flag", CLIFlag: "--yolo", + }, +} + +func copilotStaticModels() []Model { + return []Model{ + {ID: "gpt-4.1", Name: "GPT-4.1", Description: "OpenAI GPT-4.1", Provider: "openai", IsDefault: true, Source: "static"}, + {ID: "claude-sonnet-4", Name: "Claude Sonnet 4", Description: "Anthropic Claude Sonnet 4", Provider: "anthropic", Source: "static"}, + {ID: "claude-sonnet-4.5", Name: "Claude Sonnet 4.5", Description: "Anthropic Claude Sonnet 4.5", Provider: "anthropic", Source: "static"}, + {ID: "claude-opus-4.5", Name: "Claude Opus 4.5", Description: "Anthropic Claude Opus 4.5", Provider: "anthropic", Source: "static"}, + {ID: "claude-haiku-4.5", Name: "Claude Haiku 4.5", Description: "Anthropic Claude Haiku 4.5", Provider: "anthropic", Source: "static"}, + {ID: "gpt-5.2-codex", Name: "GPT-5.2 Codex", Description: "OpenAI GPT-5.2 Codex", Provider: "openai", Source: "static"}, + {ID: "gpt-5.2", Name: "GPT-5.2", Description: "OpenAI GPT-5.2", Provider: "openai", Source: "static"}, + {ID: "gpt-5.1-codex-max", Name: "GPT-5.1 Codex Max", Description: "OpenAI GPT-5.1 Codex Max", Provider: "openai", Source: "static"}, + {ID: "gpt-5.1-codex", Name: "GPT-5.1 Codex", Description: "OpenAI GPT-5.1 Codex", Provider: "openai", Source: "static"}, + {ID: "gpt-5.1", Name: "GPT-5.1", Description: "OpenAI GPT-5.1", Provider: "openai", Source: "static"}, + {ID: "gpt-5", Name: "GPT-5", Description: "OpenAI GPT-5", Provider: "openai", Source: "static"}, + {ID: "gpt-5.1-codex-mini", Name: "GPT-5.1 Codex Mini", Description: "OpenAI GPT-5.1 Codex Mini", Provider: "openai", Source: "static"}, + {ID: "gpt-5-mini", Name: "GPT-5 Mini", Description: "OpenAI GPT-5 Mini", Provider: "openai", Source: "static"}, + {ID: "gemini-3-pro-preview", Name: "Gemini 3 Pro Preview", Description: "Google Gemini 3 Pro Preview", Provider: "google", Source: "static"}, + } +} diff --git a/apps/backend/internal/agent/agents/detect.go b/apps/backend/internal/agent/agents/detect.go new file mode 100644 index 0000000000..c05fdaaa17 --- /dev/null +++ b/apps/backend/internal/agent/agents/detect.go @@ -0,0 +1,135 @@ +package agents + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" +) + +// DetectOption is a detection strategy. Returns (found, matchedPath, err). +type DetectOption func(ctx context.Context) (bool, string, error) + +// WithFileExists checks if any of the given paths exist (supports ~ expansion). +func WithFileExists(paths ...string) DetectOption { + return func(ctx context.Context) (bool, string, error) { + for _, p := range paths { + expanded := expandHomePath(p) + if expanded == "" { + continue + } + if _, err := os.Stat(expanded); err == nil { + return true, expanded, nil + } + } + return false, "", nil + } +} + +// WithCommand checks if a command is in PATH (exec.LookPath). +func WithCommand(name string) DetectOption { + return func(ctx context.Context) (bool, string, error) { + path, err := exec.LookPath(name) + if err != nil { + return false, "", nil + } + return true, path, nil + } +} + +// WithCommandOutput runs a command and checks stdout matches regex. +func WithCommandOutput(pattern string, name string, args ...string) DetectOption { + return func(ctx context.Context) (bool, string, error) { + cmd := exec.CommandContext(ctx, name, args...) + out, err := cmd.Output() + if err != nil { + return false, "", nil + } + re, err := regexp.Compile(pattern) + if err != nil { + return false, "", err + } + if re.Match(out) { + return true, name, nil + } + return false, "", nil + } +} + +// WithEnvVar checks if an environment variable is set and non-empty. +func WithEnvVar(name string) DetectOption { + return func(ctx context.Context) (bool, string, error) { + val := os.Getenv(name) + if val != "" { + return true, name, nil + } + return false, "", nil + } +} + +// Detect runs options in order, returns first match. +// If none match, returns DiscoveryResult{Available: false}. +func Detect(ctx context.Context, opts ...DetectOption) (*DiscoveryResult, error) { + for _, opt := range opts { + found, matched, err := opt(ctx) + if err != nil { + return &DiscoveryResult{Available: false}, err + } + if found { + return &DiscoveryResult{ + Available: true, + MatchedPath: matched, + }, nil + } + } + return &DiscoveryResult{Available: false}, nil +} + +// expandHomePath expands ~ to the user's home directory. +func expandHomePath(path string) string { + if path == "" { + return "" + } + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + path = filepath.Join(home, strings.TrimPrefix(path, "~")) + } + return filepath.Clean(filepath.FromSlash(path)) +} + +// OSPaths holds per-OS path lists. Use Resolve() to get the paths for the current OS. +type OSPaths struct { + Linux []string + MacOS []string + Windows []string +} + +// Resolve returns the raw paths for the current operating system. +func (p OSPaths) Resolve() []string { + switch runtime.GOOS { + case "darwin": + return p.MacOS + case "windows": + return p.Windows + default: + return p.Linux + } +} + +// Expanded returns the paths for the current OS with ~ expanded to the home directory. +func (p OSPaths) Expanded() []string { + paths := p.Resolve() + result := make([]string, 0, len(paths)) + for _, path := range paths { + if expanded := expandHomePath(path); expanded != "" { + result = append(result, expanded) + } + } + return result +} diff --git a/apps/backend/internal/agent/agents/gemini.go b/apps/backend/internal/agent/agents/gemini.go new file mode 100644 index 0000000000..d54f2e8b82 --- /dev/null +++ b/apps/backend/internal/agent/agents/gemini.go @@ -0,0 +1,135 @@ +package agents + +import ( + "context" + _ "embed" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/acp" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/gemini_light.svg +var geminiLogoLight []byte + +//go:embed logos/gemini_dark.svg +var geminiLogoDark []byte + +var ( + _ Agent = (*Gemini)(nil) + _ PassthroughAgent = (*Gemini)(nil) +) + +type Gemini struct { + StandardPassthrough +} + +func NewGemini() *Gemini { + return &Gemini{ + StandardPassthrough: StandardPassthrough{ + PermSettings: geminiPermSettings, + Cfg: PassthroughConfig{ + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("npx", "@google/gemini-cli"), + ModelFlag: NewParam("--model", "{model}"), + PromptFlag: NewParam("--prompt-interactive", "{prompt}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + ResumeFlag: NewParam("--resume", "latest"), + }, + }, + } +} + +func (a *Gemini) ID() string { return "gemini" } +func (a *Gemini) Name() string { return "Google Gemini CLI Agent" } +func (a *Gemini) DisplayName() string { return "Gemini" } +func (a *Gemini) Description() string { + return "Google Gemini CLI-powered autonomous coding agent using ACP protocol." +} +func (a *Gemini) Enabled() bool { return true } + +func (a *Gemini) Logo(v LogoVariant) []byte { + if v == LogoDark { + return geminiLogoDark + } + return geminiLogoLight +} + +func (a *Gemini) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + install := OSPaths{ + Linux: []string{"~/.gemini/oauth_creds.json", "~/.gemini/installation_id"}, + MacOS: []string{"~/.gemini/oauth_creds.json", "~/.gemini/installation_id"}, + } + mcp := OSPaths{ + Linux: []string{"~/.gemini/settings.json"}, + MacOS: []string{"~/.gemini/settings.json"}, + } + + result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + if err != nil { + return result, err + } + result.SupportsMCP = true + result.InstallationPaths = install.Expanded() + result.MCPConfigPaths = mcp.Expanded() + result.Capabilities = DiscoveryCapabilities{ + SupportsSessionResume: true, + } + return result, nil +} + +func (a *Gemini) DefaultModel() string { return "gemini-3-flash-preview" } + +func (a *Gemini) ListModels(ctx context.Context) (*ModelList, error) { + return &ModelList{Models: geminiStaticModels(), SupportsDynamic: false}, nil +} + +func (a *Gemini) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return newACPAdapterWrapper(acp.NewAdapter(cfg.ToSharedConfig(), log)), nil +} + +func (a *Gemini) BuildCommand(opts CommandOptions) Command { + return Cmd("npx", "-y", "@google/gemini-cli@0.25.2", "--experimental-acp"). + Model(NewParam("--model", "{model}"), opts.Model). + Settings(geminiPermSettings, opts.PermissionValues). + Build() +} + + +func (a *Gemini) Runtime() *RuntimeConfig { + canRecover := false + return &RuntimeConfig{ + Cmd: Cmd("npx", "-y", "@google/gemini-cli@0.25.2", "--experimental-acp").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolACP, + ModelFlag: NewParam("--model", "{model}"), + SessionConfig: SessionConfig{ + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.gemini", + }, + } +} + +func (a *Gemini) PermissionSettings() map[string]PermissionSetting { + return geminiPermSettings +} + +var geminiPermSettings = map[string]PermissionSetting{ + "auto_approve": {Supported: true, Default: true, Label: "Auto-approve (YOLO mode)", Description: "Automatically approve all tool calls", + ApplyMethod: "cli_flag", CLIFlag: "--yolo --allowed-tools run_shell_command"}, +} + +func geminiStaticModels() []Model { + return []Model{ + {ID: "gemini-3-flash-preview", Name: "Gemini 3 Flash", Description: "Fast and efficient model", Provider: "google", ContextWindow: 1000000, IsDefault: true, Source: "static"}, + {ID: "gemini-3-pro-preview", Name: "Gemini 3 Pro", Description: "Most capable model with 2M context", Provider: "google", ContextWindow: 2000000, Source: "static"}, + } +} diff --git a/apps/backend/internal/agent/agents/helpers.go b/apps/backend/internal/agent/agents/helpers.go new file mode 100644 index 0000000000..5f9824574c --- /dev/null +++ b/apps/backend/internal/agent/agents/helpers.go @@ -0,0 +1,129 @@ +package agents + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// CmdBuilder constructs CLI command slices using a fluent API. +type CmdBuilder struct { + args []string +} + +// Cmd starts building a command from a base command and arguments. +func Cmd(base ...string) *CmdBuilder { + return &CmdBuilder{args: append([]string{}, base...)} +} + +// Model appends a model flag if model is non-empty. +// flag args support {model} placeholder, e.g. NewParam("--model", "{model}"). +func (b *CmdBuilder) Model(flag Param, model string) *CmdBuilder { + if flag.IsEmpty() || model == "" { + return b + } + for _, arg := range flag.args { + b.args = append(b.args, strings.ReplaceAll(arg, "{model}", model)) + } + return b +} + +// Resume appends a resume flag with sessionID if applicable. +// Skipped when sessionID is empty, nativeResume is true, or flag is empty. +func (b *CmdBuilder) Resume(flag Param, sessionID string, nativeResume bool) *CmdBuilder { + if sessionID == "" || nativeResume || flag.IsEmpty() { + return b + } + b.args = append(b.args, flag.args...) + b.args = append(b.args, sessionID) + return b +} + +// Permissions appends per-tool permission flags when not auto-approving. +// Each tool gets: flag tool:ask-user (e.g. --permission launch-process:ask-user). +func (b *CmdBuilder) Permissions(flag string, tools []string, opts CommandOptions) *CmdBuilder { + if opts.AutoApprove || flag == "" || len(tools) == 0 { + return b + } + for _, tool := range tools { + b.args = append(b.args, flag, tool+":ask-user") + } + return b +} + +// Settings appends CLI flags for enabled permission settings. +func (b *CmdBuilder) Settings(settings map[string]PermissionSetting, values map[string]bool) *CmdBuilder { + if settings == nil || values == nil { + return b + } + for settingName, setting := range settings { + if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { + continue + } + value, exists := values[settingName] + if !exists || !value { + continue + } + if setting.CLIFlagValue != "" { + b.args = append(b.args, setting.CLIFlag, setting.CLIFlagValue) + } else { + b.args = append(b.args, strings.Fields(setting.CLIFlag)...) + } + } + return b +} + +// Prompt appends a prompt flag if prompt is non-empty. +// flag args support {prompt} placeholder, e.g. NewParam("--prompt", "{prompt}"). +// If flag is empty, the prompt is appended as a positional argument. +func (b *CmdBuilder) Prompt(flag Param, prompt string) *CmdBuilder { + if prompt == "" { + return b + } + if flag.IsEmpty() { + b.args = append(b.args, prompt) + return b + } + for _, arg := range flag.args { + b.args = append(b.args, strings.ReplaceAll(arg, "{prompt}", prompt)) + } + return b +} + +// Flag appends arbitrary flag parts to the command. +func (b *CmdBuilder) Flag(parts ...string) *CmdBuilder { + b.args = append(b.args, parts...) + return b +} + +// Build returns the final Command value. +func (b *CmdBuilder) Build() Command { + return Command{args: b.args} +} + +// execAndParse runs a command with a timeout and parses stdout with the given parser function. +func execAndParse(ctx context.Context, timeout time.Duration, parse func(string) ([]Model, error), name string, args ...string) ([]Model, error) { + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return nil, fmt.Errorf("command timed out after %v", timeout) + } + return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) + } + + return parse(stdout.String()) +} + diff --git a/apps/backend/internal/agent/agents/logos/amp_dark.svg b/apps/backend/internal/agent/agents/logos/amp_dark.svg new file mode 100644 index 0000000000..db94af9d0e --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/amp_dark.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/amp_light.svg b/apps/backend/internal/agent/agents/logos/amp_light.svg new file mode 100644 index 0000000000..ff6a21f05d --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/amp_light.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/auggie_dark.svg b/apps/backend/internal/agent/agents/logos/auggie_dark.svg new file mode 100644 index 0000000000..ceb2bcf707 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/auggie_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/backend/internal/agent/agents/logos/auggie_light.svg b/apps/backend/internal/agent/agents/logos/auggie_light.svg new file mode 100644 index 0000000000..9ce7429066 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/auggie_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/backend/internal/agent/agents/logos/claude_code_dark.svg b/apps/backend/internal/agent/agents/logos/claude_code_dark.svg new file mode 100644 index 0000000000..14547cb4ce --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/claude_code_dark.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/claude_code_light.svg b/apps/backend/internal/agent/agents/logos/claude_code_light.svg new file mode 100644 index 0000000000..334356996c --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/claude_code_light.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/codex_dark.svg b/apps/backend/internal/agent/agents/logos/codex_dark.svg new file mode 100644 index 0000000000..a740b38b0e --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/codex_dark.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/codex_light.svg b/apps/backend/internal/agent/agents/logos/codex_light.svg new file mode 100644 index 0000000000..c6ed6ee876 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/codex_light.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/copilot_dark.svg b/apps/backend/internal/agent/agents/logos/copilot_dark.svg new file mode 100644 index 0000000000..48701c8f5e --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/copilot_dark.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/copilot_light.svg b/apps/backend/internal/agent/agents/logos/copilot_light.svg new file mode 100644 index 0000000000..c78306b306 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/copilot_light.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/gemini_dark.svg b/apps/backend/internal/agent/agents/logos/gemini_dark.svg new file mode 100644 index 0000000000..5cbafc73b2 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/gemini_dark.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/gemini_light.svg b/apps/backend/internal/agent/agents/logos/gemini_light.svg new file mode 100644 index 0000000000..0b33a302ac --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/gemini_light.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/mock_dark.svg b/apps/backend/internal/agent/agents/logos/mock_dark.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/mock_dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/mock_light.svg b/apps/backend/internal/agent/agents/logos/mock_light.svg new file mode 100644 index 0000000000..c77e389205 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/mock_light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/backend/internal/agent/agents/logos/opencode_dark.svg b/apps/backend/internal/agent/agents/logos/opencode_dark.svg new file mode 100644 index 0000000000..7894ee47ef --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/opencode_dark.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/logos/opencode_light.svg b/apps/backend/internal/agent/agents/logos/opencode_light.svg new file mode 100644 index 0000000000..bd61ebe6b7 --- /dev/null +++ b/apps/backend/internal/agent/agents/logos/opencode_light.svg @@ -0,0 +1 @@ + diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go new file mode 100644 index 0000000000..c8ff965240 --- /dev/null +++ b/apps/backend/internal/agent/agents/mock.go @@ -0,0 +1,107 @@ +package agents + +import ( + "context" + _ "embed" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/streamjson" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/mock_light.svg +var mockLogoLight []byte + +//go:embed logos/mock_dark.svg +var mockLogoDark []byte + +var _ Agent = (*MockAgent)(nil) + +type MockAgent struct { + enabled bool + binaryPath string +} + +func NewMockAgent() *MockAgent { return &MockAgent{} } + +// SetEnabled enables or disables the mock agent at runtime. +func (a *MockAgent) SetEnabled(enabled bool) { a.enabled = enabled } + +// SetBinaryPath overrides the mock-agent binary path. +func (a *MockAgent) SetBinaryPath(path string) { a.binaryPath = path } + +func (a *MockAgent) ID() string { return "mock-agent" } +func (a *MockAgent) Name() string { return "Mock Agent" } +func (a *MockAgent) DisplayName() string { return "Mock" } +func (a *MockAgent) Description() string { + return "Mock agent for testing. Generates simulated responses with all message types." +} +func (a *MockAgent) Enabled() bool { return a.enabled } + +func (a *MockAgent) Logo(v LogoVariant) []byte { + if v == LogoDark { + return mockLogoDark + } + return mockLogoLight +} + +func (a *MockAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + // Mock agent is always "available" when enabled (forced by settings controller) + return &DiscoveryResult{Available: false}, nil +} + +func (a *MockAgent) DefaultModel() string { return "mock-default" } + +func (a *MockAgent) ListModels(ctx context.Context) (*ModelList, error) { + return &ModelList{Models: mockStaticModels(), SupportsDynamic: false}, nil +} + +func (a *MockAgent) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return newStreamJSONAdapterWrapper(streamjson.NewAdapter(cfg.ToSharedConfig(), log)), nil +} + +func (a *MockAgent) BuildCommand(opts CommandOptions) Command { + binary := "mock-agent" + if a.binaryPath != "" { + binary = a.binaryPath + } + return Cmd(binary). + Model(NewParam("--model", "{model}"), opts.Model). + Build() +} + +func (a *MockAgent) Runtime() *RuntimeConfig { + canRecover := false + return &RuntimeConfig{ + Cmd: Cmd("mock-agent").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, + ResourceLimits: ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolClaudeCode, + ModelFlag: NewParam("--model", "{model}"), + SessionConfig: SessionConfig{ + CanRecover: &canRecover, + }, + } +} + +func (a *MockAgent) PermissionSettings() map[string]PermissionSetting { + return mockPermSettings +} + +var mockPermSettings = map[string]PermissionSetting{ + "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls", + ApplyMethod: "stdio"}, + "dangerously_skip_permissions": {Supported: true, Default: false, Label: "Skip Permissions (YOLO)", Description: "Bypass all permission checks"}, +} + +func mockStaticModels() []Model { + return []Model{ + {ID: "mock-default", Name: "Mock Default", Provider: "mock", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "mock-slow", Name: "Mock Slow", Description: "Longer delays", Provider: "mock", ContextWindow: 200000, Source: "static"}, + {ID: "mock-fast", Name: "Mock Fast", Description: "Minimal delays", Provider: "mock", ContextWindow: 200000, Source: "static"}, + } +} diff --git a/apps/backend/internal/agent/agents/opencode.go b/apps/backend/internal/agent/agents/opencode.go new file mode 100644 index 0000000000..b7380966b1 --- /dev/null +++ b/apps/backend/internal/agent/agents/opencode.go @@ -0,0 +1,173 @@ +package agents + +import ( + "context" + _ "embed" + "strings" + "time" + + "github.com/kandev/kandev/internal/agentctl/server/adapter" + "github.com/kandev/kandev/internal/agentctl/server/adapter/transport/opencode" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/pkg/agent" +) + +//go:embed logos/opencode_light.svg +var opencodeLogoLight []byte + +//go:embed logos/opencode_dark.svg +var opencodeLogoDark []byte + +var ( + _ Agent = (*OpenCode)(nil) + _ PassthroughAgent = (*OpenCode)(nil) +) + +type OpenCode struct { + StandardPassthrough +} + +func NewOpenCode() *OpenCode { + return &OpenCode{ + StandardPassthrough: StandardPassthrough{ + PermSettings: opencodePermSettings, + Cfg: PassthroughConfig{ + Supported: true, + Label: "CLI Passthrough", + Description: "Show terminal directly instead of chat interface", + PassthroughCmd: NewCommand("opencode"), + ModelFlag: NewParam("--model", "{model}"), + PromptFlag: NewParam("--prompt", "{prompt}"), + IdleTimeout: 3 * time.Second, + BufferMaxBytes: DefaultBufferMaxBytes, + ResumeFlag: NewParam("-c"), + }, + }, + } +} + +func (a *OpenCode) ID() string { return "opencode" } +func (a *OpenCode) Name() string { return "OpenCode AI Agent" } +func (a *OpenCode) DisplayName() string { return "OpenCode" } +func (a *OpenCode) Description() string { + return "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol." +} +func (a *OpenCode) Enabled() bool { return true } + +func (a *OpenCode) Logo(v LogoVariant) []byte { + if v == LogoDark { + return opencodeLogoDark + } + return opencodeLogoLight +} + +func (a *OpenCode) IsInstalled(ctx context.Context) (*DiscoveryResult, error) { + install := OSPaths{ + Linux: []string{"~/.opencode", "~/.config/opencode"}, + MacOS: []string{"~/.opencode", "~/.config/opencode", "~/Library/Application Support/ai.opencode.desktop"}, + } + mcp := OSPaths{ + Linux: []string{"~/.config/opencode/opencode.json", "~/.config/opencode/opencode.jsonc"}, + MacOS: []string{"~/.config/opencode/opencode.json", "~/.config/opencode/opencode.jsonc", "~/Library/Application Support/opencode/opencode.json"}, + } + + result, err := Detect(ctx, WithFileExists(install.Resolve()...)) + if err != nil { + return result, err + } + result.SupportsMCP = true + result.InstallationPaths = install.Expanded() + result.MCPConfigPaths = mcp.Expanded() + result.Capabilities = DiscoveryCapabilities{ + SupportsSessionResume: true, + } + return result, nil +} + +func (a *OpenCode) DefaultModel() string { return "anthropic/claude-sonnet-4-20250514" } + +func (a *OpenCode) ListModels(ctx context.Context) (*ModelList, error) { + models, err := execAndParse(ctx, 30*time.Second, opencodeParseModels, "opencode", "models") + if err != nil { + return &ModelList{Models: opencodeStaticModels(), SupportsDynamic: true}, nil + } + return &ModelList{Models: models, SupportsDynamic: true}, nil +} + +func (a *OpenCode) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return newOpenCodeAdapterWrapper(opencode.NewAdapter(cfg.ToSharedConfig(), log)), nil +} + +func (a *OpenCode) BuildCommand(opts CommandOptions) Command { + return Cmd("npx", "-y", "opencode-ai@1.1.25", "serve", "--hostname", "127.0.0.1", "--port", "0").Build() +} + + +func (a *OpenCode) Runtime() *RuntimeConfig { + canRecover := true + return &RuntimeConfig{ + Cmd: Cmd("npx", "-y", "opencode-ai@1.1.25", "serve", "--hostname", "127.0.0.1", "--port", "0").Build(), + WorkingDir: "{workspace}", + Env: map[string]string{}, + ResourceLimits: ResourceLimits{MemoryMB: 4096, CPUCores: 2.0, Timeout: time.Hour}, + Capabilities: []string{"code_generation", "code_review", "refactoring", "testing", "shell_execution"}, + Protocol: agent.ProtocolOpenCode, + SessionConfig: SessionConfig{ + NativeSessionResume: true, + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.opencode", + }, + } +} + +func (a *OpenCode) PermissionSettings() map[string]PermissionSetting { + return opencodePermSettings +} + +var opencodePermSettings = map[string]PermissionSetting{ + "auto_approve": {Supported: true, Default: true, Label: "Auto-approve", Description: "Automatically approve tool calls", + ApplyMethod: "env"}, +} + +func opencodeStaticModels() []Model { + return []Model{ + {ID: "anthropic/claude-sonnet-4-20250514", Name: "Claude Sonnet 4", Description: "Anthropic Claude Sonnet 4", Provider: "anthropic", ContextWindow: 200000, IsDefault: true, Source: "static"}, + {ID: "anthropic/claude-opus-4-20250514", Name: "Claude Opus 4", Description: "Anthropic Claude Opus 4", Provider: "anthropic", ContextWindow: 200000, Source: "static"}, + {ID: "openai/gpt-4.1", Name: "GPT-4.1", Description: "OpenAI GPT-4.1", Provider: "openai", ContextWindow: 200000, Source: "static"}, + {ID: "google/gemini-2.5-pro", Name: "Gemini 2.5 Pro", Description: "Google Gemini 2.5 Pro", Provider: "google", ContextWindow: 2000000, Source: "static"}, + } +} + +// opencodeParseModels parses "opencode models" output. +// Format: one model ID per line, optionally in "provider/model" format. +func opencodeParseModels(output string) ([]Model, error) { + lines := strings.Split(strings.TrimSpace(output), "\n") + models := make([]Model, 0, len(lines)) + defaultModel := "anthropic/claude-sonnet-4-20250514" + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var provider, modelID, name string + if idx := strings.LastIndex(line, "/"); idx > 0 { + provider = line[:idx] + modelID = line + name = line[idx+1:] + } else { + modelID = line + name = line + } + + models = append(models, Model{ + ID: modelID, + Name: name, + Provider: provider, + IsDefault: modelID == defaultModel, + Source: "dynamic", + }) + } + return models, nil +} diff --git a/apps/backend/internal/agent/agents/passthrough.go b/apps/backend/internal/agent/agents/passthrough.go new file mode 100644 index 0000000000..19f6542ea3 --- /dev/null +++ b/apps/backend/internal/agent/agents/passthrough.go @@ -0,0 +1,30 @@ +package agents + +// StandardPassthrough provides a data-driven implementation of PassthroughAgent. +// Agents embed this struct and configure it declaratively to get passthrough support. +type StandardPassthrough struct { + Cfg PassthroughConfig + PermSettings map[string]PermissionSetting +} + +// PassthroughConfig returns the passthrough configuration. +func (p *StandardPassthrough) PassthroughConfig() PassthroughConfig { + return p.Cfg +} + +// BuildPassthroughCommand builds a CLI command for passthrough mode. +func (p *StandardPassthrough) BuildPassthroughCommand(opts PassthroughOptions) Command { + b := p.Cfg.PassthroughCmd.With(). + Model(p.Cfg.ModelFlag, opts.Model). + Settings(p.PermSettings, opts.PermissionValues) + + if opts.SessionID != "" && !p.Cfg.SessionResumeFlag.IsEmpty() { + b.Resume(p.Cfg.SessionResumeFlag, opts.SessionID, false) + } else if opts.Resume && !p.Cfg.ResumeFlag.IsEmpty() { + b.Flag(p.Cfg.ResumeFlag.Args()...) + } else if opts.Prompt != "" { + b.Prompt(p.Cfg.PromptFlag, opts.Prompt) + } + + return b.Build() +} diff --git a/apps/backend/internal/agent/controller/controller.go b/apps/backend/internal/agent/controller/controller.go index 65b1c9cba6..64a0f8107f 100644 --- a/apps/backend/internal/agent/controller/controller.go +++ b/apps/backend/internal/agent/controller/controller.go @@ -147,13 +147,17 @@ func (c *Controller) ListAgentTypes(ctx context.Context, req dto.ListAgentTypesR } for _, t := range types { + var image string + if rt := t.Runtime(); rt != nil { + image = rt.Image + } resp.Types = append(resp.Types, dto.FromAgentType(&dto.AgentTypeData{ - ID: t.ID, - Name: t.Name, - Description: t.Description, - Image: t.Image, - Capabilities: t.Capabilities, - Enabled: t.Enabled, + ID: t.ID(), + Name: t.Name(), + Description: t.Description(), + Image: image, + Capabilities: t.Runtime().Capabilities, + Enabled: t.Enabled(), })) } diff --git a/apps/backend/internal/agent/discovery/discovery.go b/apps/backend/internal/agent/discovery/discovery.go index 07aaf5fafc..2c9a28e5da 100644 --- a/apps/backend/internal/agent/discovery/discovery.go +++ b/apps/backend/internal/agent/discovery/discovery.go @@ -1,59 +1,38 @@ // Package discovery provides agent installation detection and discovery functionality. -// It reads agent definitions from the unified agents.json configuration in the registry package. +// It delegates to the agents.Agent interface for discovery and model information. package discovery import ( "context" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/common/logger" ) -// OSPaths defines OS-specific paths for discovery (re-exported from registry for API compatibility) -type OSPaths = registry.OSPaths - -// Capabilities defines discovery-specific capabilities (re-exported from registry) -type Capabilities = registry.DiscoveryCapabilities - -// ModelConfig defines the model configuration for an agent (re-exported from registry) -type ModelConfig = registry.ModelConfig - -// 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 - case "darwin": - return p.MacOS - default: - return p.Linux - } +// Capabilities describes what the agent supports. +type Capabilities struct { + SupportsSessionResume bool `json:"supports_session_resume"` + SupportsShell bool `json:"supports_shell"` + SupportsWorkspaceOnly bool `json:"supports_workspace_only"` } -// KnownAgent represents an agent definition with discovery metadata +// KnownAgent represents an agent definition with discovery metadata. type KnownAgent struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` - SupportsMCP bool `json:"supports_mcp"` - MCPConfigPath OSPaths `json:"mcp_config_path"` - InstallationPath OSPaths `json:"installation_path"` - Capabilities Capabilities `json:"capabilities"` - ModelConfig ModelConfig `json:"model_config"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + SupportsMCP bool `json:"supports_mcp"` + MCPConfigPaths []string `json:"mcp_config_paths"` + InstallationPaths []string `json:"installation_paths"` + Capabilities Capabilities `json:"capabilities"` + DefaultModel string `json:"default_model"` + Models []agents.Model `json:"models"` + SupportsDynamic bool `json:"supports_dynamic"` } -// 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 +// Availability represents the result of detecting an agent's installation. type Availability struct { Name string `json:"name"` SupportsMCP bool `json:"supports_mcp"` @@ -63,49 +42,81 @@ 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 +// Registry manages agent discovery using the agents.Agent interface. type Registry struct { - adapters []Adapter + agents []agents.Agent definitions []KnownAgent + logger *logger.Logger } -// LoadRegistry creates a new discovery registry by loading agent definitions -// from the unified agents.json configuration in the registry package. -func LoadRegistry() (*Registry, error) { - agentConfigs, err := registry.GetAgentDefinitions() - if err != nil { - return nil, fmt.Errorf("load agent definitions: %w", err) - } +// LoadRegistry creates a new discovery registry from the agent registry. +// It iterates over all enabled agents, calls IsInstalled and ListModels +// to populate the KnownAgent definitions. +func LoadRegistry(ctx context.Context, reg *registry.Registry, log *logger.Logger) (*Registry, error) { + enabled := reg.ListEnabled() - definitions := make([]KnownAgent, 0, len(agentConfigs)) - adapters := make([]Adapter, 0, len(agentConfigs)) + definitions := make([]KnownAgent, 0, len(enabled)) + agentList := make([]agents.Agent, 0, len(enabled)) + + for _, ag := range enabled { + // Gather discovery info from the agent. + result, err := ag.IsInstalled(ctx) + if err != nil { + log.Warn("discovery: failed to check agent installation", + zap.String("agent", ag.ID()), + zap.Error(err), + ) + // Still include the agent but with empty discovery data. + result = &agents.DiscoveryResult{} + } + + // Gather model info from the agent. + var models []agents.Model + var supportsDynamic bool + modelList, err := ag.ListModels(ctx) + if err != nil { + log.Warn("discovery: failed to list agent models", + zap.String("agent", ag.ID()), + zap.Error(err), + ) + } else if modelList != nil { + models = modelList.Models + supportsDynamic = modelList.SupportsDynamic + } + + displayName := ag.DisplayName() + if displayName == "" { + displayName = ag.Name() + } - for _, agentCfg := range agentConfigs { - // Convert registry.AgentTypeConfig to discovery.KnownAgent knownAgent := KnownAgent{ - Name: agentCfg.ID, // Use ID directly as the short name (e.g., "auggie", "codex") - 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 + Name: ag.ID(), + DisplayName: displayName, + SupportsMCP: result.SupportsMCP, + MCPConfigPaths: result.MCPConfigPaths, + InstallationPaths: result.InstallationPaths, + Capabilities: Capabilities{ + SupportsSessionResume: result.Capabilities.SupportsSessionResume, + SupportsShell: result.Capabilities.SupportsShell, + SupportsWorkspaceOnly: result.Capabilities.SupportsWorkspaceOnly, + }, + DefaultModel: ag.DefaultModel(), + Models: models, + SupportsDynamic: supportsDynamic, } + definitions = append(definitions, knownAgent) - adapters = append(adapters, NewFilePresenceAdapter(knownAgent)) + agentList = append(agentList, ag) } - return &Registry{adapters: adapters, definitions: definitions}, nil + return &Registry{ + agents: agentList, + definitions: definitions, + logger: log, + }, nil } +// Definitions returns a copy of all known agent definitions. func (r *Registry) Definitions() []KnownAgent { if r == nil { return nil @@ -113,87 +124,32 @@ func (r *Registry) Definitions() []KnownAgent { return append([]KnownAgent(nil), r.definitions...) } +// Detect checks whether each agent is installed by calling IsInstalled. func (r *Registry) Detect(ctx context.Context) ([]Availability, error) { - results := make([]Availability, 0, len(r.adapters)) - for _, adapter := range r.adapters { - availability, err := adapter.Detect(ctx) + results := make([]Availability, 0, len(r.agents)) + for _, ag := range r.agents { + result, err := ag.IsInstalled(ctx) if err != nil { - return nil, err + r.logger.Warn("discovery: detect failed for agent", + zap.String("agent", ag.ID()), + zap.Error(err), + ) + continue } - results = append(results, availability) + + mcpPath := "" + if len(result.MCPConfigPaths) > 0 { + mcpPath = result.MCPConfigPaths[0] + } + + results = append(results, Availability{ + Name: ag.ID(), + SupportsMCP: result.SupportsMCP, + MCPConfigPath: mcpPath, + InstallationPaths: result.InstallationPaths, + Available: result.Available, + MatchedPath: result.MatchedPath, + }) } return results, nil } - -type FilePresenceAdapter struct { - definition KnownAgent -} - -func NewFilePresenceAdapter(def KnownAgent) *FilePresenceAdapter { - return &FilePresenceAdapter{definition: def} -} - -func (a *FilePresenceAdapter) Detect(ctx context.Context) (Availability, error) { - _ = ctx - 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 { - mcpPath = mcpPaths[0] - } - return Availability{ - Name: a.definition.Name, - SupportsMCP: a.definition.SupportsMCP, - MCPConfigPath: mcpPath, - InstallationPaths: paths, - Available: available, - MatchedPath: matched, - }, nil -} - -func resolvePaths(paths []string) []string { - resolved := make([]string, 0, len(paths)) - for _, rawPath := range paths { - expanded := expandPath(rawPath) - if expanded == "" { - continue - } - resolved = append(resolved, expanded) - } - return resolved -} - -func expandPath(path string) string { - if path == "" { - return "" - } - if strings.Contains(path, "$XDG_CONFIG_HOME") || strings.Contains(path, "${XDG_CONFIG_HOME}") { - if _, ok := os.LookupEnv("XDG_CONFIG_HOME"); !ok { - return "" - } - } - expanded := os.ExpandEnv(path) - if strings.HasPrefix(expanded, "~") { - home, err := os.UserHomeDir() - if err == nil { - expanded = filepath.Join(home, strings.TrimPrefix(expanded, "~")) - } - } - if expanded == "" { - return "" - } - return filepath.Clean(filepath.FromSlash(expanded)) -} - -func anyPathExists(paths []string) (bool, string) { - for _, candidate := range paths { - if candidate == "" { - continue - } - if _, err := os.Stat(candidate); err == nil { - return true, candidate - } - } - return false, "" -} diff --git a/apps/backend/internal/agent/lifecycle/command.go b/apps/backend/internal/agent/lifecycle/command.go index c01a82f6d2..307916da76 100644 --- a/apps/backend/internal/agent/lifecycle/command.go +++ b/apps/backend/internal/agent/lifecycle/command.go @@ -6,10 +6,11 @@ import ( "os" "strings" - "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/agents" ) -// CommandBuilder builds agent commands from registry configuration +// CommandBuilder builds agent commands from agent configuration. +// Delegates to the Agent interface's BuildCommand method. type CommandBuilder struct{} // NewCommandBuilder creates a new CommandBuilder @@ -17,96 +18,23 @@ func NewCommandBuilder() *CommandBuilder { return &CommandBuilder{} } -// CommandOptions contains options for building a command -type CommandOptions struct { - Model string // Model to use (appended via ModelFlag if set) - SessionID string // Session ID to resume (appended via SessionConfig.ResumeFlag if not NativeSessionResume) - AutoApprove bool // If true, skip permission flags (auto-approve all tool calls) - - // PermissionValues contains the values for each permission setting - // Keys are setting names (e.g., "allow_indexing", "auto_approve") - // Values are the boolean state of each setting - PermissionValues map[string]bool -} - -// BuildCommand builds a command slice from agent config and options -// Returns the command as a string slice ready for execution -func (cb *CommandBuilder) BuildCommand(agentConfig *registry.AgentTypeConfig, opts CommandOptions) []string { - // Start with base command from config - cmd := make([]string, len(agentConfig.Cmd)) - copy(cmd, agentConfig.Cmd) - - // Append model flag if agent supports it and model is specified - // ModelFlag supports {model} placeholder, e.g. "--model {model}" or "-c model=\"{model}\"" - if agentConfig.ModelFlag != "" && opts.Model != "" { - expanded := strings.ReplaceAll(agentConfig.ModelFlag, "{model}", opts.Model) - // Split on first space to separate flag from value (if combined) - parts := strings.SplitN(expanded, " ", 2) - cmd = append(cmd, parts...) - } - - // Append session resume flag if: - // 1. Session ID is provided - // 2. Agent does NOT use native session loading (uses CLI flag instead) - // 3. Agent has a ResumeFlag configured - // 4. Agent supports session recovery (CanRecover is not false) - if opts.SessionID != "" && !agentConfig.SessionConfig.NativeSessionResume && agentConfig.SessionConfig.ResumeFlag != "" && agentConfig.SessionConfig.SupportsRecovery() { - cmd = append(cmd, agentConfig.SessionConfig.ResumeFlag, opts.SessionID) - } - - // Add permission flags when AutoApprove is false - // This makes the agent request permission via ACP for tool calls - permConfig := agentConfig.PermissionConfig - if !opts.AutoApprove && permConfig.PermissionFlag != "" && len(permConfig.ToolsRequiringPermission) > 0 { - for _, tool := range permConfig.ToolsRequiringPermission { - cmd = append(cmd, permConfig.PermissionFlag, tool+":ask-user") - } - } - - // Apply permission settings that use CLI flags - // Each permission setting can define its own CLI flag via ApplyMethod="cli_flag" - if permSettings := agentConfig.PermissionSettings; permSettings != nil { - for settingName, setting := range permSettings { - // Skip if not supported or not a CLI flag setting - if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { - continue - } - - // Get the value for this setting from options - value, exists := opts.PermissionValues[settingName] - if !exists { - continue - } - - // Only apply if the setting value is true - if value { - if setting.CLIFlagValue != "" { - // Flag with value: "--flag value" - cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) - } else { - // Boolean flag or multiple flags: "--flag" or "--flag1 --flag2 arg" - // Split on spaces to handle multiple flags in one setting - parts := strings.Fields(setting.CLIFlag) - cmd = append(cmd, parts...) - } - } - } - } - - return cmd +// BuildCommand builds a Command from agent config and options. +// Delegates to the Agent.BuildCommand method. +func (cb *CommandBuilder) BuildCommand(ag agents.Agent, opts agents.CommandOptions) agents.Command { + return ag.BuildCommand(opts) } // BuildCommandString builds a command as a single string (for standalone mode) -func (cb *CommandBuilder) BuildCommandString(agentConfig *registry.AgentTypeConfig, opts CommandOptions) string { - cmd := cb.BuildCommand(agentConfig, opts) - return strings.Join(cmd, " ") +func (cb *CommandBuilder) BuildCommandString(ag agents.Agent, opts agents.CommandOptions) string { + cmd := cb.BuildCommand(ag, opts) + return strings.Join(cmd.Args(), " ") } -// ExpandSessionDir expands the session directory template from SessionConfig -// Replaces {home} with the user's home directory -// Returns empty string if no session directory is configured -func (cb *CommandBuilder) ExpandSessionDir(agentConfig *registry.AgentTypeConfig) string { - template := agentConfig.SessionConfig.SessionDirTemplate +// ExpandSessionDir expands the session directory template from SessionConfig. +// Replaces {home} with the user's home directory. +// Returns empty string if no session directory is configured. +func (cb *CommandBuilder) ExpandSessionDir(ag agents.Agent) string { + template := ag.Runtime().SessionConfig.SessionDirTemplate if template == "" { return "" } @@ -124,8 +52,8 @@ func (cb *CommandBuilder) ExpandSessionDir(agentConfig *registry.AgentTypeConfig return result } -// GetSessionDirTarget returns the container path for session directory mount -// Returns empty string if no session directory is configured -func (cb *CommandBuilder) GetSessionDirTarget(agentConfig *registry.AgentTypeConfig) string { - return agentConfig.SessionConfig.SessionDirTarget +// GetSessionDirTarget returns the container path for session directory mount. +// Returns empty string if no session directory is configured. +func (cb *CommandBuilder) GetSessionDirTarget(ag agents.Agent) string { + return ag.Runtime().SessionConfig.SessionDirTarget } diff --git a/apps/backend/internal/agent/lifecycle/container.go b/apps/backend/internal/agent/lifecycle/container.go index f1574a9859..393a583e9d 100644 --- a/apps/backend/internal/agent/lifecycle/container.go +++ b/apps/backend/internal/agent/lifecycle/container.go @@ -12,14 +12,14 @@ import ( "go.uber.org/zap" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/docker" - "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/common/logger" ) // ContainerConfig holds configuration for launching a Docker container type ContainerConfig struct { - AgentConfig *registry.AgentTypeConfig + AgentConfig agents.Agent WorkspacePath string TaskID string TaskDescription string @@ -104,7 +104,7 @@ func (cm *ContainerManager) LaunchContainer(ctx context.Context, config Containe // Create an instance via the control API (same flow as standalone mode) agentType := "" if config.AgentConfig != nil { - agentType = config.AgentConfig.ID + agentType = config.AgentConfig.ID() } createReq := &agentctl.CreateInstanceRequest{ ID: config.InstanceID, @@ -177,16 +177,17 @@ func (cm *ContainerManager) StopContainer(ctx context.Context, containerID strin // buildContainerConfig builds the Docker container configuration func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker.ContainerConfig, error) { - agentConfig := config.AgentConfig + ag := config.AgentConfig + rt := ag.Runtime() // Build image name with tag - imageName := agentConfig.Image - if agentConfig.Tag != "" { - imageName = fmt.Sprintf("%s:%s", agentConfig.Image, agentConfig.Tag) + imageName := rt.Image + if rt.Tag != "" { + imageName = fmt.Sprintf("%s:%s", rt.Image, rt.Tag) } - // Build command using CommandBuilder - cmdOpts := CommandOptions{ + // Build command using Agent's BuildCommand + cmdOpts := agents.CommandOptions{ Model: config.Model, SessionID: config.SessionID, PermissionValues: make(map[string]bool), @@ -198,10 +199,10 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker cmdOpts.PermissionValues["allow_indexing"] = config.ProfileInfo.AllowIndexing cmdOpts.PermissionValues["dangerously_skip_permissions"] = config.ProfileInfo.DangerouslySkipPermissions } - cmd := cm.commandBuilder.BuildCommand(agentConfig, cmdOpts) + cmd := ag.BuildCommand(cmdOpts) // Expand mounts - mounts := cm.expandMounts(agentConfig.Mounts, config.WorkspacePath, agentConfig) + mounts := cm.expandMounts(rt.Mounts, config.WorkspacePath, ag) // Add main repo .git directory mount for worktrees if config.MainRepoGitDir != "" { @@ -218,17 +219,17 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker env := cm.buildEnvVars(config) // Calculate resource limits - memoryBytes := agentConfig.ResourceLimits.MemoryMB * 1024 * 1024 - cpuQuota := int64(agentConfig.ResourceLimits.CPUCores * 100000) // Docker CPU quota + memoryBytes := rt.ResourceLimits.MemoryMB * 1024 * 1024 + cpuQuota := int64(rt.ResourceLimits.CPUCores * 100000) // Docker CPU quota containerName := fmt.Sprintf("kandev-agent-%s", config.InstanceID[:8]) return docker.ContainerConfig{ Name: containerName, Image: imageName, - Cmd: cmd, + Cmd: cmd.Args(), Env: env, - WorkingDir: agentConfig.WorkingDir, + WorkingDir: rt.WorkingDir, Mounts: mounts, NetworkMode: cm.networkName, Memory: memoryBytes, @@ -244,7 +245,7 @@ func (cm *ContainerManager) buildContainerConfig(config ContainerConfig) (docker } // expandMounts expands mount templates with actual paths -func (cm *ContainerManager) expandMounts(templates []registry.MountTemplate, workspacePath string, agentConfig *registry.AgentTypeConfig) []docker.MountConfig { +func (cm *ContainerManager) expandMounts(templates []agents.MountTemplate, workspacePath string, ag agents.Agent) []docker.MountConfig { mounts := make([]docker.MountConfig, 0, len(templates)+1) // +1 for potential session dir for _, mt := range templates { @@ -264,8 +265,8 @@ func (cm *ContainerManager) expandMounts(templates []registry.MountTemplate, wor } // Add session directory mount from SessionConfig - sessionDirSource := cm.commandBuilder.ExpandSessionDir(agentConfig) - sessionDirTarget := cm.commandBuilder.GetSessionDirTarget(agentConfig) + sessionDirSource := cm.commandBuilder.ExpandSessionDir(ag) + sessionDirTarget := cm.commandBuilder.GetSessionDirTarget(ag) if sessionDirSource != "" && sessionDirTarget != "" { mounts = append(mounts, docker.MountConfig{ Source: sessionDirSource, @@ -299,11 +300,12 @@ func (cm *ContainerManager) expandMountSource(source, workspacePath string) stri // buildEnvVars builds environment variables for the container func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string { - agentConfig := config.AgentConfig + ag := config.AgentConfig + rt := ag.Runtime() env := make([]string, 0) // Add default env from agent config - for k, v := range agentConfig.Env { + for k, v := range rt.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } @@ -314,17 +316,15 @@ func (cm *ContainerManager) buildEnvVars(config ContainerConfig) []string { ) // Pass protocol to agentctl inside the container - if agentConfig.Protocol != "" { - env = append(env, fmt.Sprintf("AGENTCTL_PROTOCOL=%s", agentConfig.Protocol)) + if rt.Protocol != "" { + env = append(env, fmt.Sprintf("AGENTCTL_PROTOCOL=%s", rt.Protocol)) } // Configure Git to trust the workspace directory - // This is needed because the container runs as root but files are owned by host user - // Uses Git's environment-based config (GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n) env = append(env, "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=safe.directory", - "GIT_CONFIG_VALUE_0=*", // Trust all directories in the container + "GIT_CONFIG_VALUE_0=*", ) // Inject credentials from the provided credentials map diff --git a/apps/backend/internal/agent/lifecycle/manager.go b/apps/backend/internal/agent/lifecycle/manager.go index 5b7e9b15f4..a6e2a06e37 100644 --- a/apps/backend/internal/agent/lifecycle/manager.go +++ b/apps/backend/internal/agent/lifecycle/manager.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "path/filepath" - "sort" "strings" "sync" "time" @@ -15,6 +14,7 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/runtime" @@ -468,7 +468,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work SessionID: info.SessionID, AgentProfileID: info.AgentProfileID, WorkspacePath: info.WorkspacePath, - Protocol: string(agentConfig.Protocol), + Protocol: string(agentConfig.Runtime().Protocol), AgentConfig: agentConfig, } @@ -678,7 +678,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti return nil, fmt.Errorf("agent type %q not found in registry", agentTypeName) } - if !agentConfig.Enabled { + if !agentConfig.Enabled() { return nil, fmt.Errorf("agent type %q is disabled", agentTypeName) } @@ -803,7 +803,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti SessionID: reqWithWorktree.SessionID, AgentProfileID: reqWithWorktree.AgentProfileID, WorkspacePath: reqWithWorktree.WorkspacePath, - Protocol: string(agentConfig.Protocol), + Protocol: string(agentConfig.Runtime().Protocol), Env: env, Metadata: metadata, AgentConfig: agentConfig, @@ -840,7 +840,7 @@ func (m *Manager) Launch(ctx context.Context, req *LaunchRequest) (*AgentExecuti if req.ModelOverride != "" { model = req.ModelOverride } - cmdOpts := CommandOptions{ + cmdOpts := agents.CommandOptions{ Model: model, SessionID: req.ACPSessionID, AutoApprove: autoApprove, @@ -944,8 +944,8 @@ func (m *Manager) StartAgentProcess(ctx context.Context, executionID string) err approvalPolicy = "untrusted" } // Look up display name from registry (e.g. "Claude", "Auggie", "Codex") - if agentConfig, ok := m.registry.Get(profileInfo.AgentName); ok && agentConfig.DisplayName != "" { - agentDisplayName = agentConfig.DisplayName + if agentConfig, ok := m.registry.Get(profileInfo.AgentName); ok && agentConfig.DisplayName() != "" { + agentDisplayName = agentConfig.DisplayName() } else { agentDisplayName = profileInfo.AgentName } @@ -1110,7 +1110,7 @@ func (m *Manager) finalizeBootMessage(msg *models.Message, stopCh chan struct{}, // buildEnvForRuntime builds environment variables for any runtime. // This is the unified method used by the runtime interface. -func (m *Manager) buildEnvForRuntime(executionID string, req *LaunchRequest, agentConfig *registry.AgentTypeConfig) map[string]string { +func (m *Manager) buildEnvForRuntime(executionID string, req *LaunchRequest, agentConfig agents.Agent) map[string]string { env := make(map[string]string) // Copy request environment @@ -1128,7 +1128,7 @@ func (m *Manager) buildEnvForRuntime(executionID string, req *LaunchRequest, age // Add required credentials from agent config if m.credsMgr != nil && agentConfig != nil { ctx := context.Background() - for _, credKey := range agentConfig.RequiredEnv { + for _, credKey := range agentConfig.Runtime().RequiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { env[credKey] = value } @@ -1224,7 +1224,7 @@ func (m *Manager) waitForAgentctlReady(execution *AgentExecution) { // getAgentConfigForExecution retrieves the agent configuration for an execution. // The execution must have AgentCommand set (which includes the agent type). -func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (*registry.AgentTypeConfig, error) { +func (m *Manager) getAgentConfigForExecution(execution *AgentExecution) (agents.Agent, error) { if execution.AgentProfileID == "" { return nil, fmt.Errorf("execution %s has no agent profile ID", execution.ID) } @@ -1261,7 +1261,7 @@ func (m *Manager) ResolveAgentProfile(ctx context.Context, profileID string) (*A // - loads per-agent MCP config, // - applies executor-scoped transport rules, allow/deny lists, URL rewrites, and env injection, // - converts to ACP stdio server definitions used during session initialization. -func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecution, agentConfig *registry.AgentTypeConfig) ([]agentctltypes.McpServer, error) { +func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { if execution == nil { return nil, nil } @@ -1270,7 +1270,7 @@ func (m *Manager) resolveMcpServers(ctx context.Context, execution *AgentExecuti // resolveMcpServersWithParams resolves MCP servers with explicit parameters. // This is used by Launch() before the execution object exists. -func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID string, metadata map[string]interface{}, agentConfig *registry.AgentTypeConfig) ([]agentctltypes.McpServer, error) { +func (m *Manager) resolveMcpServersWithParams(ctx context.Context, profileID string, metadata map[string]interface{}, agentConfig agents.Agent) ([]agentctltypes.McpServer, error) { if m.mcpProvider == nil || agentConfig == nil { return nil, nil } @@ -1338,7 +1338,7 @@ func runtimeName(rt Runtime) runtime.Name { } // initializeACPSession delegates to SessionManager for full ACP session initialization and prompting -func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig *registry.AgentTypeConfig, taskDescription string, mcpServers []agentctltypes.McpServer) error { +func (m *Manager) initializeACPSession(ctx context.Context, execution *AgentExecution, agentConfig agents.Agent, taskDescription string, mcpServers []agentctltypes.McpServer) error { return m.sessionManager.InitializeAndPrompt(ctx, execution, agentConfig, taskDescription, mcpServers, m.MarkReady) } @@ -2550,10 +2550,14 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE } // Validate passthrough support - if !agentConfig.PassthroughConfig.Supported { - return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID) + ptAgent, ok := agentConfig.(agents.PassthroughAgent) + if !ok { + return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) } + pt := ptAgent.PassthroughConfig() + rt := agentConfig.Runtime() + // Get task description from metadata for initial prompt taskDescription := "" if execution.Metadata != nil { @@ -2563,14 +2567,19 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE } // Build passthrough command with initial prompt and profile settings - cmd := m.buildPassthroughCommand(agentConfig, execution.ACPSessionID, taskDescription, profileInfo) - if len(cmd) == 0 { - return fmt.Errorf("passthrough command is empty for agent %s", agentConfig.ID) + cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: profileModel(profileInfo), + SessionID: execution.ACPSessionID, + Prompt: taskDescription, + PermissionValues: profilePermissionValues(profileInfo), + }) + if cmd.IsEmpty() { + return fmt.Errorf("passthrough command is empty for agent %s", agentConfig.ID()) } m.logger.Info("passthrough command built", zap.String("session_id", execution.SessionID), - zap.Strings("full_command", cmd)) + zap.Strings("full_command", cmd.Args())) // Get the interactive runner from runtime interactiveRunner := m.GetInteractiveRunner() @@ -2586,7 +2595,7 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE // Add required credentials from agent config if m.credsMgr != nil { - for _, credKey := range agentConfig.RequiredEnv { + for _, credKey := range rt.RequiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { env[credKey] = value } @@ -2598,19 +2607,19 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE // Some agents (like Codex) require the terminal to be connected first because // they query the terminal for cursor position on startup startReq := process.InteractiveStartRequest{ - SessionID: execution.SessionID, - Command: cmd, - WorkingDir: execution.WorkspacePath, - Env: env, - PromptPattern: agentConfig.PassthroughConfig.PromptPattern, - IdleTimeoutMs: agentConfig.PassthroughConfig.IdleTimeoutMs, - BufferMaxBytes: agentConfig.PassthroughConfig.BufferMaxBytes, - StatusDetector: agentConfig.PassthroughConfig.StatusDetector, - CheckIntervalMs: agentConfig.PassthroughConfig.CheckIntervalMs, - StabilityWindowMs: agentConfig.PassthroughConfig.StabilityWindowMs, - ImmediateStart: !agentConfig.PassthroughConfig.WaitForTerminal, - DefaultCols: 120, - DefaultRows: 40, + SessionID: execution.SessionID, + Command: cmd.Args(), + WorkingDir: execution.WorkspacePath, + Env: env, + PromptPattern: pt.PromptPattern, + IdleTimeout: pt.IdleTimeout, + BufferMaxBytes: pt.BufferMaxBytes, + StatusDetector: pt.StatusDetector, + CheckInterval: pt.CheckInterval, + StabilityWindow: pt.StabilityWindow, + ImmediateStart: !pt.WaitForTerminal, + DefaultCols: 120, + DefaultRows: 40, } processInfo, err := interactiveRunner.Start(ctx, startReq) @@ -2627,7 +2636,7 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE zap.String("task_id", execution.TaskID), zap.String("session_id", execution.SessionID), zap.String("process_id", processInfo.ID), - zap.Strings("command", cmd)) + zap.Strings("command", cmd.Args())) // Emit agentctl ready event to indicate session is available m.eventPublisher.PublishAgentctlEvent(ctx, events.AgentctlReady, execution, "") @@ -2658,78 +2667,24 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE return nil } -// buildPassthroughCommand builds the command for passthrough mode. -// It uses the passthrough_cmd from config, applies profile settings as CLI flags, -// and appends resume flag if resuming or initial prompt for new sessions. -func (m *Manager) buildPassthroughCommand(agentConfig *registry.AgentTypeConfig, acpSessionID string, initialPrompt string, profileInfo *AgentProfileInfo) []string { - // Start with passthrough_cmd - cmd := make([]string, len(agentConfig.PassthroughConfig.PassthroughCmd)) - copy(cmd, agentConfig.PassthroughConfig.PassthroughCmd) - - // Apply model flag if configured and profile has a model - if profileInfo != nil && profileInfo.Model != "" && agentConfig.PassthroughConfig.ModelFlag != "" { - expanded := strings.ReplaceAll(agentConfig.PassthroughConfig.ModelFlag, "{model}", profileInfo.Model) - // Split on first space to separate flag from value (if combined) - parts := strings.SplitN(expanded, " ", 2) - cmd = append(cmd, parts...) +// profileModel extracts the model from profile info, returning empty string if nil. +func profileModel(p *AgentProfileInfo) string { + if p == nil { + return "" } + return p.Model +} - // Apply permission settings that use CLI flags - // Build a map of permission values from profile info - if profileInfo != nil && agentConfig.PermissionSettings != nil { - permissionValues := map[string]bool{ - "auto_approve": profileInfo.AutoApprove, - "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, - "allow_indexing": profileInfo.AllowIndexing, - } - - for settingName, setting := range agentConfig.PermissionSettings { - // Skip if not supported or not a CLI flag setting - if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { - continue - } - - // Get the value for this setting - value, exists := permissionValues[settingName] - if !exists || !value { - continue - } - - // Apply the CLI flag - if setting.CLIFlagValue != "" { - // Flag with value: "--flag value" - cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) - } else { - // Boolean flag or multiple flags: "--flag" or "--flag1 --flag2 arg" - // Split on spaces to handle multiple flags in one setting - parts := strings.Fields(setting.CLIFlag) - cmd = append(cmd, parts...) - } - } +// profilePermissionValues builds a permission values map from profile info. +func profilePermissionValues(p *AgentProfileInfo) map[string]bool { + if p == nil { + return nil } - - // Add resume flag if: - // 1. Session ID is provided (resuming existing session) - // 2. Agent uses CLI-based resume (not ACP) - // 3. Agent has a ResumeFlag configured - if acpSessionID != "" && - !agentConfig.SessionConfig.NativeSessionResume && - agentConfig.SessionConfig.ResumeFlag != "" { - cmd = append(cmd, agentConfig.SessionConfig.ResumeFlag, acpSessionID) - } else if initialPrompt != "" { - // For new sessions, add the initial prompt - // Use PromptFlag if configured (e.g., "--prompt {prompt}"), otherwise append directly - if agentConfig.PassthroughConfig.PromptFlag != "" { - expanded := strings.ReplaceAll(agentConfig.PassthroughConfig.PromptFlag, "{prompt}", initialPrompt) - parts := strings.SplitN(expanded, " ", 2) - cmd = append(cmd, parts...) - } else { - // Default: append prompt as a positional argument - cmd = append(cmd, initialPrompt) - } + return map[string]bool{ + "auto_approve": p.AutoApprove, + "dangerously_skip_permissions": p.DangerouslySkipPermissions, + "allow_indexing": p.AllowIndexing, } - - return cmd } // ResumePassthroughSession restarts a passthrough session after backend restart. @@ -2748,10 +2703,14 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string return fmt.Errorf("failed to get agent config: %w", err) } - if !agentConfig.PassthroughConfig.Supported { - return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID) + ptAgent, ok := agentConfig.(agents.PassthroughAgent) + if !ok { + return fmt.Errorf("agent %s does not support passthrough mode", agentConfig.ID()) } + pt := ptAgent.PassthroughConfig() + rt := agentConfig.Runtime() + // Get profile info for permission settings var profileInfo *AgentProfileInfo if m.profileResolver != nil && execution.AgentProfileID != "" { @@ -2759,16 +2718,19 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string } // Build the resume command - cmd := m.buildPassthroughResumeCommand(agentConfig, profileInfo) - if len(cmd) == 0 { - return fmt.Errorf("passthrough resume command is empty for agent %s", agentConfig.ID) + cmd := ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: profileModel(profileInfo), + Resume: true, + PermissionValues: profilePermissionValues(profileInfo), + }) + if cmd.IsEmpty() { + return fmt.Errorf("passthrough resume command is empty for agent %s", agentConfig.ID()) } m.logger.Info("resuming passthrough session", zap.String("session_id", sessionID), zap.String("execution_id", execution.ID), - zap.Strings("command", cmd), - zap.Bool("has_resume_flag", agentConfig.PassthroughConfig.ResumeFlag != "")) + zap.Strings("command", cmd.Args())) // Get the interactive runner interactiveRunner := m.GetInteractiveRunner() @@ -2784,7 +2746,7 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string // Add required credentials if m.credsMgr != nil { - for _, credKey := range agentConfig.RequiredEnv { + for _, credKey := range rt.RequiredEnv { if value, err := m.credsMgr.GetCredentialValue(ctx, credKey); err == nil && value != "" { env[credKey] = value } @@ -2794,18 +2756,18 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string // Start the interactive process // Use the same settings as initial start (including wait_for_terminal) startReq := process.InteractiveStartRequest{ - SessionID: sessionID, - Command: cmd, - WorkingDir: execution.WorkspacePath, - Env: env, - IdleTimeoutMs: agentConfig.PassthroughConfig.IdleTimeoutMs, - BufferMaxBytes: agentConfig.PassthroughConfig.BufferMaxBytes, - StatusDetector: agentConfig.PassthroughConfig.StatusDetector, - CheckIntervalMs: agentConfig.PassthroughConfig.CheckIntervalMs, - StabilityWindowMs: agentConfig.PassthroughConfig.StabilityWindowMs, - ImmediateStart: !agentConfig.PassthroughConfig.WaitForTerminal, - DefaultCols: 120, - DefaultRows: 40, + SessionID: sessionID, + Command: cmd.Args(), + WorkingDir: execution.WorkspacePath, + Env: env, + IdleTimeout: pt.IdleTimeout, + BufferMaxBytes: pt.BufferMaxBytes, + StatusDetector: pt.StatusDetector, + CheckInterval: pt.CheckInterval, + StabilityWindow: pt.StabilityWindow, + ImmediateStart: !pt.WaitForTerminal, + DefaultCols: 120, + DefaultRows: 40, } processInfo, err := interactiveRunner.Start(ctx, startReq) @@ -2844,65 +2806,6 @@ func (m *Manager) ResumePassthroughSession(ctx context.Context, sessionID string return nil } -// buildPassthroughResumeCommand builds the command for resuming a passthrough session. -// If the agent supports resume (has ResumeFlag), it adds the resume flag. -// Otherwise, it starts a fresh session with profile settings but no initial prompt. -func (m *Manager) buildPassthroughResumeCommand(agentConfig *registry.AgentTypeConfig, profileInfo *AgentProfileInfo) []string { - // Start with passthrough_cmd - cmd := make([]string, len(agentConfig.PassthroughConfig.PassthroughCmd)) - copy(cmd, agentConfig.PassthroughConfig.PassthroughCmd) - - // Apply model flag if configured - if profileInfo != nil && profileInfo.Model != "" && agentConfig.PassthroughConfig.ModelFlag != "" { - expanded := strings.ReplaceAll(agentConfig.PassthroughConfig.ModelFlag, "{model}", profileInfo.Model) - parts := strings.SplitN(expanded, " ", 2) - cmd = append(cmd, parts...) - } - - // Apply permission settings that use CLI flags - // Sort keys for deterministic output order - if profileInfo != nil && agentConfig.PermissionSettings != nil { - permissionValues := map[string]bool{ - "auto_approve": profileInfo.AutoApprove, - "dangerously_skip_permissions": profileInfo.DangerouslySkipPermissions, - "allow_indexing": profileInfo.AllowIndexing, - } - - // Get sorted keys for deterministic iteration - settingNames := make([]string, 0, len(agentConfig.PermissionSettings)) - for name := range agentConfig.PermissionSettings { - settingNames = append(settingNames, name) - } - sort.Strings(settingNames) - - for _, settingName := range settingNames { - setting := agentConfig.PermissionSettings[settingName] - if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { - continue - } - value, exists := permissionValues[settingName] - if !exists || !value { - continue - } - if setting.CLIFlagValue != "" { - cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) - } else { - parts := strings.Fields(setting.CLIFlag) - cmd = append(cmd, parts...) - } - } - } - - // Add resume flag if agent supports it - if agentConfig.PassthroughConfig.ResumeFlag != "" { - // Split on spaces to handle flags like "--resume latest" - parts := strings.Fields(agentConfig.PassthroughConfig.ResumeFlag) - cmd = append(cmd, parts...) - } - - return cmd -} - // handlePassthroughTurnComplete is called when turn detection fires for a passthrough session. // This marks the execution as ready for follow-up prompts when the agent finishes processing. func (m *Manager) handlePassthroughTurnComplete(sessionID string) { diff --git a/apps/backend/internal/agent/lifecycle/manager_test.go b/apps/backend/internal/agent/lifecycle/manager_test.go index bd1c69b0a4..022c7e1d40 100644 --- a/apps/backend/internal/agent/lifecycle/manager_test.go +++ b/apps/backend/internal/agent/lifecycle/manager_test.go @@ -8,15 +8,72 @@ import ( "testing" "time" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/docker" "github.com/kandev/kandev/internal/agent/registry" agentctl "github.com/kandev/kandev/internal/agentctl/client" + "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) +// testAgent implements agents.Agent for use in lifecycle tests. +// Embed StandardPassthrough to optionally satisfy agents.PassthroughAgent. +type testAgent struct { + agents.StandardPassthrough + id string + name string + displayName string + description string + enabled bool + defaultModel string + permissionSettings map[string]agents.PermissionSetting + runtimeConfig *agents.RuntimeConfig +} + +func (a *testAgent) ID() string { return a.id } +func (a *testAgent) Name() string { return a.name } +func (a *testAgent) DisplayName() string { return a.displayName } +func (a *testAgent) Description() string { return a.description } +func (a *testAgent) Enabled() bool { return a.enabled } + +func (a *testAgent) Logo(_ agents.LogoVariant) []byte { return nil } +func (a *testAgent) IsInstalled(_ context.Context) (*agents.DiscoveryResult, error) { + return &agents.DiscoveryResult{Available: false}, nil +} + +func (a *testAgent) DefaultModel() string { return a.defaultModel } +func (a *testAgent) ListModels(_ context.Context) (*agents.ModelList, error) { + return &agents.ModelList{}, nil +} + +func (a *testAgent) CreateAdapter(_ *adapter.Config, _ *logger.Logger) (adapter.AgentAdapter, error) { + return nil, agents.ErrNotSupported +} + +func (a *testAgent) BuildCommand(_ agents.CommandOptions) agents.Command { return agents.Command{} } + +func (a *testAgent) PermissionSettings() map[string]agents.PermissionSetting { + return a.permissionSettings +} + +func (a *testAgent) Runtime() *agents.RuntimeConfig { + if a.runtimeConfig != nil { + return a.runtimeConfig + } + return &agents.RuntimeConfig{ + Cmd: agents.NewCommand(a.id), + ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, + } +} + +var ( + _ agents.Agent = (*testAgent)(nil) + _ agents.PassthroughAgent = (*testAgent)(nil) +) + // MockDockerClient implements a mock for the docker.Client for testing type MockDockerClient struct { CreateContainerFn func(ctx context.Context, cfg docker.ContainerConfig) (string, error) @@ -398,156 +455,163 @@ func TestManager_StartStop(t *testing.T) { } } -func TestManager_BuildPassthroughResumeCommand(t *testing.T) { - mgr := newTestManager() - +func TestBuildPassthroughCommand(t *testing.T) { tests := []struct { - name string - agentConfig *registry.AgentTypeConfig - profileInfo *AgentProfileInfo - wantCmd []string + name string + agent agents.PassthroughAgent + opts agents.PassthroughOptions + wantCmd []string }{ { - name: "basic command without profile", - agentConfig: ®istry.AgentTypeConfig{ - ID: "test-agent", - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"test-cli", "--verbose"}, + name: "basic command without options", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli", "--verbose")}, }, }, - profileInfo: nil, - wantCmd: []string{"test-cli", "--verbose"}, + opts: agents.PassthroughOptions{Resume: true}, + wantCmd: []string{"test-cli", "--verbose"}, }, { - name: "command with model flag", - agentConfig: ®istry.AgentTypeConfig{ - ID: "test-agent", - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"test-cli"}, - ModelFlag: "--model {model}", + name: "command with model", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ModelFlag: agents.NewParam("--model", "{model}")}, }, }, - profileInfo: &AgentProfileInfo{ - Model: "gpt-4", - }, + opts: agents.PassthroughOptions{Model: "gpt-4"}, wantCmd: []string{"test-cli", "--model", "gpt-4"}, }, { - name: "command with resume flag", - agentConfig: ®istry.AgentTypeConfig{ - ID: "test-agent", - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"test-cli"}, - ResumeFlag: "-c", + name: "resume with single-word flag", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), ResumeFlag: agents.NewParam("-c")}, }, }, - profileInfo: nil, - wantCmd: []string{"test-cli", "-c"}, + opts: agents.PassthroughOptions{Resume: true}, + wantCmd: []string{"test-cli", "-c"}, }, { - name: "command with multi-word resume flag", - agentConfig: ®istry.AgentTypeConfig{ - ID: "gemini-agent", - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"gemini"}, - ResumeFlag: "--resume latest", + name: "resume with multi-word flag", + agent: &testAgent{ + id: "gemini-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("gemini"), ResumeFlag: agents.NewParam("--resume", "latest")}, }, }, - profileInfo: nil, - wantCmd: []string{"gemini", "--resume", "latest"}, + opts: agents.PassthroughOptions{Resume: true}, + wantCmd: []string{"gemini", "--resume", "latest"}, }, { - name: "command with permission settings", - agentConfig: ®istry.AgentTypeConfig{ - ID: "test-agent", - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"test-cli"}, - }, - PermissionSettings: map[string]registry.PermissionSetting{ - "dangerously_skip_permissions": { - Supported: true, - ApplyMethod: "cli_flag", - CLIFlag: "--dangerous", - }, - "auto_approve": { - Supported: true, - ApplyMethod: "cli_flag", - CLIFlag: "--yes", + name: "permission settings as CLI flags", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, + PermSettings: map[string]agents.PermissionSetting{ + "auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes"}, }, }, }, - profileInfo: &AgentProfileInfo{ - DangerouslySkipPermissions: true, - AutoApprove: true, + opts: agents.PassthroughOptions{ + PermissionValues: map[string]bool{"auto_approve": true}, }, - // Order is alphabetical by setting name: auto_approve, dangerously_skip_permissions - wantCmd: []string{"test-cli", "--yes", "--dangerous"}, + wantCmd: []string{"test-cli", "--yes"}, }, { - name: "full command with all options", - agentConfig: ®istry.AgentTypeConfig{ - ID: "claude-code", - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"npx", "-y", "@anthropic-ai/claude-code"}, - ModelFlag: "--model {model}", - ResumeFlag: "-c", - }, - PermissionSettings: map[string]registry.PermissionSetting{ - "dangerously_skip_permissions": { - Supported: true, - ApplyMethod: "cli_flag", - CLIFlag: "--dangerously-skip-permissions", + name: "full resume with model + settings + resume flag", + agent: &testAgent{ + id: "claude-code", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("npx", "-y", "@anthropic-ai/claude-code"), + ModelFlag: agents.NewParam("--model", "{model}"), + ResumeFlag: agents.NewParam("-c"), + }, + PermSettings: map[string]agents.PermissionSetting{ + "dangerously_skip_permissions": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--dangerously-skip-permissions"}, }, }, }, - profileInfo: &AgentProfileInfo{ - Model: "claude-sonnet-4", - DangerouslySkipPermissions: true, + opts: agents.PassthroughOptions{ + Model: "claude-sonnet-4", + Resume: true, + PermissionValues: map[string]bool{"dangerously_skip_permissions": true}, }, wantCmd: []string{"npx", "-y", "@anthropic-ai/claude-code", "--model", "claude-sonnet-4", "--dangerously-skip-permissions", "-c"}, }, { name: "permission setting with cli_flag_value", - agentConfig: ®istry.AgentTypeConfig{ - ID: "test-agent", - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"test-cli"}, - }, - PermissionSettings: map[string]registry.PermissionSetting{ - "auto_approve": { - Supported: true, - ApplyMethod: "cli_flag", - CLIFlag: "--approve-level", - CLIFlagValue: "all", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, + PermSettings: map[string]agents.PermissionSetting{ + "auto_approve": {Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--approve-level", CLIFlagValue: "all"}, }, }, }, - profileInfo: &AgentProfileInfo{ - AutoApprove: true, + opts: agents.PassthroughOptions{ + PermissionValues: map[string]bool{"auto_approve": true}, }, wantCmd: []string{"test-cli", "--approve-level", "all"}, }, + { + name: "new session with prompt (positional)", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli")}, + }, + }, + opts: agents.PassthroughOptions{Prompt: "fix the bug"}, + wantCmd: []string{"test-cli", "fix the bug"}, + }, + { + name: "new session with prompt flag", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{Supported: true, PassthroughCmd: agents.NewCommand("test-cli"), PromptFlag: agents.NewParam("--prompt", "{prompt}")}, + }, + }, + opts: agents.PassthroughOptions{Prompt: "fix the bug"}, + wantCmd: []string{"test-cli", "--prompt", "fix the bug"}, + }, + { + name: "resume with session ID", + agent: &testAgent{ + id: "test-agent", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("test-cli"), + SessionResumeFlag: agents.NewParam("--resume"), + }, + }, + }, + opts: agents.PassthroughOptions{SessionID: "sess-123"}, + wantCmd: []string{"test-cli", "--resume", "sess-123"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := mgr.buildPassthroughResumeCommand(tt.agentConfig, tt.profileInfo) + got := tt.agent.BuildPassthroughCommand(tt.opts).Args() if len(got) != len(tt.wantCmd) { - t.Errorf("buildPassthroughResumeCommand() = %v, want %v", got, tt.wantCmd) + t.Errorf("BuildPassthroughCommand() = %v, want %v", got, tt.wantCmd) return } for i, arg := range got { if arg != tt.wantCmd[i] { - t.Errorf("buildPassthroughResumeCommand()[%d] = %q, want %q", i, arg, tt.wantCmd[i]) + t.Errorf("BuildPassthroughCommand()[%d] = %q, want %q", i, arg, tt.wantCmd[i]) } } }) diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver.go b/apps/backend/internal/agent/lifecycle/profile_resolver.go index 7f2c9de93a..070f612a39 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver.go @@ -37,9 +37,8 @@ func (r *StoreProfileResolver) ResolveProfile(ctx context.Context, profileID str // Determine the model to use: profile's model, or fall back to agent's default model model := profile.Model if model == "" && r.registry != nil { - // Look up the agent type in the registry to get the default model - if agentType, ok := r.registry.Get(agent.Name); ok { - model = agentType.ModelConfig.DefaultModel + if ag, ok := r.registry.Get(agent.Name); ok { + model = ag.DefaultModel() } } diff --git a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go index 2396ebe541..aa9afb7cd0 100644 --- a/apps/backend/internal/agent/lifecycle/profile_resolver_test.go +++ b/apps/backend/internal/agent/lifecycle/profile_resolver_test.go @@ -11,6 +11,8 @@ import ( "github.com/kandev/kandev/internal/common/logger" ) +// Note: testAgent is defined in manager_test.go (same package) + // MockRepository implements store.Repository for testing type MockRepository struct { GetAgentFn func(ctx context.Context, id string) (*models.Agent, error) @@ -243,18 +245,11 @@ func TestStoreProfileResolver_ResolveProfile_FallbackToRegistryDefaultModel(t *t // Create a registry with a default model for claude-code log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"}) reg := registry.NewRegistry(log) - err := reg.Register(®istry.AgentTypeConfig{ - ID: "claude-code", - Name: "claude-code", - Cmd: []string{"claude"}, // Standalone agent uses Cmd - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1, - TimeoutSeconds: 3600, - }, - ModelConfig: registry.ModelConfig{ - DefaultModel: "claude-sonnet-4-20250514", - }, + err := reg.Register(&testAgent{ + id: "claude-code", + name: "claude-code", + enabled: true, + defaultModel: "claude-sonnet-4-20250514", }) if err != nil { t.Fatalf("failed to register agent: %v", err) diff --git a/apps/backend/internal/agent/lifecycle/runtime.go b/apps/backend/internal/agent/lifecycle/runtime.go index 5e198db339..8d27d90bc6 100644 --- a/apps/backend/internal/agent/lifecycle/runtime.go +++ b/apps/backend/internal/agent/lifecycle/runtime.go @@ -5,7 +5,7 @@ import ( "context" "time" - "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/runtime" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/agentctl/server/process" @@ -66,7 +66,7 @@ type RuntimeCreateRequest struct { Env map[string]string Metadata map[string]interface{} McpServers []McpServerConfig - AgentConfig *registry.AgentTypeConfig // Agent type info needed by runtimes + AgentConfig agents.Agent // Agent type info needed by runtimes } // RuntimeInstance represents an agentctl instance created by a runtime. diff --git a/apps/backend/internal/agent/lifecycle/runtime_standalone.go b/apps/backend/internal/agent/lifecycle/runtime_standalone.go index 02ee6f57ec..6c28fd3efb 100644 --- a/apps/backend/internal/agent/lifecycle/runtime_standalone.go +++ b/apps/backend/internal/agent/lifecycle/runtime_standalone.go @@ -97,7 +97,7 @@ func (r *StandaloneRuntime) CreateInstance(ctx context.Context, req *RuntimeCrea // Agent command is NOT set - workspace access only. Agent is started explicitly via agentctl client. agentType := "" if req.AgentConfig != nil { - agentType = req.AgentConfig.ID + agentType = req.AgentConfig.ID() } createReq := &agentctl.CreateInstanceRequest{ ID: req.InstanceID, diff --git a/apps/backend/internal/agent/lifecycle/session.go b/apps/backend/internal/agent/lifecycle/session.go index 633380f97e..a1dd7b773c 100644 --- a/apps/backend/internal/agent/lifecycle/session.go +++ b/apps/backend/internal/agent/lifecycle/session.go @@ -8,7 +8,7 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/agents" agentctl "github.com/kandev/kandev/internal/agentctl/client" agentctltypes "github.com/kandev/kandev/internal/agentctl/types" "github.com/kandev/kandev/internal/common/appctx" @@ -57,31 +57,32 @@ type InitializeResult struct { // It handles the initialize handshake and session creation/loading based on config. // // Session behavior: -// - If agentConfig.SessionConfig.NativeSessionResume is true AND existingSessionID is provided: use session/load -// - If agentConfig.SessionConfig.NativeSessionResume is false (CLI handles resume): always use session/new +// - If agentConfig.Runtime().SessionConfig.NativeSessionResume is true AND existingSessionID is provided: use session/load +// - If NativeSessionResume is false (CLI handles resume): always use session/new // - Otherwise: use session/new func (sm *SessionManager) InitializeSession( ctx context.Context, client *agentctl.Client, - agentConfig *registry.AgentTypeConfig, + agentConfig agents.Agent, existingSessionID string, workspacePath string, mcpServers []agentctltypes.McpServer, ) (*InitializeResult, error) { + rt := agentConfig.Runtime() sm.logger.Info("initializing ACP session", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("workspace_path", workspacePath), - zap.Bool("native_session_resume", agentConfig.SessionConfig.NativeSessionResume), + zap.Bool("native_session_resume", rt.SessionConfig.NativeSessionResume), zap.String("existing_session_id", existingSessionID)) // Step 1: Send initialize request sm.logger.Info("sending ACP initialize request", - zap.String("agent_type", agentConfig.ID)) + zap.String("agent_type", agentConfig.ID())) agentInfo, err := client.Initialize(ctx, "kandev", "1.0.0") if err != nil { sm.logger.Error("ACP initialize failed", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.Error(err)) return nil, fmt.Errorf("initialize failed: %w", err) } @@ -96,7 +97,7 @@ func (sm *SessionManager) InitializeSession( } sm.logger.Info("ACP initialize response received", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("agent_name", result.AgentName), zap.String("agent_version", result.AgentVersion)) @@ -114,12 +115,13 @@ func (sm *SessionManager) InitializeSession( func (sm *SessionManager) createOrLoadSession( ctx context.Context, client *agentctl.Client, - agentConfig *registry.AgentTypeConfig, + agentConfig agents.Agent, existingSessionID string, workspacePath string, mcpServers []agentctltypes.McpServer, ) (string, error) { - if agentConfig.SessionConfig.NativeSessionResume && existingSessionID != "" { + rt := agentConfig.Runtime() + if rt.SessionConfig.NativeSessionResume && existingSessionID != "" { sessionID, err := sm.loadSession(ctx, client, agentConfig, existingSessionID) if err != nil { // If session/load fails with "Method not found" or "LoadSession capability is false", @@ -127,7 +129,7 @@ func (sm *SessionManager) createOrLoadSession( if strings.Contains(err.Error(), "Method not found") || strings.Contains(err.Error(), "LoadSession capability is false") { sm.logger.Warn("agent does not support session loading, falling back to session/new", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("existing_session_id", existingSessionID), zap.String("reason", err.Error())) return sm.createNewSession(ctx, client, agentConfig, workspacePath, mcpServers) @@ -145,18 +147,20 @@ func (sm *SessionManager) createOrLoadSession( // 2. The agent doesn't support native session loading (NativeSessionResume is false) // 3. We have a task session ID (for history lookup) // 4. There's existing history for this session -func (sm *SessionManager) shouldInjectResumeContext(agentConfig *registry.AgentTypeConfig, taskSessionID string) bool { +func (sm *SessionManager) shouldInjectResumeContext(agentConfig agents.Agent, taskSessionID string) bool { if sm.historyManager == nil { return false } + rt := agentConfig.Runtime() + // Only inject for ACP agents that don't support session/load - if agentConfig.Protocol != agent.ProtocolACP { + if rt.Protocol != agent.ProtocolACP { return false } // If agent supports native session loading, don't inject (it will restore context natively) - if agentConfig.SessionConfig.NativeSessionResume { + if rt.SessionConfig.NativeSessionResume { return false } @@ -166,7 +170,7 @@ func (sm *SessionManager) shouldInjectResumeContext(agentConfig *registry.AgentT // getResumeContextPrompt generates a prompt with resume context if available. // If there's no history or context injection is disabled, returns the original prompt. -func (sm *SessionManager) getResumeContextPrompt(agentConfig *registry.AgentTypeConfig, taskSessionID, originalPrompt string) string { +func (sm *SessionManager) getResumeContextPrompt(agentConfig agents.Agent, taskSessionID, originalPrompt string) string { if !sm.shouldInjectResumeContext(agentConfig, taskSessionID) { return originalPrompt } @@ -192,23 +196,23 @@ func (sm *SessionManager) injectKandevContext(taskID, sessionID, prompt string) func (sm *SessionManager) loadSession( ctx context.Context, client *agentctl.Client, - agentConfig *registry.AgentTypeConfig, + agentConfig agents.Agent, sessionID string, ) (string, error) { sm.logger.Info("sending ACP session/load request", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID)) if err := client.LoadSession(ctx, sessionID); err != nil { sm.logger.Error("ACP session/load failed", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID), zap.Error(err)) return "", fmt.Errorf("session/load failed: %w", err) } sm.logger.Info("ACP session loaded successfully", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID)) return sessionID, nil @@ -218,25 +222,25 @@ func (sm *SessionManager) loadSession( func (sm *SessionManager) createNewSession( ctx context.Context, client *agentctl.Client, - agentConfig *registry.AgentTypeConfig, + agentConfig agents.Agent, workspacePath string, mcpServers []agentctltypes.McpServer, ) (string, error) { sm.logger.Info("sending ACP session/new request", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("workspace_path", workspacePath)) sessionID, err := client.NewSession(ctx, workspacePath, mcpServers) if err != nil { sm.logger.Error("ACP session/new failed", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("workspace_path", workspacePath), zap.Error(err)) return "", fmt.Errorf("session/new failed: %w", err) } sm.logger.Info("ACP session created successfully", - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("session_id", sessionID)) return sessionID, nil @@ -254,17 +258,18 @@ func (sm *SessionManager) createNewSession( func (sm *SessionManager) InitializeAndPrompt( ctx context.Context, execution *AgentExecution, - agentConfig *registry.AgentTypeConfig, + agentConfig agents.Agent, taskDescription string, mcpServers []agentctltypes.McpServer, markReady func(executionID string) error, ) error { + rt := agentConfig.Runtime() sm.logger.Info("initializing ACP session", zap.String("execution_id", execution.ID), zap.String("agentctl_url", execution.agentctl.BaseURL()), - zap.String("agent_type", agentConfig.ID), + zap.String("agent_type", agentConfig.ID()), zap.String("existing_acp_session_id", execution.ACPSessionID), - zap.Bool("native_session_resume", agentConfig.SessionConfig.NativeSessionResume)) + zap.Bool("native_session_resume", rt.SessionConfig.NativeSessionResume)) // Connect WebSocket streams FIRST — agent operations now go over the stream if sm.streamManager != nil { diff --git a/apps/backend/internal/agent/lifecycle/session_test.go b/apps/backend/internal/agent/lifecycle/session_test.go index 3233e21178..5654d79b23 100644 --- a/apps/backend/internal/agent/lifecycle/session_test.go +++ b/apps/backend/internal/agent/lifecycle/session_test.go @@ -12,7 +12,7 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/agents" agentctl "github.com/kandev/kandev/internal/agentctl/client" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/pkg/agent" @@ -210,11 +210,16 @@ func TestInitializeAndPrompt_StreamBeforeInitialize(t *testing.T) { promptDoneCh: make(chan PromptCompletionSignal, 1), } - agentConfig := ®istry.AgentTypeConfig{ - ID: "test-agent", - Protocol: agent.ProtocolACP, - SessionConfig: registry.SessionConfig{ - NativeSessionResume: false, + agentConfig := &testAgent{ + id: "test-agent", + enabled: true, + runtimeConfig: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-agent"), + Protocol: agent.ProtocolACP, + SessionConfig: agents.SessionConfig{ + NativeSessionResume: false, + }, + ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, }, } @@ -279,9 +284,14 @@ func TestInitializeAndPrompt_StreamTimeout(t *testing.T) { promptDoneCh: make(chan PromptCompletionSignal, 1), } - agentConfig := ®istry.AgentTypeConfig{ - ID: "test-agent", - Protocol: agent.ProtocolACP, + agentConfig := &testAgent{ + id: "test-agent", + enabled: true, + runtimeConfig: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-agent"), + Protocol: agent.ProtocolACP, + ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, + }, } // Use short timeout so test doesn't take 10s @@ -325,11 +335,16 @@ func TestInitializeAndPrompt_WithTaskDescription(t *testing.T) { promptDoneCh: make(chan PromptCompletionSignal, 1), } - agentConfig := ®istry.AgentTypeConfig{ - ID: "test-agent", - Protocol: agent.ProtocolACP, - SessionConfig: registry.SessionConfig{ - NativeSessionResume: false, + agentConfig := &testAgent{ + id: "test-agent", + enabled: true, + runtimeConfig: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-agent"), + Protocol: agent.ProtocolACP, + SessionConfig: agents.SessionConfig{ + NativeSessionResume: false, + }, + ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, }, } @@ -389,9 +404,14 @@ func TestInitializeAndPrompt_NoStreamManager(t *testing.T) { promptDoneCh: make(chan PromptCompletionSignal, 1), } - agentConfig := ®istry.AgentTypeConfig{ - ID: "test-agent", - Protocol: agent.ProtocolACP, + agentConfig := &testAgent{ + id: "test-agent", + enabled: true, + runtimeConfig: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-agent"), + Protocol: agent.ProtocolACP, + ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, + }, } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -427,11 +447,16 @@ func TestInitializeSession_CreatesNewSession(t *testing.T) { } time.Sleep(100 * time.Millisecond) // let goroutine start - agentConfig := ®istry.AgentTypeConfig{ - ID: "test-agent", - Protocol: agent.ProtocolACP, - SessionConfig: registry.SessionConfig{ - NativeSessionResume: false, + agentConfig := &testAgent{ + id: "test-agent", + enabled: true, + runtimeConfig: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-agent"), + Protocol: agent.ProtocolACP, + SessionConfig: agents.SessionConfig{ + NativeSessionResume: false, + }, + ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, }, } @@ -465,11 +490,16 @@ func TestInitializeSession_LoadsExistingSession(t *testing.T) { } time.Sleep(100 * time.Millisecond) - agentConfig := ®istry.AgentTypeConfig{ - ID: "test-agent", - Protocol: agent.ProtocolACP, - SessionConfig: registry.SessionConfig{ - NativeSessionResume: true, + agentConfig := &testAgent{ + id: "test-agent", + enabled: true, + runtimeConfig: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-agent"), + Protocol: agent.ProtocolACP, + SessionConfig: agents.SessionConfig{ + NativeSessionResume: true, + }, + ResourceLimits: agents.ResourceLimits{MemoryMB: 512, CPUCores: 0.5, Timeout: time.Hour}, }, } diff --git a/apps/backend/internal/agent/registry/agents.json b/apps/backend/internal/agent/registry/agents.json deleted file mode 100644 index 6ee93f2a7a..0000000000 --- a/apps/backend/internal/agent/registry/agents.json +++ /dev/null @@ -1,1190 +0,0 @@ -{ - "$schema": "./agents.schema.json", - "version": "1.0", - "agents": [ - { - "id": "auggie", - "name": "Augment Coding Agent", - "display_name": "Auggie", - "description": "Auggie CLI-powered autonomous coding agent.", - "enabled": true, - "image": "kandev/multi-agent", - "tag": "latest", - "cmd": [ - "npx", - "-y", - "@augmentcode/auggie@0.15.0", - "--acp" - ], - "passthrough_config": { - "supported": true, - "label": "CLI Passthrough", - "description": "Show terminal directly instead of chat interface", - "passthrough_cmd": [ - "npx", - "-y", - "@augmentcode/auggie" - ], - "model_flag": "--model {model}", - "idle_timeout_ms": 3000, - "buffer_max_bytes": 2097152, - "resume_flag": "-c" - }, - "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 {model}", - "workspace_flag": "--workspace-root", - "session_config": { - "native_session_resume": false, - "resume_flag": "--resume", - "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" - ] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve", - "description": "Automatically approve tool calls", - "apply_method": "", - "cli_flag": "" - }, - "allow_indexing": { - "supported": true, - "default": true, - "label": "Allow indexing", - "description": "Enable workspace indexing without confirmation", - "apply_method": "cli_flag", - "cli_flag": "--allow-indexing" - } - }, - "discovery": { - "supports_mcp": true, - "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", - "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/multi-agent", - "tag": "latest", - "cmd": [ - "npx", - "-y", - "@openai/codex@0.98.0", - "app-server" - ], - "passthrough_config": { - "supported": true, - "label": "CLI Passthrough", - "description": "Show terminal directly instead of chat interface", - "passthrough_cmd": [ - "npx", - "-y", - "@openai/codex", - "--full-auto" - ], - "model_flag": "--model {model}", - "idle_timeout_ms": 3000, - "buffer_max_bytes": 2097152, - "resume_flag": "" - }, - "working_dir": "/workspace", - "required_env": [], - "env": {}, - "mounts": [ - { - "source": "{workspace}", - "target": "/workspace", - "read_only": false - }, - { - "source": "{home}/.codex", - "target": "/root/.codex", - "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": "-c model=\"{model}\"", - "workspace_flag": "", - "session_config": { - "native_session_resume": true, - "resume_flag": "", - "can_recover": true, - "session_dir_template": "", - "session_dir_target": "" - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": [] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve", - "description": "Skip approval requests from Codex", - "apply_method": "acp", - "cli_flag": "--full-auto" - } - }, - "discovery": { - "supports_mcp": true, - "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", - "description": "Latest frontier agentic coding model", - "provider": "openai", - "context_window": 200000, - "is_default": true - }, - { - "id": "gpt-5.1-codex-max", - "name": "GPT-5.1 Codex Max", - "description": "Codex-optimized flagship for deep and fast reasoning", - "provider": "openai", - "context_window": 200000, - "is_default": false - }, - { - "id": "gpt-5.1-codex-mini", - "name": "GPT-5.1 Codex Mini", - "description": "Optimized for codex. Cheaper, faster, but less capable", - "provider": "openai", - "context_window": 200000, - "is_default": false - }, - { - "id": "gpt-5.2", - "name": "GPT-5.2", - "description": "Latest frontier model with improvements across knowledge, reasoning and coding", - "provider": "openai", - "context_window": 200000, - "is_default": false - } - ] - } - }, - { - "id": "claude-code", - "name": "Claude Code CLI Agent", - "display_name": "Claude", - "description": "Anthropic Claude Code CLI-powered autonomous coding agent using the stream-json protocol.", - "enabled": true, - "image": "", - "tag": "", - "cmd": [ - "npx", - "-y", - "@anthropic-ai/claude-code@2.1.29", - "-p", - "--output-format=stream-json", - "--input-format=stream-json", - "--permission-prompt-tool=stdio", - "--disallowedTools=AskUserQuestion", - "--setting-sources=user,project", - "--verbose" - ], - "passthrough_config": { - "supported": true, - "label": "CLI Passthrough", - "description": "Show terminal directly instead of chat interface", - "passthrough_cmd": [ - "npx", - "-y", - "@anthropic-ai/claude-code", - "--verbose" - ], - "model_flag": "--model {model}", - "idle_timeout_ms": 3000, - "buffer_max_bytes": 2097152, - "resume_flag": "-c" - }, - "working_dir": "{workspace}", - "required_env": [ - "ANTHROPIC_API_KEY" - ], - "env": {}, - "mounts": [], - "resource_limits": { - "memory_mb": 4096, - "cpu_cores": 2.0, - "timeout_seconds": 3600 - }, - "capabilities": [ - "code_generation", - "code_review", - "refactoring", - "testing", - "shell_execution" - ], - "protocol": "claude-code", - "protocol_config": {}, - "model_flag": "--model {model}", - "workspace_flag": "", - "session_config": { - "native_session_resume": false, - "resume_flag": "--resume", - "can_recover": true, - "session_dir_template": "{home}/.claude", - "session_dir_target": "" - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": [ - "Bash", - "Write", - "Edit", - "NotebookEdit" - ] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve", - "description": "Automatically approve tool calls via stdio protocol", - "apply_method": "stdio", - "cli_flag": "" - }, - "dangerously_skip_permissions": { - "supported": true, - "default": true, - "label": "Skip Permissions", - "description": "Bypass all permission checks (dangerous but fast for trusted tasks)", - "apply_method": "cli_flag", - "cli_flag": "--dangerously-skip-permissions" - } - }, - "discovery": { - "supports_mcp": true, - "mcp_config_path": { - "linux": [], - "windows": [], - "macos": [] - }, - "installation_path": { - "linux": [ - "~/.claude.json" - ], - "windows": [ - "~/.claude.json" - ], - "macos": [ - "~/.claude.json" - ] - }, - "discovery_capabilities": { - "supports_session_resume": true, - "supports_shell": false, - "supports_workspace_only": false - } - }, - "model_config": { - "default_model": "claude-sonnet-4-5", - "context_window_dynamic": true, - "default_context_window": 200000, - "available_models": [ - { - "id": "claude-sonnet-4-5", - "name": "Claude Sonnet 4.5", - "description": "Latest Sonnet with improved reasoning", - "provider": "anthropic", - "context_window": 200000, - "is_default": true - }, - { - "id": "claude-opus-4-6", - "name": "Claude Opus 4.6", - "description": "Latest and most capable model for complex tasks", - "provider": "anthropic", - "context_window": 200000, - "is_default": false - }, - { - "id": "claude-opus-4-5", - "name": "Claude Opus 4.5", - "description": "Most capable model for complex tasks", - "provider": "anthropic", - "context_window": 200000, - "is_default": false - }, - { - "id": "claude-haiku-4-5", - "name": "Claude Haiku 4.5", - "description": "Fast and affordable model for simple tasks", - "provider": "anthropic", - "context_window": 200000, - "is_default": false - } - ] - } - }, - { - "id": "gemini", - "name": "Google Gemini CLI Agent", - "display_name": "Gemini", - "description": "Google Gemini CLI-powered autonomous coding agent using ACP protocol.", - "enabled": true, - "image": "", - "tag": "", - "cmd": [ - "npx", - "-y", - "@google/gemini-cli@0.25.2", - "--experimental-acp" - ], - "working_dir": "{workspace}", - "required_env": [], - "env": {}, - "mounts": [], - "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 {model}", - "workspace_flag": "", - "session_config": { - "native_session_resume": false, - "resume_flag": "", - "can_recover": false, - "session_dir_template": "{home}/.gemini", - "session_dir_target": "" - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": [ - "run_shell_command", - "write_file", - "edit_file" - ] - }, - "passthrough_config": { - "supported": true, - "label": "CLI Passthrough", - "description": "Show terminal directly instead of chat interface", - "passthrough_cmd": [ - "npx @google/gemini-cli" - ], - "model_flag": "--model {model}", - "prompt_flag": "--prompt-interactive {prompt}", - "idle_timeout_ms": 3000, - "buffer_max_bytes": 2097152, - "resume_flag": "--resume latest" - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve (YOLO mode)", - "description": "Automatically approve all tool calls", - "apply_method": "cli_flag", - "cli_flag": "--yolo --allowed-tools run_shell_command" - } - }, - "discovery": { - "supports_mcp": true, - "mcp_config_path": { - "linux": [ - "~/.gemini/settings.json" - ], - "windows": [], - "macos": [ - "~/.gemini/settings.json" - ] - }, - "installation_path": { - "linux": [ - "~/.gemini/oauth_creds.json", - "~/.gemini/installation_id" - ], - "windows": [], - "macos": [ - "~/.gemini/oauth_creds.json", - "~/.gemini/installation_id" - ] - }, - "discovery_capabilities": { - "supports_session_resume": true, - "supports_shell": false, - "supports_workspace_only": false - } - }, - "model_config": { - "default_model": "gemini-3-flash-preview", - "available_models": [ - { - "id": "gemini-3-flash-preview", - "name": "Gemini 3 Flash", - "description": "Fast and efficient model", - "provider": "google", - "context_window": 1000000, - "is_default": true - }, - { - "id": "gemini-3-pro-preview", - "name": "Gemini 3 Pro", - "description": "Most capable model with 2M context", - "provider": "google", - "context_window": 2000000, - "is_default": false - } - ] - } - }, - { - "id": "opencode", - "name": "OpenCode AI Agent", - "display_name": "OpenCode", - "description": "OpenCode CLI-powered autonomous coding agent using REST/SSE protocol.", - "enabled": true, - "image": "", - "tag": "", - "cmd": [ - "npx", - "-y", - "opencode-ai@1.1.25", - "serve", - "--hostname", - "127.0.0.1", - "--port", - "0" - ], - "passthrough_config": { - "supported": true, - "label": "CLI Passthrough", - "description": "Show terminal directly instead of chat interface", - "passthrough_cmd": [ - "opencode" - ], - "model_flag": "--model {model}", - "prompt_flag": "--prompt {prompt}", - "idle_timeout_ms": 3000, - "buffer_max_bytes": 2097152, - "resume_flag": "-c" - }, - "working_dir": "{workspace}", - "required_env": [], - "env": {}, - "mounts": [], - "resource_limits": { - "memory_mb": 4096, - "cpu_cores": 2.0, - "timeout_seconds": 3600 - }, - "capabilities": [ - "code_generation", - "code_review", - "refactoring", - "testing", - "shell_execution" - ], - "protocol": "opencode", - "protocol_config": {}, - "model_flag": "", - "workspace_flag": "", - "session_config": { - "native_session_resume": true, - "resume_flag": "", - "can_recover": true, - "session_dir_template": "{home}/.opencode", - "session_dir_target": "" - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": [ - "edit", - "bash", - "webfetch" - ] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve", - "description": "Automatically approve tool calls", - "apply_method": "env", - "cli_flag": "" - } - }, - "discovery": { - "supports_mcp": true, - "mcp_config_path": { - "linux": [ - "~/.config/opencode/opencode.json", - "~/.config/opencode/opencode.jsonc" - ], - "windows": [], - "macos": [ - "~/.config/opencode/opencode.json", - "~/.config/opencode/opencode.jsonc", - "~/Library/Application Support/opencode/opencode.json" - ] - }, - "installation_path": { - "linux": [ - "~/.opencode", - "~/.config/opencode" - ], - "windows": [], - "macos": [ - "~/.opencode", - "~/.config/opencode", - "~/Library/Application Support/ai.opencode.desktop" - ] - }, - "discovery_capabilities": { - "supports_session_resume": true, - "supports_shell": false, - "supports_workspace_only": false - } - }, - "model_config": { - "default_model": "anthropic/claude-sonnet-4-20250514", - "dynamic_models_cmd": [ - "opencode", - "models" - ], - "dynamic_models_timeout_ms": 30000, - "available_models": [ - { - "id": "anthropic/claude-sonnet-4-20250514", - "name": "Claude Sonnet 4", - "description": "Anthropic Claude Sonnet 4", - "provider": "anthropic", - "context_window": 200000, - "is_default": true - }, - { - "id": "anthropic/claude-opus-4-20250514", - "name": "Claude Opus 4", - "description": "Anthropic Claude Opus 4", - "provider": "anthropic", - "context_window": 200000, - "is_default": false - }, - { - "id": "openai/gpt-4.1", - "name": "GPT-4.1", - "description": "OpenAI GPT-4.1", - "provider": "openai", - "context_window": 200000, - "is_default": false - }, - { - "id": "google/gemini-2.5-pro", - "name": "Gemini 2.5 Pro", - "description": "Google Gemini 2.5 Pro", - "provider": "google", - "context_window": 2000000, - "is_default": false - } - ] - } - }, - { - "id": "copilot", - "name": "GitHub Copilot Agent", - "display_name": "Copilot", - "description": "GitHub Copilot CLI-powered autonomous coding agent using the Copilot SDK protocol.", - "enabled": true, - "image": "", - "tag": "", - "cmd": [ - "npx", - "-y", - "@github/copilot@0.0.406", - "--server", - "--log-level", - "error" - ], - "passthrough_config": { - "supported": true, - "label": "CLI Passthrough", - "description": "Show terminal directly instead of chat interface", - "passthrough_cmd": [ - "npx", - "-y", - "@github/copilot" - ], - "model_flag": "--model {model}", - "idle_timeout_ms": 3000, - "buffer_max_bytes": 2097152, - "resume_flag": "--resume" - }, - "working_dir": "{workspace}", - "required_env": [], - "env": {}, - "mounts": [], - "resource_limits": { - "memory_mb": 4096, - "cpu_cores": 2.0, - "timeout_seconds": 3600 - }, - "capabilities": [ - "code_generation", - "code_review", - "refactoring", - "testing", - "shell_execution" - ], - "protocol": "copilot", - "protocol_config": {}, - "model_flag": "--model {model}", - "workspace_flag": "", - "session_config": { - "native_session_resume": true, - "resume_flag": "--resume", - "can_recover": true, - "session_dir_template": "{home}/.copilot", - "session_dir_target": "" - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": [ - "bash", - "write_file", - "edit_file" - ] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve (YOLO mode)", - "description": "Enable all tools, folders and urls without confirmation.", - "apply_method": "cli_flag", - "cli_flag": "--yolo" - } - }, - "discovery": { - "supports_mcp": true, - "mcp_config_path": { - "linux": [ - "~/.copilot/mcp-config.json" - ], - "windows": [], - "macos": [ - "~/.copilot/mcp-config.json" - ] - }, - "installation_path": { - "linux": [], - "windows": [], - "macos": [ - "~/.copilot/pkg" - ] - }, - "discovery_capabilities": { - "supports_session_resume": true, - "supports_shell": false, - "supports_workspace_only": false - } - }, - "model_config": { - "default_model": "gpt-4.1", - "available_models": [ - { - "id": "gpt-4.1", - "name": "GPT-4.1", - "description": "OpenAI GPT-4.1", - "provider": "openai", - "is_default": true - }, - { - "id": "claude-sonnet-4", - "name": "Claude Sonnet 4", - "description": "Anthropic Claude Sonnet 4", - "provider": "anthropic", - "is_default": false - }, - { - "id": "claude-sonnet-4.5", - "name": "Claude Sonnet 4.5", - "description": "Anthropic Claude Sonnet 4.5", - "provider": "anthropic", - "is_default": false - }, - { - "id": "claude-opus-4.5", - "name": "Claude Opus 4.5", - "description": "Anthropic Claude Opus 4.5", - "provider": "anthropic", - "is_default": false - }, - { - "id": "claude-haiku-4.5", - "name": "Claude Haiku 4.5", - "description": "Anthropic Claude Haiku 4.5", - "provider": "anthropic", - "is_default": false - }, - { - "id": "gpt-5.2-codex", - "name": "GPT-5.2 Codex", - "description": "OpenAI GPT-5.2 Codex", - "provider": "openai", - "is_default": false - }, - { - "id": "gpt-5.2", - "name": "GPT-5.2", - "description": "OpenAI GPT-5.2", - "provider": "openai", - "is_default": false - }, - { - "id": "gpt-5.1-codex-max", - "name": "GPT-5.1 Codex Max", - "description": "OpenAI GPT-5.1 Codex Max", - "provider": "openai", - "is_default": false - }, - { - "id": "gpt-5.1-codex", - "name": "GPT-5.1 Codex", - "description": "OpenAI GPT-5.1 Codex", - "provider": "openai", - "is_default": false - }, - { - "id": "gpt-5.1", - "name": "GPT-5.1", - "description": "OpenAI GPT-5.1", - "provider": "openai", - "is_default": false - }, - { - "id": "gpt-5", - "name": "GPT-5", - "description": "OpenAI GPT-5", - "provider": "openai", - "is_default": false - }, - { - "id": "gpt-5.1-codex-mini", - "name": "GPT-5.1 Codex Mini", - "description": "OpenAI GPT-5.1 Codex Mini", - "provider": "openai", - "is_default": false - }, - { - "id": "gpt-5-mini", - "name": "GPT-5 Mini", - "description": "OpenAI GPT-5 Mini", - "provider": "openai", - "is_default": false - }, - { - "id": "gemini-3-pro-preview", - "name": "Gemini 3 Pro Preview", - "description": "Google Gemini 3 Pro Preview", - "provider": "google", - "is_default": false - } - ] - } - }, - { - "id": "amp", - "name": "Sourcegraph Amp Agent", - "display_name": "Amp", - "description": "Sourcegraph Amp CLI-powered autonomous coding agent using stream-json protocol.", - "enabled": true, - "image": "", - "tag": "", - "cmd": [ - "npx", - "-y", - "@sourcegraph/amp@latest", - "--execute", - "--stream-json", - "--stream-json-input" - ], - "passthrough_config": { - "supported": true, - "label": "CLI Passthrough", - "description": "Show terminal directly instead of chat interface", - "passthrough_cmd": [ - "npx", - "-y", - "@sourcegraph/amp@latest" - ], - "model_flag": "-m {model}", - "idle_timeout_ms": 3000, - "buffer_max_bytes": 2097152, - "resume_flag": "" - }, - "working_dir": "{workspace}", - "required_env": [], - "env": {}, - "mounts": [], - "resource_limits": { - "memory_mb": 4096, - "cpu_cores": 2.0, - "timeout_seconds": 3600 - }, - "capabilities": [ - "code_generation", - "code_review", - "refactoring", - "testing", - "shell_execution" - ], - "protocol": "amp", - "protocol_config": {}, - "model_flag": "-m {model}", - "workspace_flag": "", - "session_config": { - "native_session_resume": false, - "resume_flag": "", - "can_recover": true, - "session_dir_template": "{home}/.config/amp", - "session_dir_target": "", - "fork_session_cmd": [ - "npx", - "-y", - "@sourcegraph/amp@latest", - "threads", - "fork" - ], - "continue_session_cmd": [ - "npx", - "-y", - "@sourcegraph/amp@latest", - "threads", - "continue", - "--execute", - "--stream-json", - "--stream-json-input" - ] - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": [ - "bash", - "write_file", - "edit_file" - ] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve (Dangerously Allow All)", - "description": "Automatically approve all tool calls including shell commands", - "apply_method": "cli_flag", - "cli_flag": "--dangerously-allow-all" - } - }, - "discovery": { - "supports_mcp": true, - "mcp_config_path": { - "linux": [ - "~/.config/amp/settings.json" - ], - "windows": [], - "macos": [ - "~/.amp/bin" - ] - }, - "installation_path": { - "linux": [ - "~/.amp/bin" - ], - "windows": [], - "macos": [ - "~/.amp/bin" - ] - }, - "discovery_capabilities": { - "supports_session_resume": true, - "supports_shell": false, - "supports_workspace_only": false - } - }, - "model_config": { - "default_model": "smart", - "available_models": [ - { - "id": "smart", - "name": "Smart Mode", - "description": "State-of-the-art models for maximum capability and autonomy", - "provider": "amp", - "is_default": true - }, - { - "id": "deep", - "name": "Deep Mode", - "description": "Deep reasoning with extended thinking on complex problems", - "provider": "amp", - "is_default": false - } - ] - } - }, - { - "id": "mock-agent", - "name": "Mock Agent", - "display_name": "Mock", - "description": "Mock agent for testing. Generates simulated responses with all message types.", - "enabled": false, - "image": "", - "tag": "", - "cmd": [ - "mock-agent" - ], - "working_dir": "{workspace}", - "required_env": [], - "env": {}, - "mounts": [], - "resource_limits": { - "memory_mb": 512, - "cpu_cores": 0.5, - "timeout_seconds": 3600 - }, - "capabilities": [ - "code_generation", - "code_review", - "refactoring", - "testing", - "shell_execution" - ], - "protocol": "claude-code", - "protocol_config": {}, - "model_flag": "--model {model}", - "workspace_flag": "", - "session_config": { - "native_session_resume": false, - "resume_flag": "", - "can_recover": false - }, - "permission_config": { - "permission_flag": "", - "tools_requiring_permission": [ - "Edit", - "Write", - "Bash", - "NotebookEdit" - ] - }, - "permission_settings": { - "auto_approve": { - "supported": true, - "default": true, - "label": "Auto-approve", - "description": "Automatically approve tool calls", - "apply_method": "stdio", - "cli_flag": "" - }, - "dangerously_skip_permissions": { - "supported": true, - "default": false, - "label": "Skip Permissions (YOLO)", - "description": "Bypass all permission checks", - "apply_method": "", - "cli_flag": "" - } - }, - "discovery": { - "supports_mcp": false, - "mcp_config_path": { - "linux": [], - "windows": [], - "macos": [] - }, - "installation_path": { - "linux": [], - "windows": [], - "macos": [] - }, - "discovery_capabilities": { - "supports_session_resume": false, - "supports_shell": false, - "supports_workspace_only": false - } - }, - "model_config": { - "default_model": "mock-default", - "available_models": [ - { - "id": "mock-default", - "name": "Mock Default", - "provider": "mock", - "context_window": 200000, - "is_default": true - }, - { - "id": "mock-slow", - "name": "Mock Slow", - "description": "Longer delays", - "provider": "mock", - "context_window": 200000, - "is_default": false - }, - { - "id": "mock-fast", - "name": "Mock Fast", - "description": "Minimal delays", - "provider": "mock", - "context_window": 200000, - "is_default": false - } - ] - } - } - ] -} diff --git a/apps/backend/internal/agent/registry/provider.go b/apps/backend/internal/agent/registry/provider.go index 8588df7049..78acded059 100644 --- a/apps/backend/internal/agent/registry/provider.go +++ b/apps/backend/internal/agent/registry/provider.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) @@ -14,13 +15,16 @@ func Provide(log *logger.Logger) (*Registry, func() error, error) { reg.LoadDefaults() if os.Getenv("KANDEV_MOCK_AGENT") == "true" { - if cfg, ok := reg.Get("mock-agent"); ok { - cfg.Enabled = true - // Resolve binary path: same directory as the running executable - if exePath, err := os.Executable(); err == nil { - cfg.Cmd = []string{filepath.Join(filepath.Dir(exePath), "mock-agent")} + if ag, ok := reg.Get("mock-agent"); ok { + if mock, ok := ag.(*agents.MockAgent); ok { + mock.SetEnabled(true) + // Resolve binary path: same directory as the running executable + if exePath, err := os.Executable(); err == nil { + binaryPath := filepath.Join(filepath.Dir(exePath), "mock-agent") + mock.SetBinaryPath(binaryPath) + log.Info("mock agent enabled", zap.String("cmd", binaryPath)) + } } - log.Info("mock agent enabled", zap.String("cmd", cfg.Cmd[0])) } } diff --git a/apps/backend/internal/agent/registry/registry.go b/apps/backend/internal/agent/registry/registry.go index 1dd4defb4f..9b4fc2899b 100644 --- a/apps/backend/internal/agent/registry/registry.go +++ b/apps/backend/internal/agent/registry/registry.go @@ -1,266 +1,31 @@ -// Package registry manages available agent types and their Docker image configurations. +// Package registry manages available agent types and their configurations. package registry import ( - "embed" - "encoding/json" "fmt" - "os" "sync" "time" "go.uber.org/zap" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/common/logger" - "github.com/kandev/kandev/pkg/agent" v1 "github.com/kandev/kandev/pkg/api/v1" ) -//go:embed agents.json -var agentsFS embed.FS +// Type aliases for backward compatibility during migration. +// These allow existing code to reference registry.PermissionSetting etc. +type PermissionSetting = agents.PermissionSetting +type MountTemplate = agents.MountTemplate +type ResourceLimits = agents.ResourceLimits +type SessionConfig = agents.SessionConfig +type PassthroughConfig = agents.PassthroughConfig +type DiscoveryCapabilities = agents.DiscoveryCapabilities +type Model = agents.Model -// 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"` - Name string `json:"name"` - Description string `json:"description"` - Image string `json:"image"` // Docker image - Tag string `json:"tag"` // Default tag - Cmd []string `json:"cmd,omitempty"` // Override container command - Entrypoint []string `json:"entrypoint,omitempty"` - WorkingDir string `json:"working_dir"` - Env map[string]string `json:"env,omitempty"` // Default env vars - RequiredEnv []string `json:"required_env"` // Required env vars (credentials) - Mounts []MountTemplate `json:"mounts,omitempty"` - ResourceLimits ResourceLimits `json:"resource_limits"` - Capabilities []string `json:"capabilities"` // What the agent can do - Enabled bool `json:"enabled"` - ModelFlag string `json:"model_flag,omitempty"` // CLI flag for model selection (e.g., "--model") - WorkspaceFlag string `json:"workspace_flag,omitempty"` // CLI flag for workspace path (e.g., "--workspace-root"), empty means use cwd only - - // Protocol configuration - Protocol agent.Protocol `json:"protocol,omitempty"` // Communication protocol: "acp", "rest", "mcp" - ProtocolConfig map[string]string `json:"protocol_config,omitempty"` // Protocol-specific settings (e.g., base_url for REST) - - // Session resumption configuration - SessionConfig SessionConfig `json:"session_config,omitempty"` // How to handle session resumption - - // Permission configuration - PermissionConfig PermissionConfig `json:"permission_config,omitempty"` // How to handle tool permissions - - // Permission settings metadata (what settings are supported for this agent) - PermissionSettings map[string]PermissionSetting `json:"permission_settings,omitempty"` // Supported permission settings - - // 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 - - // Passthrough configuration (for CLI passthrough mode) - PassthroughConfig PassthroughConfig `json:"passthrough_config,omitempty"` // CLI passthrough mode settings - - // Display name for UI (shorter than Name) - DisplayName string `json:"display_name,omitempty"` -} - -// SessionConfig defines how session resumption is handled for an agent type -type SessionConfig struct { - // NativeSessionResume indicates whether session resumption should use the adapter's - // native session loading mechanism (e.g., LoadSession method). This works across - // different protocols (ACP, Copilot SDK, Codex, OpenCode, etc.). - // If false, session resumption is handled via CLI flags (ResumeFlag) or context injection. - NativeSessionResume bool `json:"native_session_resume"` - - // ResumeFlag is the CLI flag used for session resumption when NativeSessionResume is false. - // Example: "--resume" for auggie, empty for agents that use native session loading. - ResumeFlag string `json:"resume_flag,omitempty"` - - // CanRecover indicates whether this agent supports session recovery after a backend restart. - // When false, sessions for this agent cannot be resumed after the backend restarts. - // On recovery, a new session will be started instead of trying to resume. - // Default is true (agent supports recovery). - CanRecover *bool `json:"can_recover,omitempty"` - - // SessionDirTemplate is a template for the host directory where session data is stored. - // Supports variables: {home} (user home directory). - // Example: "{home}/.augment/sessions" for auggie. - SessionDirTemplate string `json:"session_dir_template,omitempty"` - - // SessionDirTarget is the container path where the session directory is mounted. - // Example: "/root/.augment/sessions" for auggie. - SessionDirTarget string `json:"session_dir_target,omitempty"` -} - -// SupportsRecovery returns whether the agent supports session recovery after backend restart. -// Returns true by default if CanRecover is not explicitly set. -func (c SessionConfig) SupportsRecovery() bool { - if c.CanRecover == nil { - return true - } - return *c.CanRecover -} - -// PermissionConfig defines how tool permissions are requested for an agent type -type PermissionConfig struct { - // PermissionFlag is the CLI flag format for requesting tool permissions. - // Example: "--permission" for auggie (used as "--permission tool:ask-user"). - // If empty, the agent doesn't support CLI-based permission configuration. - PermissionFlag string `json:"permission_flag,omitempty"` - - // ToolsRequiringPermission lists tools that should require user permission. - // When AutoApprove is false, these tools will be configured to ask for permission. - // Example: ["launch-process", "save-file", "str-replace-editor", "remove-files"] - ToolsRequiringPermission []string `json:"tools_requiring_permission,omitempty"` -} - -// PermissionSetting defines metadata for a permission setting option -type PermissionSetting struct { - // Supported indicates whether this setting is available for the agent - Supported bool `json:"supported"` - - // Default is the default value for this setting when creating new profiles - Default bool `json:"default"` - - // Label is the display label for the UI - Label string `json:"label"` - - // Description explains what the setting does - Description string `json:"description"` - - // ApplyMethod defines how this setting is applied: "cli_flag", "acp", "config", or empty (none) - ApplyMethod string `json:"apply_method,omitempty"` - - // CLIFlag is the CLI flag to use when ApplyMethod is "cli_flag" - // Example: "--allow-indexing" for auggie - CLIFlag string `json:"cli_flag,omitempty"` - - // CLIFlagValue is an optional value template for the CLI flag - // If empty and the setting is true, just appends the flag - // If set, appends "flag value" (e.g., "--approval-policy never") - CLIFlagValue string `json:"cli_flag_value,omitempty"` -} - -// MountTemplate defines a mount with template support -type MountTemplate struct { - Source string `json:"source"` // Can use {workspace}, {task_id} - Target string `json:"target"` - ReadOnly bool `json:"read_only"` -} - -// ResourceLimits defines resource constraints -type ResourceLimits struct { - MemoryMB int64 `json:"memory_mb"` - CPUCores float64 `json:"cpu_cores"` - 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"` - DynamicModelsCmd []string `json:"dynamic_models_cmd,omitempty"` - DynamicModelsTimeoutMs int `json:"dynamic_models_timeout_ms,omitempty"` -} - -// 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"` - Source string `json:"source,omitempty"` // "static" or "dynamic" -} - -// 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"` -} - -// PassthroughConfig defines configuration for CLI passthrough mode -type PassthroughConfig struct { - // Supported indicates whether passthrough mode is available for this agent - Supported bool `json:"supported"` - - // Label is the display label for the UI toggle - Label string `json:"label"` - - // Description explains what passthrough mode does - Description string `json:"description"` - - // PassthroughCmd is the command to run in passthrough mode (without ACP/protocol flags) - PassthroughCmd []string `json:"passthrough_cmd,omitempty"` - - // ModelFlag is the CLI flag template for specifying the model, e.g. "--model {model}" - // The {model} placeholder will be replaced with the model ID from the profile - ModelFlag string `json:"model_flag,omitempty"` - - // PromptFlag is the CLI flag template for specifying the prompt, e.g. "--prompt {prompt}" - // The {prompt} placeholder will be replaced with the actual prompt. - // If not set, the prompt is appended directly at the end of the command. - PromptFlag string `json:"prompt_flag,omitempty"` - - // PromptPattern is the regex pattern to detect agent prompts (for turn detection) - PromptPattern string `json:"prompt_pattern,omitempty"` - - // IdleTimeoutMs is the idle timeout in milliseconds for turn detection - IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` - - // BufferMaxBytes is the max size of the output ring buffer (default 2MB) - BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` - - // StatusDetector specifies which detector to use for TUI state detection - // Valid values: "claude_code", "codex", "" (no detection) - StatusDetector string `json:"status_detector,omitempty"` - - // CheckIntervalMs is how often to check agent state (default 100ms) - CheckIntervalMs int `json:"check_interval_ms,omitempty"` - - // StabilityWindowMs is how long state must be stable before reporting (default 0) - // Codex uses 1000ms to prevent false exits from working state - StabilityWindowMs int `json:"stability_window_ms,omitempty"` - - // ResumeFlag is the CLI flag to continue the last conversation in the workspace. - // This is used when the backend restarts and user reconnects to an existing session. - // Examples: "-c" for auggie/claude-code/opencode, "--resume latest" for gemini - // If empty, the agent doesn't support resume and starts a fresh session. - ResumeFlag string `json:"resume_flag,omitempty"` - - // WaitForTerminal indicates that the agent requires a terminal to be connected - // before starting. Some agents (like Codex) query the terminal for cursor position - // on startup and will fail if no terminal is connected to respond. - // When true, the process waits for the terminal WebSocket to connect and send - // dimensions before starting. When false (default), starts immediately. - WaitForTerminal bool `json:"wait_for_terminal,omitempty"` -} - -// Registry manages agent type configurations +// Registry manages agent configurations type Registry struct { - agents map[string]*AgentTypeConfig + agents map[string]agents.Agent mu sync.RWMutex logger *logger.Logger } @@ -268,72 +33,115 @@ type Registry struct { // NewRegistry creates a new agent registry func NewRegistry(log *logger.Logger) *Registry { return &Registry{ - agents: make(map[string]*AgentTypeConfig), + agents: make(map[string]agents.Agent), logger: log, } } -// LoadFromFile loads agent configurations from a JSON file -func (r *Registry) LoadFromFile(path string) error { - data, err := os.ReadFile(path) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - var configs []*AgentTypeConfig - if err := json.Unmarshal(data, &configs); err != nil { - return fmt.Errorf("failed to parse config file: %w", err) - } - - r.mu.Lock() - defer r.mu.Unlock() - - for _, config := range configs { - if err := ValidateConfig(config); err != nil { - r.logger.Warn("skipping invalid agent config", - zap.String("id", config.ID), - zap.Error(err)) - continue - } - r.agents[config.ID] = config - r.logger.Debug("loaded agent type", zap.String("id", config.ID)) - } - - return nil -} - // LoadDefaults loads default agent configurations func (r *Registry) LoadDefaults() { - defaults := DefaultAgents() + all := []agents.Agent{ + agents.NewAuggie(), + agents.NewClaudeCode(), + agents.NewCodex(), + agents.NewCopilot(), + agents.NewGemini(), + agents.NewOpenCode(), + agents.NewAmp(), + agents.NewMockAgent(), + } r.mu.Lock() defer r.mu.Unlock() - for _, config := range defaults { - r.agents[config.ID] = config - r.logger.Debug("loaded default agent type", zap.String("id", config.ID)) + for _, ag := range all { + r.agents[ag.ID()] = ag + r.logger.Debug("loaded default agent type", zap.String("id", ag.ID())) } } -// Register adds a new agent type -func (r *Registry) Register(config *AgentTypeConfig) error { - if err := ValidateConfig(config); err != nil { - return err +// Get returns an agent by ID +func (r *Registry) Get(id string) (agents.Agent, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + + ag, exists := r.agents[id] + return ag, exists +} + +// GetDefault returns the default agent. +// It tries "auggie" first, then falls back to the first enabled agent. +func (r *Registry) GetDefault() (agents.Agent, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + if ag, exists := r.agents["auggie"]; exists && ag.Enabled() { + return ag, nil + } + + for _, ag := range r.agents { + if ag.Enabled() { + return ag, nil + } + } + + return nil, fmt.Errorf("no default agent type available") +} + +// List returns all registered agents +func (r *Registry) List() []agents.Agent { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]agents.Agent, 0, len(r.agents)) + for _, ag := range r.agents { + result = append(result, ag) + } + return result +} + +// ListEnabled returns only enabled agents +func (r *Registry) ListEnabled() []agents.Agent { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]agents.Agent, 0, len(r.agents)) + for _, ag := range r.agents { + if ag.Enabled() { + result = append(result, ag) + } + } + return result +} + +// Exists checks if an agent exists +func (r *Registry) Exists(id string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + + _, exists := r.agents[id] + return exists +} + +// Register adds a new agent +func (r *Registry) Register(ag agents.Agent) error { + if ag.ID() == "" { + return fmt.Errorf("agent type ID is required") } r.mu.Lock() defer r.mu.Unlock() - if _, exists := r.agents[config.ID]; exists { - return fmt.Errorf("agent type %q already registered", config.ID) + if _, exists := r.agents[ag.ID()]; exists { + return fmt.Errorf("agent type %q already registered", ag.ID()) } - r.agents[config.ID] = config - r.logger.Info("registered agent type", zap.String("id", config.ID)) + r.agents[ag.ID()] = ag + r.logger.Info("registered agent type", zap.String("id", ag.ID())) return nil } -// Unregister removes an agent type +// Unregister removes an agent func (r *Registry) Unregister(id string) error { r.mu.Lock() defer r.mu.Unlock() @@ -347,175 +155,23 @@ func (r *Registry) Unregister(id string) error { return nil } -// Get returns an agent type configuration -func (r *Registry) Get(id string) (*AgentTypeConfig, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - - config, exists := r.agents[id] - return config, exists -} - -// GetDefault returns the default agent type configuration. -// It tries "auggie" first, then falls back to the first enabled agent. -func (r *Registry) GetDefault() (*AgentTypeConfig, error) { - r.mu.RLock() - defer r.mu.RUnlock() - - // Try auggie first - if config, exists := r.agents["auggie"]; exists && config.Enabled { - return config, nil - } - - // Fall back to first enabled agent - for _, config := range r.agents { - if config.Enabled { - return config, nil - } - } - - return nil, fmt.Errorf("no default agent type available") -} - -// List returns all registered agent types -func (r *Registry) List() []*AgentTypeConfig { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*AgentTypeConfig, 0, len(r.agents)) - for _, config := range r.agents { - result = append(result, config) - } - return result -} - -// ListEnabled returns only enabled agent types -func (r *Registry) ListEnabled() []*AgentTypeConfig { - r.mu.RLock() - defer r.mu.RUnlock() - - result := make([]*AgentTypeConfig, 0, len(r.agents)) - for _, config := range r.agents { - if config.Enabled { - result = append(result, config) - } - } - return result -} - -// Exists checks if an agent type exists -func (r *Registry) Exists(id string) bool { - r.mu.RLock() - defer r.mu.RUnlock() - - _, exists := r.agents[id] - return exists -} - -// ToAPIType converts to API response type -func (c *AgentTypeConfig) ToAPIType() *v1.AgentType { +// ToAPIType converts an agent to an API response type +func ToAPIType(ag agents.Agent) *v1.AgentType { + rt := ag.Runtime() return &v1.AgentType{ - ID: c.ID, - Name: c.Name, - Description: c.Description, - DockerImage: c.Image, - DockerTag: c.Tag, + ID: ag.ID(), + Name: ag.Name(), + Description: ag.Description(), + DockerImage: rt.Image, + DockerTag: rt.Tag, DefaultResources: v1.ResourceLimits{ - CPULimit: fmt.Sprintf("%.1f", c.ResourceLimits.CPUCores), - MemoryLimit: fmt.Sprintf("%dM", c.ResourceLimits.MemoryMB), + CPULimit: fmt.Sprintf("%.1f", rt.ResourceLimits.CPUCores), + MemoryLimit: fmt.Sprintf("%dM", rt.ResourceLimits.MemoryMB), }, - EnvironmentVars: c.Env, - Capabilities: c.Capabilities, - Enabled: c.Enabled, - CreatedAt: time.Now(), // These would be set properly when persisted + EnvironmentVars: rt.Env, + Capabilities: rt.Capabilities, + Enabled: ag.Enabled(), + CreatedAt: time.Now(), UpdatedAt: time.Now(), } } - -// ValidateConfig validates an agent type configuration -func ValidateConfig(config *AgentTypeConfig) error { - if config.ID == "" { - return fmt.Errorf("agent type ID is required") - } - if config.Name == "" { - return fmt.Errorf("agent type name is required") - } - // Standalone agents (like Claude Code) don't need a Docker image - they use Cmd directly - // Docker-based agents need an image - if config.Image == "" && len(config.Cmd) == 0 { - return fmt.Errorf("agent type requires either image (Docker) or cmd (standalone)") - } - if config.Image != "" && config.Tag == "" { - config.Tag = "latest" // Default to latest if not specified - } - if config.ResourceLimits.MemoryMB <= 0 { - return fmt.Errorf("memory limit must be positive") - } - if config.ResourceLimits.CPUCores <= 0 { - return fmt.Errorf("CPU cores must be positive") - } - if config.ResourceLimits.TimeoutSeconds <= 0 { - return fmt.Errorf("timeout must be positive") - } - 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 - case "claude-code": - return agent.ProtocolClaudeCode - case "opencode": - return agent.ProtocolOpenCode - case "copilot": - return agent.ProtocolCopilot - case "amp": - return agent.ProtocolAmp - 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/registry/registry_test.go b/apps/backend/internal/agent/registry/registry_test.go index d3acaa1f33..de186a1f25 100644 --- a/apps/backend/internal/agent/registry/registry_test.go +++ b/apps/backend/internal/agent/registry/registry_test.go @@ -1,14 +1,46 @@ package registry import ( - "encoding/json" - "os" - "path/filepath" + "context" "testing" + "time" + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/common/logger" ) +// testAgent is a minimal implementation of agents.Agent for testing. +type testAgent struct { + id string + name string + description string + enabled bool + runtime *agents.RuntimeConfig +} + +func (a *testAgent) ID() string { return a.id } +func (a *testAgent) Name() string { return a.name } +func (a *testAgent) DisplayName() string { return a.name } +func (a *testAgent) Description() string { return a.description } +func (a *testAgent) Enabled() bool { return a.enabled } +func (a *testAgent) Logo(agents.LogoVariant) []byte { return nil } +func (a *testAgent) IsInstalled(context.Context) (*agents.DiscoveryResult, error) { + return nil, agents.ErrNotSupported +} +func (a *testAgent) DefaultModel() string { return "" } +func (a *testAgent) ListModels(context.Context) (*agents.ModelList, error) { + return nil, agents.ErrNotSupported +} +func (a *testAgent) CreateAdapter(*adapter.Config, *logger.Logger) (adapter.AgentAdapter, error) { + return nil, agents.ErrNotSupported +} +func (a *testAgent) BuildCommand(agents.CommandOptions) agents.Command { return agents.Command{} } +func (a *testAgent) PermissionSettings() map[string]agents.PermissionSetting { + return nil +} +func (a *testAgent) Runtime() *agents.RuntimeConfig { return a.runtime } + func newTestLogger() *logger.Logger { log, _ := logger.NewLogger(logger.LoggingConfig{ Level: "error", @@ -17,21 +49,23 @@ func newTestLogger() *logger.Logger { return log } -func validAgentConfig(id, name string) *AgentTypeConfig { - return &AgentTypeConfig{ - ID: id, - Name: name, - Description: "Test agent", - Image: "test/image", - Tag: "latest", - WorkingDir: "/workspace", - ResourceLimits: ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, +func validAgentConfig(id, name string) *testAgent { + return &testAgent{ + id: id, + name: name, + description: "Test agent", + enabled: true, + runtime: &agents.RuntimeConfig{ + Image: "test/image", + Tag: "latest", + WorkingDir: "/workspace", + ResourceLimits: agents.ResourceLimits{ + MemoryMB: 1024, + CPUCores: 1.0, + Timeout: time.Hour, + }, + Capabilities: []string{"test"}, }, - Capabilities: []string{"test"}, - Enabled: true, } } @@ -50,16 +84,16 @@ func TestRegistry_Register(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - config := validAgentConfig("test-agent", "Test Agent") + ag := validAgentConfig("test-agent", "Test Agent") // Test successful registration - err := reg.Register(config) + err := reg.Register(ag) if err != nil { t.Fatalf("unexpected error: %v", err) } // Test duplicate registration - err = reg.Register(config) + err = reg.Register(ag) if err == nil { t.Error("expected error for duplicate registration") } @@ -71,44 +105,19 @@ func TestRegistry_RegisterValidation(t *testing.T) { tests := []struct { name string - config *AgentTypeConfig + agent agents.Agent errMsg string }{ { name: "empty ID", - config: &AgentTypeConfig{Name: "test", Image: "img", ResourceLimits: ResourceLimits{MemoryMB: 1, CPUCores: 1, TimeoutSeconds: 1}}, + agent: &testAgent{id: "", name: "test"}, errMsg: "agent type ID is required", }, - { - name: "empty name", - config: &AgentTypeConfig{ID: "test", Image: "img", ResourceLimits: ResourceLimits{MemoryMB: 1, CPUCores: 1, TimeoutSeconds: 1}}, - errMsg: "agent type name is required", - }, - { - name: "empty image and cmd", - config: &AgentTypeConfig{ID: "test", Name: "test", ResourceLimits: ResourceLimits{MemoryMB: 1, CPUCores: 1, TimeoutSeconds: 1}}, - errMsg: "agent type requires either image (Docker) or cmd (standalone)", - }, - { - name: "zero memory", - config: &AgentTypeConfig{ID: "test", Name: "test", Image: "img", ResourceLimits: ResourceLimits{MemoryMB: 0, CPUCores: 1, TimeoutSeconds: 1}}, - errMsg: "memory limit must be positive", - }, - { - name: "zero CPU", - config: &AgentTypeConfig{ID: "test", Name: "test", Image: "img", ResourceLimits: ResourceLimits{MemoryMB: 1, CPUCores: 0, TimeoutSeconds: 1}}, - errMsg: "CPU cores must be positive", - }, - { - name: "zero timeout", - config: &AgentTypeConfig{ID: "test", Name: "test", Image: "img", ResourceLimits: ResourceLimits{MemoryMB: 1, CPUCores: 1, TimeoutSeconds: 0}}, - errMsg: "timeout must be positive", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := reg.Register(tt.config) + err := reg.Register(tt.agent) if err == nil { t.Error("expected error") } else if err.Error() != tt.errMsg { @@ -122,16 +131,16 @@ func TestRegistry_Get(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - config := validAgentConfig("test-agent", "Test Agent") - _ = reg.Register(config) + ag := validAgentConfig("test-agent", "Test Agent") + _ = reg.Register(ag) // Test successful get got, ok := reg.Get("test-agent") if !ok { t.Fatal("expected agent to be found") } - if got.ID != config.ID { - t.Errorf("expected ID %q, got %q", config.ID, got.ID) + if got.ID() != ag.ID() { + t.Errorf("expected ID %q, got %q", ag.ID(), got.ID()) } // Test not found @@ -145,8 +154,8 @@ func TestRegistry_Unregister(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - config := validAgentConfig("test-agent", "Test Agent") - _ = reg.Register(config) + ag := validAgentConfig("test-agent", "Test Agent") + _ = reg.Register(ag) // Test successful unregister err := reg.Unregister("test-agent") @@ -191,19 +200,19 @@ func TestRegistry_ListEnabled(t *testing.T) { reg := NewRegistry(log) enabled := validAgentConfig("enabled-agent", "Enabled Agent") - enabled.Enabled = true + enabled.enabled = true _ = reg.Register(enabled) disabled := validAgentConfig("disabled-agent", "Disabled Agent") - disabled.Enabled = false + disabled.enabled = false _ = reg.Register(disabled) enabledList := reg.ListEnabled() if len(enabledList) != 1 { t.Errorf("expected 1 enabled agent, got %d", len(enabledList)) } - if enabledList[0].ID != "enabled-agent" { - t.Errorf("expected enabled-agent, got %s", enabledList[0].ID) + if enabledList[0].ID() != "enabled-agent" { + t.Errorf("expected enabled-agent, got %s", enabledList[0].ID()) } } @@ -211,8 +220,8 @@ func TestRegistry_Exists(t *testing.T) { log := newTestLogger() reg := NewRegistry(log) - config := validAgentConfig("test-agent", "Test Agent") - _ = reg.Register(config) + ag := validAgentConfig("test-agent", "Test Agent") + _ = reg.Register(ag) if !reg.Exists("test-agent") { t.Error("expected agent type to exist") @@ -228,146 +237,42 @@ func TestRegistry_LoadDefaults(t *testing.T) { reg.LoadDefaults() - // Check that default agents are loaded - defaults := DefaultAgents() - if len(defaults) == 0 { + // Verify at least some default agents are loaded + list := reg.List() + if len(list) == 0 { t.Skip("no default agents configured") } - for _, def := range defaults { - if !reg.Exists(def.ID) { - t.Errorf("default agent %q not loaded", def.ID) - } - } -} - -func TestRegistry_LoadFromFile(t *testing.T) { - log := newTestLogger() - reg := NewRegistry(log) - - // Create temp file with valid config - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "agents.json") - - configs := []*AgentTypeConfig{ - validAgentConfig("file-agent-1", "File Agent 1"), - validAgentConfig("file-agent-2", "File Agent 2"), - } - - data, _ := json.Marshal(configs) - if err := os.WriteFile(configPath, data, 0644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - // Test loading from file - err := reg.LoadFromFile(configPath) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !reg.Exists("file-agent-1") || !reg.Exists("file-agent-2") { - t.Error("agents from file not loaded") - } -} - -func TestRegistry_LoadFromFile_NotFound(t *testing.T) { - log := newTestLogger() - reg := NewRegistry(log) - - err := reg.LoadFromFile("/non/existent/path.json") - if err == nil { - t.Error("expected error for non-existent file") - } -} - -func TestRegistry_LoadFromFile_InvalidJSON(t *testing.T) { - log := newTestLogger() - reg := NewRegistry(log) - - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "invalid.json") - - if err := os.WriteFile(configPath, []byte("invalid json"), 0644); err != nil { - t.Fatalf("failed to write test file: %v", err) - } - - err := reg.LoadFromFile(configPath) - if err == nil { - t.Error("expected error for invalid JSON") + // Check a known default agent exists + if !reg.Exists("auggie") { + t.Error("expected default agent 'auggie' to be loaded") } } func TestAgentTypeConfig_ToAPIType(t *testing.T) { - config := validAgentConfig("test-agent", "Test Agent") - config.Description = "Test description" - config.Capabilities = []string{"cap1", "cap2"} - config.Env = map[string]string{"KEY": "value"} + ag := validAgentConfig("test-agent", "Test Agent") + ag.description = "Test description" + ag.runtime.Capabilities = []string{"cap1", "cap2"} + ag.runtime.Env = map[string]string{"KEY": "value"} - apiType := config.ToAPIType() + apiType := ToAPIType(ag) - if apiType.ID != config.ID { - t.Errorf("expected ID %q, got %q", config.ID, apiType.ID) + if apiType.ID != ag.ID() { + t.Errorf("expected ID %q, got %q", ag.ID(), apiType.ID) } - if apiType.Name != config.Name { - t.Errorf("expected Name %q, got %q", config.Name, apiType.Name) + if apiType.Name != ag.Name() { + t.Errorf("expected Name %q, got %q", ag.Name(), apiType.Name) } - if apiType.Description != config.Description { - t.Errorf("expected Description %q, got %q", config.Description, apiType.Description) + if apiType.Description != ag.Description() { + t.Errorf("expected Description %q, got %q", ag.Description(), apiType.Description) } - if apiType.DockerImage != config.Image { - t.Errorf("expected DockerImage %q, got %q", config.Image, apiType.DockerImage) + if apiType.DockerImage != ag.runtime.Image { + t.Errorf("expected DockerImage %q, got %q", ag.runtime.Image, apiType.DockerImage) } - if len(apiType.Capabilities) != len(config.Capabilities) { - t.Errorf("expected %d capabilities, got %d", len(config.Capabilities), len(apiType.Capabilities)) + if len(apiType.Capabilities) != len(ag.runtime.Capabilities) { + t.Errorf("expected %d capabilities, got %d", len(ag.runtime.Capabilities), len(apiType.Capabilities)) } - if apiType.Enabled != config.Enabled { - t.Errorf("expected Enabled %v, got %v", config.Enabled, apiType.Enabled) + if apiType.Enabled != ag.Enabled() { + t.Errorf("expected Enabled %v, got %v", ag.Enabled(), apiType.Enabled) } } - -func TestValidateConfig(t *testing.T) { - // Valid config with default tag (Docker-based agent) - config := &AgentTypeConfig{ - ID: "test", - Name: "test", - Image: "test/image", - ResourceLimits: ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - } - - err := ValidateConfig(config) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Tag should be set to default - if config.Tag != "latest" { - t.Errorf("expected default tag 'latest', got %q", config.Tag) - } - - // Valid standalone agent with cmd but no image (like Claude Code) - standaloneConfig := &AgentTypeConfig{ - ID: "standalone-test", - Name: "Standalone Test", - Cmd: []string{"npx", "-y", "some-cli"}, - ResourceLimits: ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - } - - err = ValidateConfig(standaloneConfig) - if err != nil { - t.Fatalf("standalone agent should be valid: %v", err) - } - - // Tag should NOT be set when image is empty - if standaloneConfig.Tag != "" { - t.Errorf("expected no tag for standalone agent, got %q", standaloneConfig.Tag) - } -} - diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index 96cb367041..c526353ff9 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/mcpconfig" "github.com/kandev/kandev/internal/agent/registry" @@ -19,6 +20,20 @@ import ( "go.uber.org/zap" ) +// buildCommandString builds a display-friendly command string with proper quoting. +func buildCommandString(cmd []string) string { + var parts []string + for _, arg := range cmd { + if strings.ContainsAny(arg, " \t\n\"'`$\\") { + escaped := strings.ReplaceAll(arg, "\"", "\\\"") + parts = append(parts, "\""+escaped+"\"") + } else { + parts = append(parts, arg) + } + } + return strings.Join(parts, " ") +} + var ( ErrAgentNotFound = errors.New("agent not found") ErrAgentProfileNotFound = errors.New("agent profile not found") @@ -35,7 +50,7 @@ type Controller struct { agentRegistry *registry.Registry sessionChecker SessionChecker mcpService *mcpconfig.Service - modelFetcher *modelfetcher.Fetcher + modelCache *modelfetcher.Cache logger *logger.Logger } @@ -51,7 +66,7 @@ func NewController(repo store.Repository, discoveryRegistry *discovery.Registry, agentRegistry: agentRegistry, sessionChecker: sessionChecker, mcpService: mcpconfig.NewService(repo), - modelFetcher: modelfetcher.NewFetcher(agentRegistry, log), + modelCache: modelfetcher.NewCache(), logger: log.WithFields(zap.String("component", "agent-settings-controller")), } } @@ -145,81 +160,95 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl for _, result := range results { availabilityByName[result.Name] = result } - definitions := c.discovery.Definitions() + + enabled := c.agentRegistry.ListEnabled() now := time.Now().UTC() - payload := make([]dto.AvailableAgentDTO, 0, len(definitions)) - for _, def := range definitions { - availability, ok := availabilityByName[def.Name] + payload := make([]dto.AvailableAgentDTO, 0, len(enabled)) + for _, ag := range enabled { + availability, ok := availabilityByName[ag.ID()] if !ok { availability = discovery.Availability{ - Name: def.Name, - SupportsMCP: def.SupportsMCP, - MCPConfigPath: "", - Available: false, + Name: ag.ID(), + Available: false, } } - displayName := def.DisplayName + displayName := ag.DisplayName() if displayName == "" { - displayName = def.Name + displayName = ag.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, - }) + + // Get models from the agent + var modelEntries []dto.ModelEntryDTO + var supportsDynamic bool + modelList, err := ag.ListModels(ctx) + if err == nil && modelList != nil { + supportsDynamic = modelList.SupportsDynamic + modelEntries = make([]dto.ModelEntryDTO, 0, len(modelList.Models)) + for _, model := range modelList.Models { + modelEntries = append(modelEntries, dto.ModelEntryDTO{ + ID: model.ID, + Name: model.Name, + Provider: model.Provider, + ContextWindow: model.ContextWindow, + IsDefault: model.IsDefault, + }) + } + } + + // Get discovery result for capabilities + disc, discErr := ag.IsInstalled(ctx) + var capabilities dto.AgentCapabilitiesDTO + if discErr == nil && disc != nil { + capabilities = dto.AgentCapabilitiesDTO{ + SupportsSessionResume: disc.Capabilities.SupportsSessionResume, + SupportsShell: disc.Capabilities.SupportsShell, + SupportsWorkspaceOnly: disc.Capabilities.SupportsWorkspaceOnly, + } } // Convert permission settings var permissionSettings map[string]dto.PermissionSettingDTO - var passthroughConfig *dto.PassthroughConfigDTO - if agentConfig, ok := c.agentRegistry.Get(def.Name); ok { - if agentConfig.PermissionSettings != nil { - permissionSettings = make(map[string]dto.PermissionSettingDTO, len(agentConfig.PermissionSettings)) - for key, setting := range agentConfig.PermissionSettings { - permissionSettings[key] = dto.PermissionSettingDTO{ - Supported: setting.Supported, - Default: setting.Default, - Label: setting.Label, - Description: setting.Description, - ApplyMethod: setting.ApplyMethod, - CLIFlag: setting.CLIFlag, - CLIFlagValue: setting.CLIFlagValue, - } - } - } - // Convert passthrough config - if agentConfig.PassthroughConfig.Supported { - passthroughConfig = &dto.PassthroughConfigDTO{ - Supported: agentConfig.PassthroughConfig.Supported, - Label: agentConfig.PassthroughConfig.Label, - Description: agentConfig.PassthroughConfig.Description, + permSettings := ag.PermissionSettings() + if permSettings != nil { + permissionSettings = make(map[string]dto.PermissionSettingDTO, len(permSettings)) + for key, setting := range permSettings { + permissionSettings[key] = dto.PermissionSettingDTO{ + Supported: setting.Supported, + Default: setting.Default, + Label: setting.Label, + Description: setting.Description, + ApplyMethod: setting.ApplyMethod, + CLIFlag: setting.CLIFlag, + CLIFlagValue: setting.CLIFlagValue, } } } + // Convert passthrough config + var passthroughConfig *dto.PassthroughConfigDTO + if ptAgent, ok := ag.(agents.PassthroughAgent); ok { + pt := ptAgent.PassthroughConfig() + passthroughConfig = &dto.PassthroughConfigDTO{ + Supported: pt.Supported, + Label: pt.Label, + Description: pt.Description, + } + } + payload = append(payload, dto.AvailableAgentDTO{ - Name: availability.Name, + Name: ag.ID(), DisplayName: displayName, SupportsMCP: availability.SupportsMCP, MCPConfigPath: availability.MCPConfigPath, InstallationPaths: availability.InstallationPaths, Available: availability.Available, MatchedPath: availability.MatchedPath, - Capabilities: dto.AgentCapabilitiesDTO{ - SupportsSessionResume: def.Capabilities.SupportsSessionResume, - SupportsShell: def.Capabilities.SupportsShell, - SupportsWorkspaceOnly: def.Capabilities.SupportsWorkspaceOnly, - }, + Capabilities: capabilities, ModelConfig: dto.ModelConfigDTO{ - DefaultModel: def.ModelConfig.DefaultModel, + DefaultModel: ag.DefaultModel(), AvailableModels: modelEntries, - SupportsDynamicModels: len(def.ModelConfig.DynamicModelsCmd) > 0, + SupportsDynamicModels: supportsDynamic, }, PermissionSettings: permissionSettings, PassthroughConfig: passthroughConfig, @@ -250,18 +279,23 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { if err != nil { return err } - displayNameByAgent := c.displayNameByAgent() - defaultModelByAgent := c.defaultModelByAgent() for _, result := range results { if !result.Available { continue } - displayName, ok := displayNameByAgent[result.Name] - if !ok || displayName == "" { + agentConfig, ok := c.agentRegistry.Get(result.Name) + if !ok { + return fmt.Errorf("unknown agent: %s", result.Name) + } + displayName := agentConfig.DisplayName() + if displayName == "" { + displayName = agentConfig.Name() + } + if displayName == "" { return fmt.Errorf("unknown agent display name: %s", result.Name) } - defaultModel, ok := defaultModelByAgent[result.Name] - if !ok || defaultModel == "" { + defaultModel := agentConfig.DefaultModel() + if defaultModel == "" { return fmt.Errorf("unknown agent default model: %s", result.Name) } agent, err := c.repo.GetAgentByName(ctx, result.Name) @@ -300,16 +334,17 @@ func (c *Controller) EnsureInitialAgentProfiles(ctx context.Context) error { if len(profiles) > 0 { continue } - // Get agent config to read permission settings defaults + // Read permission settings defaults from agent config var autoApprove, allowIndexing, skipPermissions bool - if agentConfig, ok := c.agentRegistry.Get(agent.Name); ok && agentConfig.PermissionSettings != nil { - if setting, exists := agentConfig.PermissionSettings["auto_approve"]; exists { + permSettings := agentConfig.PermissionSettings() + if permSettings != nil { + if setting, exists := permSettings["auto_approve"]; exists { autoApprove = setting.Default } - if setting, exists := agentConfig.PermissionSettings["allow_indexing"]; exists { + if setting, exists := permSettings["allow_indexing"]; exists { allowIndexing = setting.Default } - if setting, exists := agentConfig.PermissionSettings["dangerously_skip_permissions"]; exists { + if setting, exists := permSettings["dangerously_skip_permissions"]; exists { skipPermissions = setting.Default } } @@ -435,7 +470,14 @@ func (c *Controller) CreateAgent(ctx context.Context, req CreateAgentRequest) (* if !matched.Available { return nil, fmt.Errorf("agent not installed: %s", req.Name) } - displayName := c.mustDisplayName(req.Name) + agentConfig, agOk := c.agentRegistry.Get(req.Name) + if !agOk { + return nil, fmt.Errorf("unknown agent: %s", req.Name) + } + displayName := agentConfig.DisplayName() + if displayName == "" { + displayName = agentConfig.Name() + } if displayName == "" { return nil, fmt.Errorf("unknown agent display name: %s", req.Name) } @@ -528,7 +570,14 @@ func (c *Controller) CreateProfile(ctx context.Context, req CreateProfileRequest if err != nil { return nil, err } - displayName := c.mustDisplayName(agent.Name) + agentConfig, agOk := c.agentRegistry.Get(agent.Name) + if !agOk { + return nil, fmt.Errorf("unknown agent: %s", agent.Name) + } + displayName := agentConfig.DisplayName() + if displayName == "" { + displayName = agentConfig.Name() + } if displayName == "" { return nil, fmt.Errorf("unknown agent display name: %s", agent.Name) } @@ -653,43 +702,6 @@ func toProfileDTO(profile *models.AgentProfile) dto.AgentProfileDTO { } } -func (c *Controller) displayNameByAgent() map[string]string { - definitions := c.discovery.Definitions() - mapped := make(map[string]string, len(definitions)) - for _, def := range definitions { - if def.Name == "" || def.DisplayName == "" { - continue - } - mapped[def.Name] = def.DisplayName - } - return mapped -} - -func (c *Controller) mustDisplayName(agentName string) string { - if agentName == "" { - return "" - } - definitions := c.discovery.Definitions() - for _, def := range definitions { - if def.Name == agentName { - return def.DisplayName - } - } - return "" -} - -func (c *Controller) defaultModelByAgent() map[string]string { - definitions := c.discovery.Definitions() - mapped := make(map[string]string, len(definitions)) - for _, def := range definitions { - if def.Name == "" || def.ModelConfig.DefaultModel == "" { - continue - } - mapped[def.Name] = def.ModelConfig.DefaultModel - } - return mapped -} - // detectAgents runs discovery and forces mock-agent available when enabled. func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability, error) { results, err := c.discovery.Detect(ctx) @@ -698,7 +710,7 @@ func (c *Controller) detectAgents(ctx context.Context) ([]discovery.Availability } // Force mock-agent as available when enabled (skip file-presence discovery) agentConfig, ok := c.agentRegistry.Get("mock-agent") - if ok && agentConfig.Enabled { + if ok && agentConfig.Enabled() { for i := range results { if results[i].Name == "mock-agent" { results[i].Available = true @@ -718,148 +730,101 @@ type CommandPreviewRequest struct { // PreviewAgentCommand generates a preview of the CLI command that will be executed func (c *Controller) PreviewAgentCommand(ctx context.Context, agentName string, req CommandPreviewRequest) (*dto.CommandPreviewResponse, error) { - // Get agent config from registry agentConfig, ok := c.agentRegistry.Get(agentName) if !ok { return nil, fmt.Errorf("agent type %q not found in registry", agentName) } - // Build the command based on whether passthrough is supported AND enabled - var cmd []string - if agentConfig.PassthroughConfig.Supported && req.CLIPassthrough { - cmd = c.buildPassthroughCommandPreview(agentConfig, req) + var cmd agents.Command + if ptAgent, ok := agentConfig.(agents.PassthroughAgent); ok && req.CLIPassthrough { + cmd = ptAgent.BuildPassthroughCommand(agents.PassthroughOptions{ + Model: req.Model, + Prompt: "{prompt}", + PermissionValues: req.PermissionSettings, + }) } else { - cmd = c.buildStandardCommandPreview(agentConfig, req) + cmd = agentConfig.BuildCommand(agents.CommandOptions{ + Model: req.Model, + PermissionValues: req.PermissionSettings, + }) } - // Build the command string with proper quoting for display - cmdString := c.buildCommandString(cmd) - return &dto.CommandPreviewResponse{ Supported: true, - Command: cmd, - CommandString: cmdString, + Command: cmd.Args(), + CommandString: buildCommandString(cmd.Args()), }, nil } -// buildStandardCommandPreview builds the standard command (non-passthrough) for preview purposes -func (c *Controller) buildStandardCommandPreview(agentConfig *registry.AgentTypeConfig, req CommandPreviewRequest) []string { - // Start with base command from config - cmd := make([]string, len(agentConfig.Cmd)) - copy(cmd, agentConfig.Cmd) - - // Apply model flag if configured and model is set - if req.Model != "" && agentConfig.ModelFlag != "" { - expanded := strings.ReplaceAll(agentConfig.ModelFlag, "{model}", req.Model) - parts := strings.SplitN(expanded, " ", 2) - cmd = append(cmd, parts...) - } - - // Apply permission settings that use CLI flags - cmd = c.applyPermissionFlags(cmd, agentConfig, req.PermissionSettings) - - return cmd -} - -// buildPassthroughCommandPreview builds the passthrough command for preview purposes -func (c *Controller) buildPassthroughCommandPreview(agentConfig *registry.AgentTypeConfig, req CommandPreviewRequest) []string { - // Start with passthrough_cmd - cmd := make([]string, len(agentConfig.PassthroughConfig.PassthroughCmd)) - copy(cmd, agentConfig.PassthroughConfig.PassthroughCmd) - - // Apply model flag if configured and model is set - if req.Model != "" && agentConfig.PassthroughConfig.ModelFlag != "" { - expanded := strings.ReplaceAll(agentConfig.PassthroughConfig.ModelFlag, "{model}", req.Model) - // Split on first space to separate flag from value (if combined) - parts := strings.SplitN(expanded, " ", 2) - cmd = append(cmd, parts...) - } - - // Apply permission settings that use CLI flags - cmd = c.applyPermissionFlags(cmd, agentConfig, req.PermissionSettings) - - // Add prompt - use PromptFlag if configured, otherwise append directly - cmd = c.appendPromptPlaceholder(cmd, agentConfig.PassthroughConfig.PromptFlag) - - return cmd -} - -// appendPromptPlaceholder adds the {prompt} placeholder to the command -// If promptFlag is set (e.g., "--prompt {prompt}"), it uses that format -// Otherwise, it appends {prompt} directly at the end -func (c *Controller) appendPromptPlaceholder(cmd []string, promptFlag string) []string { - if promptFlag != "" { - // Use the configured prompt flag format, e.g., "--prompt {prompt}" - parts := strings.SplitN(promptFlag, " ", 2) - return append(cmd, parts...) - } - // Default: append prompt directly at the end - return append(cmd, "{prompt}") -} - -// applyPermissionFlags applies CLI flags for permission settings that are enabled -func (c *Controller) applyPermissionFlags(cmd []string, agentConfig *registry.AgentTypeConfig, permissionValues map[string]bool) []string { - if agentConfig.PermissionSettings == nil || permissionValues == nil { - return cmd - } - - for settingName, setting := range agentConfig.PermissionSettings { - // Skip if not supported or not a CLI flag setting - if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { - continue - } - - // Get the value for this setting from the request - value, exists := permissionValues[settingName] - if !exists || !value { - continue - } - - // Apply the CLI flag - if setting.CLIFlagValue != "" { - // Flag with value: "--flag value" - cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) - } else { - // Boolean flag or multiple flags: "--flag" or "--flag1 --flag2 arg" - // Split on spaces to handle multiple flags in one setting - parts := strings.Fields(setting.CLIFlag) - cmd = append(cmd, parts...) - } - } - - return cmd -} - -// buildCommandString builds a display-friendly command string with proper quoting. -// Note: We intentionally don't use strconv.Quote here because it produces Go string -// syntax (e.g., "\t" becomes "\\t") which is not ideal for shell command display. -// The manual quoting approach produces more readable shell-style output. -func (c *Controller) buildCommandString(cmd []string) string { - var parts []string - for _, arg := range cmd { - // Quote arguments that contain spaces or special characters - if strings.ContainsAny(arg, " \t\n\"'`$\\") { - // Use double quotes and escape internal double quotes - escaped := strings.ReplaceAll(arg, "\"", "\\\"") - parts = append(parts, "\""+escaped+"\"") - } else { - parts = append(parts, arg) - } - } - return strings.Join(parts, " ") -} - // FetchDynamicModels fetches models for an agent, optionally refreshing the cache func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, refresh bool) (*dto.DynamicModelsResponse, error) { - result, err := c.modelFetcher.Fetch(ctx, agentName, refresh) - if err != nil { - return nil, err + ag, ok := c.agentRegistry.Get(agentName) + if !ok { + return nil, fmt.Errorf("agent %q not found", agentName) } - // Convert to DTOs - modelDTOs := make([]dto.ModelEntryDTO, 0, len(result.Models)) - for _, m := range result.Models { - modelDTOs = append(modelDTOs, dto.ModelEntryDTO{ + // Check cache unless refresh is requested + if !refresh { + if entry, exists := c.modelCache.Get(agentName); exists && entry.IsValid() { + cachedAt := entry.CachedAt + modelDTOs := modelsToDTO(entry.Models) + var errStr *string + if entry.Error != nil { + s := entry.Error.Error() + errStr = &s + } + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: modelDTOs, + Cached: true, + CachedAt: &cachedAt, + Error: errStr, + }, nil + } + } + + // Fetch models from the agent + modelList, err := ag.ListModels(ctx) + if err != nil { + c.logger.Warn("model fetch failed", + zap.String("agent", agentName), + zap.Error(err)) + c.modelCache.Set(agentName, nil, err) + s := err.Error() + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: nil, + Cached: false, + Error: &s, + }, nil + } + + models := modelList.Models + + // Cache the result if dynamic models are supported + if modelList.SupportsDynamic { + c.modelCache.Set(agentName, models, nil) + cachedAt := time.Now() + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: modelsToDTO(models), + Cached: true, + CachedAt: &cachedAt, + }, nil + } + + return &dto.DynamicModelsResponse{ + AgentName: agentName, + Models: modelsToDTO(models), + Cached: false, + }, nil +} + +// modelsToDTO converts agent models to DTOs. +func modelsToDTO(models []agents.Model) []dto.ModelEntryDTO { + dtos := make([]dto.ModelEntryDTO, 0, len(models)) + for _, m := range models { + dtos = append(dtos, dto.ModelEntryDTO{ ID: m.ID, Name: m.Name, Provider: m.Provider, @@ -868,19 +833,5 @@ func (c *Controller) FetchDynamicModels(ctx context.Context, agentName string, r Source: m.Source, }) } - - // Convert error to string pointer - var errStr *string - if result.Error != nil { - s := result.Error.Error() - errStr = &s - } - - return &dto.DynamicModelsResponse{ - AgentName: result.AgentName, - Models: modelDTOs, - Cached: result.Cached, - CachedAt: result.CachedAt, - Error: errStr, - }, nil + return dtos } diff --git a/apps/backend/internal/agent/settings/controller/controller_test.go b/apps/backend/internal/agent/settings/controller/controller_test.go index b6caadef15..61feeaf22a 100644 --- a/apps/backend/internal/agent/settings/controller/controller_test.go +++ b/apps/backend/internal/agent/settings/controller/controller_test.go @@ -2,55 +2,138 @@ package controller import ( "context" + "strings" "testing" + "github.com/kandev/kandev/internal/agent/agents" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/dto" + "github.com/kandev/kandev/internal/agent/settings/modelfetcher" + "github.com/kandev/kandev/internal/agentctl/server/adapter" "github.com/kandev/kandev/internal/common/logger" ) -func newTestController(agents map[string]*registry.AgentTypeConfig) *Controller { +// testAgent is a minimal implementation of agents.Agent for testing purposes. +// Embeds StandardPassthrough to optionally satisfy agents.PassthroughAgent. +type testAgent struct { + agents.StandardPassthrough + id string + name string + displayName string + description string + enabled bool + runtime *agents.RuntimeConfig + permissionSettings map[string]agents.PermissionSetting +} + +func (a *testAgent) ID() string { return a.id } +func (a *testAgent) Name() string { return a.name } +func (a *testAgent) DisplayName() string { return a.displayName } +func (a *testAgent) Description() string { return a.description } +func (a *testAgent) Enabled() bool { return a.enabled } + +func (a *testAgent) Logo(v agents.LogoVariant) []byte { return nil } + +func (a *testAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) { + return &agents.DiscoveryResult{Available: false}, nil +} + +func (a *testAgent) DefaultModel() string { return "" } + +func (a *testAgent) ListModels(ctx context.Context) (*agents.ModelList, error) { + return &agents.ModelList{}, nil +} + +func (a *testAgent) CreateAdapter(cfg *adapter.Config, log *logger.Logger) (adapter.AgentAdapter, error) { + return nil, agents.ErrNotSupported +} + +// BuildCommand builds a command using runtime config, model flag, and permission flags. +func (a *testAgent) BuildCommand(opts agents.CommandOptions) agents.Command { + rt := a.Runtime() + if rt == nil { + return agents.Command{} + } + cmd := make([]string, len(rt.Cmd.Args())) + copy(cmd, rt.Cmd.Args()) + + if opts.Model != "" && !rt.ModelFlag.IsEmpty() { + for _, arg := range rt.ModelFlag.Args() { + cmd = append(cmd, strings.ReplaceAll(arg, "{model}", opts.Model)) + } + } + + cmd = applyTestPermissionFlags(cmd, a.permissionSettings, opts.PermissionValues) + return agents.NewCommand(cmd...) +} + +func applyTestPermissionFlags(cmd []string, permSettings map[string]agents.PermissionSetting, values map[string]bool) []string { + if permSettings == nil || values == nil { + return cmd + } + for name, setting := range permSettings { + if !setting.Supported || setting.ApplyMethod != "cli_flag" || setting.CLIFlag == "" { + continue + } + v, ok := values[name] + if !ok || !v { + continue + } + if setting.CLIFlagValue != "" { + cmd = append(cmd, setting.CLIFlag, setting.CLIFlagValue) + } else { + parts := strings.Fields(setting.CLIFlag) + cmd = append(cmd, parts...) + } + } + return cmd +} + +func (a *testAgent) PermissionSettings() map[string]agents.PermissionSetting { + return a.permissionSettings +} + +func (a *testAgent) Runtime() *agents.RuntimeConfig { + return a.runtime +} + +func newTestController(agentList map[string]agents.Agent) *Controller { log, _ := logger.NewLogger(logger.LoggingConfig{ Level: "error", Format: "json", }) - // Create a real registry and register the agents reg := registry.NewRegistry(log) - for _, agent := range agents { - _ = reg.Register(agent) + for _, ag := range agentList { + _ = reg.Register(ag) } return &Controller{ agentRegistry: reg, + modelCache: modelfetcher.NewCache(), logger: log, } } func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "test-agent": { - ID: "test-agent", - Name: "test-agent", - Cmd: []string{"test-cli", "--verbose"}, - ModelFlag: "--model {model}", - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, + agentList := map[string]agents.Agent{ + "test-agent": &testAgent{ + id: "test-agent", + name: "test-agent", + enabled: true, + runtime: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-cli", "--verbose"), + ModelFlag: agents.NewParam("--model", "{model}"), }, - PermissionSettings: map[string]registry.PermissionSetting{ + permissionSettings: map[string]agents.PermissionSetting{ "auto_approve": { Supported: true, ApplyMethod: "cli_flag", CLIFlag: "--yes", }, }, - PassthroughConfig: registry.PassthroughConfig{ - Supported: false, - }, }, } - controller := newTestController(agents) + controller := newTestController(agentList) req := CommandPreviewRequest{ Model: "gpt-4", @@ -67,7 +150,6 @@ func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { t.Error("PreviewAgentCommand() Supported = false, want true") } - // Verify command contains expected parts expectedParts := []string{"test-cli", "--verbose", "--model", "gpt-4", "--yes"} for _, part := range expectedParts { found := false @@ -84,24 +166,31 @@ func TestController_PreviewAgentCommand_StandardCommand(t *testing.T) { } func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "claude-code": { - ID: "claude-code", - Name: "claude-code", - Cmd: []string{"claude"}, - ModelFlag: "--model {model}", - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, + agentList := map[string]agents.Agent{ + "claude-code": &testAgent{ + id: "claude-code", + name: "claude-code", + enabled: true, + runtime: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("claude"), + ModelFlag: agents.NewParam("--model", "{model}"), }, - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"npx", "-y", "@anthropic-ai/claude-code"}, - ModelFlag: "--model {model}", - PromptFlag: "--prompt {prompt}", + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("npx", "-y", "@anthropic-ai/claude-code"), + ModelFlag: agents.NewParam("--model", "{model}"), + PromptFlag: agents.NewParam("--prompt", "{prompt}"), + }, + PermSettings: map[string]agents.PermissionSetting{ + "dangerously_skip_permissions": { + Supported: true, + ApplyMethod: "cli_flag", + CLIFlag: "--dangerously-skip-permissions", + }, + }, }, - PermissionSettings: map[string]registry.PermissionSetting{ + permissionSettings: map[string]agents.PermissionSetting{ "dangerously_skip_permissions": { Supported: true, ApplyMethod: "cli_flag", @@ -111,7 +200,7 @@ func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) { }, } - controller := newTestController(agents) + controller := newTestController(agentList) req := CommandPreviewRequest{ Model: "claude-sonnet-4-20250514", @@ -167,7 +256,7 @@ func TestController_PreviewAgentCommand_PassthroughCommand(t *testing.T) { } func TestController_PreviewAgentCommand_AgentNotFound(t *testing.T) { - controller := newTestController(map[string]*registry.AgentTypeConfig{}) + controller := newTestController(map[string]agents.Agent{}) _, err := controller.PreviewAgentCommand(context.Background(), "nonexistent", CommandPreviewRequest{}) if err == nil { @@ -176,24 +265,24 @@ func TestController_PreviewAgentCommand_AgentNotFound(t *testing.T) { } func TestController_PreviewAgentCommand_PassthroughDisabled(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "test-agent": { - ID: "test-agent", - Name: "test-agent", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, + agentList := map[string]agents.Agent{ + "test-agent": &testAgent{ + id: "test-agent", + name: "test-agent", + enabled: true, + runtime: &agents.RuntimeConfig{ + Cmd: agents.NewCommand("test-cli"), }, - PassthroughConfig: registry.PassthroughConfig{ - Supported: true, - PassthroughCmd: []string{"passthrough-cli"}, + StandardPassthrough: agents.StandardPassthrough{ + Cfg: agents.PassthroughConfig{ + Supported: true, + PassthroughCmd: agents.NewCommand("passthrough-cli"), + }, }, }, } - controller := newTestController(agents) + controller := newTestController(agentList) // CLIPassthrough is false, so should use standard command req := CommandPreviewRequest{ @@ -210,9 +299,7 @@ func TestController_PreviewAgentCommand_PassthroughDisabled(t *testing.T) { } } -func TestController_BuildCommandString(t *testing.T) { - controller := newTestController(nil) - +func TestBuildCommandString(t *testing.T) { tests := []struct { name string cmd []string @@ -247,7 +334,7 @@ func TestController_BuildCommandString(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := controller.buildCommandString(tt.cmd) + result := buildCommandString(tt.cmd) if result != tt.expected { t.Errorf("buildCommandString(%v) = %q, want %q", tt.cmd, result, tt.expected) } @@ -255,136 +342,6 @@ func TestController_BuildCommandString(t *testing.T) { } } -func TestController_ApplyPermissionFlags(t *testing.T) { - controller := newTestController(nil) - - agentConfig := ®istry.AgentTypeConfig{ - PermissionSettings: map[string]registry.PermissionSetting{ - "auto_approve": { - Supported: true, - ApplyMethod: "cli_flag", - CLIFlag: "--yes", - }, - "skip_permissions": { - Supported: true, - ApplyMethod: "cli_flag", - CLIFlag: "--skip", - CLIFlagValue: "all", - }, - "unsupported": { - Supported: false, - CLIFlag: "--unsupported", - }, - "env_method": { - Supported: true, - ApplyMethod: "env_var", // Not cli_flag - CLIFlag: "--env", - }, - }, - } - - tests := []struct { - name string - initial []string - permissions map[string]bool - expected []string - }{ - { - name: "boolean flag enabled", - initial: []string{"cmd"}, - permissions: map[string]bool{"auto_approve": true}, - expected: []string{"cmd", "--yes"}, - }, - { - name: "flag with value", - initial: []string{"cmd"}, - permissions: map[string]bool{"skip_permissions": true}, - expected: []string{"cmd", "--skip", "all"}, - }, - { - name: "flag disabled", - initial: []string{"cmd"}, - permissions: map[string]bool{"auto_approve": false}, - expected: []string{"cmd"}, - }, - { - name: "unsupported flag ignored", - initial: []string{"cmd"}, - permissions: map[string]bool{"unsupported": true}, - expected: []string{"cmd"}, - }, - { - name: "non-cli method ignored", - initial: []string{"cmd"}, - permissions: map[string]bool{"env_method": true}, - expected: []string{"cmd"}, - }, - { - name: "multiple flags", - initial: []string{"cmd"}, - permissions: map[string]bool{"auto_approve": true, "skip_permissions": true}, - expected: []string{"cmd", "--yes", "--skip", "all"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := controller.applyPermissionFlags(tt.initial, agentConfig, tt.permissions) - - // Check that all expected parts are present (order may vary for maps) - if len(result) != len(tt.expected) { - t.Errorf("applyPermissionFlags() length = %d, want %d", len(result), len(tt.expected)) - t.Errorf("got: %v, want: %v", result, tt.expected) - } - }) - } -} - -func TestController_AppendPromptPlaceholder(t *testing.T) { - controller := newTestController(nil) - - tests := []struct { - name string - cmd []string - promptFlag string - expected []string - }{ - { - name: "with prompt flag", - cmd: []string{"cli"}, - promptFlag: "--prompt {prompt}", - expected: []string{"cli", "--prompt", "{prompt}"}, - }, - { - name: "without prompt flag", - cmd: []string{"cli"}, - promptFlag: "", - expected: []string{"cli", "{prompt}"}, - }, - { - name: "single flag format", - cmd: []string{"cli"}, - promptFlag: "-p {prompt}", - expected: []string{"cli", "-p", "{prompt}"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := controller.appendPromptPlaceholder(tt.cmd, tt.promptFlag) - if len(result) != len(tt.expected) { - t.Errorf("appendPromptPlaceholder() = %v, want %v", result, tt.expected) - return - } - for i := range result { - if result[i] != tt.expected[i] { - t.Errorf("appendPromptPlaceholder()[%d] = %q, want %q", i, result[i], tt.expected[i]) - } - } - }) - } -} - func TestCommandPreviewResponse_DTO(t *testing.T) { resp := dto.CommandPreviewResponse{ Supported: true, diff --git a/apps/backend/internal/agent/settings/modelfetcher/cache.go b/apps/backend/internal/agent/settings/modelfetcher/cache.go index e73a038942..7ff2992982 100644 --- a/apps/backend/internal/agent/settings/modelfetcher/cache.go +++ b/apps/backend/internal/agent/settings/modelfetcher/cache.go @@ -4,7 +4,7 @@ import ( "sync" "time" - "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/agents" ) const ( @@ -14,7 +14,7 @@ const ( // CacheEntry holds cached model data for an agent type CacheEntry struct { - Models []registry.ModelEntry + Models []agents.Model CachedAt time.Time ExpiresAt time.Time Error error @@ -60,7 +60,7 @@ func (c *Cache) Get(agentName string) (*CacheEntry, bool) { } // Set caches the models for an agent -func (c *Cache) Set(agentName string, models []registry.ModelEntry, err error) { +func (c *Cache) Set(agentName string, models []agents.Model, err error) { c.mu.Lock() defer c.mu.Unlock() diff --git a/apps/backend/internal/agent/settings/modelfetcher/cache_test.go b/apps/backend/internal/agent/settings/modelfetcher/cache_test.go index 74d67ae171..be61043801 100644 --- a/apps/backend/internal/agent/settings/modelfetcher/cache_test.go +++ b/apps/backend/internal/agent/settings/modelfetcher/cache_test.go @@ -4,13 +4,13 @@ import ( "testing" "time" - "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/agents" ) func TestCache_SetAndGet(t *testing.T) { cache := NewCache() - models := []registry.ModelEntry{ + models := []agents.Model{ {ID: "anthropic/claude-sonnet", Name: "Claude Sonnet", Provider: "anthropic"}, {ID: "openai/gpt-4", Name: "GPT-4", Provider: "openai"}, } @@ -67,7 +67,7 @@ func TestCache_SetWithError(t *testing.T) { func TestCache_Invalidate(t *testing.T) { cache := NewCache() - models := []registry.ModelEntry{ + models := []agents.Model{ {ID: "test/model", Name: "Test Model", Provider: "test"}, } @@ -92,7 +92,7 @@ func TestCache_Invalidate(t *testing.T) { func TestCache_Clear(t *testing.T) { cache := NewCache() - models := []registry.ModelEntry{ + models := []agents.Model{ {ID: "test/model", Name: "Test Model", Provider: "test"}, } @@ -118,7 +118,7 @@ func TestCacheEntry_IsValid(t *testing.T) { { name: "valid entry", entry: CacheEntry{ - Models: []registry.ModelEntry{{ID: "test"}}, + Models: []agents.Model{{ID: "test"}}, CachedAt: time.Now(), ExpiresAt: time.Now().Add(5 * time.Second), Error: nil, @@ -128,7 +128,7 @@ func TestCacheEntry_IsValid(t *testing.T) { { name: "expired entry", entry: CacheEntry{ - Models: []registry.ModelEntry{{ID: "test"}}, + Models: []agents.Model{{ID: "test"}}, CachedAt: time.Now().Add(-1 * time.Minute), ExpiresAt: time.Now().Add(-30 * time.Second), Error: nil, diff --git a/apps/backend/internal/agent/settings/modelfetcher/fetcher.go b/apps/backend/internal/agent/settings/modelfetcher/fetcher.go deleted file mode 100644 index 23e937dc26..0000000000 --- a/apps/backend/internal/agent/settings/modelfetcher/fetcher.go +++ /dev/null @@ -1,187 +0,0 @@ -package modelfetcher - -import ( - "bytes" - "context" - "fmt" - "os/exec" - "time" - - "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/common/logger" - "go.uber.org/zap" -) - -const ( - defaultTimeout = 30 * time.Second - maxOutputSize = 1024 * 1024 // 1MB max output -) - -// Fetcher handles dynamic model fetching for agents -type Fetcher struct { - agentRegistry *registry.Registry - cache *Cache - logger *logger.Logger -} - -// NewFetcher creates a new model fetcher -func NewFetcher(agentRegistry *registry.Registry, log *logger.Logger) *Fetcher { - return &Fetcher{ - agentRegistry: agentRegistry, - cache: NewCache(), - logger: log.WithFields(zap.String("component", "model-fetcher")), - } -} - -// FetchResult contains the result of fetching models -type FetchResult struct { - AgentName string - Models []registry.ModelEntry - Cached bool - CachedAt *time.Time - Error error -} - -// Fetch fetches models for an agent, using cache if available -func (f *Fetcher) Fetch(ctx context.Context, agentName string, refresh bool) (*FetchResult, error) { - // Get agent config - agentConfig, ok := f.agentRegistry.Get(agentName) - if !ok { - return nil, fmt.Errorf("agent %q not found", agentName) - } - - // Check if agent supports dynamic models - if len(agentConfig.ModelConfig.DynamicModelsCmd) == 0 { - // Return static models - return &FetchResult{ - AgentName: agentName, - Models: f.markModelsAsStatic(agentConfig.ModelConfig.AvailableModels), - Cached: false, - Error: nil, - }, nil - } - - // Check cache unless refresh is requested - if !refresh { - if entry, exists := f.cache.Get(agentName); exists && entry.IsValid() { - cachedAt := entry.CachedAt - return &FetchResult{ - AgentName: agentName, - Models: entry.Models, - Cached: true, - CachedAt: &cachedAt, - Error: entry.Error, - }, nil - } - } - - // Fetch dynamic models - models, err := f.executeDynamicFetch(ctx, agentConfig) - if err != nil { - f.logger.Warn("dynamic model fetch failed, using static fallback", - zap.String("agent", agentName), - zap.Error(err)) - - // Cache the error briefly to avoid repeated failures - f.cache.Set(agentName, nil, err) - - // Return static models as fallback - return &FetchResult{ - AgentName: agentName, - Models: f.markModelsAsStatic(agentConfig.ModelConfig.AvailableModels), - Cached: false, - Error: err, - }, nil - } - - // Cache the result - f.cache.Set(agentName, models, nil) - cachedAt := time.Now() - - return &FetchResult{ - AgentName: agentName, - Models: models, - Cached: true, - CachedAt: &cachedAt, - Error: nil, - }, nil -} - -// executeDynamicFetch runs the dynamic models command and parses output -func (f *Fetcher) executeDynamicFetch(ctx context.Context, agentConfig *registry.AgentTypeConfig) ([]registry.ModelEntry, error) { - if len(agentConfig.ModelConfig.DynamicModelsCmd) == 0 { - return nil, fmt.Errorf("no dynamic models command configured") - } - - // Determine timeout - timeout := defaultTimeout - if agentConfig.ModelConfig.DynamicModelsTimeoutMs > 0 { - timeout = time.Duration(agentConfig.ModelConfig.DynamicModelsTimeoutMs) * time.Millisecond - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - // Build command - use exec.Command directly (no shell) - cmd := agentConfig.ModelConfig.DynamicModelsCmd - execCmd := exec.CommandContext(ctx, cmd[0], cmd[1:]...) - - // Capture output - var stdout, stderr bytes.Buffer - execCmd.Stdout = &stdout - execCmd.Stderr = &stderr - - // Run command - err := execCmd.Run() - if err != nil { - if ctx.Err() == context.DeadlineExceeded { - return nil, fmt.Errorf("command timed out after %v", timeout) - } - return nil, fmt.Errorf("command failed: %w (stderr: %s)", err, stderr.String()) - } - - // Check output size - if stdout.Len() > maxOutputSize { - return nil, fmt.Errorf("output exceeds maximum size of %d bytes", maxOutputSize) - } - - // Get the appropriate parser for this agent - parser := GetParser(agentConfig.ID) - - // Parse output using the agent-specific parser - models, err := parser.Parse(stdout.String(), agentConfig.ModelConfig.DefaultModel) - if err != nil { - return nil, fmt.Errorf("failed to parse model output: %w", err) - } - - if len(models) == 0 { - return nil, fmt.Errorf("no models found in output") - } - - return models, nil -} - -// markModelsAsStatic sets the Source field to "static" for all models -func (f *Fetcher) markModelsAsStatic(models []registry.ModelEntry) []registry.ModelEntry { - result := make([]registry.ModelEntry, len(models)) - for i, m := range models { - m.Source = "static" - result[i] = m - } - return result -} - -// SupportsynamicModels returns true if the agent supports dynamic model fetching -func (f *Fetcher) SupportsDynamicModels(agentName string) bool { - agentConfig, ok := f.agentRegistry.Get(agentName) - if !ok { - return false - } - return len(agentConfig.ModelConfig.DynamicModelsCmd) > 0 -} - -// InvalidateCache invalidates the cache for an agent -func (f *Fetcher) InvalidateCache(agentName string) { - f.cache.Invalidate(agentName) -} diff --git a/apps/backend/internal/agent/settings/modelfetcher/fetcher_test.go b/apps/backend/internal/agent/settings/modelfetcher/fetcher_test.go deleted file mode 100644 index 457857463e..0000000000 --- a/apps/backend/internal/agent/settings/modelfetcher/fetcher_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package modelfetcher - -import ( - "context" - "testing" - - "github.com/kandev/kandev/internal/agent/registry" - "github.com/kandev/kandev/internal/common/logger" -) - -func newTestFetcher(t *testing.T, agents map[string]*registry.AgentTypeConfig) *Fetcher { - t.Helper() - - log, err := logger.NewLogger(logger.LoggingConfig{ - Level: "error", - Format: "json", - }) - if err != nil { - t.Fatalf("failed to create logger: %v", err) - } - - reg := registry.NewRegistry(log) - for _, agent := range agents { - if err := reg.Register(agent); err != nil { - t.Fatalf("failed to register agent: %v", err) - } - } - - return NewFetcher(reg, log) -} - -func TestFetcher_SupportsDynamicModels(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "with-dynamic": { - ID: "with-dynamic", - Name: "With Dynamic", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - ModelConfig: registry.ModelConfig{ - DefaultModel: "test/model", - DynamicModelsCmd: []string{"test", "models"}, - }, - }, - "without-dynamic": { - ID: "without-dynamic", - Name: "Without Dynamic", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - ModelConfig: registry.ModelConfig{ - DefaultModel: "test/model", - }, - }, - } - - fetcher := newTestFetcher(t, agents) - - tests := []struct { - name string - agentName string - want bool - }{ - { - name: "agent with dynamic models cmd", - agentName: "with-dynamic", - want: true, - }, - { - name: "agent without dynamic models cmd", - agentName: "without-dynamic", - want: false, - }, - { - name: "non-existent agent", - agentName: "non-existent", - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fetcher.SupportsDynamicModels(tt.agentName) - if got != tt.want { - t.Errorf("SupportsDynamicModels(%q) = %v, want %v", tt.agentName, got, tt.want) - } - }) - } -} - -func TestFetcher_MarkModelsAsStatic(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "test-agent": { - ID: "test-agent", - Name: "Test Agent", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - }, - } - - fetcher := newTestFetcher(t, agents) - - models := []registry.ModelEntry{ - {ID: "model1", Name: "Model 1", Provider: "test"}, - {ID: "model2", Name: "Model 2", Provider: "test", Source: "dynamic"}, - {ID: "model3", Name: "Model 3", Provider: "test", Source: ""}, - } - - result := fetcher.markModelsAsStatic(models) - - if len(result) != len(models) { - t.Fatalf("markModelsAsStatic() returned %d models, want %d", len(result), len(models)) - } - - for i, m := range result { - if m.Source != "static" { - t.Errorf("model %d Source = %q, want 'static'", i, m.Source) - } - } - - // Verify original slice is not modified - if models[1].Source != "dynamic" { - t.Error("original slice should not be modified") - } -} - -func TestFetcher_Fetch_AgentNotFound(t *testing.T) { - fetcher := newTestFetcher(t, map[string]*registry.AgentTypeConfig{}) - - _, err := fetcher.Fetch(context.Background(), "non-existent", false) - if err == nil { - t.Error("Fetch() should return error for non-existent agent") - } -} - -func TestFetcher_Fetch_StaticModels(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "static-agent": { - ID: "static-agent", - Name: "Static Agent", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - ModelConfig: registry.ModelConfig{ - DefaultModel: "test/default", - AvailableModels: []registry.ModelEntry{ - {ID: "test/model1", Name: "Model 1", Provider: "test"}, - {ID: "test/model2", Name: "Model 2", Provider: "test"}, - }, - // No DynamicModelsCmd - uses static models - }, - }, - } - - fetcher := newTestFetcher(t, agents) - - result, err := fetcher.Fetch(context.Background(), "static-agent", false) - if err != nil { - t.Fatalf("Fetch() error = %v", err) - } - - if result.Cached { - t.Error("Fetch() Cached = true for static models, want false") - } - - if len(result.Models) != 2 { - t.Errorf("Fetch() returned %d models, want 2", len(result.Models)) - } - - // Verify all models are marked as static - for i, m := range result.Models { - if m.Source != "static" { - t.Errorf("model %d Source = %q, want 'static'", i, m.Source) - } - } -} - -func TestFetcher_InvalidateCache(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "test-agent": { - ID: "test-agent", - Name: "Test Agent", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - }, - } - - fetcher := newTestFetcher(t, agents) - - // Manually set cache - fetcher.cache.Set("test-agent", []registry.ModelEntry{{ID: "test"}}, nil) - - // Verify cache exists - if _, exists := fetcher.cache.Get("test-agent"); !exists { - t.Fatal("cache entry should exist before invalidation") - } - - // Invalidate - fetcher.InvalidateCache("test-agent") - - // Verify cache is cleared - if _, exists := fetcher.cache.Get("test-agent"); exists { - t.Error("cache entry should not exist after invalidation") - } -} - -func TestFetcher_Fetch_UsesCache(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "cached-agent": { - ID: "cached-agent", - Name: "Cached Agent", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - ModelConfig: registry.ModelConfig{ - DefaultModel: "test/default", - DynamicModelsCmd: []string{"echo", "test/model"}, // Will fail or return something - }, - }, - } - - fetcher := newTestFetcher(t, agents) - - // Pre-populate cache - cachedModels := []registry.ModelEntry{ - {ID: "cached/model", Name: "Cached Model", Provider: "cached", Source: "dynamic"}, - } - fetcher.cache.Set("cached-agent", cachedModels, nil) - - // Fetch without refresh - should use cache - result, err := fetcher.Fetch(context.Background(), "cached-agent", false) - if err != nil { - t.Fatalf("Fetch() error = %v", err) - } - - if !result.Cached { - t.Error("Fetch() Cached = false, want true (should use cache)") - } - - if len(result.Models) != 1 || result.Models[0].ID != "cached/model" { - t.Error("Fetch() should return cached models") - } -} - -func TestFetcher_Fetch_RefreshBypassesCache(t *testing.T) { - agents := map[string]*registry.AgentTypeConfig{ - "refresh-agent": { - ID: "refresh-agent", - Name: "Refresh Agent", - Cmd: []string{"test-cli"}, - ResourceLimits: registry.ResourceLimits{ - MemoryMB: 1024, - CPUCores: 1.0, - TimeoutSeconds: 3600, - }, - ModelConfig: registry.ModelConfig{ - DefaultModel: "test/default", - DynamicModelsCmd: []string{"echo", "fresh/model"}, // Returns fresh model - AvailableModels: []registry.ModelEntry{ - {ID: "static/fallback", Name: "Fallback", Provider: "static"}, - }, - }, - }, - } - - fetcher := newTestFetcher(t, agents) - - // Pre-populate cache with different data - cachedModels := []registry.ModelEntry{ - {ID: "cached/model", Name: "Cached Model", Provider: "cached", Source: "dynamic"}, - } - fetcher.cache.Set("refresh-agent", cachedModels, nil) - - // Fetch with refresh - should bypass cache and execute command - result, err := fetcher.Fetch(context.Background(), "refresh-agent", true) - if err != nil { - t.Fatalf("Fetch() error = %v", err) - } - - // Should have executed the command and got "fresh/model" - if len(result.Models) == 0 { - t.Fatal("Fetch() returned no models") - } - - // The echo command returns "fresh/model", so that's what we should get - if result.Models[0].ID != "fresh/model" { - t.Errorf("Fetch() first model ID = %q, want 'fresh/model'", result.Models[0].ID) - } -} diff --git a/apps/backend/internal/agent/settings/modelfetcher/parser.go b/apps/backend/internal/agent/settings/modelfetcher/parser.go deleted file mode 100644 index e8669db634..0000000000 --- a/apps/backend/internal/agent/settings/modelfetcher/parser.go +++ /dev/null @@ -1,83 +0,0 @@ -package modelfetcher - -import ( - "strings" - - "github.com/kandev/kandev/internal/agent/registry" -) - -// ModelParser defines the interface for parsing model output from different agents -type ModelParser interface { - // Parse parses the raw output from a model listing command into ModelEntry structs - Parse(output string, defaultModel string) ([]registry.ModelEntry, error) -} - -// DefaultParser implements ModelParser with a default parsing strategy -// Expected format: one model ID per line, optionally in "provider/model" format -type DefaultParser struct{} - -func (p *DefaultParser) Parse(output string, defaultModel string) ([]registry.ModelEntry, error) { - lines := strings.Split(strings.TrimSpace(output), "\n") - models := make([]registry.ModelEntry, 0, len(lines)) - - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - - entry := p.parseLine(line, defaultModel) - models = append(models, entry) - } - - return models, nil -} - -func (p *DefaultParser) parseLine(line string, defaultModel string) registry.ModelEntry { - var provider, modelID, name string - - // Check for provider/model format - split on the LAST "/" to get provider and model name - // Examples: - // "opencode/big-pickle" -> provider: "opencode", name: "big-pickle" - // "github-copilot/gemini-3-flash" -> provider: "github-copilot", name: "gemini-3-flash" - // "openrouter/qwen/qwen3-30b" -> provider: "openrouter/qwen", name: "qwen3-30b" - if idx := strings.LastIndex(line, "/"); idx > 0 { - provider = line[:idx] - modelID = line - name = line[idx+1:] // Use the model part after last "/" as name - } else { - // No provider prefix - use the whole line - provider = "" - modelID = line - name = line - } - - return registry.ModelEntry{ - ID: modelID, - Name: name, - Provider: provider, - IsDefault: modelID == defaultModel, - Source: "dynamic", - } -} - -// OpenCodeParser implements ModelParser for OpenCode agent -// OpenCode outputs models in "provider/model-name" format, one per line -type OpenCodeParser struct { - DefaultParser -} - -// NewOpenCodeParser creates a new OpenCode model parser -func NewOpenCodeParser() *OpenCodeParser { - return &OpenCodeParser{} -} - -// GetParser returns the appropriate parser for an agent -func GetParser(agentID string) ModelParser { - switch agentID { - case "opencode": - return NewOpenCodeParser() - default: - return &DefaultParser{} - } -} diff --git a/apps/backend/internal/agent/settings/modelfetcher/parser_test.go b/apps/backend/internal/agent/settings/modelfetcher/parser_test.go deleted file mode 100644 index bdd8131557..0000000000 --- a/apps/backend/internal/agent/settings/modelfetcher/parser_test.go +++ /dev/null @@ -1,284 +0,0 @@ -package modelfetcher - -import ( - "testing" -) - -func TestDefaultParser_Parse(t *testing.T) { - parser := &DefaultParser{} - - tests := []struct { - name string - output string - defaultModel string - wantCount int - wantFirst string - wantErr bool - }{ - { - name: "single model with provider", - output: `anthropic/claude-sonnet-4-20250514`, - defaultModel: "", - wantCount: 1, - wantFirst: "anthropic/claude-sonnet-4-20250514", - wantErr: false, - }, - { - name: "multiple models with providers", - output: `anthropic/claude-sonnet-4-20250514 -openai/gpt-4o -google/gemini-2.5-pro`, - defaultModel: "", - wantCount: 3, - wantFirst: "anthropic/claude-sonnet-4-20250514", - wantErr: false, - }, - { - name: "model without provider", - output: `gpt-4o`, - defaultModel: "", - wantCount: 1, - wantFirst: "gpt-4o", - wantErr: false, - }, - { - name: "empty lines ignored", - output: `anthropic/claude-sonnet - -openai/gpt-4o - -`, - defaultModel: "", - wantCount: 2, - wantFirst: "anthropic/claude-sonnet", - wantErr: false, - }, - { - name: "empty output", - output: "", - defaultModel: "", - wantCount: 0, - wantErr: false, - }, - { - name: "whitespace only", - output: " \n\t\n ", - defaultModel: "", - wantCount: 0, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - models, err := parser.Parse(tt.output, tt.defaultModel) - - if (err != nil) != tt.wantErr { - t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if len(models) != tt.wantCount { - t.Errorf("Parse() returned %d models, want %d", len(models), tt.wantCount) - return - } - - if tt.wantCount > 0 && models[0].ID != tt.wantFirst { - t.Errorf("Parse() first model ID = %q, want %q", models[0].ID, tt.wantFirst) - } - }) - } -} - -func TestDefaultParser_ParseLine(t *testing.T) { - parser := &DefaultParser{} - - tests := []struct { - name string - line string - defaultModel string - wantID string - wantName string - wantProvider string - wantDefault bool - wantSource string - }{ - { - name: "provider/model format", - line: "anthropic/claude-sonnet-4-20250514", - defaultModel: "", - wantID: "anthropic/claude-sonnet-4-20250514", - wantName: "claude-sonnet-4-20250514", - wantProvider: "anthropic", - wantDefault: false, - wantSource: "dynamic", - }, - { - name: "model only format", - line: "gpt-4o", - defaultModel: "", - wantID: "gpt-4o", - wantName: "gpt-4o", - wantProvider: "", - wantDefault: false, - wantSource: "dynamic", - }, - { - name: "is default model", - line: "anthropic/claude-sonnet-4", - defaultModel: "anthropic/claude-sonnet-4", - wantID: "anthropic/claude-sonnet-4", - wantName: "claude-sonnet-4", - wantProvider: "anthropic", - wantDefault: true, - wantSource: "dynamic", - }, - { - name: "simple provider/model", - line: "openai/gpt-4", - defaultModel: "", - wantID: "openai/gpt-4", - wantName: "gpt-4", - wantProvider: "openai", - wantDefault: false, - wantSource: "dynamic", - }, - { - name: "nested provider path - split on last slash", - line: "openrouter/qwen/qwen3-30b-a3b-thinking", - defaultModel: "", - wantID: "openrouter/qwen/qwen3-30b-a3b-thinking", - wantName: "qwen3-30b-a3b-thinking", - wantProvider: "openrouter/qwen", - wantDefault: false, - wantSource: "dynamic", - }, - { - name: "github-copilot provider", - line: "github-copilot/gemini-3-flash-preview", - defaultModel: "", - wantID: "github-copilot/gemini-3-flash-preview", - wantName: "gemini-3-flash-preview", - wantProvider: "github-copilot", - wantDefault: false, - wantSource: "dynamic", - }, - { - name: "opencode provider", - line: "opencode/big-pickle", - defaultModel: "", - wantID: "opencode/big-pickle", - wantName: "big-pickle", - wantProvider: "opencode", - wantDefault: false, - wantSource: "dynamic", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - entry := parser.parseLine(tt.line, tt.defaultModel) - - if entry.ID != tt.wantID { - t.Errorf("parseLine() ID = %q, want %q", entry.ID, tt.wantID) - } - if entry.Name != tt.wantName { - t.Errorf("parseLine() Name = %q, want %q", entry.Name, tt.wantName) - } - if entry.Provider != tt.wantProvider { - t.Errorf("parseLine() Provider = %q, want %q", entry.Provider, tt.wantProvider) - } - if entry.IsDefault != tt.wantDefault { - t.Errorf("parseLine() IsDefault = %v, want %v", entry.IsDefault, tt.wantDefault) - } - if entry.Source != tt.wantSource { - t.Errorf("parseLine() Source = %q, want %q", entry.Source, tt.wantSource) - } - }) - } -} - -func TestGetParser(t *testing.T) { - tests := []struct { - name string - agentID string - wantType string - }{ - { - name: "opencode agent", - agentID: "opencode", - wantType: "*modelfetcher.OpenCodeParser", - }, - { - name: "unknown agent uses default", - agentID: "unknown-agent", - wantType: "*modelfetcher.DefaultParser", - }, - { - name: "claude-code uses default", - agentID: "claude-code", - wantType: "*modelfetcher.DefaultParser", - }, - { - name: "empty string uses default", - agentID: "", - wantType: "*modelfetcher.DefaultParser", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - parser := GetParser(tt.agentID) - - // Check type by attempting type assertions - switch tt.wantType { - case "*modelfetcher.OpenCodeParser": - if _, ok := parser.(*OpenCodeParser); !ok { - t.Errorf("GetParser(%q) returned %T, want *OpenCodeParser", tt.agentID, parser) - } - case "*modelfetcher.DefaultParser": - if _, ok := parser.(*DefaultParser); !ok { - t.Errorf("GetParser(%q) returned %T, want *DefaultParser", tt.agentID, parser) - } - } - }) - } -} - -func TestOpenCodeParser_Parse(t *testing.T) { - parser := NewOpenCodeParser() - - // OpenCode outputs models in provider/model format - output := `anthropic/claude-sonnet-4-20250514 -anthropic/claude-opus-4-20250514 -openai/gpt-4.1 -google/gemini-2.5-pro` - - models, err := parser.Parse(output, "anthropic/claude-sonnet-4-20250514") - if err != nil { - t.Fatalf("Parse() error = %v", err) - } - - if len(models) != 4 { - t.Fatalf("Parse() returned %d models, want 4", len(models)) - } - - // Check first model is marked as default - if !models[0].IsDefault { - t.Error("first model should be marked as default") - } - - // Check other models are not default - for i := 1; i < len(models); i++ { - if models[i].IsDefault { - t.Errorf("model %d should not be marked as default", i) - } - } - - // Verify all are marked as dynamic - for i, m := range models { - if m.Source != "dynamic" { - t.Errorf("model %d Source = %q, want 'dynamic'", i, m.Source) - } - } -} diff --git a/apps/backend/internal/agentctl/server/process/interactive_runner.go b/apps/backend/internal/agentctl/server/process/interactive_runner.go index ff768239c6..79d7654fa3 100644 --- a/apps/backend/internal/agentctl/server/process/interactive_runner.go +++ b/apps/backend/internal/agentctl/server/process/interactive_runner.go @@ -29,21 +29,21 @@ import ( // InteractiveStartRequest contains parameters for starting an interactive passthrough process. type InteractiveStartRequest struct { - SessionID string `json:"session_id"` // Required: Agent session owning this process - Command []string `json:"command"` // Required: Command and args to execute - WorkingDir string `json:"working_dir"` // Working directory - Env map[string]string `json:"env,omitempty"` // Additional environment variables - PromptPattern string `json:"prompt_pattern,omitempty"` // Regex pattern to detect agent prompt for turn completion - IdleTimeoutMs int `json:"idle_timeout_ms,omitempty"` // Idle timeout in ms for turn detection - BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` // Max output buffer size - StatusDetector string `json:"status_detector,omitempty"` // Status detector type: "claude_code", "codex", "" - CheckIntervalMs int `json:"check_interval_ms,omitempty"` // How often to check state (default 100ms) - StabilityWindowMs int `json:"stability_window_ms,omitempty"` // State stability window (default 0) - ImmediateStart bool `json:"immediate_start,omitempty"` // Start immediately with default dimensions (don't wait for resize) - DefaultCols int `json:"default_cols,omitempty"` // Default columns if ImmediateStart (default 120) - DefaultRows int `json:"default_rows,omitempty"` // Default rows if ImmediateStart (default 40) - InitialCommand string `json:"initial_command,omitempty"` // Command to write to stdin after shell starts (for script terminals) - DisableTurnDetection bool `json:"disable_turn_detection,omitempty"` // Disable idle timer and turn detection (for user shell terminals) + SessionID string `json:"session_id"` // Required: Agent session owning this process + Command []string `json:"command"` // Required: Command and args to execute + WorkingDir string `json:"working_dir"` // Working directory + Env map[string]string `json:"env,omitempty"` // Additional environment variables + PromptPattern string `json:"prompt_pattern,omitempty"` // Regex pattern to detect agent prompt for turn completion + IdleTimeout time.Duration `json:"idle_timeout,omitempty"` // Idle timeout for turn detection + BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"` // Max output buffer size + StatusDetector string `json:"status_detector,omitempty"` // Status detector type: "claude_code", "codex", "" + CheckInterval time.Duration `json:"check_interval,omitempty"` // How often to check state (default 100ms) + StabilityWindow time.Duration `json:"stability_window,omitempty"` // State stability window (default 0) + ImmediateStart bool `json:"immediate_start,omitempty"` // Start immediately with default dimensions (don't wait for resize) + DefaultCols int `json:"default_cols,omitempty"` // Default columns if ImmediateStart (default 120) + DefaultRows int `json:"default_rows,omitempty"` // Default rows if ImmediateStart (default 40) + InitialCommand string `json:"initial_command,omitempty"` // Command to write to stdin after shell starts (for script terminals) + DisableTurnDetection bool `json:"disable_turn_detection,omitempty"` // Disable idle timer and turn detection (for user shell terminals) } // InteractiveProcessInfo represents the state of an interactive process. @@ -231,7 +231,7 @@ func (r *InteractiveRunner) Start(ctx context.Context, req InteractiveStartReque if req.DisableTurnDetection { idleTimeout = 0 // No idle timer for user shell terminals } else { - idleTimeout = time.Duration(req.IdleTimeoutMs) * time.Millisecond + idleTimeout = req.IdleTimeout if idleTimeout <= 0 { idleTimeout = 5 * time.Second // Default 5 seconds } @@ -345,8 +345,8 @@ func (r *InteractiveRunner) startProcess(proc *interactiveProcess, cols, rows in config := StatusTrackerConfig{ Rows: rows, Cols: cols, - CheckInterval: time.Duration(req.CheckIntervalMs) * time.Millisecond, - StabilityWindow: time.Duration(req.StabilityWindowMs) * time.Millisecond, + CheckInterval: req.CheckInterval, + StabilityWindow: req.StabilityWindow, } if config.CheckInterval <= 0 { config.CheckInterval = 100 * time.Millisecond