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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ manager-data/
# Editor directories
.idea/
.vscode/
.cursor/*
!.cursor/rules/
!.cursor/rules/**

# Vendored native libraries fetched on demand
third_party/boxlite-go/libboxlite.a
2 changes: 1 addition & 1 deletion cli/onboard/onboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globa
}

func createManagerBot(ctx context.Context, agentsPath, imStatePath string, cfg config.Config, forceRecreateManager bool) (bot.Bot, error) {
agentSvc, err := agent.NewServiceWithLLMAndChannels(effectiveLLMConfig(cfg), cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, agentsPath)
agentSvc, err := agent.NewServiceWithLLMAndChannels(effectiveLLMConfig(cfg), cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, cfg.Bootstrap.ManagerBoxBaseURL, cfg.Bootstrap.BoxliteRegistries, agentsPath)
if err != nil {
return bot.Bot{}, err
}
Expand Down
6 changes: 4 additions & 2 deletions cli/serve/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,12 @@ access_token = %q

[bootstrap]
manager_image = %q
manager_box_base_url = %q
boxlite_registries = %s

[models]
default = %q
`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), cfg.Bootstrap.ManagerImage, llmCfg.DefaultSelector()) + formatEffectiveProviders(llmCfg)
`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), cfg.Bootstrap.ManagerImage, cfg.Bootstrap.ManagerBoxBaseURL, config.FormatStringArray(cfg.Bootstrap.BoxliteRegistries), llmCfg.DefaultSelector()) + formatEffectiveProviders(llmCfg)

if strings.TrimSpace(cfg.Channels.FeishuAdminOpenID) != "" {
content += fmt.Sprintf(`
Expand Down Expand Up @@ -536,7 +538,7 @@ func newAgentService(cfg config.Config) (*agent.Service, error) {
if err != nil {
return nil, err
}
return agent.NewServiceWithLLMAndChannels(effectiveLLMConfig(cfg), cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, agentsPath)
return agent.NewServiceWithLLMAndChannels(effectiveLLMConfig(cfg), cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, cfg.Bootstrap.ManagerBoxBaseURL, cfg.Bootstrap.BoxliteRegistries, agentsPath)
}

func newIMService() (*im.Service, error) {
Expand Down
3 changes: 3 additions & 0 deletions cli/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ func TestServeForegroundPassesContextToServer(t *testing.T) {
`advertise_base_url = "http://example.test"`,
`api_key = "sk*****et"`,
`access_token = "pc*****et"`,
`manager_image = "ghcr.io/example/manager:latest"`,
`manager_box_base_url = ""`,
`boxlite_registries = []`,
`[models]`,
`default = "default.model-test"`,
`[models.providers.default]`,
Expand Down
13 changes: 9 additions & 4 deletions internal/agent/box.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func (s *Service) gatewayBoxOptions(name, botID string, modelCfg config.ModelCon
modelCfg = s.model.Resolved()
}
modelID := modelCfg.ModelID
managerBaseURL := resolveManagerBaseURL(s.server)
managerBaseURL := resolveManagerBaseURL(s.server, s.managerBoxBaseURL)
llmBaseURL := llmBridgeBaseURL(managerBaseURL, botID)
envVars := picoclawBoxEnvVars(managerBaseURL, s.server.AccessToken, botID, llmBaseURL, modelID)
addFeishuBoxEnvVars(envVars, botID, s.channels)
Expand All @@ -71,16 +71,21 @@ func (s *Service) gatewayBoxOptions(name, botID string, modelCfg config.ModelCon
}
//entrypoint, cmd := gatewayStartCommand(managerDebugMode)
opts = append(opts,
//boxlite.WithEntrypoint(entrypoint...),
//boxlite.WithCmd(cmd...),
boxlite.WithCmd("/bin/sh", "-c", "/usr/local/bin/picoclaw gateway -d 1>~/.picoclaw/gateway.log 2>/dev/null"),
// Override image entrypoint so command is executed by shell.
// Otherwise, if the image entrypoint is "picoclaw", "/bin/sh" becomes an invalid subcommand.
boxlite.WithEntrypoint("/bin/sh"),
// Keep gateway process in foreground and capture stderr for troubleshooting.
boxlite.WithCmd("-c", "picoclaw gateway 1>~/.picoclaw/gateway.log 2>&1"),
//boxlite.WithCmd("sleep", "infinity"),
)

hostWorkspaceRoot, err := ensureAgentWorkspace(name, workspaceTemplateForAgent(name, botID))
if err != nil {
return nil, err
}
if _, err := ensureAgentPicoClawConfig(name, botID, s.server, s.managerBoxBaseURL, modelCfg); err != nil {
return nil, err
}
projectsRoot, err := ensureAgentProjectsRoot()
if err != nil {
return nil, err
Expand Down
4 changes: 1 addition & 3 deletions internal/agent/defaults/picoclaw-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,7 @@
},
"placeholder": {
"enabled": true,
"text": [
"Thinking... 💭"
]
"text": ["Thinking... 💭"]
},
"reasoning_channel_id": "",
"crypto_passphrase": ""
Expand Down
35 changes: 19 additions & 16 deletions internal/agent/manager_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ var defaultManagerPicoClawConfig []byte
//go:embed defaults/manager-security.yml
var defaultManagerSecurityConfig string

func ensureManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) (string, error) {
return ensureAgentPicoClawConfig(ManagerName, "u-manager", server, model)
func ensureManagerPicoClawConfig(server config.ServerConfig, managerBoxBaseURL string, model config.ModelConfig) (string, error) {
return ensureAgentPicoClawConfig(ManagerName, "u-manager", server, managerBoxBaseURL, model)
}

func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConfig, model config.ModelConfig) (string, error) {
func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConfig, managerBoxBaseURL string, model config.ModelConfig) (string, error) {
hostRoot, err := agentPicoClawRoot(agentName)
if err != nil {
return "", err
Expand All @@ -33,7 +33,7 @@ func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConf
return "", fmt.Errorf("create manager picoclaw logs dir: %w", err)
}

data, err := renderAgentPicoClawConfig(botID, server, model)
data, err := renderAgentPicoClawConfig(botID, server, managerBoxBaseURL, model)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -61,20 +61,20 @@ func agentPicoClawRoot(agentName string) (string, error) {
return filepath.Join(homeDir, config.AppDirName, managerAgentsDirName, agentName, hostPicoClawDir), nil
}

func renderManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) ([]byte, error) {
return renderAgentPicoClawConfig("u-manager", server, model)
func renderManagerPicoClawConfig(server config.ServerConfig, managerBoxBaseURL string, model config.ModelConfig) ([]byte, error) {
return renderAgentPicoClawConfig("u-manager", server, managerBoxBaseURL, model)
}

func renderAgentPicoClawConfig(botID string, server config.ServerConfig, model config.ModelConfig) ([]byte, error) {
func renderAgentPicoClawConfig(botID string, server config.ServerConfig, managerBoxBaseURL string, model config.ModelConfig) ([]byte, error) {
var cfg map[string]any
if err := json.Unmarshal(defaultManagerPicoClawConfig, &cfg); err != nil {
return nil, fmt.Errorf("decode embedded manager picoclaw config: %w", err)
}

if err := updateModelList(cfg, botID, server, model); err != nil {
if err := updateModelList(cfg, botID, server, managerBoxBaseURL, model); err != nil {
return nil, err
}
if err := updateCSGClawChannel(cfg, botID, server); err != nil {
if err := updateCSGClawChannel(cfg, botID, server, managerBoxBaseURL); err != nil {
return nil, err
}

Expand All @@ -85,7 +85,7 @@ func renderAgentPicoClawConfig(botID string, server config.ServerConfig, model c
return data, nil
}

func updateModelList(cfg map[string]any, botID string, server config.ServerConfig, modelCfg config.ModelConfig) error {
func updateModelList(cfg map[string]any, botID string, server config.ServerConfig, managerBoxBaseURL string, modelCfg config.ModelConfig) error {
modelList, ok := cfg["model_list"].([]any)
if !ok || len(modelList) == 0 {
return fmt.Errorf("embedded manager picoclaw config is missing model_list[0]")
Expand All @@ -104,7 +104,7 @@ func updateModelList(cfg map[string]any, botID string, server config.ServerConfi
}
}

if managerBaseURL := resolveManagerBaseURL(server); managerBaseURL != "" {
if managerBaseURL := resolveManagerBaseURL(server, managerBoxBaseURL); managerBaseURL != "" {
model["api_base"] = llmBridgeBaseURL(managerBaseURL, botID)
}
if server.AccessToken != "" {
Expand All @@ -113,7 +113,7 @@ func updateModelList(cfg map[string]any, botID string, server config.ServerConfi
return nil
}

func updateCSGClawChannel(cfg map[string]any, botID string, server config.ServerConfig) error {
func updateCSGClawChannel(cfg map[string]any, botID string, server config.ServerConfig, managerBoxBaseURL string) error {
channels, ok := cfg["channels"].(map[string]any)
if !ok {
return fmt.Errorf("embedded manager picoclaw config is missing channels")
Expand All @@ -122,7 +122,7 @@ func updateCSGClawChannel(cfg map[string]any, botID string, server config.Server
if !ok {
return fmt.Errorf("embedded manager picoclaw config is missing channels.csgclaw")
}
if baseURL := resolveManagerBaseURL(server); baseURL != "" {
if baseURL := resolveManagerBaseURL(server, managerBoxBaseURL); baseURL != "" {
channel["base_url"] = baseURL
}
if server.AccessToken != "" {
Expand All @@ -133,13 +133,16 @@ func updateCSGClawChannel(cfg map[string]any, botID string, server config.Server
return nil
}

func resolveManagerBaseURL(server config.ServerConfig) string {
func resolveManagerBaseURL(server config.ServerConfig, managerBoxBaseURL string) string {
if box := strings.TrimSpace(managerBoxBaseURL); box != "" {
return strings.TrimRight(box, "/")
}
port := config.ListenPort(server.ListenAddr)
if ip := localIPv4Resolver(); ip != "" {
return fmt.Sprintf("http://%s:%s", ip, port)
}
if server.AdvertiseBaseURL != "" {
return strings.TrimRight(server.AdvertiseBaseURL, "/")
if adv := strings.TrimSpace(server.AdvertiseBaseURL); adv != "" {
return strings.TrimRight(adv, "/")
}
return ""
}
Expand Down
4 changes: 2 additions & 2 deletions internal/agent/manager_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) {
data, err := renderAgentPicoClawConfig("u-ux", config.ServerConfig{
ListenAddr: "0.0.0.0:18080",
AccessToken: "shared-token",
}, config.ModelConfig{
}, "", config.ModelConfig{
Provider: config.ProviderLLMAPI,
ModelID: "gpt-5.4",
BaseURL: "https://cloud.infini-ai.com/maas/v1",
Expand Down Expand Up @@ -73,7 +73,7 @@ func TestEnsureAgentPicoClawConfigWritesConfigFiles(t *testing.T) {
root, err := ensureAgentPicoClawConfig("ux", "u-ux", config.ServerConfig{
ListenAddr: "0.0.0.0:18080",
AccessToken: "shared-token",
}, config.ModelConfig{
}, "", config.ModelConfig{
ModelID: "gpt-5.4",
})
if err != nil {
Expand Down
3 changes: 3 additions & 0 deletions internal/agent/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ func (s *Service) ensureRuntimeAtHome(homeDir string) (*boxlite.Runtime, error)
}

opts := []boxlite.RuntimeOption{boxlite.WithHomeDir(homeDir)}
if len(s.boxliteRegistries) > 0 {
opts = append(opts, boxlite.WithRegistries(s.boxliteRegistries...))
}
rt, err := boxlite.NewRuntime(opts...)
if err != nil {
return nil, fmt.Errorf("create boxlite runtime: %w", err)
Expand Down
48 changes: 26 additions & 22 deletions internal/agent/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,30 +89,32 @@ func TestOnlySetRunBoxCommandHook(hook func(*Service, context.Context, *boxlite.
}

type Service struct {
model config.ModelConfig
llm config.LLMConfig
server config.ServerConfig
channels config.ChannelsConfig
managerImage string
state string
mu sync.RWMutex
runtimes map[string]*boxlite.Runtime
agents map[string]Agent
model config.ModelConfig
llm config.LLMConfig
server config.ServerConfig
channels config.ChannelsConfig
managerImage string
managerBoxBaseURL string
boxliteRegistries []string
state string
mu sync.RWMutex
runtimes map[string]*boxlite.Runtime
agents map[string]Agent
}

func NewService(model config.ModelConfig, server config.ServerConfig, managerImage, statePath string) (*Service, error) {
return NewServiceWithLLM(config.SingleProfileLLM(model), server, managerImage, statePath)
}

func NewServiceWithChannels(model config.ModelConfig, server config.ServerConfig, channels config.ChannelsConfig, managerImage, statePath string) (*Service, error) {
return NewServiceWithLLMAndChannels(config.SingleProfileLLM(model), server, channels, managerImage, statePath)
return NewServiceWithLLMAndChannels(config.SingleProfileLLM(model), server, channels, managerImage, "", nil, statePath)
}

func NewServiceWithLLM(llmCfg config.LLMConfig, server config.ServerConfig, managerImage, statePath string) (*Service, error) {
return NewServiceWithLLMAndChannels(llmCfg, server, config.ChannelsConfig{}, managerImage, statePath)
return NewServiceWithLLMAndChannels(llmCfg, server, config.ChannelsConfig{}, managerImage, "", nil, statePath)
}

func NewServiceWithLLMAndChannels(llmCfg config.LLMConfig, server config.ServerConfig, channels config.ChannelsConfig, managerImage, statePath string) (*Service, error) {
func NewServiceWithLLMAndChannels(llmCfg config.LLMConfig, server config.ServerConfig, channels config.ChannelsConfig, managerImage string, managerBoxBaseURL string, boxliteRegistries []string, statePath string) (*Service, error) {
// step 8.0 agent.Service owns two things together:
// step 8.0.1 the persisted registry of manager/worker metadata
// step 8.0.2 the live Boxlite runtime/box lifecycle.
Expand All @@ -128,14 +130,16 @@ func NewServiceWithLLMAndChannels(llmCfg config.LLMConfig, server config.ServerC
model = config.ModelConfig{}.Resolved()
}
svc := &Service{
model: model,
llm: llmCfg.Normalized(),
server: server,
channels: cloneChannelsConfig(channels),
managerImage: managerImage,
state: statePath,
runtimes: make(map[string]*boxlite.Runtime),
agents: make(map[string]Agent),
model: model,
llm: llmCfg.Normalized(),
server: server,
channels: cloneChannelsConfig(channels),
managerImage: managerImage,
managerBoxBaseURL: managerBoxBaseURL,
boxliteRegistries: append([]string(nil), boxliteRegistries...),
state: statePath,
runtimes: make(map[string]*boxlite.Runtime),
agents: make(map[string]Agent),
}
if strings.TrimSpace(svc.llm.DefaultProfile) == "" {
svc.llm.DefaultProfile = defaultProfile
Expand Down Expand Up @@ -182,7 +186,7 @@ func (svc *Service) EnsureBootstrapManager(ctx context.Context, forceRecreate bo
if err != nil {
return err
}
if _, err := ensureAgentPicoClawConfig(ManagerName, ManagerUserID, svc.server, defaultModel); err != nil {
if _, err := ensureAgentPicoClawConfig(ManagerName, ManagerUserID, svc.server, svc.managerBoxBaseURL, defaultModel); err != nil {
return err
}

Expand Down Expand Up @@ -381,7 +385,7 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (Agent, error)
if err != nil {
return Agent{}, err
}
managerBaseURL := resolveManagerBaseURL(s.server)
managerBaseURL := resolveManagerBaseURL(s.server, s.managerBoxBaseURL)
llmBaseURL := llmBridgeBaseURL(managerBaseURL, id)
boxOpts := []boxlite.BoxOption{
boxlite.WithName(name),
Expand Down
40 changes: 38 additions & 2 deletions internal/agent/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1169,7 +1169,7 @@ func TestAddFeishuBoxEnvVarsRequiresExactBotIDMatch(t *testing.T) {
}
}

func TestResolveManagerBaseURLPrefersLocalIP(t *testing.T) {
func TestResolveManagerBaseURLPrefersManagerBoxBaseURL(t *testing.T) {
orig := localIPv4Resolver
localIPv4Resolver = func() string { return "10.0.0.8" }
t.Cleanup(func() {
Expand All @@ -1178,11 +1178,47 @@ func TestResolveManagerBaseURLPrefersLocalIP(t *testing.T) {

got := resolveManagerBaseURL(config.ServerConfig{
ListenAddr: "0.0.0.0:19090",
AdvertiseBaseURL: "http://127.0.0.1:18080",
AdvertiseBaseURL: "http://192.0.2.1:19090",
}, "http://127.0.0.1:19090")

want := "http://127.0.0.1:19090"
if got != want {
t.Fatalf("resolveManagerBaseURL() = %q, want %q", got, want)
}
}

func TestResolveManagerBaseURLFallsBackToLocalIP(t *testing.T) {
orig := localIPv4Resolver
localIPv4Resolver = func() string { return "10.0.0.8" }
t.Cleanup(func() {
localIPv4Resolver = orig
})

got := resolveManagerBaseURL(config.ServerConfig{
ListenAddr: "0.0.0.0:19090",
AdvertiseBaseURL: "",
}, "")

want := "http://10.0.0.8:19090"
if got != want {
t.Fatalf("resolveManagerBaseURL() = %q, want %q", got, want)
}
}

func TestResolveManagerBaseURLFallsBackToAdvertise(t *testing.T) {
orig := localIPv4Resolver
localIPv4Resolver = func() string { return "" }
t.Cleanup(func() {
localIPv4Resolver = orig
})

got := resolveManagerBaseURL(config.ServerConfig{
ListenAddr: "0.0.0.0:19090",
AdvertiseBaseURL: "http://192.0.2.2:19090",
}, "")

want := "http://192.0.2.2:19090"
if got != want {
t.Fatalf("resolveManagerBaseURL() = %q, want %q", got, want)
}
}
Loading