feat(agents): let a custom agent speak ACP (#3863)

Co-authored-by: Joao Manso <X363081@bcpcorp.net>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
João Manso
2026-09-23 11:01:32 +01:00
committed by GitHub
parent f8708da1e0
commit f2d52f1826
55 changed files with 2303 additions and 274 deletions
@@ -453,6 +453,11 @@ type InferenceConfig struct {
Command Command
// ModelFlag is the flag template for specifying the model (e.g., ["--model", "{model}"]).
ModelFlag Param
// OperatorDefined marks a Command that the install operator registered in
// Settings rather than one compiled into this binary. The host utility's
// probe allow-list is built from literals, which a command that does not
// exist until it is typed can never join.
OperatorDefined bool
}
// InferenceModel describes a model available for inference.
@@ -0,0 +1,116 @@
package agents
import (
"context"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/pkg/agent"
)
// CustomACPAgentConfig is the declarative specification for a user-defined
// agent whose CLI speaks ACP on stdin/stdout.
type CustomACPAgentConfig struct {
// Required
AgentID string
AgentName string
Command string // binary name, e.g. "my-agent"
Desc string
// Optional (zero values get sensible defaults)
Display string // defaults to AgentName
Order *int // defaults to 99 when nil; use a pointer so 0 is a valid explicit value
CommandArgs []string // extra args after Command, e.g. "--acp"
DetectOpts []DetectOption // defaults to WithCommand(Command)
}
// CustomACPAgent implements Agent for a user-defined CLI that kandev drives
// over ACP.
//
// It is deliberately not a TUIAgent. IsPassthroughOnly is a type assertion on
// that type, so being one would seed the default profile as terminal
// passthrough and hide the agent from the host-utility capability probe.
//
// It offers no passthrough mode either. A custom definition carries one
// command, and an ACP server command is not the command that renders an
// interactive terminal: launching `my-agent --acp` under a PTY shows the user
// JSON-RPC frames.
type CustomACPAgent struct {
cfg CustomACPAgentConfig
cmd Command // cached command built once from cfg.Command + cfg.CommandArgs
}
// Compile-time interface checks.
var (
_ Agent = (*CustomACPAgent)(nil)
_ InferenceAgent = (*CustomACPAgent)(nil)
)
// NewCustomACPAgent creates a CustomACPAgent from a declarative config,
// applying defaults.
func NewCustomACPAgent(cfg CustomACPAgentConfig) *CustomACPAgent {
if cfg.Display == "" {
cfg.Display = cfg.AgentName
}
if cfg.Order == nil {
defaultOrder := 99
cfg.Order = &defaultOrder
}
if len(cfg.DetectOpts) == 0 {
cfg.DetectOpts = []DetectOption{WithCommand(cfg.Command)}
}
return &CustomACPAgent{
cfg: cfg,
cmd: Cmd(cfg.Command).Flag(cfg.CommandArgs...).Build(),
}
}
func (a *CustomACPAgent) ID() string { return a.cfg.AgentID }
func (a *CustomACPAgent) Name() string { return a.cfg.AgentName }
func (a *CustomACPAgent) DisplayName() string { return a.cfg.Display }
func (a *CustomACPAgent) Description() string { return a.cfg.Desc }
func (a *CustomACPAgent) Enabled() bool { return true }
func (a *CustomACPAgent) DisplayOrder() int { return *a.cfg.Order }
// Logo returns nil: a user-defined agent ships no artwork, and the frontend
// renders a placeholder.
func (a *CustomACPAgent) Logo(LogoVariant) []byte { return nil }
func (a *CustomACPAgent) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
result, err := Detect(ctx, a.cfg.DetectOpts...)
if err != nil {
return result, err
}
// Resolved MCP servers travel in ACP session/new, so support does not
// depend on a per-CLI config-file strategy the way passthrough does.
result.SupportsMCP = true
return result, nil
}
func (a *CustomACPAgent) BuildCommand(_ CommandOptions) Command { return a.cmd }
func (a *CustomACPAgent) Runtime() *RuntimeConfig {
return &RuntimeConfig{
Cmd: a.cmd,
WorkingDir: "{workspace}",
Env: map[string]string{},
ResourceLimits: DefaultResourceLimits,
Protocol: agent.ProtocolACP,
}
}
// InferenceConfig opts the agent into the host-utility capability probe, which
// is where its models and modes come from.
func (a *CustomACPAgent) InferenceConfig() *InferenceConfig {
return &InferenceConfig{Supported: true, Command: a.cmd, OperatorDefined: true}
}
func (a *CustomACPAgent) InstallScript() string { return "" }
func (a *CustomACPAgent) RemoteAuth() *RemoteAuth { return nil }
func (a *CustomACPAgent) BillingType() usage.BillingType { return defaultBillingType() }
// PermissionSettings has no CLI-driven entries: permission stance is expressed
// through ACP session modes and per-tool-call prompts, as for built-in ACP
// agents.
func (a *CustomACPAgent) PermissionSettings() map[string]PermissionSetting {
return emptyPermSettings
}
@@ -0,0 +1,107 @@
package agents
import (
"context"
"slices"
"testing"
"github.com/kandev/kandev/pkg/agent"
)
func newTestCustomACPAgent() *CustomACPAgent {
return NewCustomACPAgent(CustomACPAgentConfig{
AgentID: "my-agent",
AgentName: "my-agent",
Display: "My Agent",
Command: "my-agent",
CommandArgs: []string{"--acp"},
})
}
// A custom agent registered as ACP must not be classified as passthrough-only:
// that predicate decides whether the agent can only run as a raw CLI under a
// PTY, and it seeds the default profile as terminal passthrough.
func TestCustomACPAgentRunsStructured(t *testing.T) {
a := newTestCustomACPAgent()
if IsPassthroughOnly(a) {
t.Error("IsPassthroughOnly = true for a custom ACP agent")
}
if !SupportsInteractiveMCPTools(a) {
t.Error("SupportsInteractiveMCPTools = false for a custom ACP agent")
}
if _, ok := any(a).(PassthroughAgent); ok {
t.Error("custom ACP agent advertises passthrough; its command is an ACP server, not a TUI")
}
}
func TestCustomACPAgentRuntimeSpeaksACP(t *testing.T) {
a := newTestCustomACPAgent()
rt := a.Runtime()
if rt.Protocol != agent.ProtocolACP {
t.Errorf("Runtime().Protocol = %q, want %q", rt.Protocol, agent.ProtocolACP)
}
want := []string{"my-agent", "--acp"}
if got := rt.Cmd.Args(); !slices.Equal(got, want) {
t.Errorf("Runtime().Cmd = %#v, want %#v", got, want)
}
if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) {
t.Errorf("BuildCommand = %#v, want %#v", got, want)
}
}
// Models and modes come from the host-utility probe, which only runs for
// agents that advertise inference. Without it the profile has no model to
// select and session start fails the no-silent-model-fallback policy.
func TestCustomACPAgentIsProbedForCapabilities(t *testing.T) {
a := newTestCustomACPAgent()
inference, ok := any(a).(InferenceAgent)
if !ok {
t.Fatal("custom ACP agent does not implement InferenceAgent")
}
cfg := inference.InferenceConfig()
if cfg == nil || !cfg.Supported {
t.Fatal("InferenceConfig is not supported")
}
want := []string{"my-agent", "--acp"}
if got := cfg.Command.Args(); !slices.Equal(got, want) {
t.Errorf("InferenceConfig().Command = %#v, want %#v", got, want)
}
}
// Resolved MCP servers travel in ACP session/new, so a custom ACP agent needs
// no per-CLI config-file strategy to get kandev's tools.
func TestCustomACPAgentSupportsMCP(t *testing.T) {
a := newTestCustomACPAgent()
result, err := a.IsInstalled(context.Background())
if err != nil {
t.Fatalf("IsInstalled: %v", err)
}
if !result.SupportsMCP {
t.Error("SupportsMCP = false for a custom ACP agent")
}
}
// The host-utility probe spawns from an allow-list of compiled-in literals. A
// command the operator typed can never be on it, so the agent has to carry the
// marker that lets the probe accept it.
func TestCustomACPAgentMarksItsCommandOperatorDefined(t *testing.T) {
cfg := newTestCustomACPAgent().InferenceConfig()
if !cfg.OperatorDefined {
t.Error("OperatorDefined = false; the probe would refuse this command")
}
}
// A built-in agent must not claim the marker: its command is compiled in and
// belongs on the allow-list, where a drift between the two is a real bug.
func TestBuiltinACPAgentDoesNotClaimOperatorDefined(t *testing.T) {
cfg := NewGooseACP().InferenceConfig()
if cfg.OperatorDefined {
t.Error("OperatorDefined = true for a built-in agent")
}
}
@@ -23,20 +23,6 @@ type Capabilities struct {
SupportsWorkspaceOnly bool `json:"supports_workspace_only"`
}
// KnownAgent represents an agent definition with discovery metadata.
// Model data now comes from the host utility capability cache — see
// internal/agent/hostutility. The Models/DefaultModel fields below are
// intentionally empty; discovery only advertises *which* agent types exist
// and whether their CLI is installed.
type KnownAgent struct {
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"`
}
// Availability represents the result of detecting an agent's installation.
type Availability struct {
Name string `json:"name"`
@@ -49,77 +35,50 @@ type Availability struct {
}
// Registry manages agent discovery using the agents.Agent interface.
//
// The agent list is read from the agent registry on every sweep rather than
// captured once. Custom agents are registered, replaced, and unregistered
// while the backend runs (agent creation, MCP-strategy changes, deletion), and
// a captured list would keep reporting a deleted agent as installed — and keep
// a replaced one's superseded discovery flags — until the next restart.
type Registry struct {
agents []agents.Agent
definitions []KnownAgent
logger *logger.Logger
registry *registry.Registry
logger *logger.Logger
mu sync.RWMutex
cachedResults []Availability
cachedAt time.Time
cacheTTL time.Duration
// generation counts invalidations. A sweep reads the registry when it
// starts and writes its results when it finishes, so an invalidation can
// land in between; the sweep carries the generation it began with and
// discards its results when that no longer matches.
generation uint64
}
// 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(enabled))
agentList := make([]agents.Agent, 0, len(enabled))
// LoadRegistry creates a new discovery registry backed by the agent registry.
// The context is unused: nothing is probed here, because detection resolves the
// agent list when a sweep runs.
func LoadRegistry(_ context.Context, reg *registry.Registry, log *logger.Logger) (*Registry, error) {
return &Registry{
registry: reg,
logger: log,
cacheTTL: defaultCacheTTL,
}, nil
}
// enabledAgents returns the agents a sweep should probe: every enabled entry in
// the agent registry except virtual families, which have no CLI to detect.
func (r *Registry) enabledAgents() []agents.Agent {
enabled := r.registry.ListEnabled()
result := make([]agents.Agent, 0, len(enabled))
for _, ag := range enabled {
if agents.IsVirtualAgent(ag) {
continue
}
// 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{}
}
displayName := ag.DisplayName()
if displayName == "" {
displayName = ag.Name()
}
knownAgent := KnownAgent{
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,
},
}
definitions = append(definitions, knownAgent)
agentList = append(agentList, ag)
result = append(result, ag)
}
return &Registry{
agents: agentList,
definitions: definitions,
logger: log,
cacheTTL: defaultCacheTTL,
}, nil
}
// Definitions returns a copy of all known agent definitions.
func (r *Registry) Definitions() []KnownAgent {
if r == nil {
return nil
}
return append([]KnownAgent(nil), r.definitions...)
return result
}
// Detect checks whether each agent is installed by calling IsInstalled.
@@ -129,11 +88,17 @@ func (r *Registry) Detect(ctx context.Context) ([]Availability, error) {
return cached, nil
}
r.mu.RLock()
startedAt := r.generation
r.mu.RUnlock()
results := r.detectAll(ctx)
r.mu.Lock()
r.cachedResults = results
r.cachedAt = time.Now()
if r.generation == startedAt {
r.cachedResults = results
r.cachedAt = time.Now()
}
r.mu.Unlock()
return results, nil
@@ -145,6 +110,7 @@ func (r *Registry) InvalidateCache() {
r.mu.Lock()
r.cachedResults = nil
r.cachedAt = time.Time{}
r.generation++
r.mu.Unlock()
}
@@ -178,8 +144,9 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
err error
}
ch := make(chan indexedResult, len(r.agents))
for i, ag := range r.agents {
sweep := r.enabledAgents()
ch := make(chan indexedResult, len(sweep))
for i, ag := range sweep {
go func(idx int, ag agents.Agent) {
result, err := ag.IsInstalled(ctx)
if err != nil {
@@ -213,14 +180,14 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
// Collect results preserving original order.
// If the context expires before all agents respond, return partial results.
slots := make([]Availability, len(r.agents))
valid := make([]bool, len(r.agents))
for range r.agents {
slots := make([]Availability, len(sweep))
valid := make([]bool, len(sweep))
for range sweep {
select {
case res := <-ch:
if res.err != nil {
r.logger.Warn("discovery: detect failed for agent",
zap.String("agent", r.agents[res.index].ID()),
zap.String("agent", sweep[res.index].ID()),
zap.Error(res.err),
)
continue
@@ -234,7 +201,7 @@ func (r *Registry) detectAll(ctx context.Context) []Availability {
}
collect:
results := make([]Availability, 0, len(r.agents))
results := make([]Availability, 0, len(sweep))
for i, v := range valid {
if v {
results = append(results, slots[i])
@@ -2,11 +2,13 @@ package discovery
import (
"context"
"slices"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/usage"
"github.com/kandev/kandev/internal/common/logger"
)
@@ -56,6 +58,20 @@ func (a *discoveryTestAgent) calls() int {
return a.installedCalls
}
// newSingleAgentRegistry builds a discovery registry over an agent registry
// holding exactly one agent, with an explicit cache TTL.
func newSingleAgentRegistry(t *testing.T, ag agents.Agent, ttl time.Duration) *Registry {
t.Helper()
log := newDiscoveryTestLogger(t)
agentRegistry := registry.NewRegistry(log)
if err := agentRegistry.Register(ag); err != nil {
t.Fatalf("register %s: %v", ag.ID(), err)
}
discoveryRegistry := loadDiscoveryRegistry(t, agentRegistry, log)
discoveryRegistry.cacheTTL = ttl
return discoveryRegistry
}
func newDiscoveryTestLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
@@ -74,11 +90,7 @@ func TestRegistryDetectCachesResults(t *testing.T) {
},
}
reg := &Registry{
agents: []agents.Agent{a},
logger: newDiscoveryTestLogger(t),
cacheTTL: time.Minute,
}
reg := newSingleAgentRegistry(t, a, time.Minute)
if _, err := reg.Detect(context.Background()); err != nil {
t.Fatalf("first detect: %v", err)
@@ -101,11 +113,7 @@ func TestRegistryInvalidateCacheForcesRefresh(t *testing.T) {
},
}
reg := &Registry{
agents: []agents.Agent{a},
logger: newDiscoveryTestLogger(t),
cacheTTL: time.Minute,
}
reg := newSingleAgentRegistry(t, a, time.Minute)
first, err := reg.Detect(context.Background())
if err != nil {
@@ -136,11 +144,7 @@ func TestRegistryDetectCacheTTLExpiry(t *testing.T) {
discovery: &agents.DiscoveryResult{Available: true},
}
reg := &Registry{
agents: []agents.Agent{a},
logger: newDiscoveryTestLogger(t),
cacheTTL: 20 * time.Millisecond,
}
reg := newSingleAgentRegistry(t, a, 20*time.Millisecond)
if _, err := reg.Detect(context.Background()); err != nil {
t.Fatalf("first detect: %v", err)
@@ -154,3 +158,195 @@ func TestRegistryDetectCacheTTLExpiry(t *testing.T) {
t.Fatalf("IsInstalled calls = %d, want 2", got)
}
}
type virtualTestAgent struct {
*discoveryTestAgent
}
func (a *virtualTestAgent) IsVirtual() bool { return true }
func detectNames(t *testing.T, r *Registry) []string {
t.Helper()
results, err := r.Detect(context.Background())
if err != nil {
t.Fatalf("detect: %v", err)
}
names := make([]string, 0, len(results))
for _, result := range results {
names = append(names, result.Name)
}
return names
}
func loadDiscoveryRegistry(t *testing.T, reg *registry.Registry, log *logger.Logger) *Registry {
t.Helper()
discoveryRegistry, err := LoadRegistry(context.Background(), reg, log)
if err != nil {
t.Fatalf("load registry: %v", err)
}
return discoveryRegistry
}
// A custom agent deleted through the settings API is unregistered from the
// agent registry, but the Installed Agents list is rendered from discovery. If
// discovery keeps its own copy of the agent list, the deleted agent stays
// listed (and a newly created one stays missing) until the backend restarts.
func TestRegistryDetectFollowsRegistryMembership(t *testing.T) {
log := newDiscoveryTestLogger(t)
reg := registry.NewRegistry(log)
if err := reg.Register(&discoveryTestAgent{
id: "agent-a",
discovery: &agents.DiscoveryResult{Available: true},
}); err != nil {
t.Fatalf("register agent-a: %v", err)
}
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
if !slices.Contains(detectNames(t, discoveryRegistry), "agent-a") {
t.Fatal("agent-a missing from the first detect")
}
if err := reg.Unregister("agent-a"); err != nil {
t.Fatalf("unregister agent-a: %v", err)
}
discoveryRegistry.InvalidateCache()
if slices.Contains(detectNames(t, discoveryRegistry), "agent-a") {
t.Error("agent-a is still detected after being unregistered")
}
if err := reg.Register(&discoveryTestAgent{
id: "agent-b",
discovery: &agents.DiscoveryResult{Available: true},
}); err != nil {
t.Fatalf("register agent-b: %v", err)
}
discoveryRegistry.InvalidateCache()
if !slices.Contains(detectNames(t, discoveryRegistry), "agent-b") {
t.Error("agent-b is not detected after being registered")
}
}
// Changing a custom agent's MCP strategy rebuilds its registry entry through
// Replace. Discovery must report the replacement, otherwise the next sweep
// writes the superseded SupportsMCP value back over the agent row.
func TestRegistryDetectReflectsReplacedAgent(t *testing.T) {
log := newDiscoveryTestLogger(t)
reg := registry.NewRegistry(log)
if err := reg.Register(&discoveryTestAgent{
id: "agent-a",
discovery: &agents.DiscoveryResult{Available: true},
}); err != nil {
t.Fatalf("register agent-a: %v", err)
}
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
detectNames(t, discoveryRegistry)
if err := reg.Replace(&discoveryTestAgent{
id: "agent-a",
discovery: &agents.DiscoveryResult{Available: true, SupportsMCP: true},
}); err != nil {
t.Fatalf("replace agent-a: %v", err)
}
discoveryRegistry.InvalidateCache()
results, err := discoveryRegistry.Detect(context.Background())
if err != nil {
t.Fatalf("detect: %v", err)
}
for _, result := range results {
if result.Name != "agent-a" {
continue
}
if !result.SupportsMCP {
t.Error("SupportsMCP = false after replacing agent-a with an MCP-capable entry")
}
return
}
t.Fatal("agent-a missing after replacement")
}
func TestRegistryDetectSkipsVirtualAgents(t *testing.T) {
log := newDiscoveryTestLogger(t)
reg := registry.NewRegistry(log)
if err := reg.Register(&virtualTestAgent{
discoveryTestAgent: &discoveryTestAgent{
id: "agent-virtual",
discovery: &agents.DiscoveryResult{Available: true},
},
}); err != nil {
t.Fatalf("register agent-virtual: %v", err)
}
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
if slices.Contains(detectNames(t, discoveryRegistry), "agent-virtual") {
t.Error("virtual agent reported as a discovery result")
}
}
// blockingDiscoveryAgent holds IsInstalled open until released, so a test can
// invalidate the cache while a sweep is still in flight.
type blockingDiscoveryAgent struct {
*discoveryTestAgent
entered chan struct{}
release chan struct{}
once sync.Once
}
func (a *blockingDiscoveryAgent) IsInstalled(ctx context.Context) (*agents.DiscoveryResult, error) {
a.once.Do(func() { close(a.entered) })
<-a.release
return a.discoveryTestAgent.IsInstalled(ctx)
}
// A sweep reads the registry when it starts and writes its results when it
// finishes, so an unregister landing in between would otherwise be undone: the
// finishing sweep caches the agent it saw, and the deleted card comes back for
// a whole TTL.
func TestRegistryDetectDropsResultsInvalidatedMidSweep(t *testing.T) {
log := newDiscoveryTestLogger(t)
reg := registry.NewRegistry(log)
blocking := &blockingDiscoveryAgent{
discoveryTestAgent: &discoveryTestAgent{
id: "agent-a",
discovery: &agents.DiscoveryResult{Available: true},
},
entered: make(chan struct{}),
release: make(chan struct{}),
}
if err := reg.Register(blocking); err != nil {
t.Fatalf("register agent-a: %v", err)
}
discoveryRegistry := loadDiscoveryRegistry(t, reg, log)
swept := make(chan []Availability, 1)
go func() {
results, err := discoveryRegistry.Detect(context.Background())
if err != nil {
t.Errorf("in-flight detect: %v", err)
}
swept <- results
}()
select {
case <-blocking.entered:
case <-time.After(5 * time.Second):
t.Fatal("sweep never reached IsInstalled")
}
if err := reg.Unregister("agent-a"); err != nil {
t.Fatalf("unregister agent-a: %v", err)
}
discoveryRegistry.InvalidateCache()
close(blocking.release)
select {
case <-swept:
case <-time.After(5 * time.Second):
t.Fatal("in-flight sweep never finished")
}
if slices.Contains(detectNames(t, discoveryRegistry), "agent-a") {
t.Error("a sweep that started before the invalidation published its stale results")
}
}
@@ -875,11 +875,12 @@ func buildProbeRequest(
AgentID: inst.agentType,
Refresh: refresh,
InferenceConfig: &agentctlutil.InferenceConfigDTO{
Command: probeCommand.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: inst.workDir,
Env: agents.RuntimeEnvFor(ia),
StripEnv: agents.StripEnvFor(ia),
Command: probeCommand.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: inst.workDir,
Env: agents.RuntimeEnvFor(ia),
StripEnv: agents.StripEnvFor(ia),
OperatorDefined: cfg.OperatorDefined,
},
}
}
@@ -80,7 +80,7 @@ func (m *Manager) ExecuteProfilePrompt(ctx context.Context, profileID, prompt st
InferenceConfig: &agentctlutil.InferenceConfigDTO{
Command: command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: inst.workDir,
Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: cliFlags, CommandPrefix: prefix,
ProviderGatewayAuth: gatewayAuth,
ProviderGatewayAuth: gatewayAuth, OperatorDefined: cfg.OperatorDefined,
},
}
release, err := inst.acquireOperation(ctx, false)
@@ -355,11 +355,12 @@ func (m *Manager) ExecutePromptWithMCP(
Model: resolved,
Mode: mode,
InferenceConfig: &agentctlutil.InferenceConfigDTO{
Command: command.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: inst.workDir,
Env: agents.RuntimeEnvFor(ia),
StripEnv: agents.StripEnvFor(ia),
Command: command.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: inst.workDir,
Env: agents.RuntimeEnvFor(ia),
StripEnv: agents.StripEnvFor(ia),
OperatorDefined: cfg.OperatorDefined,
},
MCPServers: mcpServers,
}
@@ -8,7 +8,20 @@ import (
"github.com/kandev/kandev/internal/agent/mcpconfig"
)
// CustomTUIAgentSpec is the user-provided definition of a custom TUI agent, as
// CustomAgentProtocol selects how kandev drives a user-defined agent.
type CustomAgentProtocol string
const (
// CustomAgentProtocolTerminal runs the command as a raw CLI under a PTY.
// It is the zero value, so every definition stored before this field
// existed keeps its behaviour without a migration.
CustomAgentProtocolTerminal CustomAgentProtocol = ""
// CustomAgentProtocolACP drives the command over ACP on stdin/stdout,
// giving the agent structured chat, tool calls, models, and modes.
CustomAgentProtocolACP CustomAgentProtocol = "acp"
)
// CustomTUIAgentSpec is the user-provided definition of a custom agent, as
// stored on the agent row's tui_config and replayed into the registry on boot.
type CustomTUIAgentSpec struct {
Slug string
@@ -18,8 +31,12 @@ type CustomTUIAgentSpec struct {
Model string
CommandArgs []string
// MCPStrategyKey selects how kandev injects MCP servers into the wrapped
// CLI (see mcpconfig.StrategyByKey). Empty means no injection.
// CLI (see mcpconfig.StrategyByKey). Empty means no injection. It applies
// to the terminal protocol only.
MCPStrategyKey string
// Protocol selects the runtime kandev drives the command with. Empty means
// terminal passthrough.
Protocol CustomAgentProtocol
}
// ErrUnknownMCPStrategy is returned when a spec names a strategy key that no
@@ -27,66 +44,99 @@ type CustomTUIAgentSpec struct {
// upgrade. Failing loudly beats registering the agent with MCP silently off.
var ErrUnknownMCPStrategy = fmt.Errorf("unknown MCP strategy")
// buildCustomTUIAgent turns a user-provided spec into a TUIAgent. The command
// string is split into binary + args, and any {{model}} placeholder in the
// command is replaced with the model value.
func buildCustomTUIAgent(spec CustomTUIAgentSpec) (*agents.TUIAgent, error) {
// Replace {{model}} template in the command string
// ErrUnknownCustomAgentProtocol is returned when a spec names a protocol that
// does not resolve. Falling back to terminal would silently ignore the user's
// choice and launch an ACP server into a PTY.
var ErrUnknownCustomAgentProtocol = fmt.Errorf("unknown custom agent protocol")
// ErrMCPStrategyNotApplicable is returned when a spec pairs a passthrough MCP
// strategy with a protocol that does not inject MCP through a config file. An
// ACP agent receives resolved servers in session/new, so a strategy there is a
// contradiction rather than a no-op.
var ErrMCPStrategyNotApplicable = fmt.Errorf("MCP strategy does not apply to this protocol")
// resolveCustomCommand splits a spec's command string into binary + args,
// substituting any {{model}} placeholder with the spec's model value.
func resolveCustomCommand(spec CustomTUIAgentSpec) (string, []string, error) {
resolvedCommand := spec.Command
if spec.Model != "" {
resolvedCommand = strings.ReplaceAll(spec.Command, "{{model}}", spec.Model)
}
// Split command into binary + args
parts := strings.Fields(resolvedCommand)
if len(parts) == 0 {
return nil, fmt.Errorf("command is empty")
return "", nil, fmt.Errorf("command is empty")
}
binary := parts[0]
args := parts[1:]
if len(spec.CommandArgs) > 0 {
args = append(args, spec.CommandArgs...)
}
strategy, ok := mcpconfig.StrategyByKey(spec.MCPStrategyKey)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey)
}
return agents.NewTUIAgent(agents.TUIAgentConfig{
AgentID: spec.Slug,
AgentName: spec.Slug,
Command: binary,
Desc: spec.Description,
Display: spec.DisplayName,
WaitForTerm: true,
CommandArgs: args,
MCPStrategy: strategy,
}), nil
return parts[0], args, nil
}
// RegisterCustomTUIAgent creates a TUIAgent from user-provided parameters and
// buildCustomAgent turns a user-provided spec into the agent implementation
// its protocol calls for.
func buildCustomAgent(spec CustomTUIAgentSpec) (agents.Agent, error) {
binary, args, err := resolveCustomCommand(spec)
if err != nil {
return nil, err
}
switch spec.Protocol {
case CustomAgentProtocolTerminal:
strategy, ok := mcpconfig.StrategyByKey(spec.MCPStrategyKey)
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownMCPStrategy, spec.MCPStrategyKey)
}
return agents.NewTUIAgent(agents.TUIAgentConfig{
AgentID: spec.Slug,
AgentName: spec.Slug,
Command: binary,
Desc: spec.Description,
Display: spec.DisplayName,
WaitForTerm: true,
CommandArgs: args,
MCPStrategy: strategy,
}), nil
case CustomAgentProtocolACP:
if spec.MCPStrategyKey != mcpconfig.StrategyKeyNone {
return nil, fmt.Errorf("%w: %q", ErrMCPStrategyNotApplicable, spec.MCPStrategyKey)
}
return agents.NewCustomACPAgent(agents.CustomACPAgentConfig{
AgentID: spec.Slug,
AgentName: spec.Slug,
Command: binary,
Desc: spec.Description,
Display: spec.DisplayName,
CommandArgs: args,
}), nil
default:
return nil, fmt.Errorf("%w: %q", ErrUnknownCustomAgentProtocol, spec.Protocol)
}
}
// RegisterCustomTUIAgent creates the agent the spec's protocol calls for and
// registers it, failing if the ID is already taken.
func (r *Registry) RegisterCustomTUIAgent(spec CustomTUIAgentSpec) error {
tuiAgent, err := buildCustomTUIAgent(spec)
customAgent, err := buildCustomAgent(spec)
if err != nil {
return err
}
return r.Register(tuiAgent)
return r.Register(customAgent)
}
// ReplaceCustomTUIAgent rebuilds an already-registered custom TUI agent in
// place. The MCP strategy is baked into the TUIAgent at construction, so
// changing it needs a new instance rather than a mutation.
// ReplaceCustomTUIAgent rebuilds an already-registered custom agent in place.
// The MCP strategy and the protocol are baked in at construction, so changing
// either needs a new instance rather than a mutation.
//
// This uses Replace rather than Unregister + Register because the pair leaves a
// window with no entry for the ID: a session launching in that window fails
// with "agent type not found", and a competing registration can leave the agent
// missing until restart.
func (r *Registry) ReplaceCustomTUIAgent(spec CustomTUIAgentSpec) error {
tuiAgent, err := buildCustomTUIAgent(spec)
customAgent, err := buildCustomAgent(spec)
if err != nil {
return err
}
return r.Replace(tuiAgent)
return r.Replace(customAgent)
}
@@ -1,7 +1,13 @@
package registry
import (
"errors"
"slices"
"testing"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/pkg/agent"
)
func TestRegisterCustomTUIAgent_Success(t *testing.T) {
@@ -123,3 +129,102 @@ func TestRegisterCustomTUIAgent_DuplicateID(t *testing.T) {
t.Error("expected error for duplicate registration")
}
}
// A spec that names the ACP protocol must produce an agent kandev drives over
// ACP, not a terminal passthrough one. The protocol is part of the stored
// definition, so this is what a restart replays.
func TestRegisterCustomTUIAgent_ACPProtocol(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "my-agent",
DisplayName: "My Agent",
Command: "my-agent --acp",
Protocol: CustomAgentProtocolACP,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ag, ok := reg.Get("my-agent")
if !ok {
t.Fatal("expected agent to be registered")
}
if agents.IsPassthroughOnly(ag) {
t.Error("ACP custom agent classified as passthrough-only")
}
if ag.Runtime().Protocol != agent.ProtocolACP {
t.Errorf("Runtime().Protocol = %q, want %q", ag.Runtime().Protocol, agent.ProtocolACP)
}
want := []string{"my-agent", "--acp"}
if got := ag.Runtime().Cmd.Args(); !slices.Equal(got, want) {
t.Errorf("Runtime().Cmd = %#v, want %#v", got, want)
}
}
// The empty protocol is what every definition stored before the field existed
// decodes to, so it has to keep meaning terminal passthrough.
func TestRegisterCustomTUIAgent_DefaultProtocolStaysTerminal(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
if err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "legacy-agent",
DisplayName: "Legacy Agent",
Command: "legacy-agent",
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
ag, ok := reg.Get("legacy-agent")
if !ok {
t.Fatal("expected agent to be registered")
}
if !agents.IsPassthroughOnly(ag) {
t.Error("a spec with no protocol is no longer terminal passthrough")
}
}
// An unknown protocol is a typo from an older client or a value removed by an
// upgrade. Registering it as a terminal agent would silently ignore the user's
// choice, so it has to fail loudly — same stance as an unknown MCP strategy.
func TestRegisterCustomTUIAgent_UnknownProtocol(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "bad-agent",
DisplayName: "Bad Agent",
Command: "bad-agent",
Protocol: "websocket",
})
if !errors.Is(err, ErrUnknownCustomAgentProtocol) {
t.Fatalf("error = %v, want ErrUnknownCustomAgentProtocol", err)
}
if reg.Exists("bad-agent") {
t.Error("agent registered despite an unknown protocol")
}
}
// The ACP path must not silently drop the MCP strategy field: an ACP agent
// gets kandev's tools through session/new, so a passthrough strategy is a
// contradiction rather than a no-op.
func TestRegisterCustomTUIAgent_ACPRejectsMCPStrategy(t *testing.T) {
log := newTestLogger()
reg := NewRegistry(log)
err := reg.RegisterCustomTUIAgent(CustomTUIAgentSpec{
Slug: "strategy-acp",
DisplayName: "Strategy ACP",
Command: "strategy-acp --acp",
Protocol: CustomAgentProtocolACP,
MCPStrategyKey: mcpconfig.StrategyKeyClaude,
})
if !errors.Is(err, ErrMCPStrategyNotApplicable) {
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
}
if reg.Exists("strategy-acp") {
t.Error("agent registered despite an inapplicable MCP strategy")
}
}
@@ -55,10 +55,11 @@ func (m *Manager) ExecuteInferencePrompt(ctx context.Context, sessionID, agentID
AgentID: agentID,
Model: model,
InferenceConfig: &utility.InferenceConfigDTO{
Command: cfg.Command.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: execution.WorkspacePath,
StripEnv: agents.StripEnvFor(ia),
Command: cfg.Command.Args(),
ModelFlag: cfg.ModelFlag.Args(),
WorkDir: execution.WorkspacePath,
StripEnv: agents.StripEnvFor(ia),
OperatorDefined: cfg.OperatorDefined,
},
}
@@ -144,7 +145,7 @@ func (m *Manager) ExecuteInferenceProfilePrompt(ctx context.Context, sessionID,
InferenceConfig: &utility.InferenceConfigDTO{
Command: cfg.Command.Args(), ModelFlag: cfg.ModelFlag.Args(), WorkDir: execution.WorkspacePath,
Env: env, StripEnv: agents.StripEnvFor(ia), CLIFlags: flags, CommandPrefix: prefix,
ProviderGatewayAuth: gatewayAuth,
ProviderGatewayAuth: gatewayAuth, OperatorDefined: cfg.OperatorDefined,
},
})
}
@@ -339,7 +339,8 @@ func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
}
return err
}
if agent.TUIConfig != nil {
custom := agent.TUIConfig != nil
if custom {
_ = c.agentRegistry.Unregister(agent.Name)
}
@@ -349,5 +350,11 @@ func (c *Controller) DeleteAgent(ctx context.Context, id string) error {
}
return err
}
if custom {
// Installed Agents is rendered from the cached discovery sweep, which
// reports the registry. Without this the deleted agent keeps its card
// until the cache expires, and Rescan re-detects its binary.
c.InvalidateDiscoveryCache()
}
return nil
}
@@ -53,6 +53,8 @@ var (
ErrRequireExactModelNeedsModel = errors.New("exact model requires a concrete model")
ErrRequireExactModelUnsupported = errors.New("exact model is not supported for this profile")
ErrUnknownMCPStrategy = errors.New("unknown MCP strategy")
ErrUnknownCustomAgentProtocol = errors.New("unknown custom agent protocol")
ErrMCPStrategyNotApplicable = errors.New("MCP strategy does not apply to this protocol")
ErrNotCustomTUIAgent = errors.New("agent is not a custom TUI agent")
ErrDynamicAgentRoutingDisabled = errors.New("dynamic agent routing is disabled")
ErrDynamicProfileCandidatesRequired = errors.New("dynamic profile candidates are required")
@@ -312,26 +314,119 @@ func (c *Controller) SetJobBroadcaster(hub JobBroadcaster) {
c.maintenance = newMaintenanceCoordinator()
c.jobStore = NewJobStore(hub, c.logger.Zap(), func(agentName string) {
c.InvalidateDiscoveryCache()
// Kick a fresh capability probe immediately so the UI doesn't sit on
// stale "not_installed" until the next periodic poll. When the probe
// finishes, re-broadcast the updated availability so any open profile
// page transitions out of "Probing…" without a manual refresh.
if c.hostUtility != nil {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := c.hostUtility.Refresh(ctx, agentName); err != nil {
c.logger.Debug("post-install capability refresh failed",
zap.String("agent", agentName), zap.Error(err))
}
c.BroadcastAvailableAgents()
}()
}
c.kickCapabilityProbe(agentName)
c.logger.Info("install succeeded", zap.String("agent", agentName))
}, c.maintenance)
c.initializeUpdateJobStore()
}
// kickCapabilityProbe refreshes one agent's capability cache off the request
// path. The periodic poll would get there eventually, but until it does the
// profile editor sits on a stale status — "not_installed" after an install, or
// "not_configured" for an agent registered after boot, which is every custom
// ACP agent. Re-broadcasting availability afterwards moves any open profile
// page out of "Probing…" without a manual refresh.
func (c *Controller) kickCapabilityProbe(agentName string) {
c.probeAndAdoptModel(agentName, "")
}
// probeAndAdoptModel refreshes an agent's capabilities off the request path and,
// when profileID names a profile, copies the probed default into it if it still
// has no model.
//
// ProfileReconciler already fills an empty model from the probe, but it runs
// once during startup (see backendapp/main.go), so an agent registered
// afterwards would keep an empty model until the next restart and its sessions
// would silently take whatever the agent defaults to.
func (c *Controller) probeAndAdoptModel(agentName, profileID string) {
if c.hostUtility == nil {
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
caps, err := c.hostUtility.Refresh(ctx, agentName)
if err != nil {
c.logger.Debug("capability refresh failed",
zap.String("agent", agentName), zap.Error(err))
} else if profileID != "" {
c.adoptProbedModel(ctx, profileID, caps)
}
c.BroadcastAvailableAgents()
}()
}
// adoptProbedModel fills an empty profile model from the probe. A model the
// operator already chose is left alone: overwriting it is exactly the silent
// fallback the reconciler refuses to make.
func (c *Controller) adoptProbedModel(
ctx context.Context,
profileID string,
caps hostutility.AgentCapabilities,
) {
if caps.CurrentModelID == "" {
return
}
adopter, ok := c.repo.(store.AgentProfileModelAdopter)
if !ok {
c.logger.Debug("profile store cannot adopt a probed model",
zap.String("profile_id", profileID))
return
}
updated, err := adopter.UpdateAgentProfileModelIfEmpty(ctx, profileID, caps.CurrentModelID)
if err != nil {
c.logger.Debug("adopting the probed model failed",
zap.String("profile_id", profileID), zap.Error(err))
return
}
if updated {
c.broadcastProfileUpdated(ctx, profileID)
}
}
// broadcastProfileUpdated tells settings clients that background model
// adoption changed a profile. Custom ACP profiles are global today, but keep
// workspace routing here so this path cannot leak a scoped profile if the
// creation flow gains workspace ownership later.
func (c *Controller) broadcastProfileUpdated(ctx context.Context, profileID string) {
if c.hub == nil {
return
}
profile, err := c.repo.GetAgentProfile(ctx, profileID)
if err != nil || profile == nil {
if err != nil {
c.logger.Debug("loading adopted profile for broadcast failed",
zap.String("profile_id", profileID), zap.Error(err))
}
return
}
profileDTO := toProfileDTO(profile)
c.decorateProviderSupportByAgentID(ctx, &profileDTO)
inferenceCapable := false
if agent, agentErr := c.repo.GetAgent(ctx, profile.AgentID); agentErr == nil && agent != nil && c.agentRegistry != nil {
_, inferenceCapable = c.agentRegistry.GetInferenceAgent(agent.Name)
}
notification, err := ws.NewNotification(ws.ActionAgentProfileUpdated, map[string]any{
"profile": &profileDTO,
"inference_capable": inferenceCapable,
})
if err != nil {
c.logger.Debug("building adopted profile broadcast failed",
zap.String("profile_id", profileID), zap.Error(err))
return
}
if profile.WorkspaceID != "" {
if workspaceHub, ok := c.hub.(interface {
BroadcastToWorkspaceOrDrop(string, *ws.Message)
}); ok {
workspaceHub.BroadcastToWorkspaceOrDrop(profile.WorkspaceID, notification)
}
return
}
c.hub.Broadcast(notification)
}
func (c *Controller) initializeUpdateJobStore() {
if c.hub == nil || c.runtimeUpdater == nil {
c.updateJobStore = nil
@@ -24,7 +24,7 @@ func slugify(displayName string) string {
return s
}
// CreateCustomTUIAgentRequest is the request to create a custom TUI agent.
// CreateCustomTUIAgentRequest is the request to create a custom agent.
type CreateCustomTUIAgentRequest struct {
DisplayName string
Model string
@@ -32,11 +32,37 @@ type CreateCustomTUIAgentRequest struct {
Description string
CommandArgs []string
// MCPStrategy selects how kandev injects its per-session MCP server into
// the wrapped CLI. Empty means no MCP tools, which is the default.
// the wrapped CLI. Empty means no MCP tools, which is the default. It
// applies to the terminal protocol only.
MCPStrategy string
// Protocol is the runtime kandev drives the command with
// (registry.CustomAgentProtocol*). Empty means terminal passthrough.
Protocol string
}
// CreateCustomTUIAgent registers a new custom TUI agent and persists it to the database.
// validateCustomAgentProtocol rejects a protocol/strategy pair the registry
// cannot build, so the HTTP layer answers 400 instead of surfacing a
// registration failure as a server error.
func validateCustomAgentProtocol(protocol registry.CustomAgentProtocol, mcpStrategy string) error {
switch protocol {
case registry.CustomAgentProtocolTerminal:
if _, ok := mcpconfig.StrategyByKey(mcpStrategy); !ok {
return ErrUnknownMCPStrategy
}
return nil
case registry.CustomAgentProtocolACP:
// An ACP agent receives resolved MCP servers in session/new, so a
// passthrough config-file strategy has nothing to write into.
if mcpStrategy != mcpconfig.StrategyKeyNone {
return ErrMCPStrategyNotApplicable
}
return nil
default:
return ErrUnknownCustomAgentProtocol
}
}
// CreateCustomTUIAgent registers a new custom agent and persists it to the database.
func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomTUIAgentRequest) (*dto.AgentDTO, error) {
slug := slugify(req.DisplayName)
if slug == "" {
@@ -45,8 +71,9 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
if req.Command == "" {
return nil, ErrCommandRequired
}
if _, ok := mcpconfig.StrategyByKey(req.MCPStrategy); !ok {
return nil, ErrUnknownMCPStrategy
protocol := registry.CustomAgentProtocol(req.Protocol)
if err := validateCustomAgentProtocol(protocol, req.MCPStrategy); err != nil {
return nil, err
}
// Check for conflict with existing registry entry
@@ -72,11 +99,13 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
Model: req.Model,
CommandArgs: req.CommandArgs,
MCPStrategyKey: req.MCPStrategy,
Protocol: protocol,
}); regErr != nil {
return nil, fmt.Errorf("failed to register agent: %w", regErr)
}
// Persist to DB
acp := protocol == registry.CustomAgentProtocolACP
tuiConfig := &models.TUIConfigJSON{
Command: req.Command,
DisplayName: req.DisplayName,
@@ -85,12 +114,15 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
CommandArgs: req.CommandArgs,
WaitForTerminal: true,
MCPStrategy: req.MCPStrategy,
Protocol: req.Protocol,
}
agent := &models.Agent{
Name: slug,
// Mirrors what TUIAgent.IsInstalled derives, so the profile MCP editor
// works immediately instead of only after the next discovery sweep.
SupportsMCP: req.MCPStrategy != mcpconfig.StrategyKeyNone,
// Mirrors what the registered agent's IsInstalled derives, so the
// profile MCP editor works immediately instead of only after the next
// discovery sweep. An ACP agent always supports MCP: resolved servers
// travel in session/new rather than through a config-file strategy.
SupportsMCP: acp || req.MCPStrategy != mcpconfig.StrategyKeyNone,
TUIConfig: tuiConfig,
}
if err := c.repo.CreateAgent(ctx, agent); err != nil {
@@ -99,7 +131,9 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
return nil, err
}
// Create default passthrough profile — use model as profile name for distinct dropdown labels
// Seed the default profile in the mode the protocol actually runs in. An
// ACP profile carries no model: the host-utility capability probe learns
// the agent's models and the reconciler fills one in.
profileName := req.Model
if profileName == "" {
profileName = req.DisplayName
@@ -111,10 +145,27 @@ func (c *Controller) CreateCustomTUIAgent(ctx context.Context, req CreateCustomT
Model: "passthrough",
CLIPassthrough: true,
}
if acp {
// The probe supplies a default when the operator named no model; it
// must not be the literal "passthrough" a terminal profile carries.
profile.Model = req.Model
profile.CLIPassthrough = false
}
if err := c.repo.CreateAgentProfile(ctx, profile); err != nil {
return nil, err
}
// A discovery sweep reports whatever the agent registry holds, and its
// results are cached, so a membership change has to drop that cache or the
// new agent is absent from Installed Agents until the TTL expires.
c.InvalidateDiscoveryCache()
if acp {
// An ACP agent's models and modes come from the capability probe, and
// the boot sweep is long past. A terminal agent has no ACP server to
// probe, so kicking one there would spawn the user's CLI for nothing.
c.probeAndAdoptModel(slug, profile.ID)
}
profiles := []*models.AgentProfile{profile}
result := c.toAgentDTO(agent, profiles)
return &result, nil
@@ -152,6 +203,10 @@ func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID,
// carry a user-selected one.
return nil, ErrNotCustomTUIAgent
}
protocol := registry.CustomAgentProtocol(agent.TUIConfig.Protocol)
if err := validateCustomAgentProtocol(protocol, strategyKey); err != nil {
return nil, err
}
if agent.TUIConfig.MCPStrategy == strategyKey {
return c.customTUIAgentDTO(ctx, agent)
}
@@ -173,6 +228,10 @@ func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID,
}
return nil, fmt.Errorf("failed to re-register agent: %w", err)
}
// The replacement instance reports a different SupportsMCP, and the sweep
// writes that flag back over the agent row: a cached sweep would revert the
// strategy change that just succeeded.
c.InvalidateDiscoveryCache()
return c.customTUIAgentDTO(ctx, agent)
}
@@ -183,16 +242,24 @@ func (c *Controller) SetCustomTUIAgentMCPStrategy(ctx context.Context, agentID,
// atomic: Unregister + Register would leave a window in which a launching
// session sees no entry for this agent ID.
func (c *Controller) reregisterCustomTUIAgent(agent *models.Agent) error {
cfg := agent.TUIConfig
return c.agentRegistry.ReplaceCustomTUIAgent(registry.CustomTUIAgentSpec{
Slug: agent.Name,
return c.agentRegistry.ReplaceCustomTUIAgent(CustomAgentSpecFromStored(agent.Name, agent.TUIConfig))
}
// CustomAgentSpecFromStored builds the registry spec a stored custom-agent
// definition replays into. Strategy changes and the boot replay both go
// through it, so a field added to the stored config cannot reach one path and
// silently miss the other.
func CustomAgentSpecFromStored(name string, cfg *models.TUIConfigJSON) registry.CustomTUIAgentSpec {
return registry.CustomTUIAgentSpec{
Slug: name,
DisplayName: cfg.DisplayName,
Command: cfg.Command,
Description: cfg.Description,
Model: cfg.Model,
CommandArgs: cfg.CommandArgs,
MCPStrategyKey: cfg.MCPStrategy,
})
Protocol: registry.CustomAgentProtocol(cfg.Protocol),
}
}
func (c *Controller) customTUIAgentDTO(ctx context.Context, agent *models.Agent) (*dto.AgentDTO, error) {
@@ -5,10 +5,14 @@ import (
"errors"
"slices"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/hostutility"
"github.com/kandev/kandev/internal/agent/mcpconfig"
"github.com/kandev/kandev/internal/agent/registry"
"github.com/kandev/kandev/internal/agent/settings/dto"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
)
@@ -300,3 +304,525 @@ func TestSetCustomTUIAgentMCPStrategy_RejectsUnknownStrategy(t *testing.T) {
t.Errorf("stored strategy = %q, want unchanged", stored.TUIConfig.MCPStrategy)
}
}
func newCustomTUIControllerWithDiscovery(t *testing.T, st *fakeStore) *Controller {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("logger: %v", err)
}
agentRegistry := registry.NewRegistry(log)
discoveryRegistry, err := discovery.LoadRegistry(context.Background(), agentRegistry, log)
if err != nil {
t.Fatalf("load discovery registry: %v", err)
}
return &Controller{
agentRegistry: agentRegistry,
discovery: discoveryRegistry,
repo: st,
logger: log,
}
}
func discoveryLists(t *testing.T, c *Controller, name string) bool {
t.Helper()
resp, err := c.ListDiscovery(context.Background())
if err != nil {
t.Fatalf("ListDiscovery: %v", err)
}
return slices.ContainsFunc(resp.Agents, func(a dto.AgentDiscoveryDTO) bool {
return a.Name == name
})
}
// The Installed Agents list is rendered from the discovery sweep, so a custom
// agent has to enter it on creation and leave it on deletion without a restart.
// Both directions were broken while discovery kept its own copy of the agent
// list: a deleted agent stayed listed (Rescan re-detected its binary from the
// stale list) and a new one stayed missing.
func TestCustomTUIAgentEntersAndLeavesDiscovery(t *testing.T) {
st := newFakeStore()
c := newCustomTUIControllerWithDiscovery(t, st)
ctx := context.Background()
if discoveryLists(t, c, "ghost-cli") {
t.Fatal("ghost-cli reported by discovery before it was created")
}
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
DisplayName: "Ghost CLI",
Command: "ghost-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if !discoveryLists(t, c, "ghost-cli") {
t.Error("ghost-cli missing from discovery after it was created")
}
if err := c.DeleteAgent(ctx, created.ID); err != nil {
t.Fatalf("DeleteAgent: %v", err)
}
if discoveryLists(t, c, "ghost-cli") {
t.Error("ghost-cli still reported by discovery after it was deleted")
}
}
// Discovery writes SupportsMCP back over the agent row on every sweep, so a
// strategy change that discovery cannot see is reverted by the next sweep.
func TestCustomTUIAgentMCPStrategyChangeReachesDiscovery(t *testing.T) {
st := newFakeStore()
c := newCustomTUIControllerWithDiscovery(t, st)
ctx := context.Background()
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
DisplayName: "Strategy CLI",
Command: "strategy-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if discoverySupportsMCP(t, c, "strategy-cli") {
t.Fatal("SupportsMCP = true before a strategy was selected")
}
if _, err := c.SetCustomTUIAgentMCPStrategy(ctx, created.ID, mcpconfig.StrategyKeyClaude); err != nil {
t.Fatalf("SetCustomTUIAgentMCPStrategy: %v", err)
}
if !discoverySupportsMCP(t, c, "strategy-cli") {
t.Error("SupportsMCP = false after selecting an MCP strategy")
}
}
func discoverySupportsMCP(t *testing.T, c *Controller, name string) bool {
t.Helper()
resp, err := c.ListDiscovery(context.Background())
if err != nil {
t.Fatalf("ListDiscovery: %v", err)
}
for _, a := range resp.Agents {
if a.Name == name {
return a.SupportsMCP
}
}
t.Fatalf("%s missing from discovery", name)
return false
}
// A custom agent created as ACP must be seeded as a structured profile. A
// passthrough profile launches the command under a PTY, which is what the
// terminal protocol is for — with an ACP command that shows JSON-RPC frames.
func TestCreateCustomACPAgent_SeedsStructuredProfile(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "My Agent",
Command: "my-agent --acp",
Protocol: string(registry.CustomAgentProtocolACP),
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
ag, ok := c.agentRegistry.Get("my-agent")
if !ok {
t.Fatal("agent not registered")
}
if agents.IsPassthroughOnly(ag) {
t.Error("ACP custom agent registered as passthrough-only")
}
stored, ok := st.byName["my-agent"]
if !ok || stored.TUIConfig == nil {
t.Fatal("agent not persisted with a TUI config")
}
if stored.TUIConfig.Protocol != string(registry.CustomAgentProtocolACP) {
t.Errorf("stored protocol = %q, want %q", stored.TUIConfig.Protocol, registry.CustomAgentProtocolACP)
}
if !stored.SupportsMCP {
t.Error("stored SupportsMCP = false; ACP agents receive servers in session/new")
}
profiles := st.profiles[created.ID]
if len(profiles) != 1 {
t.Fatalf("seeded %d profiles, want 1", len(profiles))
}
if profiles[0].CLIPassthrough {
t.Error("seeded profile is CLI passthrough")
}
if profiles[0].Model == "passthrough" {
t.Error(`seeded profile model is "passthrough"; the capability probe fills it`)
}
}
// The terminal protocol is the default, and its seeding must not drift.
func TestCreateCustomTUIAgent_DefaultsToTerminalProfile(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
created, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Terminal CLI",
Command: "terminal-cli",
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
stored, ok := st.byName["terminal-cli"]
if !ok || stored.TUIConfig == nil {
t.Fatal("agent not persisted with a TUI config")
}
if stored.TUIConfig.Protocol != "" {
t.Errorf("stored protocol = %q, want empty", stored.TUIConfig.Protocol)
}
profiles := st.profiles[created.ID]
if len(profiles) != 1 {
t.Fatalf("seeded %d profiles, want 1", len(profiles))
}
if !profiles[0].CLIPassthrough {
t.Error("seeded profile is not CLI passthrough")
}
}
func TestCreateCustomTUIAgent_RejectsUnknownProtocol(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Bad Protocol",
Command: "bad-protocol",
Protocol: "websocket",
})
if !errors.Is(err, ErrUnknownCustomAgentProtocol) {
t.Fatalf("error = %v, want ErrUnknownCustomAgentProtocol", err)
}
if c.agentRegistry.Exists("bad-protocol") {
t.Error("agent registered despite an unknown protocol")
}
}
func TestCreateCustomACPAgent_RejectsMCPStrategy(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
_, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "ACP With Strategy",
Command: "acp-with-strategy --acp",
Protocol: string(registry.CustomAgentProtocolACP),
MCPStrategy: mcpconfig.StrategyKeyClaude,
})
if !errors.Is(err, ErrMCPStrategyNotApplicable) {
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
}
if c.agentRegistry.Exists("acp-with-strategy") {
t.Error("agent registered despite an inapplicable MCP strategy")
}
}
// A stored ACP definition has to replay as an ACP agent after a restart;
// creation, strategy changes, and the boot replay all build their spec here.
func TestCustomAgentSpecFromStored_CarriesProtocol(t *testing.T) {
spec := CustomAgentSpecFromStored("my-agent", &models.TUIConfigJSON{
Command: "my-agent --acp",
DisplayName: "My Agent",
Protocol: string(registry.CustomAgentProtocolACP),
})
if spec.Protocol != registry.CustomAgentProtocolACP {
t.Errorf("spec.Protocol = %q, want %q", spec.Protocol, registry.CustomAgentProtocolACP)
}
if spec.Slug != "my-agent" {
t.Errorf("spec.Slug = %q, want %q", spec.Slug, "my-agent")
}
}
type probeRecordingHostUtility struct {
refreshed chan string
caps hostutility.AgentCapabilities
}
func (h *probeRecordingHostUtility) Get(string) (hostutility.AgentCapabilities, bool) {
return hostutility.AgentCapabilities{}, false
}
func (h *probeRecordingHostUtility) Refresh(
_ context.Context,
agentType string,
) (hostutility.AgentCapabilities, error) {
select {
case h.refreshed <- agentType:
default:
}
return h.caps, nil
}
func (h *probeRecordingHostUtility) ResolveModelConfig(
context.Context,
string,
hostutility.ModelConfigResolutionRequest,
) (hostutility.ModelConfigResolution, error) {
return hostutility.ModelConfigResolution{}, nil
}
// An ACP agent's models and modes come from the capability probe, and the
// probe only runs at boot. Without one kicked at creation, the profile editor
// reports "not_configured" until the user finds the manual refresh.
func TestCreateCustomACPAgent_ProbesCapabilities(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
probe := &probeRecordingHostUtility{refreshed: make(chan string, 1)}
c.hostUtility = probe
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Probed Acp",
Command: "probed-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case agentType := <-probe.refreshed:
if agentType != "probed-acp" {
t.Errorf("probed %q, want %q", agentType, "probed-acp")
}
case <-time.After(5 * time.Second):
t.Fatal("no capability probe was kicked for a new ACP agent")
}
}
// A terminal agent is never probed, so kicking one would spawn the user's CLI
// for nothing and park a failed capability status on the agent.
func TestCreateCustomTUIAgent_DoesNotProbeCapabilities(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
probe := &probeRecordingHostUtility{refreshed: make(chan string, 1)}
c.hostUtility = probe
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Unprobed Terminal",
Command: "unprobed-terminal",
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case agentType := <-probe.refreshed:
t.Errorf("probed %q; terminal agents have no ACP server to probe", agentType)
case <-time.After(200 * time.Millisecond):
}
}
// The MCP-strategy route is the one place a stored definition's protocol can be
// paired with a new strategy. An ACP agent has no passthrough config file to
// write, so the pair has to be refused before the row is touched rather than
// written and rolled back.
func TestSetCustomTUIAgentMCPStrategy_RejectedForACPAgent(t *testing.T) {
st := newFakeStore()
c := newCustomTUIController(t, st)
ctx := context.Background()
created, err := c.CreateCustomTUIAgent(ctx, CreateCustomTUIAgentRequest{
DisplayName: "Strategy Acp",
Command: "strategy-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
})
if err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
if _, err := c.SetCustomTUIAgentMCPStrategy(ctx, created.ID, mcpconfig.StrategyKeyClaude); !errors.Is(err, ErrMCPStrategyNotApplicable) {
t.Fatalf("error = %v, want ErrMCPStrategyNotApplicable", err)
}
stored, ok := st.byName["strategy-acp"]
if !ok || stored.TUIConfig == nil {
t.Fatal("agent not persisted with a TUI config")
}
if stored.TUIConfig.MCPStrategy != "" {
t.Errorf("stored strategy = %q, want empty", stored.TUIConfig.MCPStrategy)
}
if !stored.SupportsMCP {
t.Error("SupportsMCP flipped off by a rejected strategy write")
}
}
// profileUpdateSignalStore publishes each profile model write. The probe runs
// on its own goroutine, and fakeStore is not safe to read while it writes, so
// tests synchronise on the write instead of polling the store.
type profileUpdateSignalStore struct {
*fakeStore
models chan string
}
// profileAdoptionRaceStore simulates a user changing the profile after a
// background probe read it but before the probe can write. The conditional
// model update sees that edit and must leave it untouched. The old full-row
// read/modify/write path overwrote it.
type profileAdoptionRaceStore struct {
*fakeStore
userEditInjected bool
fullRowWrites int
}
func (s *profileAdoptionRaceStore) GetAgentProfile(
ctx context.Context,
profileID string,
) (*models.AgentProfile, error) {
profile, err := s.fakeStore.GetAgentProfile(ctx, profileID)
if err != nil || profile == nil || s.userEditInjected {
return profile, err
}
s.userEditInjected = true
userEdit := copyProfile(profile)
userEdit.Model = "operator-choice"
if err := s.fakeStore.UpdateAgentProfile(ctx, userEdit); err != nil {
return nil, err
}
return profile, nil
}
func (s *profileAdoptionRaceStore) UpdateAgentProfile(
ctx context.Context,
profile *models.AgentProfile,
) error {
s.fullRowWrites++
return s.fakeStore.UpdateAgentProfile(ctx, profile)
}
func (s *profileAdoptionRaceStore) UpdateAgentProfileModelIfEmpty(
ctx context.Context,
profileID, model string,
) (bool, error) {
if !s.userEditInjected {
s.userEditInjected = true
current, err := s.fakeStore.GetAgentProfile(ctx, profileID)
if err != nil {
return false, err
}
current.Model = "operator-choice"
if err := s.fakeStore.UpdateAgentProfile(ctx, current); err != nil {
return false, err
}
}
current, err := s.fakeStore.GetAgentProfile(ctx, profileID)
if err != nil || current == nil || current.Model != "" {
return false, err
}
current.Model = model
if err := s.fakeStore.UpdateAgentProfile(ctx, current); err != nil {
return false, err
}
return true, nil
}
func (s *profileUpdateSignalStore) UpdateAgentProfile(ctx context.Context, p *models.AgentProfile) error {
err := s.fakeStore.UpdateAgentProfile(ctx, p)
select {
case s.models <- p.Model:
default:
}
return err
}
func (s *profileUpdateSignalStore) UpdateAgentProfileModelIfEmpty(
ctx context.Context,
profileID, model string,
) (bool, error) {
profile, err := s.GetAgentProfile(ctx, profileID)
if err != nil || profile == nil || profile.Model != "" {
return false, err
}
profile.Model = model
if err := s.UpdateAgentProfile(ctx, profile); err != nil {
return false, err
}
return true, nil
}
func newProbedController(
t *testing.T,
caps hostutility.AgentCapabilities,
) (*Controller, *profileUpdateSignalStore) {
t.Helper()
st := &profileUpdateSignalStore{fakeStore: newFakeStore(), models: make(chan string, 4)}
c := newCustomTUIController(t, st.fakeStore)
c.repo = st
c.hostUtility = &probeRecordingHostUtility{refreshed: make(chan string, 1), caps: caps}
return c, st
}
// The seeded ACP profile carries no model on purpose: the probe is what learns
// one. ProfileReconciler copies a probed default into an empty profile, but it
// runs once during startup, so an agent registered afterwards would keep an
// empty model until the next restart and its sessions would silently take
// whatever the agent defaults to.
func TestCreateCustomACPAgent_AdoptsProbedModel(t *testing.T) {
c, st := newProbedController(t, hostutility.AgentCapabilities{CurrentModelID: "probed-model"})
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Adopting Acp",
Command: "adopting-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case model := <-st.models:
if model != "probed-model" {
t.Errorf("wrote model %q, want the probed default", model)
}
case <-time.After(5 * time.Second):
t.Fatal("the probed model was never written to the seeded profile")
}
}
// A model the operator already chose is theirs. Overwriting it from the probe
// is exactly the silent fallback the reconciler refuses to make.
func TestCreateCustomACPAgent_KeepsAnOperatorModel(t *testing.T) {
c, st := newProbedController(t, hostutility.AgentCapabilities{CurrentModelID: "probed-model"})
if _, err := c.CreateCustomTUIAgent(context.Background(), CreateCustomTUIAgentRequest{
DisplayName: "Chosen Acp",
Command: "chosen-acp --acp",
Protocol: string(registry.CustomAgentProtocolACP),
Model: "operator-choice",
}); err != nil {
t.Fatalf("CreateCustomTUIAgent: %v", err)
}
select {
case model := <-st.models:
t.Errorf("overwrote the operator's model with %q", model)
case <-time.After(500 * time.Millisecond):
}
}
func TestAdoptProbedModelDoesNotOverwriteConcurrentProfileEdit(t *testing.T) {
base := newFakeStore()
profile := &models.AgentProfile{AgentID: "agent-1", Name: "Default"}
if err := base.CreateAgentProfile(context.Background(), profile); err != nil {
t.Fatalf("create profile: %v", err)
}
st := &profileAdoptionRaceStore{fakeStore: base}
c := newCustomTUIController(t, base)
c.repo = st
c.adoptProbedModel(context.Background(), profile.ID, hostutility.AgentCapabilities{
CurrentModelID: "probed-model",
})
got, err := base.GetAgentProfile(context.Background(), profile.ID)
if err != nil {
t.Fatalf("get profile: %v", err)
}
if got.Model != "operator-choice" {
t.Fatalf("model = %q, want concurrent operator edit to win", got.Model)
}
if st.fullRowWrites != 0 {
t.Fatalf("full-row writes = %d, want no full-row adoption write", st.fullRowWrites)
}
}
@@ -1225,6 +1225,7 @@ func (c *Controller) toAgentDTO(agent *models.Agent, profiles []*models.AgentPro
CommandArgs: agent.TUIConfig.CommandArgs,
WaitForTerminal: agent.TUIConfig.WaitForTerminal,
MCPStrategy: agent.TUIConfig.MCPStrategy,
Protocol: agent.TUIConfig.Protocol,
}
}
if c.agentRegistry != nil {
@@ -293,6 +293,21 @@ func (f *fakeStore) UpdateAgentProfile(_ context.Context, p *models.AgentProfile
return nil
}
func (f *fakeStore) UpdateAgentProfileModelIfEmpty(
ctx context.Context,
profileID, model string,
) (bool, error) {
profile, err := f.GetAgentProfile(ctx, profileID)
if err != nil || profile == nil || profile.Model != "" {
return false, err
}
profile.Model = model
if err := f.UpdateAgentProfile(ctx, profile); err != nil {
return false, err
}
return true, nil
}
func (f *fakeStore) UpdateAgentProfileEnabled(ctx context.Context, id string, enabled bool) (time.Time, error) {
profile, err := f.GetAgentProfile(ctx, id)
if err != nil {
@@ -143,6 +143,9 @@ type TUIConfigDTO struct {
WaitForTerminal bool `json:"wait_for_terminal"`
// MCPStrategy is the selected MCP injection mechanism ("" = none).
MCPStrategy string `json:"mcp_strategy,omitempty"`
// Protocol is the runtime kandev drives the command with ("" = terminal
// passthrough, "acp" = ACP on stdin/stdout).
Protocol string `json:"protocol,omitempty"`
}
type AgentDTO struct {
@@ -23,6 +23,9 @@ type createCustomTUIAgentRequest struct {
// MCPStrategy names how the wrapped CLI loads MCP servers, so kandev can
// point it at the per-session server. Empty (the default) = no MCP tools.
MCPStrategy string `json:"mcp_strategy"`
// Protocol is the runtime kandev drives the command with: empty (the
// default) for terminal passthrough, "acp" for ACP on stdin/stdout.
Protocol string `json:"protocol"`
}
func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCustomTUIAgentRequest {
@@ -33,6 +36,7 @@ func (r createCustomTUIAgentRequest) toControllerRequest() controller.CreateCust
Description: r.Description,
CommandArgs: r.CommandArgs,
MCPStrategy: r.MCPStrategy,
Protocol: r.Protocol,
}
}
@@ -54,7 +58,8 @@ func (h *Handlers) httpCreateCustomTUIAgent(c *gin.Context) {
resp, err := h.controller.CreateCustomTUIAgent(c.Request.Context(), body.toControllerRequest())
if err != nil {
switch err {
case controller.ErrInvalidSlug, controller.ErrCommandRequired, controller.ErrUnknownMCPStrategy:
case controller.ErrInvalidSlug, controller.ErrCommandRequired, controller.ErrUnknownMCPStrategy,
controller.ErrUnknownCustomAgentProtocol, controller.ErrMCPStrategyNotApplicable:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case controller.ErrAgentAlreadyExists:
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
@@ -114,7 +119,8 @@ func (h *Handlers) httpUpdateCustomTUIAgentMCP(c *gin.Context) {
resp, err := h.controller.SetCustomTUIAgentMCPStrategy(c.Request.Context(), c.Param("id"), body.MCPStrategy)
if err != nil {
switch err {
case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent:
case controller.ErrUnknownMCPStrategy, controller.ErrNotCustomTUIAgent,
controller.ErrMCPStrategyNotApplicable, controller.ErrUnknownCustomAgentProtocol:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
case controller.ErrAgentNotFound:
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
@@ -46,6 +46,10 @@ type TUIConfigJSON struct {
// user's CLI definition, and because tui_config is JSON: adding the field
// needs no migration.
MCPStrategy string `json:"mcp_strategy,omitempty"`
// Protocol selects the runtime kandev drives the command with
// (registry.CustomAgentProtocol*). Empty — the value every row written
// before this field existed decodes to — means terminal passthrough.
Protocol string `json:"protocol,omitempty"`
}
type AgentProfile struct {
@@ -1263,6 +1263,29 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
return r.updateAgentProfile(ctx, r.db, profile)
}
// UpdateAgentProfileModelIfEmpty adopts a probed model without replacing any
// other profile fields. The model predicate makes the read/check/write one
// atomic operation, so a concurrent profile edit wins over the background
// probe instead of being overwritten by a full-row update.
func (r *sqliteRepository) UpdateAgentProfileModelIfEmpty(
ctx context.Context,
profileID, model string,
) (bool, error) {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE agent_profiles
SET model = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL AND model = ''
`), model, time.Now().UTC(), profileID)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
if err != nil {
return false, err
}
return rows == 1, nil
}
func (r *sqliteRepository) updateAgentProfile(ctx context.Context, execer profileExecer, profile *models.AgentProfile) error {
profile.UpdatedAt = time.Now().UTC()
cliFlagsJSON, err := cliFlagsToJSON(profile.CLIFlags)
@@ -0,0 +1,55 @@
package store
import (
"context"
"testing"
"github.com/kandev/kandev/internal/agent/settings/models"
)
func TestUpdateAgentProfileModelIfEmptyIsConditional(t *testing.T) {
repo := newFreshRepo(t)
ctx := context.Background()
agent := &models.Agent{Name: "custom-acp"}
if err := repo.CreateAgent(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
profile := &models.AgentProfile{
AgentID: agent.ID,
Name: "default",
AgentDisplayName: "Custom ACP",
}
if err := repo.CreateAgentProfile(ctx, profile); err != nil {
t.Fatalf("create profile: %v", err)
}
updated, err := repo.UpdateAgentProfileModelIfEmpty(ctx, profile.ID, "probed-model")
if err != nil {
t.Fatalf("adopt model: %v", err)
}
if !updated {
t.Fatal("first model adoption returned updated=false")
}
got, err := repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("get adopted profile: %v", err)
}
if got.Model != "probed-model" {
t.Fatalf("model = %q, want probed-model", got.Model)
}
updated, err = repo.UpdateAgentProfileModelIfEmpty(ctx, profile.ID, "other-model")
if err != nil {
t.Fatalf("second model adoption: %v", err)
}
if updated {
t.Fatal("second model adoption returned updated=true for a configured model")
}
got, err = repo.GetAgentProfile(ctx, profile.ID)
if err != nil {
t.Fatalf("get preserved profile: %v", err)
}
if got.Model != "probed-model" {
t.Fatalf("model = %q after second adoption, want probed-model", got.Model)
}
}
@@ -93,6 +93,13 @@ type AgentProfileMcpConfigPatcher interface {
) (*models.AgentProfileMcpConfig, error)
}
// AgentProfileModelAdopter fills an unset profile model without replacing the
// rest of the profile row. The boolean is false when the profile already has a
// model or was deleted before the background probe completed.
type AgentProfileModelAdopter interface {
UpdateAgentProfileModelIfEmpty(ctx context.Context, profileID, model string) (bool, error)
}
// DynamicProfileRepository is the optional extension implemented by settings
// stores that persist the dynamic profile document. Keeping it separate from
// Repository lets small controller fakes and plugin adapters retain the
@@ -68,9 +68,9 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
return &PromptResponse{Success: false, Error: "work_dir is required for ACP inference"}, nil
}
model, modelConfigOptions, _ := acpcompat.MigrateCursorModel(req.AgentID, req.Model, nil)
resolvedCmd := resolveProbeCommand(cfg.Command[0])
if resolvedCmd == "" {
return &PromptResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP command", cfg.Command[0])}, nil
resolvedCmd, cmdErr := resolveSpawnCommand(cfg)
if cmdErr != "" {
return &PromptResponse{Success: false, Error: cmdErr}, nil
}
startTime := time.Now()
@@ -83,9 +83,6 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
zap.String("model", model),
zap.Strings("command", args))
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
// the executable name is not derived from tainted input.
//nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags
cmdArgs := args[1:]
if len(cfg.CommandPrefix) > 0 {
args = append(append([]string{}, cfg.CommandPrefix...), args...)
@@ -96,8 +93,10 @@ func (e *ACPInferenceExecutor) Execute(ctx context.Context, req *PromptRequest)
cmdArgs = args[1:]
}
cmdArgs = append(cmdArgs, cfg.CLIFlags...)
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
// the executable name is not derived from tainted input.
// Use resolvedCmd (not args[0]) so the executable name the taint tracker
// sees is an allow-list literal, a validated command prefix, or the
// operator-registered command resolveSpawnCommand documents.
//nolint:gosec // resolvedCmd is an allow-list literal, a validated prefix, or an operator-registered command
cmd := exec.CommandContext(ctx, resolvedCmd, cmdArgs...)
cmd.Dir = workDir
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
@@ -476,9 +475,9 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
if workDir == "" {
return &ProbeResponse{Success: false, Error: "work_dir is required for ACP probe"}, nil
}
resolvedCmd := resolveProbeCommand(cfg.Command[0])
if resolvedCmd == "" {
return &ProbeResponse{Success: false, Error: fmt.Sprintf("command %q is not an allowed ACP probe command", cfg.Command[0])}, nil
resolvedCmd, cmdErr := resolveSpawnCommand(cfg)
if cmdErr != "" {
return &ProbeResponse{Success: false, Error: cmdErr}, nil
}
startTime := time.Now()
@@ -496,9 +495,10 @@ func (e *ACPInferenceExecutor) Probe(ctx context.Context, req *ProbeRequest) (*P
zap.String("agent_id", req.AgentID),
zap.Strings("command", args))
// Use the hard-coded resolvedCmd (not args[0]) so CodeQL can see that
// the executable name is not derived from tainted input.
//nolint:gosec // resolvedCmd is from a hard-coded allow-list; args[1:] are CLI flags
// Use resolvedCmd (not args[0]) so the allow-list literal is what reaches
// exec.Command for every built-in agent, and the one exception is the
// operator-defined command resolveSpawnCommand documents.
//nolint:gosec // resolvedCmd is an allow-list literal or an operator-registered command
cmd := exec.CommandContext(ctx, resolvedCmd, args[1:]...)
cmd.Dir = workDir
cmd.Env = sanitizeEnvForAgent(req.InferenceConfig)
@@ -1269,6 +1269,32 @@ func resolveProbeCommand(name string) string {
return ""
}
// resolveSpawnCommand returns the executable a probe or inference subprocess
// should spawn, or a non-empty error message when the command is not permitted.
//
// A built-in agent's command is compiled in, so it resolves to the allow-list
// literal and the taint tracker can follow it to exec.Command unchanged.
//
// A custom agent's command cannot: it does not exist until the install
// operator types it in Settings, so no literal can cover it. Refusing it does
// not keep that command from running — the session path spawns the same string
// verbatim (process/manager.go, interactive_lifecycle.go), and agentctl's own
// piped runner accepts an arbitrary command from its request. It only denies
// the agent the capability probe, which is where its models and modes come
// from. The operator who typed the command is the operator who could run it
// directly on the host.
func resolveSpawnCommand(cfg *InferenceConfigDTO) (string, string) {
command := cfg.Command[0]
if cfg.OperatorDefined {
return command, ""
}
resolved := resolveProbeCommand(command)
if resolved == "" {
return "", fmt.Sprintf("command %q is not an allowed ACP probe command", command)
}
return resolved, ""
}
// resolveACPCommandPrefix validates the optional launcher that wraps an
// already allow-listed ACP agent command. The deployment guard is accepted
// only at its fixed image path: accepting a matching basename from another
@@ -0,0 +1,53 @@
package utility
import "testing"
// A built-in agent's command is compiled into the binary, so it keeps
// resolving to the allow-list literal the taint tracker can follow.
func TestResolveSpawnCommand_BuiltinUsesAllowlistLiteral(t *testing.T) {
t.Parallel()
cmd, errMsg := resolveSpawnCommand(&InferenceConfigDTO{Command: []string{"goose", "acp"}})
if errMsg != "" {
t.Fatalf("errMsg = %q, want empty", errMsg)
}
if cmd != "goose" {
t.Errorf("cmd = %q, want %q", cmd, "goose")
}
}
// A command that is neither allow-listed nor operator-defined is still
// refused: for a built-in that means the allow-list and the agent definition
// have drifted apart, which is a bug rather than a configuration choice.
func TestResolveSpawnCommand_RejectsUnlistedBuiltin(t *testing.T) {
t.Parallel()
cmd, errMsg := resolveSpawnCommand(&InferenceConfigDTO{Command: []string{"my-agent", "--acp"}})
if errMsg == "" {
t.Fatal("errMsg is empty; an unlisted built-in command must be refused")
}
if cmd != "" {
t.Errorf("cmd = %q, want empty", cmd)
}
}
// A custom agent's command is typed by the install operator in Settings. The
// same string is already spawned unrestricted when a session starts, so
// refusing it here only denies the agent its models and modes.
func TestResolveSpawnCommand_AllowsOperatorDefined(t *testing.T) {
t.Parallel()
cmd, errMsg := resolveSpawnCommand(&InferenceConfigDTO{
Command: []string{"my-agent", "--acp"},
OperatorDefined: true,
})
if errMsg != "" {
t.Fatalf("errMsg = %q, want empty", errMsg)
}
if cmd != "my-agent" {
t.Errorf("cmd = %q, want %q", cmd, "my-agent")
}
}
@@ -223,6 +223,12 @@ type InferenceConfigDTO struct {
// (base URL + bearer key) right after the ACP initialize handshake, the
// same mechanism the live session adapter uses.
ProviderGatewayAuth *acpprovider.GatewayAuth `json:"provider_gateway_auth,omitempty"`
// OperatorDefined marks a command that came from a custom agent the
// install operator registered in Settings rather than from an agent
// definition compiled into the binary. Such a command cannot appear in
// the probe allow-list, because it does not exist until the operator
// types it. See resolveSpawnCommand.
OperatorDefined bool `json:"operator_defined,omitempty"`
}
// PromptResponse is the response from executing a utility prompt.
+2 -10
View File
@@ -1222,16 +1222,8 @@ func loadCustomTUIAgents(ctx context.Context, repos *Repositories, agentRegistry
if agent.TUIConfig == nil {
continue
}
cfg := agent.TUIConfig
if regErr := agentRegistry.RegisterCustomTUIAgent(registry.CustomTUIAgentSpec{
Slug: agent.Name,
DisplayName: cfg.DisplayName,
Command: cfg.Command,
Description: cfg.Description,
Model: cfg.Model,
CommandArgs: cfg.CommandArgs,
MCPStrategyKey: cfg.MCPStrategy,
}); regErr != nil {
spec := agentsettingscontroller.CustomAgentSpecFromStored(agent.Name, agent.TUIConfig)
if regErr := agentRegistry.RegisterCustomTUIAgent(spec); regErr != nil {
log.Warn("failed to register custom TUI agent",
zap.String("name", agent.Name), zap.Error(regErr))
}
@@ -114,6 +114,23 @@ describe("custom TUI MCP control on the agents index", () => {
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
});
// An ACP custom agent receives kandev's MCP servers in session/new. Offering
// a passthrough config-file strategy would present a choice the backend
// rejects, because there is no passthrough command to write a config for.
it("renders nothing for a custom agent registered as ACP", () => {
renderIndexComposition(
tuiAgent({
tui_config: {
command: "fuelclaude --acp",
display_name: "Fuel Claude",
wait_for_terminal: true,
protocol: "acp",
},
}),
);
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
});
it("renders nothing for an agent that has not been saved yet", () => {
renderIndexComposition(undefined);
expect(screen.queryByTestId(STRATEGY_SELECT)).toBeNull();
+1
View File
@@ -334,6 +334,7 @@ function useAgentPageState() {
model?: string;
command: string;
mcp_strategy?: string;
protocol?: string;
}) => {
await createCustomTUIAgent(data);
const [discoveryResp, agentsResp, availableResp] = await Promise.all([
@@ -3,6 +3,9 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import { ApiError, INTERIM_SETTINGS_INTERLOCK_ERROR_CODE } from "@/lib/api/client";
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
const DISPLAY_NAME_LABEL = "Display Name";
const COMMAND_LABEL = "Command";
afterEach(cleanup);
function renderDialog(onSubmit = vi.fn()) {
@@ -31,8 +34,8 @@ describe("AddTUIAgentDialog", () => {
it("keeps the {{model}} token in the command placeholder", () => {
renderDialog();
expect(screen.getByLabelText("Command").getAttribute("placeholder")).toBe(
"e.g. superclaude --yolo --model {{model}}",
expect(screen.getByLabelText(COMMAND_LABEL).getAttribute("placeholder")).toBe(
"e.g. superagent --yolo --model {{model}}",
);
});
@@ -50,7 +53,9 @@ describe("AddTUIAgentDialog", () => {
const onSubmit = vi.fn();
renderDialog(onSubmit);
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
target: { value: "superagent" },
});
fireEvent.click(screen.getByText("Create"));
expect(screen.getByText("Command is required")).toBeTruthy();
@@ -63,14 +68,69 @@ describe("AddTUIAgentDialog", () => {
const onSubmit = vi.fn().mockRejectedValue("boom");
renderDialog(onSubmit);
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
target: { value: "superagent" },
});
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
fireEvent.click(screen.getByText("Create"));
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
expect(onSubmit).toHaveBeenCalledOnce();
});
// A custom agent that speaks ACP has to be created as one: the protocol is
// part of the stored definition and decides whether kandev drives the command
// over ACP or launches it in a terminal.
it("submits the ACP protocol when it is selected", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderDialog(onSubmit);
fireEvent.click(screen.getByTestId("agent-protocol-select"));
fireEvent.click(screen.getByRole("option", { name: /ACP/ }));
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
target: { value: "My Agent" },
});
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), {
target: { value: "superagent --acp" },
});
fireEvent.click(screen.getByText("Create"));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit.mock.calls[0][0]).toMatchObject({
display_name: "My Agent",
command: "superagent --acp",
protocol: "acp",
});
});
it("defaults to the terminal protocol", async () => {
const onSubmit = vi.fn().mockResolvedValue(undefined);
renderDialog(onSubmit);
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
target: { value: "superagent" },
});
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
fireEvent.click(screen.getByText("Create"));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit.mock.calls[0][0].protocol).toBeUndefined();
});
// The passthrough MCP strategies write a config file for the wrapped CLI.
// An ACP agent gets its servers in session/new, so the picker would offer a
// choice the backend rejects.
it("hides the MCP strategy picker for the ACP protocol", () => {
renderDialog();
expect(screen.queryByTestId("mcp-strategy-select")).not.toBeNull();
fireEvent.click(screen.getByTestId("agent-protocol-select"));
fireEvent.click(screen.getByRole("option", { name: /ACP/ }));
expect(screen.queryByTestId("mcp-strategy-select")).toBeNull();
});
it("closes without exposing a handled stale-page error", async () => {
const staleError = new ApiError("interim settings interlock required", 403, {
error_code: INTERIM_SETTINGS_INTERLOCK_ERROR_CODE,
@@ -80,8 +140,10 @@ describe("AddTUIAgentDialog", () => {
const onOpenChange = vi.fn();
render(<AddTUIAgentDialog open onOpenChange={onOpenChange} onSubmit={onSubmit} />);
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
fireEvent.change(screen.getByLabelText(DISPLAY_NAME_LABEL), {
target: { value: "superagent" },
});
fireEvent.change(screen.getByLabelText(COMMAND_LABEL), { target: { value: "superagent" } });
fireEvent.click(screen.getByText("Create"));
await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
@@ -15,6 +15,7 @@ import {
} from "@kandev/ui/dialog";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { MCPStrategySelect, useMCPStrategies } from "./mcp-strategy-select";
type TUIAgentFormData = {
@@ -22,6 +23,7 @@ type TUIAgentFormData = {
model?: string;
command: string;
mcp_strategy?: string;
protocol?: string;
};
// The placeholder kandev substitutes into the TUI command. The user types it
@@ -29,6 +31,15 @@ type TUIAgentFormData = {
// catalog. Same for the example command and model names.
const MODEL_TOKEN = "{{model}}";
// Radix Select cannot hold an empty-string value, and the stored protocol for
// terminal passthrough is exactly that, so the terminal choice needs a
// sentinel. It is a control value, never displayed and never translated.
const PROTOCOL_TERMINAL = "terminal";
const PROTOCOL_ACP = "acp";
// i18n-exempt: example shell command, typed verbatim rather than translated.
const ACP_COMMAND_EXAMPLE = "superagent --acp";
type AddTUIAgentDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -40,6 +51,7 @@ type DialogHandlersParams = {
model: string;
command: string;
mcpStrategy: string;
protocol: string;
setError: React.Dispatch<React.SetStateAction<string | null>>;
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
onSubmit: (data: TUIAgentFormData) => Promise<void>;
@@ -52,6 +64,7 @@ function useDialogHandlers({
model,
command,
mcpStrategy,
protocol,
setError,
setLoading,
onSubmit,
@@ -69,12 +82,17 @@ function useDialogHandlers({
}
setError(null);
setLoading(true);
const acp = protocol === PROTOCOL_ACP;
try {
await onSubmit({
display_name: displayName.trim(),
model: model.trim() || undefined,
// An ACP profile's model comes from the capability probe, and its MCP
// servers travel in session/new, so neither field is offered — sending
// one anyway would persist a value nothing reads.
model: acp ? undefined : model.trim() || undefined,
command: command.trim(),
mcp_strategy: mcpStrategy || undefined,
mcp_strategy: acp ? undefined : mcpStrategy || undefined,
protocol: acp ? PROTOCOL_ACP : undefined,
});
reset();
onOpenChange(false);
@@ -98,23 +116,139 @@ function useDialogHandlers({
return { handleSubmit, handleOpenChange };
}
/**
* Picks the runtime kandev drives the command with.
*
* This is an explicit choice rather than something probed from the command.
* A CLI that serves ACP normally does so behind a flag (`--acp`, `acp`), and
* the command that starts the server is not the command that renders an
* interactive terminal — so there is nothing to detect from a single command
* string, and guessing wrong launches a JSON-RPC server into a terminal tab.
*/
function AgentProtocolSelect({
value,
onChange,
}: {
value: string;
onChange: (next: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label htmlFor="tui-protocol">{t("agents:agentProtocol")}</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger
id="tui-protocol"
className="w-full min-w-0 cursor-pointer [@media(pointer:coarse)]:min-h-11"
data-testid="agent-protocol-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
value={PROTOCOL_TERMINAL}
description={t("agents:agentProtocolTerminalHelp")}
className="cursor-pointer [@media(pointer:coarse)]:min-h-11"
>
{t("agents:agentProtocolTerminal")}
</SelectItem>
<SelectItem
value={PROTOCOL_ACP}
description={t("agents:agentProtocolAcpHelp")}
className="cursor-pointer [@media(pointer:coarse)]:min-h-11"
>
{t("agents:agentProtocolAcp")}
</SelectItem>
</SelectContent>
</Select>
</div>
);
}
/** The model label and the command hint only make sense in terminal mode: an
* ACP profile's model comes from the capability probe, not from the command. */
function TerminalModelField({
value,
onChange,
}: {
value: string;
onChange: (next: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label htmlFor="tui-model">{t("agents:model")}</Label>
<Input
id="tui-model"
placeholder={t("agents:exampleValue", { example: "best" })}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{t("agents:tuiModelHelp")}</p>
</div>
);
}
function CommandField({
acp,
value,
onChange,
}: {
acp: boolean;
value: string;
onChange: (next: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="space-y-2">
<Label htmlFor="tui-command">{t("agents:command")}</Label>
<Input
id="tui-command"
placeholder={t("agents:exampleValue", {
example: acp ? ACP_COMMAND_EXAMPLE : `superagent --yolo --model ${MODEL_TOKEN}`,
})}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{acp ? (
<p className="text-xs text-muted-foreground">
{t("agents:agentProtocolHelp", { example: ACP_COMMAND_EXAMPLE })}
</p>
) : (
<p className="text-xs text-muted-foreground">
<Trans i18nKey="agents:tuiCommandHelp" values={{ token: MODEL_TOKEN }}>
<code className="rounded bg-muted px-1 py-0.5" />
</Trans>
</p>
)}
</div>
);
}
export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentDialogProps) {
const { t } = useTranslation();
const [displayName, setDisplayName] = useState("");
const [model, setModel] = useState("");
const [command, setCommand] = useState("");
const [mcpStrategy, setMcpStrategy] = useState("");
const [protocol, setProtocol] = useState(PROTOCOL_TERMINAL);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const acp = protocol === PROTOCOL_ACP;
// Only fetch the strategy list while the dialog is open.
const strategies = useMCPStrategies(open);
// Only fetch the strategy list while the dialog is open on the protocol that
// can use one.
const strategies = useMCPStrategies(open && !acp);
const reset = () => {
setDisplayName("");
setModel("");
setCommand("");
setMcpStrategy("");
setProtocol(PROTOCOL_TERMINAL);
setError(null);
setLoading(false);
};
@@ -124,6 +258,7 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
model,
command,
mcpStrategy,
protocol,
setError,
setLoading,
onSubmit,
@@ -143,43 +278,22 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
<Label htmlFor="tui-display-name">{t("agents:displayName")}</Label>
<Input
id="tui-display-name"
placeholder={t("agents:exampleValue", { example: "superclaude" })}
placeholder={t("agents:exampleValue", { example: "superagent" })}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tui-model">{t("agents:model")}</Label>
<Input
id="tui-model"
placeholder={t("agents:exampleValue", { example: "best" })}
value={model}
onChange={(e) => setModel(e.target.value)}
<AgentProtocolSelect value={protocol} onChange={setProtocol} />
{!acp && <TerminalModelField value={model} onChange={setModel} />}
<CommandField acp={acp} value={command} onChange={setCommand} />
{!acp && (
<MCPStrategySelect
id="tui-mcp-strategy"
value={mcpStrategy}
onChange={setMcpStrategy}
strategies={strategies}
/>
<p className="text-xs text-muted-foreground">{t("agents:tuiModelHelp")}</p>
</div>
<div className="space-y-2">
<Label htmlFor="tui-command">{t("agents:command")}</Label>
<Input
id="tui-command"
placeholder={t("agents:exampleValue", {
example: `superclaude --yolo --model ${MODEL_TOKEN}`,
})}
value={command}
onChange={(e) => setCommand(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
<Trans i18nKey="agents:tuiCommandHelp" values={{ token: MODEL_TOKEN }}>
<code className="rounded bg-muted px-1 py-0.5" />
</Trans>
</p>
</div>
<MCPStrategySelect
id="tui-mcp-strategy"
value={mcpStrategy}
onChange={setMcpStrategy}
strategies={strategies}
/>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
@@ -36,11 +36,14 @@ export function CustomTUIMcpCard({ agent }: CustomTUIMcpCardProps) {
// Gated: this card is rendered once per agent on the index, and hooks run
// before the early return below, so an unconditional fetch would issue one
// identical request per built-in agent as well.
const isCustomTUIAgent = Boolean(agent?.tui_config);
// An ACP custom agent receives kandev's resolved MCP servers in session/new,
// so there is no wrapped-CLI config file for a strategy to write. Offering
// the picker would present a choice the backend rejects.
const isCustomTUIAgent = Boolean(agent?.tui_config) && agent?.tui_config?.protocol !== "acp";
const strategies = useMCPStrategies(isCustomTUIAgent);
const [saving, setSaving] = useState(false);
if (!agent?.tui_config) return null;
if (!isCustomTUIAgent || !agent?.tui_config) return null;
const handleChange = async (strategy: string) => {
setSaving(true);
+9
View File
@@ -640,6 +640,15 @@ export class ApiClient {
return this.request("GET", "/api/v1/agents/available");
}
/** Removes a custom agent by slug, so a spec that creates one leaves the
* worker's agent list as it found it. Missing is not an error. */
async deleteCustomAgentByName(name: string): Promise<void> {
const { agents } = await this.listAgents();
const agent = agents.find((candidate) => candidate.name === name);
if (!agent) return;
await this.request("DELETE", `/api/v1/agents/${agent.id}`);
}
async deleteAgentProfile(profileId: string, force?: boolean): Promise<void> {
const qs = force ? "?force=true" : "";
await this.request("DELETE", `/api/v1/agent-profiles/${profileId}${qs}`);
@@ -0,0 +1,39 @@
import { test, expect } from "../../fixtures/test-base";
const AGENT_NAME = "E2E Acp Agent";
const AGENT_SLUG = "e2e-acp-agent";
test.describe("Custom ACP agent", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.deleteCustomAgentByName(AGENT_SLUG);
});
test("registers a custom agent kandev drives over ACP", async ({ testPage, apiClient }) => {
await testPage.goto("/settings/agents");
await testPage.getByTestId("new-agent-button").click();
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
await expect(dialog).toBeVisible();
await expect(dialog.getByTestId("mcp-strategy-select")).toBeVisible();
await dialog.getByTestId("agent-protocol-select").click();
await testPage.getByRole("listbox").getByRole("option").filter({ hasText: /^ACP/ }).click();
// Resolved MCP servers reach an ACP agent through session/new, so the
// passthrough strategy picker is not offered for it.
await expect(dialog.getByTestId("mcp-strategy-select")).toHaveCount(0);
await dialog.getByLabel("Display Name").fill(AGENT_NAME);
await dialog.getByLabel("Command").fill(`${AGENT_SLUG} --acp`);
await dialog.getByRole("button", { name: "Create" }).click();
await expect(testPage.getByTestId(`agent-group-${AGENT_SLUG}`)).toBeVisible();
await expect(testPage.getByTestId(`agent-mcp-strategy-${AGENT_SLUG}`)).toHaveCount(0);
// The stored definition is what a restart replays, so it has to say ACP
// rather than leaving the agent to come back as a terminal one.
const { agents } = await apiClient.listAgents();
const created = agents.find((agent) => agent.name === AGENT_SLUG);
expect(created?.tui_config?.protocol).toBe("acp");
});
});
@@ -11,7 +11,7 @@ test.describe("Custom TUI MCP strategy selector", () => {
await testPage.goto("/settings/agents");
await testPage.getByTestId("new-agent-button").click();
const dialog = testPage.getByRole("dialog", { name: "Add TUI Agent" });
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
await expect(dialog).toBeVisible();
const trigger = dialog.getByTestId("mcp-strategy-select");
@@ -0,0 +1,42 @@
import { test, expect } from "../../fixtures/test-base";
const AGENT_NAME = "Mobile ACP Agent";
const AGENT_SLUG = "mobile-acp-agent";
test.describe("Custom ACP agent on mobile", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.deleteCustomAgentByName(AGENT_SLUG);
});
test("selects ACP and keeps the protocol controls usable", async ({ testPage, apiClient }) => {
await testPage.goto("/settings/agents");
await testPage.getByTestId("new-agent-button").tap();
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
await expect(dialog).toBeVisible();
const protocol = dialog.getByTestId("agent-protocol-select");
await expect
.poll(async () => (await protocol.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(44);
await protocol.tap();
const listbox = testPage.getByRole("listbox");
const acpOption = listbox.getByRole("option", { name: "ACP" });
await expect(acpOption).toBeVisible();
await expect
.poll(async () => (await acpOption.boundingBox())?.height ?? 0)
.toBeGreaterThanOrEqual(44);
await acpOption.tap();
await expect(dialog.getByTestId("mcp-strategy-select")).toHaveCount(0);
await dialog.getByLabel("Display Name").fill(AGENT_NAME);
await dialog.getByLabel("Command").fill(`${AGENT_SLUG} --acp`);
await dialog.getByRole("button", { name: "Create" }).tap();
await expect(testPage.getByTestId(`agent-group-${AGENT_SLUG}`)).toBeVisible();
const { agents } = await apiClient.listAgents();
const created = agents.find((agent) => agent.name === AGENT_SLUG);
expect(created?.tui_config?.protocol).toBe("acp");
});
});
@@ -10,7 +10,7 @@ test.describe("Custom TUI MCP strategy selector on mobile", () => {
await testPage.goto("/settings/agents");
await testPage.getByTestId("new-agent-button").tap();
const dialog = testPage.getByRole("dialog", { name: "Add TUI Agent" });
const dialog = testPage.getByRole("dialog", { name: "Add custom agent" });
await expect(dialog).toBeVisible();
const trigger = dialog.getByTestId("mcp-strategy-select");
+1 -1
View File
@@ -524,7 +524,7 @@ export const i18nGuardFiles = [
// Deliberately left in English, each an identifier the user must read or type
// verbatim, all interpolated as values so the pseudo-locale cannot turn them
// into dead pointers: the `{prompt}` and `{{model}}` substitution tokens, the
// `--my-flag` / `greywall --` / `/init` / `superclaude` examples, the
// `--my-flag` / `greywall --` / `/init` / `superagent` examples, the
// `mcpServers` JSON key, the MCP server product names (`Playwright MCP`,
// `Chrome DevTools MCP`, `Context7 MCP`, `GitHub MCP`) and the Kandev MCP tool
// list, and the whole of the assembled command preview. Wire values stay wire
+1
View File
@@ -346,6 +346,7 @@ export async function createCustomTUIAgent(
command: string;
description?: string;
mcp_strategy?: string;
protocol?: string;
},
options?: ApiRequestOptions,
): Promise<Agent> {
+14
View File
@@ -19,6 +19,13 @@ export type {
import type { AgentProfile } from "./agent-profile";
import type { BackendMessage } from "./backend-message";
/**
* How kandev drives a custom agent's command. Absent means terminal
* passthrough, which is what every definition stored before the field existed
* decodes to.
*/
export type CustomAgentProtocol = "acp";
export type TUIConfig = {
command: string;
display_name: string;
@@ -33,6 +40,13 @@ export type TUIConfig = {
* list here, or a strategy added in Go silently stops being selectable.
*/
mcp_strategy?: string;
/**
* Runtime kandev drives the command with. Absent means terminal passthrough;
* `"acp"` means kandev speaks the Agent Client Protocol to it on standard
* input and output, which is what gives the agent structured chat, tool
* calls, models, and modes.
*/
protocol?: CustomAgentProtocol;
};
/** One selectable MCP injection mechanism, served by the backend. */
+8 -2
View File
@@ -2,8 +2,8 @@
"actionCannotBeUndone": "This action cannot be undone.",
"add": "Add",
"addProfile": "Add profile",
"addTuiAgent": "Add TUI Agent",
"addTuiAgentDescription": "Register a CLI tool as a TUI agent. It will appear in the agent list with a default passthrough profile.",
"addTuiAgent": "Add custom agent",
"addTuiAgentDescription": "Register a CLI you already have. It appears in the agent list with a default profile in the protocol you pick.",
"agentCliFlags": "Agent CLI flags",
"agentCliFlagsDescription": "Flags passed to the agent CLI on launch. Only enabled entries are applied.",
"agentInstallAlreadyInProgress": "Agent installation is already in progress.",
@@ -11,6 +11,12 @@
"agentNotFound": "Agent not found.",
"agentProfileSettings": "{{name}} profile settings",
"agentProfilesTitle": "{{name}} Profiles",
"agentProtocol": "Protocol",
"agentProtocolAcp": "ACP",
"agentProtocolAcpHelp": "Kandev drives the command over the Agent Client Protocol. Structured chat, tool calls, models, and modes.",
"agentProtocolHelp": "Choose ACP only when the command starts an ACP server on standard input and output, for example {{example}}.",
"agentProtocolTerminal": "Terminal",
"agentProtocolTerminalHelp": "Kandev runs the command in a terminal tab. No structured chat, tool calls, or model list.",
"authRequiredOpenLoginTerminal": "Authentication required - click to open login terminal",
"authRequiredOpenShell": "Authentication required - click to open a shell and sign in",
"authenticationRequired": "Authentication required",
+6
View File
@@ -11,6 +11,12 @@
"agentNotFound": "エージェントが見つかりません。",
"agentProfileSettings": "{{name}} のプロファイル設定",
"agentProfilesTitle": "{{name}} のプロファイル",
"agentProtocol": "プロトコル",
"agentProtocolAcp": "ACP",
"agentProtocolAcpHelp": "Kandev が Agent Client Protocol 経由でコマンドを制御します。構造化されたチャット、ツール呼び出し、モデル、モードを利用できます。",
"agentProtocolHelp": "コマンドが標準入出力で ACP サーバーを起動する場合にのみ ACP を選んでください。例: {{example}}。",
"agentProtocolTerminal": "ターミナル",
"agentProtocolTerminalHelp": "Kandev はコマンドをターミナルタブで実行します。構造化されたチャット、ツール呼び出し、モデル一覧はありません。",
"authRequiredOpenLoginTerminal": "認証が必要です。クリックしてログイン用ターミナルを開く",
"authRequiredOpenShell": "認証が必要です。クリックしてシェルを開き、サインインしてください",
"authenticationRequired": "認証が必要です",
+8 -2
View File
@@ -2,8 +2,8 @@
"actionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"add": "Àďď",
"addProfile": "Àďď ƥŕōƒĩĺē",
"addTuiAgent": "Àďď ŢŨĨ Àĝēńţ",
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ţōōĺ àś à ŢŨĨ àĝēńţ. Ĩţ ŵĩĺĺ àƥƥēàŕ ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥàśśţĥŕōũĝĥ ƥŕōƒĩĺē.",
"addTuiAgent": "Àďď ćũśţōḿ àĝēńţ",
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ŷōũ àĺŕēàďŷ ĥàvē. Ĩţ àƥƥēàŕś ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥŕōƒĩĺē ĩń ţĥē ƥŕōţōćōĺ ŷōũ ƥĩćķ.",
"agentCliFlags": "Àĝēńţ ĆĹĨ ƒĺàĝś",
"agentCliFlagsDescription": "Ƒĺàĝś ƥàśśēď ţō ţĥē àĝēńţ ĆĹĨ ōń ĺàũńćĥ. Ōńĺŷ ēńàƀĺēď ēńţŕĩēś àŕē àƥƥĺĩēď.",
"agentInstallAlreadyInProgress": "Àĝēńţ ĩńśţàĺĺàţĩōń ĩś àĺŕēàďŷ ĩń ƥŕōĝŕēśś.",
@@ -11,6 +11,12 @@
"agentNotFound": "Àĝēńţ ńōţ ƒōũńď.",
"agentProfileSettings": "{{name}} ƥŕōƒĩĺē śēţţĩńĝś",
"agentProfilesTitle": "{{name}} Ƥŕōƒĩĺēś",
"agentProtocol": "Ƥŕōţōćōĺ",
"agentProtocolAcp": "ÀĆƤ",
"agentProtocolAcpHelp": "Ķàńďēv ďŕĩvēś ţĥē ćōḿḿàńď ōvēŕ ţĥē Àĝēńţ Ćĺĩēńţ Ƥŕōţōćōĺ. Śţŕũćţũŕēď ćĥàţ, ţōōĺ ćàĺĺś, ḿōďēĺś, àńď ḿōďēś.",
"agentProtocolHelp": "Ćĥōōśē ÀĆƤ ōńĺŷ ŵĥēń ţĥē ćōḿḿàńď śţàŕţś àń ÀĆƤ śēŕvēŕ ōń śţàńďàŕď ĩńƥũţ àńď ōũţƥũţ, ƒōŕ ēxàḿƥĺē {{example}}.",
"agentProtocolTerminal": "Ţēŕḿĩńàĺ",
"agentProtocolTerminalHelp": "Ķàńďēv ŕũńś ţĥē ćōḿḿàńď ĩń à ţēŕḿĩńàĺ ţàƀ. Ńō śţŕũćţũŕēď ćĥàţ, ţōōĺ ćàĺĺś, ōŕ ḿōďēĺ ĺĩśţ.",
"authRequiredOpenLoginTerminal": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń ĺōĝĩń ţēŕḿĩńàĺ",
"authRequiredOpenShell": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń à śĥēĺĺ àńď śĩĝń ĩń",
"authenticationRequired": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď",
@@ -1,4 +1,5 @@
{
"agents:agentProtocolTerminal": "\"Terminal\" is the ordinary Portuguese word for the surface; translating it would change nothing.",
"common:menu": "\"Menu\" is the ordinary Portuguese word; translating it would change nothing.",
"kanban:menu": "\"Menu\" is the ordinary Portuguese word; translating it would change nothing.",
"office:desc": "\"Desc\" is the same abbreviation for descending order in Portuguese.",
+8 -2
View File
@@ -2,8 +2,8 @@
"actionCannotBeUndone": "Esta ação não pode ser anulada.",
"add": "Adicionar",
"addProfile": "Adicionar perfil",
"addTuiAgent": "Adicionar agente TUI",
"addTuiAgentDescription": "Registe uma ferramenta de CLI como agente TUI. Aparecerá na lista de agentes com um perfil de passagem direta pré-definido.",
"addTuiAgent": "Adicionar agente personalizado",
"addTuiAgentDescription": "Regista um CLI que já tens. Aparece na lista de agentes com um perfil predefinido no protocolo que escolheres.",
"agentCliFlags": "Flags da CLI do agente",
"agentCliFlagsDescription": "Flags passadas à CLI do agente no arranque. Só as entradas ativadas são aplicadas.",
"agentInstallAlreadyInProgress": "Já está em curso uma instalação do agente.",
@@ -11,6 +11,12 @@
"agentNotFound": "Agente não encontrado.",
"agentProfileSettings": "Definições do perfil {{name}}",
"agentProfilesTitle": "Perfis de {{name}}",
"agentProtocol": "Protocolo",
"agentProtocolAcp": "ACP",
"agentProtocolAcpHelp": "O Kandev conduz o comando pelo Agent Client Protocol. Conversa estruturada, chamadas de ferramentas, modelos e modos.",
"agentProtocolHelp": "Escolhe ACP apenas quando o comando inicia um servidor ACP na entrada e saída padrão, por exemplo {{example}}.",
"agentProtocolTerminal": "Terminal",
"agentProtocolTerminalHelp": "O Kandev corre o comando num separador de terminal. Sem conversa estruturada, chamadas de ferramentas ou lista de modelos.",
"authRequiredOpenLoginTerminal": "É necessária autenticação - clique para abrir o terminal de início de sessão",
"authRequiredOpenShell": "É necessária autenticação - clique para abrir uma shell e iniciar sessão",
"authenticationRequired": "É necessária autenticação",
+8 -2
View File
@@ -2,8 +2,8 @@
"actionCannotBeUndone": "此操作无法撤销。",
"add": "添加",
"addProfile": "添加配置",
"addTuiAgent": "添加 TUI 智能体",
"addTuiAgentDescription": "将 CLI 工具注册为 TUI 智能体。它会以默认透传配置出现在智能体列表中。",
"addTuiAgent": "添加自定义代理",
"addTuiAgentDescription": "注册你已安装的 CLI。它会出现在代理列表中,并带有一个使用你所选协议的默认配置。",
"agentCliFlags": "智能体 CLI 标志",
"agentCliFlagsDescription": "启动时智能体 CLI 接收的标志。仅应用已启用的项。",
"agentInstallAlreadyInProgress": "智能体安装已在进行中。",
@@ -11,6 +11,12 @@
"agentNotFound": "未找到智能体。",
"agentProfileSettings": "{{name}} 智能体配置",
"agentProfilesTitle": "{{name}} 配置",
"agentProtocol": "协议",
"agentProtocolAcp": "ACP",
"agentProtocolAcpHelp": "Kandev 通过 Agent Client Protocol 驱动该命令。支持结构化对话、工具调用、模型和模式。",
"agentProtocolHelp": "仅当该命令在标准输入输出上启动 ACP 服务器时才选择 ACP,例如 {{example}}。",
"agentProtocolTerminal": "终端",
"agentProtocolTerminalHelp": "Kandev 在终端标签页中运行该命令。没有结构化对话、工具调用或模型列表。",
"authRequiredOpenLoginTerminal": "需要身份验证;点击打开登录终端",
"authRequiredOpenShell": "需要身份验证;点击打开 shell 并登录",
"authenticationRequired": "需要身份验证",
+8 -2
View File
@@ -2,8 +2,8 @@
"actionCannotBeUndone": "此操作無法撤銷。",
"add": "新增",
"addProfile": "新增設定",
"addTuiAgent": "新增 TUI 代理程式",
"addTuiAgentDescription": "將 CLI 工具註冊為 TUI 代理程式。它會以預設透傳設定出現在代理程式列表中。",
"addTuiAgent": "新增自訂代理",
"addTuiAgentDescription": "註冊你已經安裝嘅 CLI。佢會喺代理清單入面出現,並帶有一個使用你所揀協定嘅預設設定檔。",
"agentCliFlags": "代理程式 CLI 旗標",
"agentCliFlagsDescription": "啓動時代理程式 CLI 接收的旗標。僅應用已啓用的項。",
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
@@ -11,6 +11,12 @@
"agentNotFound": "未找到代理程式。",
"agentProfileSettings": "{{name}} 代理程式設定檔",
"agentProfilesTitle": "{{name}} 設定",
"agentProtocol": "協定",
"agentProtocolAcp": "ACP",
"agentProtocolAcpHelp": "Kandev 會透過 Agent Client Protocol 驅動該指令。有結構化對話、工具呼叫、模型同模式。",
"agentProtocolHelp": "只有當該指令喺標準輸入輸出上啟動 ACP 伺服器時先揀 ACP,例如 {{example}}。",
"agentProtocolTerminal": "終端機",
"agentProtocolTerminalHelp": "Kandev 會喺終端機分頁執行該指令。冇結構化對話、工具呼叫或模型清單。",
"authRequiredOpenLoginTerminal": "需要身份驗證;點擊開啟登入終端",
"authRequiredOpenShell": "需要身份驗證;點擊開啟 shell 並登入",
"authenticationRequired": "需要身份驗證",
+8 -2
View File
@@ -2,8 +2,8 @@
"actionCannotBeUndone": "此操作無法撤銷。",
"add": "新增",
"addProfile": "新增設定",
"addTuiAgent": "新增 TUI 代理程式",
"addTuiAgentDescription": "將 CLI 工具註冊為 TUI 代理程式。它會以預設透傳設定出現在代理程式清單中。",
"addTuiAgent": "新增自訂代理程式",
"addTuiAgentDescription": "註冊你已安裝的 CLI。它會出現在代理程式清單中,並帶有一個使用你所選通訊協定的預設設定檔。",
"agentCliFlags": "代理程式 CLI 旗標",
"agentCliFlagsDescription": "啟動時代理程式 CLI 接收的旗標。僅應用已啟用的項。",
"agentInstallAlreadyInProgress": "代理程式安裝已在進行中。",
@@ -11,6 +11,12 @@
"agentNotFound": "未找到代理程式。",
"agentProfileSettings": "{{name}} 代理程式設定檔",
"agentProfilesTitle": "{{name}} 設定",
"agentProtocol": "通訊協定",
"agentProtocolAcp": "ACP",
"agentProtocolAcpHelp": "Kandev 會透過 Agent Client Protocol 驅動該指令。具備結構化對話、工具呼叫、模型與模式。",
"agentProtocolHelp": "只有當該指令會在標準輸入輸出上啟動 ACP 伺服器時才選擇 ACP,例如 {{example}}。",
"agentProtocolTerminal": "終端機",
"agentProtocolTerminalHelp": "Kandev 會在終端機分頁執行該指令。沒有結構化對話、工具呼叫或模型清單。",
"authRequiredOpenLoginTerminal": "需要身分驗證;點選開啟登入終端",
"authRequiredOpenShell": "需要身分驗證;點選開啟 shell 並登入",
"authenticationRequired": "需要身分驗證",