Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ func runGateway() {
httpapi.InitGatewayToken(cfg.Gateway.Token)
exportTokenStore := httpapi.InitExportTokenStore()
defer exportTokenStore.Stop()
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, mcpToolLister)
agentsH, skillsH, tracesH, mcpH, channelInstancesH, providersH, builtinToolsH, pendingMessagesH, teamEventsH, secureCLIH, secureCLIGrantH, mcpUserCredsH := wireHTTP(pgStores, cfg.Agents.Defaults.Workspace, dataDir, bundledSkillsDir, msgBus, toolsReg, providerRegistry, modelReg, permPE.IsOwner, gatewayAddr, cfg.Gateway.Token, mcpToolLister)

// Wire dependencies for system prompt preview parity.
if agentsH != nil {
Expand Down
23 changes: 23 additions & 0 deletions cmd/gateway_agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,29 @@ func buildEmbeddingProvider(
"provider", dbp.Name, "requested", es.Dimensions, "required", store.RequiredMemoryEmbeddingDimensions)
}

// Gemini native provider — uses its own embedding API (not OpenAI-compatible).
if dbp.ProviderType == store.ProviderGeminiNative {
apiKey := dbp.APIKey
if providerReg != nil {
if regProv, regErr := providerReg.Get(context.Background(), dbp.Name); regErr == nil {
if gp, ok := regProv.(interface{ APIKey() string }); ok && gp.APIKey() != "" {
apiKey = gp.APIKey()
}
}
}
if apiKey == "" {
slog.Warn("gemini embedding provider has no API key", "name", dbp.Name)
return nil
}
if model == "" {
model = memory.GeminiDefaultEmbeddingModel
}
ep := memory.NewGeminiEmbeddingProvider(dbp.Name, apiKey, apiBase, model)
ep.WithDimensions(dims)
slog.Info("gemini embedding provider configured", "name", dbp.Name, "model", model, "dims", dims)
return ep
}

// Try registry first for the actual API key / base (handles runtime-registered providers)
if providerReg != nil {
if regProv, regErr := providerReg.Get(context.Background(), dbp.Name); regErr == nil {
Expand Down
6 changes: 5 additions & 1 deletion cmd/gateway_http_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
)

// wireHTTP creates HTTP handlers (agents + skills + traces + MCP + channel instances + providers + builtin tools + pending messages).
func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) {
func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir string, msgBus *bus.MessageBus, toolsReg *tools.Registry, providerReg *providers.Registry, modelReg providers.ModelRegistry, isOwner func(string) bool, gatewayAddr, gatewayToken string, mcpToolLister httpapi.MCPToolLister) (*httpapi.AgentsHandler, *httpapi.SkillsHandler, *httpapi.TracesHandler, *httpapi.MCPHandler, *httpapi.ChannelInstancesHandler, *httpapi.ProvidersHandler, *httpapi.BuiltinToolsHandler, *httpapi.PendingMessagesHandler, *httpapi.TeamEventsHandler, *httpapi.SecureCLIHandler, *httpapi.SecureCLIGrantHandler, *httpapi.MCPUserCredentialsHandler) {
var agentsH *httpapi.AgentsHandler
var skillsH *httpapi.SkillsHandler
var tracesH *httpapi.TracesHandler
Expand Down Expand Up @@ -70,6 +70,10 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
if stores.MCP != nil {
providersH.SetMCPServerLookup(buildMCPServerLookup(stores.MCP))
}
acpMCPData = buildACPMCPData(gatewayAddr, gatewayToken, stores.MCP)
providersH.SetProviderReloadFn(func(p *store.LLMProviderData) {
registerACPFromDB(providerReg, *p)
})
if stores.Tracing != nil {
providersH.SetTracingStore(stores.Tracing)
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/gateway_managed.go
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,8 @@ func wireExtras(
// Unregister old instance (closes ProcessPool) then re-register
providerReg.Unregister(p.Name)
if p.Enabled {
acpMCPData = buildACPMCPData(loopbackAddr(appCfg.Gateway.Host, appCfg.Gateway.Port),
appCfg.Gateway.Token, stores.MCP)
registerACPFromDB(providerReg, *p)
}
})
Expand Down
118 changes: 74 additions & 44 deletions cmd/gateway_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,36 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/tools"
)

