Files
kandev/apps/backend/internal/agent/registry/provider.go
T
2026-05-16 20:07:57 +01:00

237 lines
8.2 KiB
Go

package registry
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
// Provide creates and loads the agent registry.
//
// KANDEV_MOCK_AGENT controls mock-agent availability:
// - "only" → E2E mode: only register mock agent, skip all others
// - "true" → Dev mode: load all agents AND enable mock agent
// - unset → Production: load all agents, mock agent disabled
//
// KANDEV_MOCK_PROVIDERS is a comma-separated list of canonical routing
// provider IDs registered as additional MockAgent instances. This
// unblocks coverage of the office provider-routing feature: routing
// config validation requires real-looking provider IDs to be present
// in the registry, while every launch still routes through the mock
// binary. Entries not in RoutableProviderIDs are skipped with a
// warning. It is honoured only when explicitly set, in both dev and
// E2E modes, and is never honoured in production.
//
// For the routing UI without real CLIs installed, set
// `KANDEV_MOCK_PROVIDERS=claude-acp,codex-acp,opencode-acp` when
// launching `make dev`.
func Provide(log *logger.Logger) (*Registry, func() error, error) {
reg := NewRegistry(log)
mockMode := os.Getenv("KANDEV_MOCK_AGENT")
mockProviders := os.Getenv("KANDEV_MOCK_PROVIDERS")
if mockMode == "only" {
// E2E mode: only register mock agent — skip agent discovery for all others
_ = reg.Register(agents.NewMockAgent())
configureMockAgent(reg, "mock-agent", log)
registerExtraMockProviders(reg, log, mockProviders)
validateMockProviders(reg, mockProviders, log)
} else {
reg.LoadDefaults()
if mockMode == "true" {
// Dev mode: enable the base mock agent alongside the real
// agents. KANDEV_MOCK_PROVIDERS is opt-in — when set, the
// listed canonicals are replaced with MockAgent aliases so
// the routing UI is exercisable without the real CLIs.
configureMockAgent(reg, "mock-agent", log)
registerExtraMockProviders(reg, log, mockProviders)
validateMockProviders(reg, mockProviders, log)
}
registerRealProvidersProber(reg, log)
}
return reg, func() error { return nil }, nil
}
// validateMockProviders logs an error if any provider listed in the raw
// comma-separated string is missing from the registry after
// registerExtraMockProviders. A missing provider means the routing
// catalogue will reject every non-empty provider_order, causing 400s
// in the office-routing-* E2E specs. Failing loudly here surfaces the
// root cause instead of leaving it to cascade into opaque HTTP errors.
func validateMockProviders(reg *Registry, raw string, log *logger.Logger) {
if raw == "" {
return
}
for _, entry := range strings.Split(raw, ",") {
id := strings.TrimSpace(entry)
if id == "" || !IsRoutableProviderID(id) {
continue
}
if !reg.Exists(id) {
log.Error("KANDEV_MOCK_PROVIDERS: provider missing from registry after registration — routing catalogue will be empty",
zap.String("id", id))
}
}
}
// registerRealProvidersProber wires the shared ACP probe to each
// routable real provider. The probe spawns the agent's CLI via
// BuildCommand, performs a JSON-RPC initialize, and tears down — no
// session ever starts. Called once at boot in non-mock mode so the
// HTTP /routing/retry endpoint can flip a degraded provider back to
// healthy without waiting for the next real launch.
func registerRealProvidersProber(reg *Registry, log *logger.Logger) {
resolver := func(providerID string) ([]string, map[string]string, bool) {
ag, ok := reg.Get(providerID)
if !ok || !ag.Enabled() {
return nil, nil, false
}
cmd := ag.BuildCommand(agents.CommandOptions{})
if cmd.IsEmpty() {
return nil, nil, false
}
var env map[string]string
if rt := ag.Runtime(); rt != nil && len(rt.Env) > 0 {
env = make(map[string]string, len(rt.Env))
for k, v := range rt.Env {
env[k] = v
}
}
return cmd.Args(), env, true
}
probe := routingerr.NewACPProbe(resolver, log)
for _, id := range RoutableProviderIDs {
// Skip ids that are not actually loaded (e.g. an agent type
// that was removed from LoadDefaults but still appears in the
// routable list). The probe is harmless to register against a
// missing agent — but registering only loaded ones keeps the
// boot log noise minimal.
if !reg.Exists(id) {
continue
}
routingerr.RegisterProber(id, probe)
}
}
// registerExtraMockProviders parses the supplied comma-separated list
// of provider IDs and registers each accepted entry as a MockAgent
// alias under that canonical provider ID.
//
// In KANDEV_MOCK_AGENT=only (E2E) mode the canonicals are not loaded by
// LoadDefaults so each entry is a fresh registration. In
// KANDEV_MOCK_AGENT=true (dev) mode the real agents were just loaded —
// we Replace the entry with a MockAgent so the routing UI exercises
// the mock binary instead of requiring the real CLI to be installed
// on the developer's machine.
func registerExtraMockProviders(reg *Registry, log *logger.Logger, raw string) {
if raw == "" {
return
}
for _, entry := range strings.Split(raw, ",") {
id := strings.TrimSpace(entry)
if id == "" {
continue
}
if !IsRoutableProviderID(id) {
log.Warn("KANDEV_MOCK_PROVIDERS: skipping unknown provider id",
zap.String("id", id),
zap.Strings("allowed", RoutableProviderIDs))
continue
}
displayName := mockDisplayNameFor(id)
ag := agents.NewMockAgentWithID(id, displayName, displayName)
if err := reg.Replace(ag); err != nil {
log.Warn("KANDEV_MOCK_PROVIDERS: register failed",
zap.String("id", id), zap.Error(err))
continue
}
configureMockAgent(reg, id, log)
// Register an E2E-only prober so /routing/retry can mark the
// provider healthy without depending on a follow-up launch.
// The prober consults the live injection map: if KANDEV_PROVIDER_
// FAILURES still names this provider, the probe fails with that
// code; otherwise it succeeds (provider flips healthy).
routingerr.RegisterProber(id, &mockProvProber{providerID: id})
}
}
// mockProvProber is the E2E-only ProviderProber wired in
// registerExtraMockProviders. It mirrors how a real prober for
// claude-acp/codex-acp would behave: a cheap availability check that
// returns the injected error when the test has armed one, and nil
// when the injection has been cleared.
type mockProvProber struct{ providerID string }
func (p *mockProvProber) Probe(_ context.Context, _ routingerr.ProbeInput) *routingerr.Error {
if _, ok := routingerr.InjectedCode(p.providerID); ok {
return routingerr.Classify(routingerr.Input{
Phase: routingerr.PhaseProcessStart,
ProviderID: p.providerID,
})
}
return nil
}
// mockDisplayNameFor returns a friendly display name for a routable
// provider id (e.g. "Mock Claude" for "claude-acp"). Falls back to the
// id itself when the prefix is unknown.
func mockDisplayNameFor(id string) string {
switch id {
case "claude-acp":
return "Mock Claude"
case "codex-acp":
return "Mock Codex"
case "opencode-acp":
return "Mock OpenCode"
case "copilot-acp":
return "Mock Copilot"
case "amp-acp":
return "Mock Amp"
default:
return "Mock " + id
}
}
// configureMockAgent enables and configures the mock agent binary path and capabilities.
// KANDEV_MOCK_AGENT_MCP=false disables MCP support (defaults to enabled).
func configureMockAgent(reg *Registry, id string, log *logger.Logger) {
ag, ok := reg.Get(id)
if !ok {
return
}
mock, ok := ag.(*agents.MockAgent)
if !ok {
return
}
mock.SetEnabled(true)
if os.Getenv("KANDEV_MOCK_AGENT_MCP") == "false" {
mock.SetSupportsMCP(false)
}
// Resolve binary path: same directory as the running executable.
// On Windows the binary is mock-agent.exe — exec.Command on Windows
// only auto-appends .exe for PATH lookups, not absolute paths, so
// the basename must include the extension here.
exePath, err := os.Executable()
if err != nil {
return
}
binaryName := "mock-agent"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binaryPath := filepath.Join(filepath.Dir(exePath), binaryName)
mock.SetBinaryPath(binaryPath)
log.Info("mock agent enabled",
zap.String("id", id),
zap.String("cmd", binaryPath),
zap.Bool("supports_mcp", mock.SupportsMCPEnabled()))
}