// acpMCPData is the package-level MCP bridge config consumed by
// registerACPFromConfig and registerACPFromDB. Callers populate it via
// buildACPMCPData before invoking either register* — keeping these functions
// at their original 2-arg signatures (registry + cfg/p) and making
// hot-reload paths idempotent (they refresh this var before re-registering).
//
// Single-process gateway scope means a package-level var is acceptable here:
// gateway addr/token/MCPStore are fixed for the lifetime of the binary, and
// the four set-sites (startup config, startup DB iteration, two hot-reload
// closures) all derive identical values.
var acpMCPData *providers.MCPConfigData

// buildACPMCPData assembles the MCP bridge config consumed by ACP providers.
// Returns nil when no gateway addr is available, which makes downstream
// settings.MCPData nil and the ACP provider skip MCP server injection.
// mcpStore is optional — when non-nil, the AgentMCPLookup closure is attached
// so per-agent MCP servers are surfaced to the ACP subprocess at session/new
// time (DB-registered providers only; config-based providers run without
// per-agent MCP).
func buildACPMCPData(gatewayAddr, gatewayToken string, mcpStore store.MCPServerStore) *providers.MCPConfigData {
if gatewayAddr == "" {
return nil
}
data := providers.BuildCLIMCPConfigData(nil, gatewayAddr, gatewayToken)
if mcpStore != nil {
data.AgentMCPLookup = buildMCPServerLookup(mcpStore)
}
return data
}

// loopbackAddr normalizes a gateway address for local connections.
// CLI processes on the same machine can't connect to 0.0.0.0 on some OSes.
func loopbackAddr(host string, port int) string {
Expand All @@ -29,6 +59,7 @@ func loopbackAddr(host string, port int) string {
}

func registerProviders(registry *providers.Registry, cfg *config.Config, modelReg providers.ModelRegistry) {
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
if cfg.Providers.Anthropic.APIKey != "" {
registry.Register(providers.NewAnthropicProvider(cfg.Providers.Anthropic.APIKey,
providers.WithAnthropicBaseURL(cfg.Providers.Anthropic.APIBase),
Expand Down Expand Up @@ -188,7 +219,6 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe
opts = append(opts, providers.WithClaudeCLIPermMode(cfg.Providers.ClaudeCLI.PermMode))
}
// Build per-session MCP config: external MCP servers + GoClaw bridge
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
mcpData := providers.BuildCLIMCPConfigData(cfg.Tools.McpServers, gatewayAddr, cfg.Gateway.Token)
opts = append(opts, providers.WithClaudeCLIMCPConfigData(mcpData))
// Enable GoClaw security hooks (shell deny patterns, path restrictions)
Expand All @@ -200,6 +230,7 @@ func registerProviders(registry *providers.Registry, cfg *config.Config, modelRe

// ACP provider (config-based) — orchestrates any ACP-compatible agent binary
if cfg.Providers.ACP.Binary != "" {
acpMCPData = buildACPMCPData(gatewayAddr, cfg.Gateway.Token, nil)
registerACPFromConfig(registry, cfg.Providers.ACP)
}
}
Expand Down Expand Up @@ -276,6 +307,7 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
slog.Warn("failed to load providers from DB", "error", err)
return
}
acpMCPData = buildACPMCPData(gatewayAddr, gatewayToken, mcpStore)
for _, p := range dbProviders {
// Claude CLI doesn't need API key
if !p.Enabled {
Expand Down Expand Up @@ -411,35 +443,43 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi
}

// registerACPFromConfig registers an ACP provider from config file settings.
// All ACP options consume one shared *providers.ACPSettings populated from cfg;
// per-binary defaults (e.g. gemini's --include-directories) are applied inside
// the relevant With* option in the providers package. The MCP bridge config
// is read from the package-level acpMCPData (set by callers via
// buildACPMCPData before invocation).
func registerACPFromConfig(registry *providers.Registry, cfg config.ACPConfig) {
if _, err := exec.LookPath(cfg.Binary); err != nil {
slog.Warn("acp: binary not found, skipping", "binary", cfg.Binary, "error", err)
return
}
idleTTL := 5 * time.Minute
if cfg.IdleTTL != "" {
if d, err := time.ParseDuration(cfg.IdleTTL); err == nil {
idleTTL = d
}
}
workDir := cfg.WorkDir
if workDir == "" {
workDir = defaultACPWorkDir()
}
var opts []providers.ACPOption
if cfg.Model != "" {
opts = append(opts, providers.WithACPModel(cfg.Model))
}
if cfg.PermMode != "" {
opts = append(opts, providers.WithACPPermMode(cfg.PermMode))
settings := &providers.ACPSettings{
Binary: cfg.Binary,
Args: cfg.Args,
Model: cfg.Model,
PermMode: cfg.PermMode,
IdleTTL: cfg.IdleTTL,
WorkDir: cfg.WorkDir,
MCPData: acpMCPData,
}
registry.Register(providers.NewACPProvider(
cfg.Binary, cfg.Args, workDir, idleTTL, tools.DefaultDenyPatterns(), opts...,
settings.Binary, settings.Args, settings.WorkDirOrDefault(),
settings.IdleTTLOrDefault(5*time.Minute),
tools.DefaultDenyPatterns(),
providers.WithACPModel(settings),
providers.WithACPPermMode(settings),
providers.WithACPMCPConfigData(settings),
providers.WithIncludeDirectories(settings),
))
slog.Info("registered provider", "name", "acp", "binary", cfg.Binary)
slog.Info("registered provider", "name", "acp", "binary", cfg.Binary, "args", cfg.Args)
}

// registerACPFromDB registers an ACP provider from a DB provider row.
// registerACPFromDB registers an ACP provider from a DB row.
// Called at startup (via registerProvidersFromDB) and on hot-reload.
// DB JSONB unmarshals directly into providers.ACPSettings — the shared struct's
// json tags match the historic schema (args, idle_ttl, perm_mode, work_dir,
// include_directories). The MCP bridge config is read from the package-level
// acpMCPData (set by callers via buildACPMCPData before invocation).
func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) {
binary := p.APIBase // repurpose api_base as binary path
if binary == "" {
Expand All @@ -454,37 +494,27 @@ func registerACPFromDB(registry *providers.Registry, p store.LLMProviderData) {
slog.Warn("acp: binary not found, skipping", "binary", binary, "error", err)
return
}
// Parse settings JSONB for extra config
var settings struct {
Args []string `json:"args"`
IdleTTL string `json:"idle_ttl"`
PermMode string `json:"perm_mode"`
WorkDir string `json:"work_dir"`
settings := &providers.ACPSettings{
Name: p.Name,
Binary: binary,
Model: p.Name, // historical: provider name doubles as default agent/model
}
if p.Settings != nil {
if err := json.Unmarshal(p.Settings, &settings); err != nil {
if err := json.Unmarshal(p.Settings, settings); err != nil {
slog.Warn("acp: invalid settings JSON, using defaults", "name", p.Name, "error", err)
}
}
idleTTL := 5 * time.Minute
if settings.IdleTTL != "" {
if d, err := time.ParseDuration(settings.IdleTTL); err == nil {
idleTTL = d
}
}
workDir := settings.WorkDir
if workDir == "" {
workDir = defaultACPWorkDir()
}
settings.MCPData = acpMCPData
registry.RegisterForTenant(p.TenantID, providers.NewACPProvider(
binary, settings.Args, workDir, idleTTL, tools.DefaultDenyPatterns(),
providers.WithACPName(p.Name),
providers.WithACPModel(p.Name),
settings.Binary, settings.Args, settings.WorkDirOrDefault(),
settings.IdleTTLOrDefault(5*time.Minute),
tools.DefaultDenyPatterns(),
providers.WithACPName(settings),
providers.WithACPModel(settings),
providers.WithACPPermMode(settings),
providers.WithACPMCPConfigData(settings),
providers.WithIncludeDirectories(settings),
))
slog.Info("registered provider from DB", "name", p.Name, "type", "acp")
}

// defaultACPWorkDir returns the default workspace directory for ACP agents.
func defaultACPWorkDir() string {
return filepath.Join(config.ResolvedDataDirFromEnv(), "acp-workspaces")
}
46 changes: 25 additions & 21 deletions cmd/providers_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func runProvidersAdd() {
if providerID != "" {
verify, err := promptConfirm("Verify connection now?", true)
if err == nil && verify {
runProviderVerify(providerID, "")
runProviderVerify(providerID)
}
}
}
Expand Down Expand Up @@ -266,41 +266,45 @@ func providersDeleteCmd() *cobra.Command {
}

func providersVerifyCmd() *cobra.Command {
var modelFlag string
cmd := &cobra.Command{
return &cobra.Command{
Use: "verify <id>",
Short: "Verify provider connectivity (ping) or a specific model",
Long: "Without --model: pings the provider (registered + reachable check).\nWith --model: sends a small chat request to validate the model alias.",
Short: "Verify provider connectivity and list models",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
runProviderVerify(args[0], modelFlag)
runProviderVerify(args[0])
},
}
cmd.Flags().StringVar(&modelFlag, "model", "", "model alias to verify (omit for connectivity ping)")
return cmd
}

func runProviderVerify(providerID, model string) {
func runProviderVerify(providerID string) {
fmt.Print("Verifying provider... ")
var body any
if model != "" {
body = map[string]string{"model": model}
}
resp, err := gatewayHTTPPost("/v1/providers/"+url.PathEscape(providerID)+"/verify", body)
resp, err := gatewayHTTPPost("/v1/providers/"+url.PathEscape(providerID)+"/verify", nil)
if err != nil {
fmt.Printf("FAILED\n %v\n", err)
return
}
if valid, _ := resp["valid"].(bool); valid {

if ok, _ := resp["success"].(bool); ok {
fmt.Println("OK")
return
}
msg, _ := resp["error"].(string)
if msg == "" {
msg = "verification failed"
// Show available models
raw, _ := json.Marshal(resp["models"])
var models []httpProviderModel
if json.Unmarshal(raw, &models) == nil && len(models) > 0 {
fmt.Printf(" Available models: %d\n", len(models))
limit := 10
for i, m := range models {
if i >= limit {
fmt.Printf(" ... and %d more\n", len(models)-limit)
break
}
fmt.Printf(" - %s\n", m.ID)
}
}
} else {
msg, _ := resp["error"].(string)
fmt.Printf("FAILED\n %s\n", msg)
}
fmt.Printf("FAILED\n %s\n", msg)
}

// defaultBaseURL returns the default API base URL for a provider type.
Expand Down
13 changes: 8 additions & 5 deletions cmd/setup_provider.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"encoding/json"
"fmt"
"net/url"
"os"
Expand Down Expand Up @@ -94,21 +95,23 @@ func addProvider() {
providerID, _ := resp["id"].(string)
fmt.Printf(" Provider %q created.\n", name)

// Auto-verify (ping mode — empty body)
// Auto-verify
if providerID != "" {
fmt.Print(" Verifying... ")
verifyResp, err := gatewayHTTPPost("/v1/providers/"+url.PathEscape(providerID)+"/verify", nil)
if err != nil {
fmt.Printf("FAILED (%v)\n", err)
return
}
if valid, _ := verifyResp["valid"].(bool); valid {
if ok, _ := verifyResp["success"].(bool); ok {
fmt.Println("OK")
raw, _ := json.Marshal(verifyResp["models"])
var models []httpProviderModel
if json.Unmarshal(raw, &models) == nil {
fmt.Printf(" %d models available.\n", len(models))
}
} else {
msg, _ := verifyResp["error"].(string)
if msg == "" {
msg = "verification failed"
}
fmt.Printf("FAILED (%s)\n", msg)
fmt.Println(" You can update the API key later with 'goclaw providers update'.")
}
Expand Down
Loading
Loading