From 38d87d5492eaee249242cf380dde21bd7093c78e Mon Sep 17 00:00:00 2001 From: Yun Long Date: Sat, 11 Apr 2026 12:22:07 +0800 Subject: [PATCH 1/5] Add profile-based LLM routing and tighten manager-worker dispatch tracking --- README.md | 60 +++- README.zh.md | 60 +++- cli/agent/agent.go | 4 +- cli/app_test.go | 34 +- cli/command/command.go | 12 +- cli/http_client.go | 13 +- cli/onboard/onboard.go | 29 +- cli/serve/serve.go | 98 +++++- cli/serve/serve_test.go | 36 +- cmd/csgclaw/main.go | 3 + docs/api.md | 92 ++++- internal/agent/box.go | 103 ++++-- internal/agent/manager_config.go | 165 ++++++++- internal/agent/manager_config_test.go | 157 ++++++++- internal/agent/model.go | 22 +- internal/agent/model_selection.go | 70 ++++ internal/agent/runtime.go | 2 + internal/agent/service.go | 171 +++++++--- internal/agent/service_test.go | 202 +++++++++-- internal/agent/store.go | 3 + internal/api/handler.go | 20 +- internal/api/handler_test.go | 126 ++++++- internal/api/llm.go | 51 +++ internal/api/picoclaw.go | 14 +- internal/api/router.go | 5 + internal/bot/service_test.go | 6 +- internal/config/config.go | 155 +++++++-- internal/config/config_test.go | 77 ++++- internal/config/model_validation.go | 205 +++++++++++ internal/im/events.go | 2 + internal/im/service.go | 14 + internal/llm/service.go | 136 ++++++++ internal/llm/service_test.go | 176 ++++++++++ internal/server/http.go | 6 +- skills/manager-worker-dispatch/SKILL.md | 35 ++ .../agents/openai.yaml | 2 +- .../references/api-contract.md | 21 +- .../scripts/manager_worker_api.py | 319 ++++++++++++++++-- .../scripts/test_manager_worker_api.py | 167 +++++++++ web/static/app.js | 7 + 40 files changed, 2634 insertions(+), 246 deletions(-) create mode 100644 internal/agent/model_selection.go create mode 100644 internal/api/llm.go create mode 100644 internal/config/model_validation.go create mode 100644 internal/llm/service.go create mode 100644 internal/llm/service_test.go create mode 100644 skills/manager-worker-dispatch/scripts/test_manager_worker_api.py diff --git a/README.md b/README.md index 2480ee0..91b44dc 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,70 @@ go build ./cmd/csgclaw ## Quick Start ```bash -csgclaw onboard --base-url --api-key --model-id +csgclaw onboard --profile default --default-profile default --base-url --api-key --model-id [--reasoning-effort ] csgclaw serve ``` Open the printed URL (e.g. `http://127.0.0.1:18080/`) in your browser to enter the IM workspace. +## LLM Profile Examples + +### Remote LLM API + +```toml +[server] +listen_addr = "0.0.0.0:18080" +advertise_base_url = "http://127.0.0.1:18080" +access_token = "your_access_token" + +[llm] +default_profile = "remote-main" + +[llm.profiles.remote-main] +provider = "llm-api" +base_url = "https://api.openai.com/v1" +api_key = "sk-your-api-key" +model_id = "gpt-5.4" +reasoning_effort = "medium" + +[bootstrap] +manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" +``` + +### Local Codex via CLIProxyAPI + +```toml +[server] +listen_addr = "0.0.0.0:18080" +advertise_base_url = "http://127.0.0.1:18080" +access_token = "your_access_token" + +[llm] +default_profile = "codex-main" + +[llm.profiles.codex-main] +provider = "llm-api" +base_url = "http://127.0.0.1:8317/v1" +api_key = "local" +model_id = "gpt-5.4" +reasoning_effort = "medium" + +[bootstrap] +manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" +``` + +### Worker Override Example + +```json +{ + "id": "u-reviewer", + "name": "reviewer", + "description": "code review worker", + "profile": "codex-main", + "role": "worker" +} +``` + ## Features - **Multi-agent coordination** — work with a team of specialized agents through a single coordination point, not a pile of chat windows diff --git a/README.zh.md b/README.zh.md index a9c15b7..d8a56fb 100644 --- a/README.zh.md +++ b/README.zh.md @@ -34,12 +34,70 @@ go build ./cmd/csgclaw ## 快速开始 ```bash -csgclaw onboard --base-url --api-key --model-id +csgclaw onboard --profile default --default-profile default --base-url --api-key --model-id [--reasoning-effort ] csgclaw serve ``` 执行后 CLI 会打印访问地址(例如 `http://127.0.0.1:18080/`),在浏览器中打开即可进入 IM 工作区。 +## LLM Profile 配置示例 + +### 远程 LLM API + +```toml +[server] +listen_addr = "0.0.0.0:18080" +advertise_base_url = "http://127.0.0.1:18080" +access_token = "your_access_token" + +[llm] +default_profile = "remote-main" + +[llm.profiles.remote-main] +provider = "llm-api" +base_url = "https://api.openai.com/v1" +api_key = "sk-your-api-key" +model_id = "gpt-5.4" +reasoning_effort = "medium" + +[bootstrap] +manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" +``` + +### 通过 CLIProxyAPI 接入本地 Codex + +```toml +[server] +listen_addr = "0.0.0.0:18080" +advertise_base_url = "http://127.0.0.1:18080" +access_token = "your_access_token" + +[llm] +default_profile = "codex-main" + +[llm.profiles.codex-main] +provider = "llm-api" +base_url = "http://127.0.0.1:8317/v1" +api_key = "local" +model_id = "gpt-5.4" +reasoning_effort = "medium" + +[bootstrap] +manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" +``` + +### Worker 覆盖示例 + +```json +{ + "id": "u-reviewer", + "name": "reviewer", + "description": "code review worker", + "profile": "codex-main", + "role": "worker" +} +``` + ## 功能特性 - **多智能体协作** — 通过单一协调入口与一组分工明确的 Worker 协作,而不是轮流操作多个聊天窗口 diff --git a/cli/agent/agent.go b/cli/agent/agent.go index 2260b58..f0003e9 100644 --- a/cli/agent/agent.go +++ b/cli/agent/agent.go @@ -89,7 +89,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, id := fs.String("id", "", "agent id") name := fs.String("name", "", "agent name") description := fs.String("description", "", "agent description") - modelID := fs.String("model-id", "", "agent model identifier") + profile := fs.String("profile", "", "agent llm profile") if err := fs.Parse(args); err != nil { return err } @@ -101,7 +101,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, ID: *id, Name: *name, Description: *description, - ModelID: *modelID, + Profile: *profile, }) if err != nil { return err diff --git a/cli/app_test.go b/cli/app_test.go index b5ca215..f058a86 100644 --- a/cli/app_test.go +++ b/cli/app_test.go @@ -52,14 +52,14 @@ func TestExecuteAgentListUsesHTTPClient(t *testing.T) { if req.URL.String() != "http://example.test/api/v1/agents" { t.Fatalf("url = %q, want %q", req.URL.String(), "http://example.test/api/v1/agents") } - return jsonResponse(http.StatusOK, `[{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z"}]`), nil + return jsonResponse(http.StatusOK, `[{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z","profile":"codex-main"}]`), nil }), } if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "agent", "list"}); err != nil { t.Fatalf("Execute() error = %v", err) } - assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running") + assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running", "codex-main") } func TestExecuteBotListUsesDefaultChannel(t *testing.T) { @@ -253,8 +253,8 @@ func TestExecuteMessageSendsToFeishuChannel(t *testing.T) { func TestRenderAgentsTableAlignsLongColumns(t *testing.T) { var buf bytes.Buffer agents := []agent.Agent{ - {ID: "u-manager", Name: "manager", Role: "manager", Status: "running"}, - {ID: "u-dev", Name: "dev", Role: "worker", Status: "running"}, + {ID: "u-manager", Name: "manager", Role: "manager", Status: "running", Profile: "codex-main"}, + {ID: "u-dev", Name: "dev", Role: "worker", Status: "running", Profile: "claude-main"}, {ID: "u-alex", Name: "alex", Role: "worker", Status: "running"}, } @@ -267,7 +267,7 @@ func TestRenderAgentsTableAlignsLongColumns(t *testing.T) { t.Fatalf("line count = %d, want 4; output=%q", len(lines), buf.String()) } - re := regexp.MustCompile(`^(\S+)(\s{2,})(\S+)(\s{2,})(\S+)(\s{2,})(\S+)$`) + re := regexp.MustCompile(`^(\S+)(\s{2,})(\S+)(\s{2,})(\S+)(\s{2,})(\S+)(\s{2,})(\S+)$`) if re.FindStringSubmatchIndex(lines[0]) == nil { t.Fatalf("header not aligned: %q", lines[0]) } @@ -282,6 +282,16 @@ func TestRenderAgentsTableAlignsLongColumns(t *testing.T) { } } +func TestRenderAgentsTableUsesDashForMissingProfile(t *testing.T) { + var buf bytes.Buffer + + if err := renderAgentsTable(&buf, []agent.Agent{{ID: "u-alice", Name: "alice", Role: "worker", Status: "running"}}); err != nil { + t.Fatalf("renderAgentsTable() error = %v", err) + } + + assertTableHasRow(t, buf.String(), "u-alice", "alice", "worker", "running", "-") +} + func TestExecuteAgentCreateUsesHTTPClient(t *testing.T) { var stdout bytes.Buffer app := &App{ @@ -308,19 +318,19 @@ func TestExecuteAgentCreateUsesHTTPClient(t *testing.T) { if payload["description"] != "worker" { t.Fatalf("payload[description] = %#v, want %q", payload["description"], "worker") } - if payload["model_id"] != "gpt-test" { - t.Fatalf("payload[model_id] = %#v, want %q", payload["model_id"], "gpt-test") + if payload["profile"] != "cliproxy-codex" { + t.Fatalf("payload[profile] = %#v, want %q", payload["profile"], "cliproxy-codex") } - return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z"}`), nil + return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z","profile":"codex-main"}`), nil }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--token", "secret-token", "agent", "create", "--name", "alice", "--description", "worker", "--model-id", "gpt-test"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--token", "secret-token", "agent", "create", "--name", "alice", "--description", "worker", "--profile", "cliproxy-codex"}) if err != nil { t.Fatalf("Execute() error = %v", err) } - assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running") + assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running", "codex-main") } func TestExecuteAgentDeleteUsesHTTPClient(t *testing.T) { @@ -357,14 +367,14 @@ func TestExecuteAgentStatusByIDUsesHTTPClient(t *testing.T) { if req.URL.String() != "http://example.test/api/v1/agents/u-alice" { t.Fatalf("url = %q, want %q", req.URL.String(), "http://example.test/api/v1/agents/u-alice") } - return jsonResponse(http.StatusOK, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z"}`), nil + return jsonResponse(http.StatusOK, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z","profile":"codex-main"}`), nil }), } if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "agent", "status", "u-alice"}); err != nil { t.Fatalf("Execute() error = %v", err) } - assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running") + assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running", "codex-main") } func TestExecuteAgentLogsUsesHTTPClient(t *testing.T) { diff --git a/cli/command/command.go b/cli/command/command.go index 90b7ccc..00f482c 100644 --- a/cli/command/command.go +++ b/cli/command/command.go @@ -143,13 +143,21 @@ func RenderMessages(output string, w io.Writer, messages []apitypes.Message) err func RenderAgentsTable(w io.Writer, agents []agent.Agent) error { tw := NewTableWriter(w) - fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS") + fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS\tPROFILE") for _, a := range agents { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status, displayAgentProfile(a.Profile)) } return tw.Flush() } +func displayAgentProfile(profile string) string { + profile = strings.TrimSpace(profile) + if profile == "" { + return "-" + } + return profile +} + func RenderBotsTable(w io.Writer, bots []apitypes.Bot) error { tw := NewTableWriter(w) fmt.Fprintln(tw, "ID\tNAME\tROLE\tCHANNEL\tAGENT\tUSER") diff --git a/cli/http_client.go b/cli/http_client.go index 4ff2fbc..3ff94fc 100644 --- a/cli/http_client.go +++ b/cli/http_client.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strings" "text/tabwriter" "csgclaw/cli/command" @@ -88,9 +89,9 @@ func writeJSON(w io.Writer, v any) error { func renderAgentsTable(w io.Writer, agents []agent.Agent) error { tw := newTableWriter(w) - fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS") + fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS\tPROFILE") for _, a := range agents { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status, displayAgentProfile(a.Profile)) } return tw.Flush() } @@ -99,6 +100,14 @@ func renderBotsTable(w io.Writer, bots []apitypes.Bot) error { return command.RenderBotsTable(w, bots) } +func displayAgentProfile(profile string) string { + profile = strings.TrimSpace(profile) + if profile == "" { + return "-" + } + return profile +} + func renderRoomsTable(w io.Writer, rooms []apitypes.Room) error { return command.RenderRoomsTable(w, rooms) } diff --git a/cli/onboard/onboard.go b/cli/onboard/onboard.go index 7dac7e4..e7aa42f 100644 --- a/cli/onboard/onboard.go +++ b/cli/onboard/onboard.go @@ -2,6 +2,7 @@ package onboard import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -110,7 +111,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.NewServiceWithChannels(cfg.Model, cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, agentsPath) + agentSvc, err := agent.NewServiceWithLLMAndChannels(effectiveLLMConfig(cfg), cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, agentsPath) if err != nil { return bot.Bot{}, err } @@ -160,14 +161,17 @@ func configPath(path string) (string, error) { } func validateModelConfig(cfg config.Config) error { - missing := cfg.Model.MissingFields() - if len(missing) == 0 { - return nil - } - return fmt.Errorf( - "model config is incomplete (%s); run `csgclaw onboard --base-url --api-key --model-id `", - strings.Join(missingModelFlags(missing), ", "), - ) + if err := effectiveLLMConfig(cfg).Validate(); err != nil { + var validationErr *config.ModelValidationError + if errors.As(err, &validationErr) && len(validationErr.MissingFields) > 0 { + return fmt.Errorf( + "llm config is incomplete (%s); run `csgclaw onboard --base-url --api-key --model-id `", + strings.Join(missingModelFlags(validationErr.MissingFields), ", "), + ) + } + return fmt.Errorf("llm config is invalid: %w", err) + } + return nil } func missingModelFlags(fields []string) []string { @@ -186,3 +190,10 @@ func missingModelFlags(fields []string) []string { } return flags } + +func effectiveLLMConfig(cfg config.Config) config.LLMConfig { + if len(cfg.LLM.Profiles) != 0 || strings.TrimSpace(cfg.LLM.DefaultProfile) != "" { + return cfg.LLM.Normalized() + } + return config.SingleProfileLLM(cfg.Model) +} diff --git a/cli/serve/serve.go b/cli/serve/serve.go index bc0d334..1ba7e39 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -22,15 +22,23 @@ import ( "csgclaw/internal/channel" "csgclaw/internal/config" "csgclaw/internal/im" + "csgclaw/internal/llm" "csgclaw/internal/server" ) var ( - RunServer = server.Run - NewAgentService = newAgentService - NewBotService = newBotService - NewIMService = newIMService - NewFeishuService = newFeishuService + RunServer = server.Run + NewAgentService = newAgentService + NewBotService = newBotService + NewIMService = newIMService + NewFeishuService = newFeishuService + NewLLMService = newLLMService + EnsureBootstrapManager = func(ctx context.Context, svc *agent.Service, forceRecreate bool) error { + if svc == nil { + return nil + } + return svc.EnsureBootstrapManager(ctx, forceRecreate) + } ) type serveCmd struct{} @@ -261,10 +269,17 @@ func serveBackground(run *command.Context, cfg config.Config, globals command.Gl } func startServer(ctx context.Context, cfg config.Config, svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, feishuSvc *channel.FeishuService) error { + if err := EnsureBootstrapManager(ctx, svc, false); err != nil { + return err + } imBus := im.NewBus() if botSvc != nil { botSvc.SetDependencies(svc, imSvc, feishuSvc) } + llmSvc, err := NewLLMService(cfg, svc) + if err != nil { + return err + } return RunServer(server.Options{ ListenAddr: cfg.Server.ListenAddr, Service: svc, @@ -273,6 +288,7 @@ func startServer(ctx context.Context, cfg config.Config, svc *agent.Service, bot IMBus: imBus, PicoClaw: im.NewPicoClawBridge(cfg.Server.AccessToken), Feishu: feishuSvc, + LLM: llmSvc, AccessToken: cfg.Server.AccessToken, Context: ctx, }) @@ -377,19 +393,18 @@ func printEffectiveConfig(run *command.Context, cfg config.Config) { } func formatEffectiveConfig(cfg config.Config) string { + llmCfg := effectiveLLMConfig(cfg) content := fmt.Sprintf(`[server] listen_addr = %q advertise_base_url = %q access_token = %q -[model] -base_url = %q -api_key = %q -model_id = %q +[llm] +default_profile = %q [bootstrap] manager_image = %q -`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), cfg.Model.BaseURL, partiallyMaskSecret(cfg.Model.APIKey), cfg.Model.ModelID, cfg.Bootstrap.ManagerImage) +`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), llmCfg.EffectiveDefaultProfile(), cfg.Bootstrap.ManagerImage) + formatEffectiveProfiles(llmCfg) if strings.TrimSpace(cfg.Channels.FeishuAdminOpenID) != "" { content += fmt.Sprintf(` @@ -435,14 +450,17 @@ func loadConfig(path string) (config.Config, error) { } func validateModelConfig(cfg config.Config) error { - missing := cfg.Model.MissingFields() - if len(missing) == 0 { - return nil + if err := effectiveLLMConfig(cfg).Validate(); err != nil { + var validationErr *config.ModelValidationError + if errors.As(err, &validationErr) && len(validationErr.MissingFields) > 0 { + return fmt.Errorf( + "llm config is incomplete (%s); run `csgclaw onboard --profile --model-id [--base-url ] [--api-key ] [--reasoning-effort ] [--default-profile ]`", + strings.Join(missingModelFlags(validationErr.MissingFields), ", "), + ) + } + return fmt.Errorf("llm config is invalid: %w", err) } - return fmt.Errorf( - "model config is incomplete (%s); run `csgclaw onboard --base-url --api-key --model-id `", - strings.Join(missingModelFlags(missing), ", "), - ) + return nil } func missingModelFlags(fields []string) []string { @@ -455,6 +473,10 @@ func missingModelFlags(fields []string) []string { flags = append(flags, "--api-key") case "model_id": flags = append(flags, "--model-id") + case "provider": + flags = append(flags, "--provider") + case "default_profile": + flags = append(flags, "--default-profile") default: flags = append(flags, field) } @@ -467,7 +489,7 @@ func newAgentService(cfg config.Config) (*agent.Service, error) { if err != nil { return nil, err } - return agent.NewServiceWithChannels(cfg.Model, cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, agentsPath) + return agent.NewServiceWithLLMAndChannels(effectiveLLMConfig(cfg), cfg.Server, cfg.Channels, cfg.Bootstrap.ManagerImage, agentsPath) } func newIMService() (*im.Service, error) { @@ -505,3 +527,43 @@ func feishuAppsFromConfig(cfg config.ChannelsConfig) map[string]channel.FeishuAp } return apps } + +func newLLMService(cfg config.Config, svc *agent.Service) (*llm.Service, error) { + if svc == nil { + return nil, nil + } + return llm.NewService(cfg.Model, svc), nil +} + +func effectiveLLMConfig(cfg config.Config) config.LLMConfig { + if len(cfg.LLM.Profiles) != 0 || strings.TrimSpace(cfg.LLM.DefaultProfile) != "" { + return cfg.LLM.Normalized() + } + return config.SingleProfileLLM(cfg.Model) +} + +func formatEffectiveProfiles(llmCfg config.LLMConfig) string { + llmCfg = llmCfg.Normalized() + var b strings.Builder + for _, name := range sortedProfileNames(llmCfg.Profiles) { + profile := llmCfg.Profiles[name].Resolved() + fmt.Fprintf(&b, ` +[llm.profiles.%s] +provider = %q +base_url = %q +api_key = %q +model_id = %q +reasoning_effort = %q +`, name, profile.EffectiveProvider(), profile.BaseURL, partiallyMaskSecret(profile.APIKey), profile.ModelID, profile.ReasoningEffort) + } + return b.String() +} + +func sortedProfileNames(profiles map[string]config.ModelConfig) []string { + names := make([]string, 0, len(profiles)) + for name := range profiles { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index fb97d42..aafb23d 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -12,6 +12,7 @@ import ( "csgclaw/internal/channel" "csgclaw/internal/config" "csgclaw/internal/im" + "csgclaw/internal/llm" "csgclaw/internal/server" ) @@ -21,18 +22,23 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { origNewBotService := NewBotService origNewIMService := NewIMService origNewFeishuService := NewFeishuService + origNewLLMService := NewLLMService + origEnsureBootstrapManager := EnsureBootstrapManager t.Cleanup(func() { RunServer = origRunServer NewAgentService = origNewAgentService NewBotService = origNewBotService NewIMService = origNewIMService NewFeishuService = origNewFeishuService + NewLLMService = origNewLLMService + EnsureBootstrapManager = origEnsureBootstrapManager }) ctx := context.WithValue(context.Background(), struct{}{}, "serve-context") + svc := &agent.Service{} NewAgentService = func(config.Config) (*agent.Service, error) { - return nil, nil + return svc, nil } NewIMService = func() (*im.Service, error) { return nil, nil @@ -47,8 +53,25 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { } return nil, nil } + NewLLMService = func(config.Config, *agent.Service) (*llm.Service, error) { + return nil, nil + } called := false + bootstrapped := false + EnsureBootstrapManager = func(gotCtx context.Context, gotSvc *agent.Service, forceRecreate bool) error { + bootstrapped = true + if gotCtx != ctx { + t.Fatalf("EnsureBootstrapManager context = %v, want %v", gotCtx, ctx) + } + if gotSvc != svc { + t.Fatalf("EnsureBootstrapManager service = %p, want %p", gotSvc, svc) + } + if forceRecreate { + t.Fatal("EnsureBootstrapManager forceRecreate = true, want false") + } + return nil + } RunServer = func(opts server.Options) error { called = true if opts.Context != ctx { @@ -68,9 +91,10 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { AccessToken: "pc-secret", }, Model: config.ModelConfig{ - BaseURL: "http://llm.test", - APIKey: "sk-secret", - ModelID: "model-test", + Provider: "llm-api", + BaseURL: "http://llm.test", + APIKey: "sk-secret", + ModelID: "model-test", }, Bootstrap: config.BootstrapConfig{ ManagerImage: "ghcr.io/example/manager:latest", @@ -92,6 +116,9 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { if !called { t.Fatal("RunServer was not called") } + if !bootstrapped { + t.Fatal("EnsureBootstrapManager was not called") + } got := run.Stdout.(*bytes.Buffer).String() for _, want := range []string{ @@ -105,6 +132,7 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { `[channels.feishu.manager]`, `app_id = "cli_manager"`, `app_secret = "ma**********et"`, + `provider = "llm-api"`, "CSGClaw IM is available at: http://example.test/", } { if !strings.Contains(got, want) { diff --git a/cmd/csgclaw/main.go b/cmd/csgclaw/main.go index 68d2598..daea8e4 100644 --- a/cmd/csgclaw/main.go +++ b/cmd/csgclaw/main.go @@ -13,6 +13,7 @@ import ( ) func main() { + // step 1.0 Start from a thin entrypoint and hand off almost everything to cli.App. log.SetFlags(0) if err := run(os.Args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -23,11 +24,13 @@ func main() { } func run(args []string) error { + // step 1.1 Build the CLI application object, which owns command dispatch and shared dependencies. app := cli.New() return executeWithSignalContext(args, app.Execute) } func executeWithSignalContext(args []string, execFn func(context.Context, []string) error) error { + // step 1.2 Give every command a cancellation context so serve/log streaming can shut down cleanly. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return execFn(ctx, args) diff --git a/docs/api.md b/docs/api.md index c751d54..f175080 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,7 +33,11 @@ ok "role": "worker", "status": "running", "created_at": "2026-03-28T12:00:03Z", - "model_id": "gpt-4o-mini" + "profile": "cliproxy-codex", + "provider": "llm-api", + "model_id": "gpt-5.4", + "reasoning_effort": "medium", + "image": "ghcr.io/russellluo/picoclaw:2026.4.8.1" } ``` @@ -58,7 +62,7 @@ ok "id": "u-alice", "name": "alice", "description": "frontend dev", - "model_id": "gpt-4o-mini", + "profile": "cliproxy-codex", "role": "worker" } ``` @@ -73,7 +77,10 @@ ok "role": "worker", "status": "running", "created_at": "2026-03-28T12:00:03Z", - "model_id": "gpt-4o-mini", + "profile": "cliproxy-codex", + "provider": "llm-api", + "model_id": "gpt-5.4", + "reasoning_effort": "medium", "image": "ghcr.io/russellluo/picoclaw:2026.4.8.1" } ``` @@ -83,6 +90,8 @@ ok - `name` 必填 - `name` 不能是 `manager` - `id` 可选;未传时服务端会自动生成 +- `profile` 可选;它引用配置中的 `llm.profiles.`,未传时使用 `llm.default_profile` +- `provider`、`model_id`、`reasoning_effort` 是服务端解析后的快照字段,便于调试 - `status`、`created_at` 以实际 box 启动结果为准 - `manager` 嵌套字段已不再支持 - 若 IM 服务可用,会自动创建对应 IM 用户,并创建 `Admin & ` 私聊 @@ -335,7 +344,7 @@ data: {"type":"message.created","room_id":"oc_f778","message":{"id":"om_x100","s ## 7. PicoClaw Bot 接口 -这组接口用于 PicoClaw 与 IM 的双向通信。 +这组接口用于 PicoClaw 与 IM 的双向通信,以及 Worker 访问服务端暴露的 OpenAI 兼容 LLM bridge。 认证要求: @@ -389,3 +398,78 @@ bot 向指定会话发送消息。 - 消息发送者固定为路径中的 `bot_id` - `room_id` 必须是已存在会话 - `text` 不能为空 + +### `GET /api/bots/{bot_id}/llm/v1/models` + +返回 bot 当前可见的模型列表,格式兼容 OpenAI `GET /v1/models`。 + +响应示例: + +```json +{ + "object": "list", + "data": [ + { + "id": "gpt-5.4", + "object": "model", + "created": 0, + "owned_by": "csgclaw" + } + ] +} +``` + +说明: + +- 服务端会根据 `bot_id` 对应 agent 的 `profile` 解析实际模型配置,并在响应中保留已解析快照字段 +- box 内看到的是统一的 OpenAI 兼容接口;不会拿到宿主机上的真实上游 `api_key` + +### `POST /api/bots/{bot_id}/llm/v1/chat/completions` + +OpenAI 兼容的聊天补全入口。 + +请求体示例: + +```json +{ + "model": "ignored-by-server", + "messages": [ + { + "role": "user", + "content": "Review this patch." + } + ] +} +``` + +响应示例: + +```json +{ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1743139200, + "model": "gpt-5.4", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "..." + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + } +} +``` + +说明: + +- 请求会由服务端转发到对应 profile 配置中的 `base_url + /chat/completions` +- `model` 字段会被服务端强制改写为该 agent 解析出的 `model_id` +- 若 profile 配置了 `reasoning_effort`,且请求体没有显式提供 `reasoning_effort`,服务端会把该默认值注入转发请求 diff --git a/internal/agent/box.go b/internal/agent/box.go index 97d9060..a6c8bdf 100644 --- a/internal/agent/box.go +++ b/internal/agent/box.go @@ -12,14 +12,15 @@ import ( "csgclaw/internal/config" ) -func (s *Service) createGatewayBox(ctx context.Context, rt *boxlite.Runtime, image, name, botID, modelID string) (*boxlite.Box, *boxlite.BoxInfo, error) { +func (s *Service) createGatewayBox(ctx context.Context, rt *boxlite.Runtime, image, name, botID string, modelCfg config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { + // step 8.2.2 Create and start one PicoClaw gateway box, then capture its Boxlite metadata for persistence. if testCreateGatewayBoxHook != nil { - return testCreateGatewayBoxHook(s, ctx, rt, image, name, botID, modelID) + return testCreateGatewayBoxHook(s, ctx, rt, image, name, botID, modelCfg) } if !runtimeValid(rt) { return nil, nil, fmt.Errorf("invalid boxlite runtime") } - boxOpts, err := s.gatewayBoxOptions(name, botID, modelID) + boxOpts, err := s.gatewayBoxOptions(name, botID, modelCfg) if err != nil { return nil, nil, err } @@ -49,11 +50,16 @@ func (s *Service) forceRemoveBox(ctx context.Context, rt *boxlite.Runtime, idOrN return rt.ForceRemove(ctx, idOrName) } -func (s *Service) gatewayBoxOptions(name, botID, modelID string) ([]boxlite.BoxOption, error) { - if strings.TrimSpace(modelID) == "" { - modelID = s.model.ModelID - } - envVars := picoclawBoxEnvVars(resolveManagerBaseURL(s.server), s.server.AccessToken, botID, s.model) +func (s *Service) gatewayBoxOptions(name, botID string, modelCfg config.ModelConfig) ([]boxlite.BoxOption, error) { + // step 8.2.2.1 The box environment carries both model config and CSGClaw channel config into PicoClaw. + modelCfg = modelCfg.Resolved() + if strings.TrimSpace(modelCfg.ModelID) == "" { + modelCfg = s.model.Resolved() + } + modelID := modelCfg.ModelID + managerBaseURL := resolveManagerBaseURL(s.server) + llmBaseURL := llmBridgeBaseURL(managerBaseURL, botID) + envVars := picoclawBoxEnvVars(managerBaseURL, s.server.AccessToken, botID, llmBaseURL, modelID) addFeishuBoxEnvVars(envVars, botID, s.channels) opts := []boxlite.BoxOption{ boxlite.WithName(name), @@ -61,16 +67,11 @@ func (s *Service) gatewayBoxOptions(name, botID, modelID string) ([]boxlite.BoxO boxlite.WithAutoRemove(false), //boxlite.WithPort(managerHostPort, managerGuestPort), boxlite.WithEnv("HOME", "/home/picoclaw"), - boxlite.WithEnv("CSGCLAW_LLM_BASE_URL", s.model.BaseURL), - boxlite.WithEnv("CSGCLAW_LLM_API_KEY", s.model.APIKey), - boxlite.WithEnv("CSGCLAW_LLM_MODEL_ID", modelID), - boxlite.WithEnv("OPENAI_BASE_URL", s.model.BaseURL), - boxlite.WithEnv("OPENAI_API_KEY", s.model.APIKey), - boxlite.WithEnv("OPENAI_MODEL", modelID), } for key, value := range envVars { opts = append(opts, boxlite.WithEnv(key, value)) } + // step 8.2.2.2 The worker process is the PicoClaw gateway itself, writing logs to ~/.picoclaw/gateway.log. //entrypoint, cmd := gatewayStartCommand(managerDebugMode) opts = append(opts, //boxlite.WithEntrypoint(entrypoint...), @@ -79,20 +80,40 @@ func (s *Service) gatewayBoxOptions(name, botID, modelID string) ([]boxlite.BoxO //boxlite.WithCmd("sleep", "infinity"), ) - //hostPicoClawRoot, err := ensureAgentPicoClawConfig(name, botID, s.server, s.model) - //if err != nil { - // return nil, err - //} - //opts = append(opts, boxlite.WithVolume(hostPicoClawRoot, boxPicoClawDir)) + hostPicoClawRoot, err := ensureAgentPicoClawConfig(name, botID, s.server, modelCfg) + if err != nil { + return nil, err + } + // step 8.2.2.3 Project files are shared through one host mount so workers can work on the same workspace tree. projectsRoot, err := ensureAgentProjectsRoot() if err != nil { return nil, err } - opts = append(opts, boxlite.WithVolume(projectsRoot, boxProjectsDir)) + for _, mount := range gatewayVolumeMounts(hostPicoClawRoot, projectsRoot) { + opts = append(opts, boxlite.WithVolume(mount.hostPath, mount.guestPath)) + } return opts, nil } +type gatewayVolumeMount struct { + hostPath string + guestPath string +} + +func gatewayVolumeMounts(hostPicoClawRoot, projectsRoot string) []gatewayVolumeMount { + return []gatewayVolumeMount{ + { + hostPath: hostPicoClawRoot, + guestPath: boxPicoClawDir, + }, + { + hostPath: projectsRoot, + guestPath: boxProjectsDir, + }, + } +} + func gatewayStartCommand(debug bool) ([]string, []string) { if debug { return []string{"sleep"}, []string{"infinity"} @@ -112,21 +133,41 @@ func ensureAgentProjectsRoot() (string, error) { return hostProjectsRoot, nil } -func picoclawBoxEnvVars(baseURL, accessToken, botID string, model config.ModelConfig) map[string]string { +func ProjectsRoot() (string, error) { + return ensureAgentProjectsRoot() +} + +func llmBridgeBaseURL(managerBaseURL, botID string) string { + managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") + return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" +} + +func bridgeLLMEnvVars(llmBaseURL, accessToken, modelID string) map[string]string { return map[string]string{ - "CSGCLAW_BASE_URL": baseURL, - "CSGCLAW_ACCESS_TOKEN": accessToken, - "PICOCLAW_CHANNELS_CSGCLAW_BASE_URL": baseURL, - "PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN": accessToken, - "PICOCLAW_CHANNELS_CSGCLAW_BOT_ID": botID, - "PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME": model.ModelID, - "PICOCLAW_CUSTOM_MODEL_NAME": model.ModelID, - "PICOCLAW_CUSTOM_MODEL_ID": model.ModelID, - "PICOCLAW_CUSTOM_MODEL_API_KEY": model.APIKey, - "PICOCLAW_CUSTOM_MODEL_BASE_URL": model.BaseURL, + "CSGCLAW_LLM_BASE_URL": llmBaseURL, + "CSGCLAW_LLM_API_KEY": accessToken, + "CSGCLAW_LLM_MODEL_ID": modelID, + "OPENAI_BASE_URL": llmBaseURL, + "OPENAI_API_KEY": accessToken, + "OPENAI_MODEL": modelID, } } +func picoclawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string) map[string]string { + env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) + env["CSGCLAW_BASE_URL"] = baseURL + env["CSGCLAW_ACCESS_TOKEN"] = accessToken + env["PICOCLAW_CHANNELS_CSGCLAW_BASE_URL"] = baseURL + env["PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN"] = accessToken + env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"] = botID + env["PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"] = modelID + env["PICOCLAW_CUSTOM_MODEL_NAME"] = modelID + env["PICOCLAW_CUSTOM_MODEL_ID"] = modelID + env["PICOCLAW_CUSTOM_MODEL_API_KEY"] = accessToken + env["PICOCLAW_CUSTOM_MODEL_BASE_URL"] = llmBaseURL + return env +} + func addFeishuBoxEnvVars(envVars map[string]string, botID string, channels config.ChannelsConfig) { if envVars == nil { return diff --git a/internal/agent/manager_config.go b/internal/agent/manager_config.go index 7d9e6bd..1ba500f 100644 --- a/internal/agent/manager_config.go +++ b/internal/agent/manager_config.go @@ -4,9 +4,11 @@ import ( _ "embed" "encoding/json" "fmt" + "io/fs" "net" "os" "path/filepath" + "runtime" "strings" "csgclaw/internal/config" @@ -20,6 +22,24 @@ var defaultManagerPicoClawConfig []byte //go:embed defaults/manager-security.yml var defaultManagerSecurityConfig string +var managerSkillSourceDirResolver = bundledManagerSkillSourceDir + +const managerMemoryContents = `# Manager Memory + +When an admin asks you to arrange or reuse workers such as ux, dev, and qa: + +- Do not do the implementation work yourself. +- Do not use message for status chatter or request restatement. +- Use the bundled manager-worker-dispatch workflow directly from ~/.picoclaw/workspace/skills/manager-worker-dispatch. +- Fast path: + 1. python scripts/manager_worker_api.py list-workers + 2. join the chosen workers to the room + 3. write todo.json under ~/.picoclaw/workspace/projects// + 4. python scripts/manager_worker_api.py start-tracking --room-id --todo-path +- Only open SKILL.md if the workflow must change or a required command is unclear. +- After tracking starts, send one concise assignment summary. +` + func ensureManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) (string, error) { return ensureAgentPicoClawConfig(ManagerName, "u-manager", server, model) } @@ -32,6 +52,9 @@ func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConf if err := os.MkdirAll(filepath.Join(hostRoot, hostPicoClawLogs), 0o755); err != nil { return "", fmt.Errorf("create manager picoclaw logs dir: %w", err) } + if err := ensureAgentWorkspace(hostRoot, agentName); err != nil { + return "", err + } data, err := renderAgentPicoClawConfig(botID, server, model) if err != nil { @@ -41,7 +64,7 @@ func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConf if err := os.WriteFile(configPath, append(data, '\n'), 0o600); err != nil { return "", fmt.Errorf("write manager picoclaw config: %w", err) } - securityData := renderManagerSecurityConfig(model) + securityData := renderManagerSecurityConfig(server, model) securityPath := filepath.Join(hostRoot, ".security.yml") if err := os.WriteFile(securityPath, []byte(securityData), 0o600); err != nil { return "", fmt.Errorf("write manager security config: %w", err) @@ -71,7 +94,7 @@ func renderAgentPicoClawConfig(botID string, server config.ServerConfig, model c return nil, fmt.Errorf("decode embedded manager picoclaw config: %w", err) } - if err := updateModelList(cfg, model); err != nil { + if err := updateModelList(cfg, botID, server, model); err != nil { return nil, err } if err := updateCSGClawChannel(cfg, botID, server); err != nil { @@ -85,7 +108,7 @@ func renderAgentPicoClawConfig(botID string, server config.ServerConfig, model c return data, nil } -func updateModelList(cfg map[string]any, modelCfg config.ModelConfig) error { +func updateModelList(cfg map[string]any, botID string, server config.ServerConfig, 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]") @@ -98,11 +121,17 @@ func updateModelList(cfg map[string]any, modelCfg config.ModelConfig) error { model["model_name"] = modelCfg.ModelID model["model"] = modelCfg.ModelID } - if modelCfg.BaseURL != "" { - model["api_base"] = strings.TrimRight(modelCfg.BaseURL, "/") + if agents, ok := cfg["agents"].(map[string]any); ok { + if defaults, ok := agents["defaults"].(map[string]any); ok && modelCfg.ModelID != "" { + defaults["model_name"] = modelCfg.ModelID + } + } + + if managerBaseURL := resolveManagerBaseURL(server); managerBaseURL != "" { + model["api_base"] = llmBridgeBaseURL(managerBaseURL, botID) } - if modelCfg.APIKey != "" { - model["api_key"] = modelCfg.APIKey + if server.AccessToken != "" { + model["api_key"] = server.AccessToken } return nil } @@ -204,9 +233,12 @@ func ipv4FromAddr(addr net.Addr) string { } } -func renderManagerSecurityConfig(model config.ModelConfig) string { +func renderManagerSecurityConfig(server config.ServerConfig, model config.ModelConfig) string { modelID := model.ModelID - apiKey := model.APIKey + apiKey := strings.TrimSpace(server.AccessToken) + if apiKey == "" { + apiKey = model.APIKey + } content := strings.ReplaceAll(defaultManagerSecurityConfig, "__MODEL_ID__", modelID) content = strings.ReplaceAll(content, "__API_KEY__", apiKey) @@ -215,3 +247,118 @@ func renderManagerSecurityConfig(model config.ModelConfig) string { } return content } + +func ensureAgentWorkspace(hostRoot, agentName string) error { + workspaceRoot := filepath.Join(hostRoot, "workspace") + for _, dir := range []string{ + filepath.Join(workspaceRoot, "memory"), + filepath.Join(workspaceRoot, "projects"), + filepath.Join(workspaceRoot, "sessions"), + filepath.Join(workspaceRoot, "state"), + filepath.Join(workspaceRoot, "skills"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create agent workspace dir %q: %w", dir, err) + } + } + if !strings.EqualFold(strings.TrimSpace(agentName), ManagerName) { + return nil + } + + dstRoot := filepath.Join(workspaceRoot, "skills", "manager-worker-dispatch") + srcRoot, err := managerSkillSourceDirResolver() + if err != nil { + return fmt.Errorf("resolve bundled manager skill: %w", err) + } + if err := copyDirTree(srcRoot, dstRoot); err != nil { + return fmt.Errorf("seed bundled manager skill: %w", err) + } + memoryPath := filepath.Join(workspaceRoot, "memory", "MEMORY.md") + if err := os.WriteFile(memoryPath, []byte(managerMemoryContents), 0o644); err != nil { + return fmt.Errorf("write manager memory: %w", err) + } + return nil +} + +func bundledManagerSkillSourceDir() (string, error) { + candidates := make([]string, 0, 4) + if wd, err := os.Getwd(); err == nil { + candidates = append(candidates, wd) + } + if exe, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Dir(exe), filepath.Dir(filepath.Dir(exe))) + } + if _, file, _, ok := runtime.Caller(0); ok { + candidates = append(candidates, filepath.Dir(file), filepath.Dir(filepath.Dir(filepath.Dir(file)))) + } + + for _, base := range candidates { + if found := findManagerSkillUnder(base); found != "" { + return found, nil + } + } + return "", os.ErrNotExist +} + +func findManagerSkillUnder(base string) string { + base = strings.TrimSpace(base) + if base == "" { + return "" + } + for current := base; ; current = filepath.Dir(current) { + candidate := filepath.Join(current, "skills", "manager-worker-dispatch") + if info, err := os.Stat(filepath.Join(candidate, "SKILL.md")); err == nil && !info.IsDir() { + return candidate + } + parent := filepath.Dir(current) + if parent == current { + return "" + } + } +} + +func copyDirTree(srcRoot, dstRoot string) error { + srcRoot = filepath.Clean(srcRoot) + dstRoot = filepath.Clean(dstRoot) + if err := os.RemoveAll(dstRoot); err != nil { + return err + } + + return filepath.WalkDir(srcRoot, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(srcRoot, path) + if err != nil { + return err + } + target := dstRoot + if rel != "." { + target = filepath.Join(dstRoot, rel) + } + + info, err := d.Info() + if err != nil { + return err + } + if d.IsDir() { + return os.MkdirAll(target, info.Mode().Perm()) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + mode := info.Mode().Perm() + if mode == 0 { + mode = 0o644 + } + if err := os.WriteFile(target, data, mode); err != nil { + return err + } + return nil + }) +} diff --git a/internal/agent/manager_config_test.go b/internal/agent/manager_config_test.go index 5fe283e..a0e246f 100644 --- a/internal/agent/manager_config_test.go +++ b/internal/agent/manager_config_test.go @@ -2,6 +2,8 @@ package agent import ( "net" + "os" + "path/filepath" "strings" "testing" @@ -9,7 +11,9 @@ import ( ) func TestRenderManagerSecurityConfig(t *testing.T) { - got := renderManagerSecurityConfig(config.ModelConfig{ + got := renderManagerSecurityConfig(config.ServerConfig{ + AccessToken: "shared-token", + }, config.ModelConfig{ ModelID: "minimax-m2.7", APIKey: "sk-1234567890", }) @@ -18,7 +22,7 @@ func TestRenderManagerSecurityConfig(t *testing.T) { "model_list:\n", " minimax-m2.7:0:\n", " api_keys:\n", - " - sk-1234567890\n", + " - shared-token\n", "channels: {}\n", "web: {}\n", "skills: {}\n", @@ -29,6 +33,155 @@ func TestRenderManagerSecurityConfig(t *testing.T) { } } +func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { + localIPv4Resolver = func() string { return "10.0.0.8" } + defer func() { localIPv4Resolver = localIPv4 }() + + data, err := renderAgentPicoClawConfig("u-ux", config.ServerConfig{ + ListenAddr: "0.0.0.0:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + Provider: config.ProviderLLMAPI, + ModelID: "gpt-5.4", + BaseURL: "https://cloud.infini-ai.com/maas/v1", + APIKey: "sk-upstream", + }) + if err != nil { + t.Fatalf("renderAgentPicoClawConfig() error = %v", err) + } + + text := string(data) + for _, want := range []string{ + `"model_name": "gpt-5.4"`, + `"api_base": "http://10.0.0.8:18080/api/bots/u-ux/llm"`, + `"api_key": "shared-token"`, + `"bot_id": "u-ux"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("renderAgentPicoClawConfig() missing %q in:\n%s", want, text) + } + } + if strings.Contains(text, "cloud.infini-ai.com") { + t.Fatalf("renderAgentPicoClawConfig() leaked upstream base URL:\n%s", text) + } +} + +func TestEnsureAgentPicoClawConfigUsesDirectoryMountRoot(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + managerSkillSourceDirResolver = func() (string, error) { + src := filepath.Join(t.TempDir(), "skills", "manager-worker-dispatch") + if err := os.MkdirAll(filepath.Join(src, "scripts"), 0o755); err != nil { + t.Fatalf("os.MkdirAll(skill source) error = %v", err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("name: test\n"), 0o644); err != nil { + t.Fatalf("os.WriteFile(SKILL.md) error = %v", err) + } + if err := os.WriteFile(filepath.Join(src, "scripts", "manager_worker_api.py"), []byte("print('ok')\n"), 0o755); err != nil { + t.Fatalf("os.WriteFile(manager_worker_api.py) error = %v", err) + } + return src, nil + } + defer func() { managerSkillSourceDirResolver = bundledManagerSkillSourceDir }() + + root, err := ensureAgentPicoClawConfig("ux", "u-ux", config.ServerConfig{ + ListenAddr: "0.0.0.0:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "gpt-5.4", + }) + if err != nil { + t.Fatalf("ensureAgentPicoClawConfig() error = %v", err) + } + + if info, err := os.Stat(root); err != nil { + t.Fatalf("os.Stat(root) error = %v", err) + } else if !info.IsDir() { + t.Fatalf("mount root %q is not a directory", root) + } + for _, path := range []string{ + filepath.Join(root, hostPicoClawConfig), + filepath.Join(root, ".security.yml"), + } { + if info, err := os.Stat(path); err != nil { + t.Fatalf("os.Stat(%q) error = %v", path, err) + } else if info.IsDir() { + t.Fatalf("config artifact %q is unexpectedly a directory", path) + } + } + + mounts := gatewayVolumeMounts(root, "/tmp/projects") + if len(mounts) != 2 { + t.Fatalf("gatewayVolumeMounts() len = %d, want 2", len(mounts)) + } + if mounts[0].hostPath != root || mounts[0].guestPath != boxPicoClawDir { + t.Fatalf("gatewayVolumeMounts()[0] = %+v, want %q => %q", mounts[0], root, boxPicoClawDir) + } + if strings.HasSuffix(mounts[0].hostPath, hostPicoClawConfig) || strings.HasSuffix(mounts[0].hostPath, ".security.yml") { + t.Fatalf("gatewayVolumeMounts()[0].hostPath = %q, want directory mount root", mounts[0].hostPath) + } +} + +func TestEnsureAgentPicoClawConfigSeedsManagerSkill(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + src := filepath.Join(t.TempDir(), "skills", "manager-worker-dispatch") + for _, dir := range []string{ + src, + filepath.Join(src, "scripts"), + filepath.Join(src, "agents"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", dir, err) + } + } + for path, content := range map[string]string{ + filepath.Join(src, "SKILL.md"): "name: manager-worker-dispatch\n", + filepath.Join(src, "scripts", "manager_worker_api.py"): "#!/usr/bin/env python\nprint('ok')\n", + filepath.Join(src, "agents", "openai.yaml"): "interface: {}\n", + } { + mode := os.FileMode(0o644) + if strings.HasSuffix(path, ".py") { + mode = 0o755 + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", path, err) + } + } + + managerSkillSourceDirResolver = func() (string, error) { return src, nil } + defer func() { managerSkillSourceDirResolver = bundledManagerSkillSourceDir }() + + root, err := ensureAgentPicoClawConfig("manager", "u-manager", config.ServerConfig{ + ListenAddr: "0.0.0.0:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "gpt-5.4", + }) + if err != nil { + t.Fatalf("ensureAgentPicoClawConfig() error = %v", err) + } + + for _, rel := range []string{ + filepath.Join("workspace", "memory", "MEMORY.md"), + filepath.Join("workspace", "skills", "manager-worker-dispatch", "SKILL.md"), + filepath.Join("workspace", "skills", "manager-worker-dispatch", "scripts", "manager_worker_api.py"), + filepath.Join("workspace", "skills", "manager-worker-dispatch", "agents", "openai.yaml"), + } { + if _, err := os.Stat(filepath.Join(root, rel)); err != nil { + t.Fatalf("os.Stat(%q) error = %v", filepath.Join(root, rel), err) + } + } + data, err := os.ReadFile(filepath.Join(root, "workspace", "memory", "MEMORY.md")) + if err != nil { + t.Fatalf("os.ReadFile(MEMORY.md) error = %v", err) + } + if !strings.Contains(string(data), "python scripts/manager_worker_api.py list-workers") { + t.Fatalf("MEMORY.md = %q, want dispatch fast path", string(data)) + } +} + func TestIPv4FromAddr(t *testing.T) { tests := []struct { name string diff --git a/internal/agent/model.go b/internal/agent/model.go index 36c2f48..c94d7e8 100644 --- a/internal/agent/model.go +++ b/internal/agent/model.go @@ -13,15 +13,18 @@ const ( ) type Agent struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Image string `json:"image,omitempty"` - BoxID string `json:"box_id,omitempty"` - Role string `json:"role"` - Status string `json:"status"` - CreatedAt time.Time `json:"created_at"` - ModelID string `json:"model_id,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Image string `json:"image,omitempty"` + BoxID string `json:"box_id,omitempty"` + Role string `json:"role"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + Profile string `json:"profile,omitempty"` + Provider string `json:"provider,omitempty"` + ModelID string `json:"model_id,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` } type CreateRequest struct { @@ -32,6 +35,7 @@ type CreateRequest struct { Role string `json:"role,omitempty"` Status string `json:"status,omitempty"` CreatedAt time.Time `json:"created_at,omitempty"` + Profile string `json:"profile,omitempty"` ModelID string `json:"model_id,omitempty"` } diff --git a/internal/agent/model_selection.go b/internal/agent/model_selection.go new file mode 100644 index 0000000..923a4ae --- /dev/null +++ b/internal/agent/model_selection.go @@ -0,0 +1,70 @@ +package agent + +import ( + "fmt" + "strings" + + "csgclaw/internal/config" +) + +func (s *Service) resolveModelProfile(profile string) (string, config.ModelConfig, error) { + if strings.TrimSpace(profile) != "" { + name, cfg, err := s.llm.Resolve(profile) + if err != nil { + return "", config.ModelConfig{}, err + } + return name, cfg, nil + } + + name, cfg, err := s.llm.Resolve("") + if err != nil { + return "", config.ModelConfig{}, err + } + return name, cfg, nil +} + +func (s *Service) inferProfileForAgent(got Agent) string { + if profile := strings.TrimSpace(got.Profile); profile != "" { + if _, _, err := s.llm.Resolve(profile); err == nil { + return profile + } + } + if strings.TrimSpace(got.Provider) != "" || strings.TrimSpace(got.ModelID) != "" { + if name, _, ok := s.llm.MatchProfile(config.ModelConfig{ + Provider: got.Provider, + ModelID: got.ModelID, + ReasoningEffort: got.ReasoningEffort, + }); ok { + return name + } + } + name, _, err := s.llm.Resolve("") + if err != nil { + return "" + } + return name +} + +func (s *Service) modelConfigForAgent(got Agent) (string, config.ModelConfig, error) { + profile := s.inferProfileForAgent(got) + if profile == "" { + return "", config.ModelConfig{}, fmt.Errorf("no llm profile could be resolved for agent %q", strings.TrimSpace(got.ID)) + } + name, cfg, err := s.llm.Resolve(profile) + if err != nil { + return "", config.ModelConfig{}, err + } + return name, cfg.Resolved(), nil +} + +func (s *Service) ResolvedModelConfig(agentID string) (config.ModelConfig, error) { + got, ok := s.Agent(agentID) + if !ok { + return config.ModelConfig{}, fmt.Errorf("agent %q not found", strings.TrimSpace(agentID)) + } + _, cfg, err := s.modelConfigForAgent(got) + if err != nil { + return config.ModelConfig{}, err + } + return cfg, nil +} diff --git a/internal/agent/runtime.go b/internal/agent/runtime.go index 9bf2efa..dec1ba7 100644 --- a/internal/agent/runtime.go +++ b/internal/agent/runtime.go @@ -15,6 +15,7 @@ import ( ) func (s *Service) ensureRuntime(agentName string) (*boxlite.Runtime, error) { + // step 8.2.1 Each agent name maps to its own Boxlite runtime home under ~/.csgclaw/agents//boxlite. if testEnsureRuntimeHook != nil { return testEnsureRuntimeHook(s, agentName) } @@ -58,6 +59,7 @@ func (s *Service) ensureRuntimeAtHome(homeDir string) (*boxlite.Runtime, error) } func (s *Service) lookupBootstrapManager(ctx context.Context) (*boxlite.Runtime, *boxlite.Box, error) { + // step 8.1.0 When bootstrapping the manager, try the saved BoxID first and fall back to the fixed name. homeDir, err := boxRuntimeHome(ManagerName) if err != nil { return nil, nil, err diff --git a/internal/agent/service.go b/internal/agent/service.go index b1d5860..f6f9fea 100644 --- a/internal/agent/service.go +++ b/internal/agent/service.go @@ -40,7 +40,7 @@ var ( testCloseBoxHook func(*Service, *boxlite.Box) error testCloseRuntimeHook func(*Service, string, *boxlite.Runtime) error testCreateBoxHook func(*Service, context.Context, *boxlite.Runtime, string, ...boxlite.BoxOption) (*boxlite.Box, error) - testCreateGatewayBoxHook func(*Service, context.Context, *boxlite.Runtime, string, string, string, string) (*boxlite.Box, *boxlite.BoxInfo, error) + testCreateGatewayBoxHook func(*Service, context.Context, *boxlite.Runtime, string, string, string, config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) testForceRemoveBoxHook func(*Service, context.Context, *boxlite.Runtime, string) error testRunBoxCommandHook func(*Service, context.Context, *boxlite.Box, string, []string, io.Writer) (int, error) ) @@ -48,7 +48,7 @@ var ( // SetTestHooks installs lightweight hooks for tests that need to bypass runtime/box creation. func SetTestHooks( ensureRuntime func(*Service, string) (*boxlite.Runtime, error), - createGatewayBox func(*Service, context.Context, *boxlite.Runtime, string, string, string, string) (*boxlite.Box, *boxlite.BoxInfo, error), + createGatewayBox func(*Service, context.Context, *boxlite.Runtime, string, string, string, config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error), ) { testEnsureRuntimeHook = ensureRuntime if ensureRuntime != nil { @@ -88,6 +88,7 @@ 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 @@ -98,15 +99,32 @@ type Service struct { } func NewService(model config.ModelConfig, server config.ServerConfig, managerImage, statePath string) (*Service, error) { - return NewServiceWithChannels(model, server, config.ChannelsConfig{}, managerImage, statePath) + 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) +} + +func NewServiceWithLLM(llmCfg config.LLMConfig, server config.ServerConfig, managerImage, statePath string) (*Service, error) { + return NewServiceWithLLMAndChannels(llmCfg, server, config.ChannelsConfig{}, managerImage, statePath) +} + +func NewServiceWithLLMAndChannels(llmCfg config.LLMConfig, server config.ServerConfig, channels config.ChannelsConfig, managerImage, 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. if managerImage == "" { managerImage = config.DefaultManagerImage } + defaultProfile, model, err := llmCfg.Resolve("") + if err != nil { + defaultProfile = llmCfg.EffectiveDefaultProfile() + model = config.ModelConfig{}.Resolved() + } svc := &Service{ model: model, + llm: llmCfg.Normalized(), server: server, channels: cloneChannelsConfig(channels), managerImage: managerImage, @@ -114,6 +132,9 @@ func NewServiceWithChannels(model config.ModelConfig, server config.ServerConfig runtimes: make(map[string]*boxlite.Runtime), agents: make(map[string]Agent), } + if strings.TrimSpace(svc.llm.DefaultProfile) == "" { + svc.llm.DefaultProfile = defaultProfile + } if err := svc.load(); err != nil { return nil, err } @@ -134,13 +155,31 @@ func cloneChannelsConfig(channels config.ChannelsConfig) config.ChannelsConfig { } func EnsureBootstrapState(ctx context.Context, statePath string, server config.ServerConfig, model config.ModelConfig, managerImage string, forceRecreate bool) error { - svc, err := NewService(model, server, managerImage, statePath) + return EnsureBootstrapStateWithLLM(ctx, statePath, server, config.SingleProfileLLM(model), managerImage, forceRecreate) +} + +func EnsureBootstrapStateWithLLM(ctx context.Context, statePath string, server config.ServerConfig, llmCfg config.LLMConfig, managerImage string, forceRecreate bool) error { + svc, err := NewServiceWithLLM(llmCfg, server, managerImage, statePath) if err != nil { return err } defer func() { _ = svc.Close() }() + return svc.EnsureBootstrapManager(ctx, forceRecreate) +} + +func (svc *Service) EnsureBootstrapManager(ctx context.Context, forceRecreate bool) error { + if svc == nil { + return nil + } + _, defaultModel, err := svc.llm.Resolve("") + if err != nil { + return err + } + if _, err := ensureAgentPicoClawConfig(ManagerName, ManagerUserID, svc.server, defaultModel); err != nil { + return err + } _, err = svc.EnsureManager(ctx, forceRecreate) return err @@ -150,6 +189,10 @@ func (s *Service) EnsureManager(ctx context.Context, forceRecreate bool) (Agent, if s == nil { return Agent{}, fmt.Errorf("agent service is required") } + defaultProfile, defaultModel, err := s.llm.Resolve("") + if err != nil { + return Agent{}, err + } rt, box, err := s.lookupBootstrapManager(ctx) if err != nil { @@ -174,6 +217,21 @@ func (s *Service) EnsureManager(ctx context.Context, forceRecreate bool) (Agent, } else { log.Printf("bootstrap manager box %q (%q) removed", ManagerName, managerBoxIDOrName) } + if err := s.closeRuntime(runtimeHome, rt); err != nil { + return Agent{}, fmt.Errorf("close bootstrap manager runtime before recreate: %w", err) + } + rt = nil + managerHome, err := agentHomeDir(ManagerName) + if err != nil { + return Agent{}, err + } + if err := os.RemoveAll(managerHome); err != nil { + return Agent{}, fmt.Errorf("remove bootstrap manager home: %w", err) + } + rt, err = s.ensureRuntimeAtHome(runtimeHome) + if err != nil { + return Agent{}, err + } box = nil } var info *boxlite.BoxInfo @@ -193,7 +251,7 @@ func (s *Service) EnsureManager(ctx context.Context, forceRecreate bool) (Agent, } } }() - box, info, err = s.createGatewayBox(ctx, rt, s.managerImage, ManagerName, ManagerUserID, s.model.ModelID) + box, info, err = s.createGatewayBox(ctx, rt, s.managerImage, ManagerName, ManagerUserID, defaultModel) close(progressDone) if err != nil { return Agent{}, fmt.Errorf("create bootstrap manager box: %w", err) @@ -216,14 +274,17 @@ func (s *Service) EnsureManager(ctx context.Context, forceRecreate bool) (Agent, defer s.mu.Unlock() manager := Agent{ - ID: ManagerUserID, - Name: ManagerName, - Image: s.managerImage, - BoxID: info.ID, - Status: string(info.State), - CreatedAt: info.CreatedAt.UTC(), - ModelID: s.model.ModelID, - Role: RoleManager, + ID: ManagerUserID, + Name: ManagerName, + Image: s.managerImage, + BoxID: info.ID, + Status: string(info.State), + CreatedAt: info.CreatedAt.UTC(), + Profile: defaultProfile, + Provider: defaultModel.Resolved().Provider, + ModelID: defaultModel.Resolved().ModelID, + ReasoningEffort: defaultModel.Resolved().ReasoningEffort, + Role: RoleManager, } for id, a := range s.agents { if isManagerAgent(a) && id != manager.ID { @@ -258,7 +319,6 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (Agent, error) description := strings.TrimSpace(req.Description) image := strings.TrimSpace(req.Image) role := normalizeRole(req.Role) - modelID := strings.TrimSpace(req.ModelID) if name == "" { return Agent{}, fmt.Errorf("name is required") } @@ -298,26 +358,35 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (Agent, error) _ = s.closeRuntime(runtimeHome, rt) }() - if modelID == "" { - modelID = s.model.ModelID + requestedProfile := strings.TrimSpace(req.Profile) + if requestedProfile == "" && strings.TrimSpace(req.ModelID) != "" { + matchedProfile, _, ok := s.llm.MatchProfile(config.ModelConfig{ModelID: req.ModelID}) + if !ok { + return Agent{}, fmt.Errorf("no llm profile matches model %q", strings.TrimSpace(req.ModelID)) + } + requestedProfile = matchedProfile + } + + profileName, resolvedModel, err := s.resolveModelProfile(requestedProfile) + if err != nil { + return Agent{}, err } projectsRoot, err := ensureAgentProjectsRoot() if err != nil { return Agent{}, err } + managerBaseURL := resolveManagerBaseURL(s.server) + llmBaseURL := llmBridgeBaseURL(managerBaseURL, id) boxOpts := []boxlite.BoxOption{ boxlite.WithName(name), boxlite.WithDetach(true), boxlite.WithAutoRemove(false), - boxlite.WithEnv("CSGCLAW_LLM_BASE_URL", s.model.BaseURL), - boxlite.WithEnv("CSGCLAW_LLM_API_KEY", s.model.APIKey), - boxlite.WithEnv("CSGCLAW_LLM_MODEL_ID", modelID), - boxlite.WithEnv("OPENAI_BASE_URL", s.model.BaseURL), - boxlite.WithEnv("OPENAI_API_KEY", s.model.APIKey), - boxlite.WithEnv("OPENAI_MODEL", modelID), boxlite.WithVolume(projectsRoot, boxProjectsDir), } + for key, value := range bridgeLLMEnvVars(llmBaseURL, s.server.AccessToken, resolvedModel.ModelID) { + boxOpts = append(boxOpts, boxlite.WithEnv(key, value)) + } box, err := s.createBox(ctx, rt, image, boxOpts...) if err != nil { return Agent{}, fmt.Errorf("create boxlite agent: %w", err) @@ -335,14 +404,17 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (Agent, error) status = "running" } agent := Agent{ - ID: id, - Name: name, - Description: description, - Image: image, - Role: role, - Status: status, - CreatedAt: createdAt, - ModelID: modelID, + ID: id, + Name: name, + Description: description, + Image: image, + Role: role, + Status: status, + CreatedAt: createdAt, + Profile: profileName, + Provider: resolvedModel.Provider, + ModelID: resolvedModel.ModelID, + ReasoningEffort: resolvedModel.ReasoningEffort, } s.mu.Lock() @@ -427,6 +499,9 @@ func (s *Service) refreshAgentBoxID(id string, got Agent, resolvedKey string, bo } func (s *Service) Delete(ctx context.Context, id string) error { + // step 8.4 Deletion removes both layers: + // step 8.4.1 the Boxlite box/runtime home + // step 8.4.2 the persisted agent registry entry. id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("agent id is required") @@ -497,11 +572,13 @@ func (s *Service) List() []Agent { } func (s *Service) CreateWorker(ctx context.Context, req CreateRequest) (Agent, error) { + // step 8.2 Create the current worker form of an agent: + // step 8.2.1 reserve a unique id/name + // step 8.2.2 create a gateway box + // step 8.2.3 persist the worker metadata. id := strings.TrimSpace(req.ID) name := strings.TrimSpace(req.Name) description := strings.TrimSpace(req.Description) - modelID := strings.TrimSpace(req.ModelID) - switch { case name == "": return Agent{}, fmt.Errorf("name is required") @@ -538,11 +615,13 @@ func (s *Service) CreateWorker(ctx context.Context, req CreateRequest) (Agent, e defer func() { _ = s.closeRuntime(runtimeHome, rt) }() - if modelID == "" { - modelID = s.model.ModelID + profileName, resolvedModel, err := s.resolveModelProfile(req.Profile) + if err != nil { + return Agent{}, err } - box, info, err := s.createGatewayBox(ctx, rt, s.managerImage, name, id, modelID) + // step 8.2.2 Workers currently reuse the manager image and run the same gateway-style runtime. + box, info, err := s.createGatewayBox(ctx, rt, s.managerImage, name, id, resolvedModel) if err != nil { return Agent{}, fmt.Errorf("create worker box: %w", err) } @@ -561,15 +640,18 @@ func (s *Service) CreateWorker(ctx context.Context, req CreateRequest) (Agent, e } worker := Agent{ - ID: id, - Name: name, - Image: s.managerImage, - BoxID: info.ID, - Description: description, - Status: string(info.State), - CreatedAt: info.CreatedAt.UTC(), - ModelID: modelID, - Role: RoleWorker, + ID: id, + Name: name, + Image: s.managerImage, + BoxID: info.ID, + Description: description, + Status: string(info.State), + CreatedAt: info.CreatedAt.UTC(), + Profile: profileName, + Provider: resolvedModel.Provider, + ModelID: resolvedModel.ModelID, + ReasoningEffort: resolvedModel.ReasoningEffort, + Role: RoleWorker, } s.agents[worker.ID] = worker if err := s.saveLocked(); err != nil { @@ -593,6 +675,7 @@ func (s *Service) ListWorkers() []Agent { } func (s *Service) StreamLogs(ctx context.Context, id string, follow bool, lines int, w io.Writer) error { + // step 8.3 Agent logs are not stored separately by CSGClaw; we tail the gateway log from inside the box. id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("agent id is required") diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index 0e6421c..095524c 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -16,8 +16,17 @@ import ( "csgclaw/internal/config" ) +func testModelConfig() config.ModelConfig { + return config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "model-1", + } +} + func TestCreateWorkerRejectsReservedManagerName(t *testing.T) { - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -35,7 +44,7 @@ func TestCreateWorkerRejectsReservedManagerName(t *testing.T) { } func TestCreateWorkerRejectsDuplicateName(t *testing.T) { - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -67,7 +76,7 @@ func TestCreateWorkerRejectsInvalidRuntime(t *testing.T) { ) defer ResetTestHooks() - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -92,7 +101,7 @@ func TestRuntimeValidRejectsNilAndZeroValue(t *testing.T) { } func TestListWorkersFiltersUnifiedAgents(t *testing.T) { - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -142,7 +151,7 @@ func TestLoadMigratesLegacyWorkersIntoAgents(t *testing.T) { } func TestDeleteRejectsManagerAgent(t *testing.T) { - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -214,7 +223,7 @@ func TestDeleteRemovesAgentHomeDirectory(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -271,7 +280,7 @@ func TestDeletePrefersBoxIDOverName(t *testing.T) { testForceRemoveBoxHook = nil }() - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -320,7 +329,7 @@ func TestDeleteFallsBackToNameWhenStoredBoxIDIsStale(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -377,7 +386,7 @@ func TestDeleteRemovesRuntimeCacheByHomeDir(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -409,7 +418,7 @@ func TestDeleteRemovesRuntimeCacheByHomeDir(t *testing.T) { func TestCreateWorkerStoresBoxID(t *testing.T) { SetTestHooks( func(_ *Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, - func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return nil, &boxlite.BoxInfo{ ID: "box-" + name, Name: name, @@ -421,7 +430,7 @@ func TestCreateWorkerStoresBoxID(t *testing.T) { ) defer ResetTestHooks() - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -435,11 +444,63 @@ func TestCreateWorkerStoresBoxID(t *testing.T) { } } +func TestCreateWorkerStoresResolvedProfileSnapshot(t *testing.T) { + SetTestHooks( + func(_ *Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, + func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { + return nil, &boxlite.BoxInfo{ + ID: "box-" + name, + Name: name, + State: boxlite.StateRunning, + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + Image: "test-image", + }, nil + }, + ) + defer ResetTestHooks() + + svc, err := NewServiceWithLLM(config.LLMConfig{ + DefaultProfile: "remote-main", + Profiles: map[string]config.ModelConfig{ + "remote-main": { + Provider: config.ProviderLLMAPI, + BaseURL: "https://example.test/v1", + APIKey: "sk-test", + ModelID: "gpt-5.4", + ReasoningEffort: "medium", + }, + }, + }, config.ServerConfig{}, "", "") + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + got, err := svc.CreateWorker(context.Background(), CreateRequest{ + Name: "alice", + Profile: "remote-main", + }) + if err != nil { + t.Fatalf("CreateWorker() error = %v", err) + } + if got.Profile != "remote-main" { + t.Fatalf("CreateWorker().Profile = %q, want %q", got.Profile, "remote-main") + } + if got.Provider != config.ProviderLLMAPI { + t.Fatalf("CreateWorker().Provider = %q, want %q", got.Provider, config.ProviderLLMAPI) + } + if got.ModelID != "gpt-5.4" { + t.Fatalf("CreateWorker().ModelID = %q, want %q", got.ModelID, "gpt-5.4") + } + if got.ReasoningEffort != "medium" { + t.Fatalf("CreateWorker().ReasoningEffort = %q, want %q", got.ReasoningEffort, "medium") + } +} + func TestCreateWorkerClosesBoxHandleAfterCreate(t *testing.T) { rt := &boxlite.Runtime{} SetTestHooks( func(_ *Service, _ string) (*boxlite.Runtime, error) { return rt, nil }, - func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return &boxlite.Box{}, &boxlite.BoxInfo{ ID: "box-" + name, Name: name, @@ -465,7 +526,7 @@ func TestCreateWorkerClosesBoxHandleAfterCreate(t *testing.T) { return nil } - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -508,7 +569,7 @@ func TestStreamLogsUsesStoredBoxIDAndTailArgs(t *testing.T) { testRunBoxCommandHook = nil }() - svc, err := NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := NewService(testModelConfig(), config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } @@ -654,7 +715,7 @@ func TestCreateClosesBoxHandleAfterCreate(t *testing.T) { func TestEnsureBootstrapStateForceRecreatePrefersStoredManagerBoxID(t *testing.T) { SetTestHooks( func(_ *Service, _ string) (*boxlite.Runtime, error) { return &boxlite.Runtime{}, nil }, - func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return &boxlite.Box{}, &boxlite.BoxInfo{ ID: "box-new", Name: name, @@ -719,11 +780,101 @@ func TestEnsureBootstrapStateForceRecreatePrefersStoredManagerBoxID(t *testing.T } } +func TestEnsureBootstrapStateForceRecreateResetsManagerHomeBeforeCreate(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + runtimeHome, err := boxRuntimeHome(ManagerName) + if err != nil { + t.Fatalf("boxRuntimeHome() error = %v", err) + } + managerHome, err := agentHomeDir(ManagerName) + if err != nil { + t.Fatalf("agentHomeDir() error = %v", err) + } + stalePath := filepath.Join(managerHome, "stale.txt") + if err := os.MkdirAll(managerHome, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(stalePath, []byte("stale"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + var ensuredHomes []string + var closeRuntimeCalls int + testEnsureRuntimeAtHomeHook = func(_ *Service, home string) (*boxlite.Runtime, error) { + ensuredHomes = append(ensuredHomes, home) + return &boxlite.Runtime{}, nil + } + testGetBoxHook = func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string) (*boxlite.Box, error) { + return nil, &boxlite.Error{Code: boxlite.ErrNotFound, Message: "missing"} + } + testForceRemoveBoxHook = func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string) error { + return nil + } + testCreateGatewayBoxHook = func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { + if _, err := os.Stat(stalePath); !os.IsNotExist(err) { + t.Fatalf("stale manager file still exists before recreate: err=%v", err) + } + return &boxlite.Box{}, &boxlite.BoxInfo{ + ID: "box-new", + Name: name, + State: boxlite.StateRunning, + CreatedAt: time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC), + Image: "test-image", + }, nil + } + testCloseRuntimeHook = func(_ *Service, gotHome string, _ *boxlite.Runtime) error { + closeRuntimeCalls++ + if gotHome != runtimeHome { + t.Fatalf("closeRuntime() home = %q, want %q", gotHome, runtimeHome) + } + return nil + } + defer ResetTestHooks() + + dir := t.TempDir() + statePath := filepath.Join(dir, "agents.json") + data, err := json.Marshal(persistedState{ + Agents: []Agent{ + { + ID: ManagerUserID, + Name: ManagerName, + Role: RoleManager, + BoxID: "box-old", + Status: "running", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + }, + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if err := os.WriteFile(statePath, data, 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + if err := EnsureBootstrapState(context.Background(), statePath, config.ServerConfig{}, config.ModelConfig{}, "", true); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + if got, want := len(ensuredHomes), 2; got != want { + t.Fatalf("ensureRuntimeAtHome() calls = %d, want %d", got, want) + } + for _, gotHome := range ensuredHomes { + if gotHome != runtimeHome { + t.Fatalf("ensureRuntimeAtHome() home = %q, want %q", gotHome, runtimeHome) + } + } + if closeRuntimeCalls != 2 { + t.Fatalf("closeRuntime() calls = %d, want %d", closeRuntimeCalls, 2) + } +} + func TestEnsureBootstrapStateClosesManagerBoxHandleAfterCreate(t *testing.T) { rt := &boxlite.Runtime{} SetTestHooks( func(_ *Service, _ string) (*boxlite.Runtime, error) { return rt, nil }, - func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return &boxlite.Box{}, &boxlite.BoxInfo{ ID: "box-" + name, Name: name, @@ -788,7 +939,7 @@ func TestEnsureBootstrapStateReusesStoredManagerBoxIDWithoutForce(t *testing.T) } var created bool - testCreateGatewayBoxHook = func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, _ string, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + testCreateGatewayBoxHook = func(_ *Service, _ context.Context, _ *boxlite.Runtime, _ string, _ string, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { created = true return nil, nil, nil } @@ -951,11 +1102,8 @@ func TestPicoclawBoxEnvVars(t *testing.T) { "http://10.0.0.8:18080", "shared-token", "u-worker-1", - config.ModelConfig{ - BaseURL: "http://127.0.0.1:4000", - APIKey: "sk-test", - ModelID: "minimax-m2.7", - }, + "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "minimax-m2.7", ) wants := map[string]string{ @@ -964,11 +1112,17 @@ func TestPicoclawBoxEnvVars(t *testing.T) { "PICOCLAW_CHANNELS_CSGCLAW_BASE_URL": "http://10.0.0.8:18080", "PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN": "shared-token", "PICOCLAW_CHANNELS_CSGCLAW_BOT_ID": "u-worker-1", + "CSGCLAW_LLM_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "CSGCLAW_LLM_API_KEY": "shared-token", + "CSGCLAW_LLM_MODEL_ID": "minimax-m2.7", + "OPENAI_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "OPENAI_API_KEY": "shared-token", + "OPENAI_MODEL": "minimax-m2.7", "PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME": "minimax-m2.7", "PICOCLAW_CUSTOM_MODEL_NAME": "minimax-m2.7", "PICOCLAW_CUSTOM_MODEL_ID": "minimax-m2.7", - "PICOCLAW_CUSTOM_MODEL_API_KEY": "sk-test", - "PICOCLAW_CUSTOM_MODEL_BASE_URL": "http://127.0.0.1:4000", + "PICOCLAW_CUSTOM_MODEL_API_KEY": "shared-token", + "PICOCLAW_CUSTOM_MODEL_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", } for key, want := range wants { if got[key] != want { diff --git a/internal/agent/store.go b/internal/agent/store.go index ff033ba..1416c18 100644 --- a/internal/agent/store.go +++ b/internal/agent/store.go @@ -124,6 +124,9 @@ func (s *Service) saveLocked() error { func (s *Service) normalizeLoadedAgent(a Agent) Agent { a = *cloneAgent(&a) a.Role = normalizeRole(a.Role) + if strings.TrimSpace(a.Profile) == "" { + a.Profile = s.inferProfileForAgent(a) + } if isManagerAgent(a) { a.ID = ManagerUserID a.Name = ManagerName diff --git a/internal/api/handler.go b/internal/api/handler.go index 75a8af0..7e1fdc0 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -14,6 +14,7 @@ import ( "csgclaw/internal/bot" "csgclaw/internal/channel" "csgclaw/internal/im" + "csgclaw/internal/llm" ) type Handler struct { @@ -23,6 +24,7 @@ type Handler struct { imBus *im.Bus picoclaw *im.PicoClawBridge feishu *channel.FeishuService + llm *llm.Service serverAccessToken string } @@ -62,15 +64,15 @@ type addRoomMembersRequest struct { Locale string `json:"locale"` } -func NewHandler(svc *agent.Service, imSvc *im.Service, imBus *im.Bus, picoclaw *im.PicoClawBridge, feishu *channel.FeishuService) *Handler { - return NewHandlerWithBot(svc, nil, imSvc, imBus, picoclaw, feishu) +func NewHandler(svc *agent.Service, imSvc *im.Service, imBus *im.Bus, picoclaw *im.PicoClawBridge, feishu *channel.FeishuService, llmSvc *llm.Service) *Handler { + return NewHandlerWithBotAndAccessToken(svc, nil, imSvc, imBus, picoclaw, feishu, llmSvc, "") } -func NewHandlerWithBot(svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, picoclaw *im.PicoClawBridge, feishu *channel.FeishuService) *Handler { - return NewHandlerWithBotAndAccessToken(svc, botSvc, imSvc, imBus, picoclaw, feishu, "") +func NewHandlerWithBot(svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, picoclaw *im.PicoClawBridge, feishu *channel.FeishuService, llmSvc *llm.Service) *Handler { + return NewHandlerWithBotAndAccessToken(svc, botSvc, imSvc, imBus, picoclaw, feishu, llmSvc, "") } -func NewHandlerWithBotAndAccessToken(svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, picoclaw *im.PicoClawBridge, feishu *channel.FeishuService, serverAccessToken string) *Handler { +func NewHandlerWithBotAndAccessToken(svc *agent.Service, botSvc *bot.Service, imSvc *im.Service, imBus *im.Bus, picoclaw *im.PicoClawBridge, feishu *channel.FeishuService, llmSvc *llm.Service, serverAccessToken string) *Handler { if botSvc != nil { botSvc.SetDependencies(svc, imSvc, feishu) } @@ -81,6 +83,7 @@ func NewHandlerWithBotAndAccessToken(svc *agent.Service, botSvc *bot.Service, im imBus: imBus, picoclaw: picoclaw, feishu: feishu, + llm: llmSvc, serverAccessToken: serverAccessToken, } } @@ -103,6 +106,7 @@ func (h *Handler) handleWorkers(w http.ResponseWriter, r *http.Request) { return } + // step 7.0 /api/v1/workers is the worker-focused HTTP branch used by current integrations. switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, h.svc.ListWorkers()) @@ -150,6 +154,7 @@ func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { return } + // step 7.1 /api/v1/agents currently shares the same create path as /workers, so creation means "create a worker". switch r.Method { case http.MethodGet: if err := h.svc.Reload(); err != nil { @@ -408,6 +413,7 @@ func (h *Handler) handleIMBootstrap(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + // step 7.3 The browser starts here: one snapshot gives it current user, users, rooms, and initial messages. writeJSON(w, http.StatusOK, presentBootstrap(h.im.Bootstrap())) } @@ -630,6 +636,7 @@ func (h *Handler) handleIMEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") + // step 7.4 The browser keeps itself fresh through SSE by subscribing to the shared IM event bus. events, cancel := h.imBus.Subscribe() defer cancel() @@ -776,6 +783,7 @@ func (h *Handler) ensureWorkerIMState(created agent.Agent) error { return nil } + // step 7.2.2 Create or reuse the IM identity for this worker, then create its admin-worker bootstrap room. user, room, err := h.im.EnsureAgentUser(im.EnsureAgentUserRequest{ ID: created.ID, Name: created.Name, @@ -787,6 +795,7 @@ func (h *Handler) ensureWorkerIMState(created agent.Agent) error { } h.publishUserEvent(im.EventTypeUserCreated, user) if room != nil { + // step 7.2.3 After the room exists, post a delayed bootstrap instruction so the worker gets its role prompt. h.publishRoomEvent(im.EventTypeRoomCreated, *room) imSvc := h.im roomID := room.ID @@ -822,6 +831,7 @@ func (h *Handler) publishMessageCreated(conversationID, senderID string, message if h.imBus == nil { return } + // step 7.5 Every persisted IM message becomes an in-memory event so UI clients and PicoClaw can react. sender, ok := h.im.User(senderID) if !ok { return diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 4b569ed..73b3789 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -19,6 +19,7 @@ import ( "csgclaw/internal/channel" "csgclaw/internal/config" "csgclaw/internal/im" + "csgclaw/internal/llm" ) func TestParsePicoClawBotPath(t *testing.T) { @@ -30,6 +31,10 @@ func TestParsePicoClawBotPath(t *testing.T) { }{ {path: "/api/bots/u-manager/events", wantBotID: "u-manager", wantAction: "events", wantOK: true}, {path: "/api/bots/u-manager/messages/send", wantBotID: "u-manager", wantAction: "messages/send", wantOK: true}, + {path: "/api/bots/u-manager/llm/models", wantBotID: "u-manager", wantAction: "llm/models", wantOK: true}, + {path: "/api/bots/u-manager/llm/v1/models", wantBotID: "u-manager", wantAction: "llm/v1/models", wantOK: true}, + {path: "/api/bots/u-manager/llm/chat/completions", wantBotID: "u-manager", wantAction: "llm/chat/completions", wantOK: true}, + {path: "/api/bots/u-manager/llm/v1/chat/completions", wantBotID: "u-manager", wantAction: "llm/v1/chat/completions", wantOK: true}, {path: "/api/bots/u-manager", wantOK: false}, {path: "/api/v1/bots/u-manager/events", wantOK: false}, {path: "/api/bots//events", wantOK: false}, @@ -325,7 +330,7 @@ func TestHandleBotsListRequiresService(t *testing.T) { func TestHandleBotsCreateCSGClawWorker(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return nil, &boxlite.BoxInfo{ ID: "box-" + name, State: boxlite.StateRunning, @@ -412,7 +417,7 @@ func TestHandleBotsCreateCSGClawWorker(t *testing.T) { func TestHandleBotsCreateFeishuWorker(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return nil, &boxlite.BoxInfo{ ID: "box-" + name, State: boxlite.StateRunning, @@ -527,7 +532,7 @@ func TestHandleBotsCreateCSGClawManagerBindsBootstrappedAgent(t *testing.T) { func TestHandleBotsCreateManagerBootstrapsMissingAgent(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return &boxlite.Runtime{}, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { if name != agent.ManagerName { t.Fatalf("create gateway name = %q, want manager", name) } @@ -824,7 +829,7 @@ func TestHandleAgentsDeleteNotFound(t *testing.T) { func TestHandleAgentsCreateUsesWorkerCompatibilityFlow(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return nil, &boxlite.BoxInfo{ State: boxlite.StateRunning, CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), @@ -1012,7 +1017,7 @@ func TestHandleRoomsInviteRequiresRoomID(t *testing.T) { func TestHandleWorkersPostRemainsCreateAlias(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, _ string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { return nil, &boxlite.BoxInfo{ State: boxlite.StateRunning, CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), @@ -1545,10 +1550,119 @@ func TestHandlePicoClawSendMessageRequiresIMService(t *testing.T) { } } +func TestHandlePicoClawModelsReturnsBridgeCatalog(t *testing.T) { + dir := t.TempDir() + statePath := filepath.Join(dir, "agents.json") + agents := []agent.Agent{ + { + ID: agent.ManagerUserID, + Name: agent.ManagerName, + Role: agent.RoleManager, + Profile: config.DefaultLLMProfile, + Provider: config.ProviderLLMAPI, + ModelID: "gpt-5.4", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + } + if err := writeSeededAgents(statePath, agents); err != nil { + t.Fatalf("writeSeededAgents() error = %v", err) + } + svc, err := agent.NewServiceWithLLM(config.SingleProfileLLM(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "gpt-5.4", + }), config.ServerConfig{}, "", statePath) + if err != nil { + t.Fatalf("NewServiceWithLLM() error = %v", err) + } + bridge := llm.NewService(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "gpt-5.4", + }, svc) + + srv := &Handler{ + svc: svc, + picoclaw: im.NewPicoClawBridge("secret"), + llm: bridge, + } + + req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/llm/v1/models", nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"id":"gpt-5.4"`) { + t.Fatalf("body = %s, want model catalog", rec.Body.String()) + } +} + +func TestHandlePicoClawModelsLegacyRouteReturnsBridgeCatalog(t *testing.T) { + dir := t.TempDir() + statePath := filepath.Join(dir, "agents.json") + agents := []agent.Agent{ + { + ID: agent.ManagerUserID, + Name: agent.ManagerName, + Role: agent.RoleManager, + Profile: config.DefaultLLMProfile, + Provider: config.ProviderLLMAPI, + ModelID: "gpt-5.4", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + } + if err := writeSeededAgents(statePath, agents); err != nil { + t.Fatalf("writeSeededAgents() error = %v", err) + } + svc, err := agent.NewServiceWithLLM(config.SingleProfileLLM(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "gpt-5.4", + }), config.ServerConfig{}, "", statePath) + if err != nil { + t.Fatalf("NewServiceWithLLM() error = %v", err) + } + bridge := llm.NewService(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "gpt-5.4", + }, svc) + + srv := &Handler{ + svc: svc, + picoclaw: im.NewPicoClawBridge("secret"), + llm: bridge, + } + + req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/llm/models", nil) + req.Header.Set("Authorization", "Bearer secret") + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if !strings.Contains(rec.Body.String(), `"id":"gpt-5.4"`) { + t.Fatalf("body = %s, want model catalog", rec.Body.String()) + } +} + func mustNewService(t *testing.T) *agent.Service { t.Helper() - svc, err := agent.NewService(config.ModelConfig{}, config.ServerConfig{}, "", "") + svc, err := agent.NewService(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "model-1", + }, config.ServerConfig{}, "", "") if err != nil { t.Fatalf("NewService() error = %v", err) } diff --git a/internal/api/llm.go b/internal/api/llm.go new file mode 100644 index 0000000..928521d --- /dev/null +++ b/internal/api/llm.go @@ -0,0 +1,51 @@ +package api + +import ( + "io" + "net/http" + + "csgclaw/internal/llm" +) + +func (h *Handler) handlePicoClawModels(w http.ResponseWriter, r *http.Request, botID string) { + if h.llm == nil { + http.Error(w, "llm bridge is not configured", http.StatusServiceUnavailable) + return + } + body, status, contentType, err := h.llm.Models(r.Context(), botID) + if err != nil { + writeLLMError(w, err) + return + } + w.Header().Set("Content-Type", contentType) + w.WriteHeader(status) + _, _ = w.Write(body) +} + +func (h *Handler) handlePicoClawChatCompletions(w http.ResponseWriter, r *http.Request, botID string) { + if h.llm == nil { + http.Error(w, "llm bridge is not configured", http.StatusServiceUnavailable) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 10*1024*1024)) + if err != nil { + http.Error(w, "read request body", http.StatusBadRequest) + return + } + respBody, status, contentType, callErr := h.llm.ChatCompletions(r.Context(), botID, body) + if callErr != nil { + writeLLMError(w, callErr) + return + } + w.Header().Set("Content-Type", contentType) + w.WriteHeader(status) + _, _ = w.Write(respBody) +} + +func writeLLMError(w http.ResponseWriter, err error) { + if httpErr, ok := err.(*llm.HTTPError); ok { + http.Error(w, httpErr.Message, httpErr.Status) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) +} diff --git a/internal/api/picoclaw.go b/internal/api/picoclaw.go index b41f9e5..7e156f0 100644 --- a/internal/api/picoclaw.go +++ b/internal/api/picoclaw.go @@ -11,6 +11,7 @@ import ( ) func (h *Handler) registerPicoClawRoutes(mux *http.ServeMux) { + // step 7.6 Bot-facing PicoClaw routes live outside /api/v1 because they follow PicoClaw's callback contract. mux.HandleFunc("/api/bots/", h.handlePicoClawBotRoutes) } @@ -26,10 +27,15 @@ func (h *Handler) PublishPicoClawEvent(evt im.Event) { if !ok { return } + // step 7.6.1 Only message.created events flow through this branch; room/user events stay browser-only. h.picoclaw.PublishMessageEvent(room, *evt.Sender, *evt.Message) } func (h *Handler) handlePicoClawBotRoutes(w http.ResponseWriter, r *http.Request) { + // step 7.6.2 A bot can do two things here: + // step 7.6.2.1 subscribe to its message event stream + // step 7.6.2.2 send a reply back into an IM room. + // step 7.6.2.3 reach the host-side LLM bridge through an OpenAI-compatible endpoint. botID, action, ok := parsePicoClawBotPath(r.URL.Path) if !ok { http.NotFound(w, r) @@ -49,12 +55,17 @@ func (h *Handler) handlePicoClawBotRoutes(w http.ResponseWriter, r *http.Request h.handlePicoClawEvents(w, r, botID) case r.Method == http.MethodPost && action == "messages/send": h.handlePicoClawSendMessage(w, r, botID) + case r.Method == http.MethodGet && (action == "llm/models" || action == "llm/v1/models"): + h.handlePicoClawModels(w, r, botID) + case r.Method == http.MethodPost && (action == "llm/chat/completions" || action == "llm/v1/chat/completions"): + h.handlePicoClawChatCompletions(w, r, botID) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *Handler) handlePicoClawEvents(w http.ResponseWriter, r *http.Request, botID string) { + // step 7.6.2.1 Bots receive SSE here instead of the server actively calling out to them. flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming is not supported", http.StatusInternalServerError) @@ -92,6 +103,7 @@ func (h *Handler) handlePicoClawSendMessage(w http.ResponseWriter, r *http.Reque return } + // step 7.6.2.2 Bot replies are written back into IM as normal messages authored by that bot's user ID. var req im.PicoClawSendMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) @@ -126,7 +138,7 @@ func parsePicoClawBotPath(path string) (botID, action string, ok bool) { botID = parts[0] action = strings.Join(parts[1:], "/") switch action { - case "events", "messages/send": + case "events", "messages/send", "llm/models", "llm/v1/models", "llm/chat/completions", "llm/v1/chat/completions": return botID, action, true default: return "", "", false diff --git a/internal/api/router.go b/internal/api/router.go index 3e1cd59..0990d6d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -3,6 +3,7 @@ package api import "net/http" func (h *Handler) Routes() *http.ServeMux { + // step 6.0 Register one route tree for the browser/UI API and a second route tree for PicoClaw bot callbacks. mux := http.NewServeMux() h.registerCoreRoutes(mux) h.registerChannelRoutes(mux) @@ -11,6 +12,10 @@ func (h *Handler) Routes() *http.ServeMux { } func (h *Handler) registerCoreRoutes(mux *http.ServeMux) { + // step 6.1 Core routes serve three clients: + // step 6.1.1 CLI commands under /api/v1/agents, /rooms, /users + // step 6.1.2 browser bootstrap/SSE under /api/v1/bootstrap and /api/v1/events + // step 6.1.3 compatibility aliases under /api/v1/im/*. mux.HandleFunc("/healthz", h.handleHealthz) mux.HandleFunc("/api/v1/bots", h.handleBots) mux.HandleFunc("/api/v1/agents", h.handleAgents) diff --git a/internal/bot/service_test.go b/internal/bot/service_test.go index 01dba34..f01fe26 100644 --- a/internal/bot/service_test.go +++ b/internal/bot/service_test.go @@ -93,7 +93,7 @@ func TestNewServiceRequiresStore(t *testing.T) { func TestServiceCreateCSGClawWorkerCreatesAgentUserAndBot(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { if name != "alice" { t.Fatalf("create gateway name = %q, want alice", name) } @@ -159,7 +159,7 @@ func TestServiceCreateCSGClawWorkerCreatesAgentUserAndBot(t *testing.T) { func TestServiceCreateFeishuWorkerCreatesAgentUserAndBot(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return nil, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { if name != "alice" { t.Fatalf("create gateway name = %q, want alice", name) } @@ -349,7 +349,7 @@ func TestServiceCreateFeishuManagerEnsuresExistingUser(t *testing.T) { func TestServiceCreateManagerBootstrapsMissingAgent(t *testing.T) { agent.SetTestHooks( func(_ *agent.Service, _ string) (*boxlite.Runtime, error) { return &boxlite.Runtime{}, nil }, - func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID, _ string) (*boxlite.Box, *boxlite.BoxInfo, error) { + func(_ *agent.Service, _ context.Context, _ *boxlite.Runtime, _ string, name, botID string, _ config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { if name != agent.ManagerName { t.Fatalf("create gateway name = %q, want manager", name) } diff --git a/internal/config/config.go b/internal/config/config.go index e48ad46..2a9714d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ import ( type Config struct { Server ServerConfig + LLM LLMConfig Model ModelConfig Bootstrap BootstrapConfig Channels ChannelsConfig @@ -27,9 +28,16 @@ type ServerConfig struct { } type ModelConfig struct { - BaseURL string - APIKey string - ModelID string + Provider string + BaseURL string + APIKey string + ModelID string + ReasoningEffort string +} + +type LLMConfig struct { + DefaultProfile string + Profiles map[string]ModelConfig } type BootstrapConfig struct { @@ -58,6 +66,7 @@ const ( DefaultHTTPPort = apiclient.DefaultHTTPPort DefaultAccessToken = "your_access_token" DefaultManagerImage = "ghcr.io/russellluo/picoclaw:2026.4.8.1" + DefaultLLMProfile = "default" ) func DefaultListenAddr() string { @@ -80,20 +89,6 @@ func ListenPort(listenAddr string) string { return port } -func (c ModelConfig) MissingFields() []string { - var missing []string - if strings.TrimSpace(c.BaseURL) == "" { - missing = append(missing, "base_url") - } - if strings.TrimSpace(c.APIKey) == "" { - missing = append(missing, "api_key") - } - if strings.TrimSpace(c.ModelID) == "" { - missing = append(missing, "model_id") - } - return missing -} - func DefaultDir() (string, error) { home, err := os.UserHomeDir() if err != nil { @@ -167,7 +162,11 @@ func Load(path string) (Config, error) { return Config{}, fmt.Errorf("read config: %w", err) } - cfg := Config{} + cfg := Config{ + LLM: LLMConfig{ + Profiles: make(map[string]ModelConfig), + }, + } section := "" scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { @@ -197,14 +196,40 @@ func Load(path string) (Config, error) { case "access_token": cfg.Server.AccessToken = value } + case section == "llm": + switch key { + case "default_profile": + cfg.LLM.DefaultProfile = value + } case section == "model": switch key { + case "provider": + cfg.Model.Provider = value case "base_url": cfg.Model.BaseURL = value case "api_key": cfg.Model.APIKey = value case "model_id": cfg.Model.ModelID = value + case "reasoning_effort": + cfg.Model.ReasoningEffort = value + } + default: + if name, ok := llmProfileSectionName(section); ok { + profile := cfg.LLM.Profiles[name] + switch key { + case "provider": + profile.Provider = value + case "base_url": + profile.BaseURL = value + case "api_key": + profile.APIKey = value + case "model_id": + profile.ModelID = value + case "reasoning_effort": + profile.ReasoningEffort = value + } + cfg.LLM.Profiles[name] = profile } case section == "bootstrap": switch key { @@ -247,6 +272,13 @@ func Load(path string) (Config, error) { if cfg.Server.AccessToken == "" { cfg.Server.AccessToken = DefaultAccessToken } + cfg.Model = cfg.Model.Resolved() + if len(cfg.LLM.Profiles) == 0 { + cfg.LLM = SingleProfileLLM(cfg.Model) + } else { + cfg.LLM = cfg.LLM.Normalized() + } + cfg.syncModelFromLLM() return cfg, nil } @@ -255,24 +287,46 @@ func (c Config) Save(path string) error { return fmt.Errorf("create config dir: %w", err) } - content := fmt.Sprintf(`# Generated by csgclaw onboard. + cfg := c + cfg.syncModelFromLLM() + llmCfg := cfg.LLM.Normalized() + if len(llmCfg.Profiles) == 0 { + llmCfg = SingleProfileLLM(cfg.Model) + } + defaultProfile := llmCfg.EffectiveDefaultProfile() + if defaultProfile == "" { + defaultProfile = DefaultLLMProfile + } + + var b strings.Builder + fmt.Fprintf(&b, `# Generated by csgclaw onboard. [server] listen_addr = %q advertise_base_url = %q access_token = %q -[model] -base_url = %q -api_key = %q -model_id = %q +[llm] +default_profile = %q [bootstrap] manager_image = %q -`, c.Server.ListenAddr, c.Server.AdvertiseBaseURL, c.Server.AccessToken, c.Model.BaseURL, c.Model.APIKey, c.Model.ModelID, c.Bootstrap.ManagerImage) +`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, cfg.Server.AccessToken, defaultProfile, cfg.Bootstrap.ManagerImage) + + for _, name := range sortedProfileNames(llmCfg.Profiles) { + profile := llmCfg.Profiles[name].Resolved() + fmt.Fprintf(&b, ` +[llm.profiles.%s] +provider = %q +base_url = %q +api_key = %q +model_id = %q +reasoning_effort = %q +`, name, profile.EffectiveProvider(), profile.BaseURL, profile.APIKey, profile.ModelID, profile.ReasoningEffort) + } if strings.TrimSpace(c.Channels.FeishuAdminOpenID) != "" { - content += fmt.Sprintf(` + fmt.Fprintf(&b, ` [channels.feishu] admin_open_id = %q `, c.Channels.FeishuAdminOpenID) @@ -286,7 +340,7 @@ admin_open_id = %q sort.Strings(names) for _, name := range names { feishu := c.Channels.Feishu[name] - content += fmt.Sprintf(` + fmt.Fprintf(&b, ` [channels.feishu.%s] app_id = %q app_secret = %q @@ -294,8 +348,55 @@ app_secret = %q } } - if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { return fmt.Errorf("write config: %w", err) } return nil } + +func llmProfileSectionName(section string) (string, bool) { + const prefix = "llm.profiles." + if !strings.HasPrefix(section, prefix) { + return "", false + } + name := strings.TrimSpace(strings.TrimPrefix(section, prefix)) + if name == "" { + return "", false + } + return name, true +} + +func SingleProfileLLM(model ModelConfig) LLMConfig { + return LLMConfig{ + DefaultProfile: DefaultLLMProfile, + Profiles: map[string]ModelConfig{ + DefaultLLMProfile: model.Resolved(), + }, + } +} + +func (c *Config) syncModelFromLLM() { + if c == nil { + return + } + c.LLM = c.LLM.Normalized() + if len(c.LLM.Profiles) == 0 { + c.LLM = SingleProfileLLM(c.Model) + } + name, model, err := c.LLM.Resolve("") + if err != nil { + c.Model = c.Model.Resolved() + return + } + c.LLM.DefaultProfile = name + c.Model = model.Resolved() +} + +func sortedProfileNames(profiles map[string]ModelConfig) []string { + names := make([]string, 0, len(profiles)) + for name := range profiles { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d9afd9f..78ddb08 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,6 +72,53 @@ model_id = "minimax-m2.7" if got, want := cfg.Server.AccessToken, DefaultAccessToken; got != want { t.Fatalf("cfg.Server.AccessToken = %q, want %q", got, want) } + if got, want := cfg.Model.Provider, ProviderLLMAPI; got != want { + t.Fatalf("cfg.Model.Provider = %q, want %q", got, want) + } + if got, want := cfg.LLM.DefaultProfile, DefaultLLMProfile; got != want { + t.Fatalf("cfg.LLM.DefaultProfile = %q, want %q", got, want) + } +} + +func TestLoadReadsLLMProfilePool(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := `[server] +listen_addr = "127.0.0.1:18080" + +[llm] +default_profile = "remote-main" + +[llm.profiles.remote-main] +provider = "llm-api" +base_url = "https://example.test/v1" +api_key = "sk-test" +model_id = "gpt-5.4" +reasoning_effort = "medium" +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got, want := cfg.LLM.DefaultProfile, "remote-main"; got != want { + t.Fatalf("cfg.LLM.DefaultProfile = %q, want %q", got, want) + } + if got, want := cfg.Model.Provider, ProviderLLMAPI; got != want { + t.Fatalf("cfg.Model.Provider = %q, want %q", got, want) + } + if got, want := cfg.Model.ModelID, "gpt-5.4"; got != want { + t.Fatalf("cfg.Model.ModelID = %q, want %q", got, want) + } + if got, want := cfg.LLM.Profiles["remote-main"].BaseURL, "https://example.test/v1"; got != want { + t.Fatalf("cfg.LLM.Profiles[remote-main].BaseURL = %q, want %q", got, want) + } + if got, want := cfg.LLM.Profiles["remote-main"].ReasoningEffort, "medium"; got != want { + t.Fatalf("cfg.LLM.Profiles[remote-main].ReasoningEffort = %q, want %q", got, want) + } } func TestLoadSupportsNamedFeishuChannelConfigs(t *testing.T) { @@ -132,11 +179,12 @@ func TestSaveWritesAccessTokenUnderServerSection(t *testing.T) { AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", }, - Model: ModelConfig{ - BaseURL: "http://127.0.0.1:4000", - APIKey: "sk", - ModelID: "minimax-m2.7", - }, + LLM: SingleProfileLLM(ModelConfig{ + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk", + ModelID: "minimax-m2.7", + ReasoningEffort: "medium", + }), Bootstrap: BootstrapConfig{ ManagerImage: "img", }, @@ -167,6 +215,12 @@ func TestSaveWritesAccessTokenUnderServerSection(t *testing.T) { if !strings.Contains(content, "access_token = \"shared-token\"") { t.Fatalf("saved config missing server access token:\n%s", content) } + if !strings.Contains(content, "[llm]") || !strings.Contains(content, "[llm.profiles.default]") { + t.Fatalf("saved config missing llm profile sections:\n%s", content) + } + if !strings.Contains(content, `reasoning_effort = "medium"`) { + t.Fatalf("saved config missing reasoning_effort:\n%s", content) + } if strings.Contains(content, "[picoclaw]") { t.Fatalf("saved config should not contain [picoclaw] section:\n%s", content) } @@ -193,3 +247,16 @@ func TestLLMConfigMissingFields(t *testing.T) { t.Fatalf("MissingFields() = %q, want %q", got, want) } } + +func TestValidateRejectsUnsupportedProvider(t *testing.T) { + err := (ModelConfig{ + Provider: "local-codex", + ModelID: "gpt-5.4", + }).Validate() + if err == nil { + t.Fatal("Validate() error = nil, want unsupported provider rejection") + } + if !strings.Contains(err.Error(), "only \"llm-api\" is supported now") { + t.Fatalf("Validate() error = %q, want unsupported provider rejection", err) + } +} diff --git a/internal/config/model_validation.go b/internal/config/model_validation.go new file mode 100644 index 0000000..06638dc --- /dev/null +++ b/internal/config/model_validation.go @@ -0,0 +1,205 @@ +package config + +import ( + "errors" + "fmt" + "strings" +) + +const ( + ProviderLLMAPI = "llm-api" +) + +type ModelValidationError struct { + MissingFields []string + Message string +} + +func (e *ModelValidationError) Error() string { + if e == nil { + return "" + } + if e.Message != "" { + return e.Message + } + if len(e.MissingFields) == 0 { + return "invalid model config" + } + return fmt.Sprintf("missing required model fields: %s", strings.Join(e.MissingFields, ", ")) +} + +func NormalizeProvider(provider string) string { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "", ProviderLLMAPI: + return ProviderLLMAPI + default: + return strings.ToLower(strings.TrimSpace(provider)) + } +} + +func (c ModelConfig) EffectiveProvider() string { + return NormalizeProvider(c.Provider) +} + +func (c ModelConfig) Resolved() ModelConfig { + out := c + out.Provider = out.EffectiveProvider() + out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/") + out.APIKey = strings.TrimSpace(out.APIKey) + out.ModelID = strings.TrimSpace(out.ModelID) + out.ReasoningEffort = strings.ToLower(strings.TrimSpace(out.ReasoningEffort)) + return out +} + +func (c ModelConfig) MissingFields() []string { + cfg := c.Resolved() + var missing []string + if cfg.BaseURL == "" { + missing = append(missing, "base_url") + } + if cfg.APIKey == "" { + missing = append(missing, "api_key") + } + if cfg.ModelID == "" { + missing = append(missing, "model_id") + } + return missing +} + +func (c ModelConfig) Validate() error { + cfg := c.Resolved() + if err := cfg.validateProvider(); err != nil { + return err + } + if missing := cfg.MissingFields(); len(missing) > 0 { + return &ModelValidationError{ + MissingFields: missing, + Message: fmt.Sprintf("provider %q is missing required fields: %s", cfg.Provider, strings.Join(missing, ", ")), + } + } + return nil +} + +func (c LLMConfig) Normalized() LLMConfig { + out := LLMConfig{ + DefaultProfile: strings.TrimSpace(c.DefaultProfile), + Profiles: make(map[string]ModelConfig, len(c.Profiles)), + } + for name, profile := range c.Profiles { + name = strings.TrimSpace(name) + if name == "" { + continue + } + out.Profiles[name] = profile.Resolved() + } + if out.DefaultProfile == "" { + out.DefaultProfile = out.EffectiveDefaultProfile() + } + return out +} + +func (c LLMConfig) EffectiveDefaultProfile() string { + defaultProfile := strings.TrimSpace(c.DefaultProfile) + if defaultProfile != "" { + return defaultProfile + } + if len(c.Profiles) == 1 { + for name := range c.Profiles { + return strings.TrimSpace(name) + } + } + if _, ok := c.Profiles[DefaultLLMProfile]; ok { + return DefaultLLMProfile + } + return "" +} + +func (c LLMConfig) Resolve(profile string) (string, ModelConfig, error) { + cfg := c.Normalized() + name := strings.TrimSpace(profile) + if name == "" { + name = cfg.EffectiveDefaultProfile() + } + if name == "" { + return "", ModelConfig{}, &ModelValidationError{Message: "llm default_profile is not configured"} + } + model, ok := cfg.Profiles[name] + if !ok { + return "", ModelConfig{}, &ModelValidationError{Message: fmt.Sprintf("llm profile %q was not found", name)} + } + return name, model.Resolved(), nil +} + +func (c LLMConfig) MatchProfile(candidate ModelConfig) (string, ModelConfig, bool) { + cfg := c.Normalized() + candidate = candidate.Resolved() + for _, name := range sortedProfileNames(cfg.Profiles) { + profile := cfg.Profiles[name].Resolved() + if !strings.EqualFold(profile.EffectiveProvider(), candidate.EffectiveProvider()) { + continue + } + if strings.TrimSpace(profile.ModelID) != strings.TrimSpace(candidate.ModelID) { + continue + } + if candidate.ReasoningEffort != "" && strings.TrimSpace(profile.ReasoningEffort) != strings.TrimSpace(candidate.ReasoningEffort) { + continue + } + return name, profile, true + } + return "", ModelConfig{}, false +} + +func (c LLMConfig) MissingFields() []string { + _, model, err := c.Resolve("") + if err != nil { + var validationErr *ModelValidationError + if errors.As(err, &validationErr) { + return append([]string(nil), validationErr.MissingFields...) + } + return nil + } + return model.MissingFields() +} + +func (c LLMConfig) Validate() error { + cfg := c.Normalized() + if len(cfg.Profiles) == 0 { + return SingleProfileLLM(ModelConfig{}).Validate() + } + defaultProfile := cfg.EffectiveDefaultProfile() + if defaultProfile == "" { + return &ModelValidationError{ + MissingFields: []string{"default_profile"}, + Message: "llm default_profile is required", + } + } + if _, ok := cfg.Profiles[defaultProfile]; !ok { + return &ModelValidationError{ + MissingFields: []string{"default_profile"}, + Message: fmt.Sprintf("llm default_profile %q does not match any llm.profiles entry", defaultProfile), + } + } + for _, name := range sortedProfileNames(cfg.Profiles) { + profile := cfg.Profiles[name] + if err := profile.Validate(); err != nil { + if name == defaultProfile { + return err + } + return fmt.Errorf("llm profile %q is invalid: %w", name, err) + } + } + return nil +} + +func (c ModelConfig) validateProvider() error { + if c.EffectiveProvider() == ProviderLLMAPI { + return nil + } + return &ModelValidationError{ + Message: fmt.Sprintf( + "unsupported model provider %q; only %q is supported now", + strings.TrimSpace(c.Provider), + ProviderLLMAPI, + ), + } +} diff --git a/internal/im/events.go b/internal/im/events.go index 6d89afd..7111307 100644 --- a/internal/im/events.go +++ b/internal/im/events.go @@ -33,6 +33,7 @@ func NewBus() *Bus { } func (b *Bus) Subscribe() (<-chan Event, func()) { + // step 9.4 IM events are fanned out in-memory to browsers and the PicoClaw bridge without touching disk again. ch := make(chan Event, 16) b.mu.Lock() @@ -58,6 +59,7 @@ func (b *Bus) Publish(evt Event) { return } + // step 9.4.1 Slow subscribers do not block the system; events are dropped when a subscriber channel is full. b.mu.Lock() targets := make([]chan Event, 0, len(b.subscribers)) for _, ch := range b.subscribers { diff --git a/internal/im/service.go b/internal/im/service.go index 13aff9e..3e63eda 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -111,6 +111,10 @@ func NewServiceFromPath(path string) (*Service, error) { } func NewServiceFromBootstrap(state Bootstrap) *Service { + // step 9.0 IM owns the collaboration model: + // step 9.0.1 users + // step 9.0.2 rooms/conversations + // step 9.0.3 persisted messages inside each room. state = normalizeBootstrap(state) users := state.Users @@ -369,6 +373,7 @@ func sessionRelativePath(roomID string) string { } func EnsureBootstrapState(path string) error { + // step 9.1 First-run IM state always guarantees admin, manager, and their bootstrap room. state, err := LoadBootstrap(path) if err != nil { return err @@ -537,6 +542,7 @@ func containsUserIDInConversation(conv Conversation, userID string) bool { } func (s *Service) Bootstrap() Bootstrap { + // step 9.2 The browser bootstrap snapshot is just a presentation of current in-memory IM state. s.mu.RLock() defer s.mu.RUnlock() @@ -672,6 +678,7 @@ func (s *Service) KickUser(userID string) error { } func (s *Service) EnsureAgentUser(req EnsureAgentUserRequest) (User, *Room, error) { + // step 9.3 Mirror an agent into IM as a user and ensure it has a direct bootstrap room with admin. id := strings.TrimSpace(req.ID) name := strings.ToLower(strings.TrimSpace(req.Name)) handle := strings.ToLower(strings.TrimSpace(req.Handle)) @@ -730,6 +737,7 @@ func (s *Service) EnsureWorkerUser(req EnsureWorkerUserRequest) (User, *Room, er } func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { + // step 9.5 User-originated messages enter IM here, are persisted, and later become SSE/PicoClaw events upstream. content := strings.TrimSpace(req.Content) roomID := strings.TrimSpace(req.RoomID) if roomID == "" { @@ -763,6 +771,7 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { } func (s *Service) DeliverMessage(req DeliverMessageRequest) (Message, error) { + // step 9.5.1 Bot-originated replies use a parallel branch that defaults sender_id when the bridge omits it. roomID := strings.TrimSpace(req.RoomID) senderID := strings.TrimSpace(req.SenderID) content := strings.TrimSpace(req.Content) @@ -796,6 +805,7 @@ func (s *Service) DeliverMessage(req DeliverMessageRequest) (Message, error) { } func (s *Service) CreateRoom(req CreateRoomRequest) (Room, error) { + // step 9.6 New rooms are created with an initial event message so the UI can explain how the room started. title := strings.TrimSpace(req.Title) description := strings.TrimSpace(req.Description) if title == "" { @@ -850,6 +860,7 @@ func (s *Service) CreateConversation(req CreateConversationRequest) (Conversatio } func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) { + // step 9.7 Member invites mutate participants and append an event message describing who was added. roomID := strings.TrimSpace(req.RoomID) if roomID == "" { return Room{}, fmt.Errorf("room_id is required") @@ -966,6 +977,7 @@ func (s *Service) User(userID string) (User, bool) { } func (s *Service) extractMentions(content string) []string { + // step 9.5.2 Mentions are resolved from @handle text into user IDs so group-room bot delivery can be selective. matches := mentionPattern.FindAllStringSubmatch(content, -1) if len(matches) == 0 { return nil @@ -1070,6 +1082,7 @@ func formatConversationSubtitle(count int) string { } func (s *Service) presentRoomLocked(room Room) Room { + // step 9.2.1 Direct rooms are presented using the "other participant" name to feel like a chat app. cloned := cloneRoom(room) if len(cloned.Participants) != 2 { return cloned @@ -1147,6 +1160,7 @@ func (s *Service) bootstrapLocked() Bootstrap { } func (s *Service) ensureAdminAgentRoomLocked(agentID, agentName string) (*Room, bool) { + // step 9.3.1 Every worker gets one admin-worker bootstrap direct room, created lazily the first time it appears. for _, room := range s.rooms { if len(room.Participants) != 2 { continue diff --git a/internal/llm/service.go b/internal/llm/service.go new file mode 100644 index 0000000..69dfafd --- /dev/null +++ b/internal/llm/service.go @@ -0,0 +1,136 @@ +package llm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/config" +) + +type Service struct { + defaults config.ModelConfig + agents *agent.Service + client *http.Client +} + +type HTTPError struct { + Status int + Message string +} + +func (e *HTTPError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func NewService(defaults config.ModelConfig, agents *agent.Service) *Service { + return &Service{ + defaults: defaults.Resolved(), + agents: agents, + client: &http.Client{Timeout: 60 * time.Second}, + } +} + +func (s *Service) Models(_ context.Context, botID string) ([]byte, int, string, error) { + cfg, err := s.resolveModelConfig(botID) + if err != nil { + return nil, 0, "", err + } + payload := map[string]any{ + "object": "list", + "data": []map[string]any{ + { + "id": cfg.ModelID, + "object": "model", + "created": 0, + "owned_by": "csgclaw", + }, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, 0, "", &HTTPError{Status: http.StatusInternalServerError, Message: fmt.Sprintf("encode models response: %v", err)} + } + return body, http.StatusOK, "application/json", nil +} + +func (s *Service) ChatCompletions(ctx context.Context, botID string, body []byte) ([]byte, int, string, error) { + cfg, err := s.resolveModelConfig(botID) + if err != nil { + return nil, 0, "", err + } + return s.forwardRemoteChat(ctx, cfg, body) +} + +func (s *Service) resolveModelConfig(botID string) (config.ModelConfig, error) { + if s.agents == nil { + return config.ModelConfig{}, &HTTPError{Status: http.StatusServiceUnavailable, Message: "agent service is not configured"} + } + cfg, err := s.agents.ResolvedModelConfig(botID) + if err != nil { + return config.ModelConfig{}, &HTTPError{Status: http.StatusNotFound, Message: err.Error()} + } + return cfg.Resolved(), nil +} + +func (s *Service) forwardRemoteChat(ctx context.Context, cfg config.ModelConfig, body []byte) ([]byte, int, string, error) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, 0, "", &HTTPError{Status: http.StatusBadRequest, Message: fmt.Sprintf("decode request: %v", err)} + } + payload["model"] = cfg.ModelID + applyReasoningEffortDefault(payload, cfg.ReasoningEffort) + encoded, err := json.Marshal(payload) + if err != nil { + return nil, 0, "", &HTTPError{Status: http.StatusBadRequest, Message: fmt.Sprintf("encode request: %v", err)} + } + + upstreamURL := strings.TrimRight(cfg.BaseURL, "/") + "/chat/completions" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL, bytes.NewReader(encoded)) + if err != nil { + return nil, 0, "", &HTTPError{Status: http.StatusInternalServerError, Message: fmt.Sprintf("build upstream request: %v", err)} + } + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.Do(req) + if err != nil { + return nil, 0, "", &HTTPError{Status: http.StatusBadGateway, Message: fmt.Sprintf("send upstream request: %v", err)} + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) + if err != nil { + return nil, 0, "", &HTTPError{Status: http.StatusBadGateway, Message: fmt.Sprintf("read upstream response: %v", err)} + } + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if contentType == "" { + contentType = "application/json" + } + return respBody, resp.StatusCode, contentType, nil +} + +func applyReasoningEffortDefault(payload map[string]any, defaultEffort string) { + defaultEffort = strings.ToLower(strings.TrimSpace(defaultEffort)) + if defaultEffort == "" { + return + } + if value, ok := payload["reasoning_effort"]; ok { + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + return + } + if value != nil { + return + } + } + payload["reasoning_effort"] = defaultEffort +} diff --git a/internal/llm/service_test.go b/internal/llm/service_test.go new file mode 100644 index 0000000..a1e3c8f --- /dev/null +++ b/internal/llm/service_test.go @@ -0,0 +1,176 @@ +package llm + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/config" +) + +func TestChatCompletionsLLMAPIOverridesModelAndProxiesUpstream(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var gotModel string + var gotReasoningEffort string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/chat/completions" { + t.Fatalf("path = %q, want %q", r.URL.Path, "/v1/chat/completions") + } + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("Decode() error = %v", err) + } + gotModel, _ = payload["model"].(string) + gotReasoningEffort, _ = payload["reasoning_effort"].(string) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"remote result"},"finish_reason":"stop"}]}`)) + })) + defer upstream.Close() + + agentSvc := mustSeededAgentService(t, config.SingleProfileLLM(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: upstream.URL + "/v1", + APIKey: "sk-test", + ModelID: "gpt-5.4", + ReasoningEffort: "medium", + }), []agent.Agent{ + { + ID: agent.ManagerUserID, + Name: agent.ManagerName, + Role: agent.RoleManager, + Profile: config.DefaultLLMProfile, + Provider: config.ProviderLLMAPI, + ModelID: "gpt-5.4", + ReasoningEffort: "medium", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + }) + + svc := NewService(config.ModelConfig{}, agentSvc) + body, status, _, err := svc.ChatCompletions(context.Background(), agent.ManagerUserID, []byte(`{"model":"client-model","messages":[{"role":"user","content":"hello"}]}`)) + if err != nil { + t.Fatalf("ChatCompletions() error = %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want %d", status, http.StatusOK) + } + if gotModel != "gpt-5.4" { + t.Fatalf("upstream model = %q, want %q", gotModel, "gpt-5.4") + } + if gotReasoningEffort != "medium" { + t.Fatalf("upstream reasoning_effort = %q, want %q", gotReasoningEffort, "medium") + } + if !strings.Contains(string(body), "remote result") { + t.Fatalf("body = %s, want remote result", body) + } +} + +func TestChatCompletionsLLMAPIDoesNotOverrideRequestReasoningEffort(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + var gotReasoningEffort string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("Decode() error = %v", err) + } + gotReasoningEffort, _ = payload["reasoning_effort"].(string) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"chatcmpl-1","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"remote result"},"finish_reason":"stop"}]}`)) + })) + defer upstream.Close() + + agentSvc := mustSeededAgentService(t, config.SingleProfileLLM(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: upstream.URL + "/v1", + APIKey: "sk-test", + ModelID: "gpt-5.4", + ReasoningEffort: "medium", + }), []agent.Agent{ + { + ID: agent.ManagerUserID, + Name: agent.ManagerName, + Role: agent.RoleManager, + Profile: config.DefaultLLMProfile, + Provider: config.ProviderLLMAPI, + ModelID: "gpt-5.4", + ReasoningEffort: "medium", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + }) + + svc := NewService(config.ModelConfig{}, agentSvc) + _, status, _, err := svc.ChatCompletions(context.Background(), agent.ManagerUserID, []byte(`{"messages":[{"role":"user","content":"hello"}],"reasoning_effort":"high"}`)) + if err != nil { + t.Fatalf("ChatCompletions() error = %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want %d", status, http.StatusOK) + } + if gotReasoningEffort != "high" { + t.Fatalf("upstream reasoning_effort = %q, want %q", gotReasoningEffort, "high") + } +} + +func TestModelsReturnsResolvedAgentModel(t *testing.T) { + agentSvc := mustSeededAgentService(t, config.SingleProfileLLM(config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "https://example.test/v1", + APIKey: "sk-test", + ModelID: "gpt-5.4-mini", + ReasoningEffort: "high", + }), []agent.Agent{ + { + ID: agent.ManagerUserID, + Name: agent.ManagerName, + Role: agent.RoleManager, + Profile: config.DefaultLLMProfile, + Provider: config.ProviderLLMAPI, + ModelID: "gpt-5.4-mini", + ReasoningEffort: "high", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + }, + }) + + svc := NewService(config.ModelConfig{}, agentSvc) + body, status, _, err := svc.Models(context.Background(), agent.ManagerUserID) + if err != nil { + t.Fatalf("Models() error = %v", err) + } + if status != http.StatusOK { + t.Fatalf("status = %d, want %d", status, http.StatusOK) + } + if !strings.Contains(string(body), `"id":"gpt-5.4-mini"`) { + t.Fatalf("body = %s, want resolved model id", body) + } +} + +func mustSeededAgentService(t *testing.T, llmCfg config.LLMConfig, agents []agent.Agent) *agent.Service { + t.Helper() + + dir := t.TempDir() + statePath := filepath.Join(dir, "agents.json") + data, err := json.Marshal(map[string]any{ + "agents": agents, + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if err := os.WriteFile(statePath, append(data, '\n'), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + svc, err := agent.NewServiceWithLLM(llmCfg, config.ServerConfig{}, "", statePath) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + return svc +} diff --git a/internal/server/http.go b/internal/server/http.go index 57eb364..ea8044f 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -10,6 +10,7 @@ import ( "csgclaw/internal/bot" "csgclaw/internal/channel" "csgclaw/internal/im" + "csgclaw/internal/llm" ) type Options struct { @@ -20,6 +21,7 @@ type Options struct { IMBus *im.Bus PicoClaw *im.PicoClawBridge Feishu *channel.FeishuService + LLM *llm.Service AccessToken string Context context.Context } @@ -29,7 +31,7 @@ func Run(opts Options) error { opts.Context = context.Background() } - handler := api.NewHandlerWithBotAndAccessToken(opts.Service, opts.Bot, opts.IM, opts.IMBus, opts.PicoClaw, opts.Feishu, opts.AccessToken) + handler := api.NewHandlerWithBotAndAccessToken(opts.Service, opts.Bot, opts.IM, opts.IMBus, opts.PicoClaw, opts.Feishu, opts.LLM, opts.AccessToken) mux := handler.Routes() mux.Handle("/", uiHandler()) @@ -40,6 +42,7 @@ func Run(opts Options) error { } if opts.IMBus != nil && opts.PicoClaw != nil { + // step 5.1 Bridge internal IM events to PicoClaw subscribers so bots can receive SSE message events. events, cancel := opts.IMBus.Subscribe() defer cancel() @@ -60,6 +63,7 @@ func Run(opts Options) error { errCh := make(chan error, 1) go func() { + // step 5.2 Use the shared context for graceful shutdown across CLI signal handling and HTTP serve loops. <-opts.Context.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/skills/manager-worker-dispatch/SKILL.md b/skills/manager-worker-dispatch/SKILL.md index 9be9c52..c125719 100644 --- a/skills/manager-worker-dispatch/SKILL.md +++ b/skills/manager-worker-dispatch/SKILL.md @@ -10,6 +10,22 @@ Break an admin request into clear tasks, choose workers by capability, and dispa Reuse the bundled script instead of writing ad hoc requests. Check the script help for the current CLI surface instead of reading reference docs. +## Fast Path + +If the admin explicitly asks the manager to arrange or reuse workers such as `ux`, `dev`, and `qa`, do this directly: + +1. Do not do the implementation work yourself. +2. Do not use `message` for progress chatter or to restate the request. +3. Do not use `spawn` or `subagent`. +4. Run `list-workers`, reuse matching workers, and create a worker only if a required capability is missing. +5. Ensure the chosen workers have joined the target room. +6. Write `todo.json` under `~/.picoclaw/workspace/projects//todo.json`. +7. Start `start-tracking`. +8. Send at most one concise final room reply after tracking starts successfully. + +If you already know this workflow and the script path is clear, do not reread this file just to paraphrase it back to the user. +Do not inspect or modify project implementation files before dispatch unless you need to choose the project slug or update `todo.json`. + ## Workflow 1. Break the admin request into concrete deliverables. @@ -18,6 +34,14 @@ Check the script help for the current CLI surface instead of reading reference d 4. Choose a suitable project directory under `~/.picoclaw/workspace/projects`; create a short slug directory if none fits. 5. Write or overwrite `todo.json` in that directory as the only source of truth for the current dispatch plan. 6. Start `scripts/manager_worker_api.py start-tracking` against that `todo.json`. +7. Let the tracker own sequential handoff; workers must reply in-room with results or blockers, and neither the manager nor workers should manually assign the next worker while tracking is active. + +Inside a manager/worker box, the shared project tree is `~/.picoclaw/workspace/projects`. +On the host machine, that same mount is `~/.csgclaw/projects`. +When reporting a project path back to a human user, translate the in-box path to the host path. Example: + +- in box: `~/.picoclaw/workspace/projects/kanban-board` +- on host: `~/.csgclaw/projects/kanban-board` ## todo.json @@ -38,6 +62,13 @@ Each task should keep these fields: `id` must always be present and should increase sequentially with the task order in `todo.json`. +While tracking is active, task completion is a two-part gate: + +- update `passes` to `true` and write a useful `progress_note` +- post a normal in-room reply to `@manager` with the result or blocker summary + +Tool trace messages are not enough for handoff. The tracker waits for both the `todo.json` update and the assignee's room reply before dispatching the next task. + Example: ```json @@ -95,10 +126,14 @@ python scripts/manager_worker_api.py stop-tracking --todo-path ~/.picoclaw/works ``` Use `python scripts/manager_worker_api.py -h` to inspect the latest commands, flags, and environment variable fallbacks before invoking or updating the workflow. +If you need to direct the human user to the project files on their Mac, point them to the host-side path such as `~/.csgclaw/projects/demo/todo.json`, not the in-box `/home/picoclaw/...` path. + ## Operating Rules - Reuse workers before creating new ones. - Keep `todo.json` aligned with the actual assignment being dispatched. - Do not casually reorder tasks in the sequential flow. - Let `start-tracking` drive dispatch from `todo.json`; do not duplicate that logic in manual room-message procedures. +- While tracking is active, do not manually tell the next worker to start in prose. The tracker is the only sequencer. +- When a worker finishes, they must reply in the shared room with a normal summary or blocker note; updating `todo.json` alone does not release the next task. - If the API response shape differs from expectations, patch the script instead of improvising around it. diff --git a/skills/manager-worker-dispatch/agents/openai.yaml b/skills/manager-worker-dispatch/agents/openai.yaml index 33b7885..f5cdf9f 100644 --- a/skills/manager-worker-dispatch/agents/openai.yaml +++ b/skills/manager-worker-dispatch/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Manager Worker Dispatch" short_description: "Dispatch tasks to workers via API" - default_prompt: "Use $manager-worker-dispatch to inspect workers, create missing roles, add them to the room, and assign tasks." + default_prompt: "Use $manager-worker-dispatch to inspect workers, create missing roles, add them to the room, and assign tasks. Let start-tracking own sequential handoff; workers should reply in-room with results or blockers and should not manually assign the next worker." diff --git a/skills/manager-worker-dispatch/references/api-contract.md b/skills/manager-worker-dispatch/references/api-contract.md index 8a51a5e..2da3dc5 100644 --- a/skills/manager-worker-dispatch/references/api-contract.md +++ b/skills/manager-worker-dispatch/references/api-contract.md @@ -85,11 +85,28 @@ When available, load the CSGClaw API settings from `~/.picoclaw/config.json`: } ``` +### Read IM bootstrap + +- Method: `GET` +- Path: `/api/v1/im/bootstrap` +- Purpose: resolve room participants and assignee handles for tracker sequencing + +### Read room message history + +- Method: `GET` +- Path: `/api/v1/messages?room_id={room_id}` +- Purpose: inspect prior tracker dispatches and assignee room replies + ## Notes - There is no dedicated task-assignment API. - Dispatch still means sending a normal bot message in the target room and mentioning the worker. - Each task in `todo.json` should carry an `id` task number, increasing in dispatch order such as `1`, `2`, `3`. -- `start-tracking` watches `todo.json`, finds the first task whose `passes` is not `true`, and sends that task to its `@assignee`. -- After a worker finishes, they are expected to update that task's `passes` to `true` and write the summary into `progress_note`; the tracker then advances to the next unfinished task. +- `start-tracking` watches `todo.json`, room history, and IM bootstrap data. +- The first task dispatches immediately. Later tasks dispatch only after the previous task both: + updates `passes` to `true`, and + receives a normal in-room reply from that assignee after the tracker dispatch message. +- Tool trace messages that start with `🔧` do not count as completion replies. +- The tracker resolves each gated assignee against real room participant handles; unresolved assignees are treated as tracker errors, not silent skips. +- While tracking is active, the tracker is the only sequencer. Manager/worker prose should not manually assign the next worker. - Worker provisioning and room membership remain explicit steps through `list-workers`, `create-worker`, and `join-worker`. diff --git a/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/skills/manager-worker-dispatch/scripts/manager_worker_api.py index 28de100..a500eab 100644 --- a/skills/manager-worker-dispatch/scripts/manager_worker_api.py +++ b/skills/manager-worker-dispatch/scripts/manager_worker_api.py @@ -12,9 +12,10 @@ import sys import tempfile import time +from datetime import datetime, timezone from pathlib import Path from typing import Any -from urllib import error, request +from urllib import error, parse, request DEFAULT_BASE_URL = "http://127.0.0.1:18080" @@ -125,6 +126,238 @@ def is_process_alive(pid: int) -> bool: return True +class TrackingError(RuntimeError): + """Raised when task tracking cannot make a safe sequencing decision.""" + + +def normalize_handle(value: Any) -> str: + return str(value or "").strip().lower().removeprefix("@") + + +def parse_created_at(value: Any) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + try: + created_at = datetime.fromisoformat(text) + except ValueError: + return None + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + return created_at.astimezone(timezone.utc) + + +def get_pending_task_index(tasks: list[dict[str, Any]]) -> int | None: + for index, task in enumerate(tasks): + if not get_task_passes(task): + return index + return None + + +def is_human_room_reply(message: dict[str, Any]) -> bool: + content = str(message.get("content") or "").strip() + if not content: + return False + return not content.startswith("🔧") + + +def find_task_dispatch_message( + messages: list[dict[str, Any]], + bot_id: str, + dispatch_text: str, +) -> dict[str, Any] | None: + expected_sender_id = str(bot_id).strip() + expected_content = dispatch_text.strip() + for message in messages: + sender_id = str(message.get("sender_id") or "").strip() + content = str(message.get("content") or "").strip() + if sender_id != expected_sender_id or content != expected_content: + continue + return message + return None + + +def has_assignee_reply_after( + messages: list[dict[str, Any]], + assignee_user_id: str, + dispatched_at: datetime, +) -> bool: + for message in messages: + sender_id = str(message.get("sender_id") or "").strip() + if sender_id != assignee_user_id: + continue + if not is_human_room_reply(message): + continue + created_at = parse_created_at(message.get("created_at")) + if created_at is None or created_at <= dispatched_at: + continue + return True + return False + + +def get_room_participant_index(bootstrap: dict[str, Any], room_id: str) -> dict[str, dict[str, Any]]: + if not isinstance(bootstrap, dict): + raise TrackingError("Unexpected bootstrap response while resolving room participants") + + rooms = bootstrap.get("rooms") + users = bootstrap.get("users") + if not isinstance(rooms, list) or not isinstance(users, list): + raise TrackingError("Bootstrap response is missing rooms/users data required for task tracking") + + room: dict[str, Any] | None = None + for candidate in rooms: + if not isinstance(candidate, dict): + continue + if str(candidate.get("id") or "").strip() == room_id: + room = candidate + break + if room is None: + raise TrackingError(f'Room "{room_id}" was not found in IM bootstrap data') + + participants = room.get("participants") + if not isinstance(participants, list): + raise TrackingError(f'Room "{room_id}" is missing participant data in IM bootstrap response') + + participant_ids = {str(participant_id).strip() for participant_id in participants if str(participant_id).strip()} + index: dict[str, dict[str, Any]] = {} + for user in users: + if not isinstance(user, dict): + continue + user_id = str(user.get("id") or "").strip() + handle = normalize_handle(user.get("handle")) + if user_id in participant_ids and handle: + index[handle] = user + return index + + +def resolve_room_assignee(bootstrap: dict[str, Any], room_id: str, assignee: Any) -> dict[str, Any]: + handle = normalize_handle(assignee) + if not handle: + raise TrackingError(f'Room "{room_id}" contains a task with an empty assignee handle') + + participants_by_handle = get_room_participant_index(bootstrap, room_id) + user = participants_by_handle.get(handle) + if user is None: + raise TrackingError( + f'Task assignee "{assignee}" is not a known participant handle in room "{room_id}"' + ) + return user + + +def build_wait_event( + event: str, + *, + todo_path: str, + room_id: str, + retry_in_seconds: float, + task_id: Any, + assignee: Any, + pending_task_id: Any | None = None, + dispatched_at: Any | None = None, +) -> dict[str, Any]: + output: dict[str, Any] = { + "event": event, + "todo_path": todo_path, + "room_id": room_id, + "task_id": task_id, + "assignee": assignee, + "retry_in_seconds": retry_in_seconds, + } + if pending_task_id is not None: + output["pending_task_id"] = pending_task_id + if dispatched_at is not None: + output["dispatched_at"] = dispatched_at + return output + + +def decide_tracking_action( + tasks: list[dict[str, Any]], + messages: list[dict[str, Any]], + bootstrap: dict[str, Any], + *, + bot_id: str, + room_id: str, + mention: str | None, + todo_path: str, + retry_in_seconds: float, +) -> dict[str, Any]: + pending_index = get_pending_task_index(tasks) + if pending_index is None: + return {"kind": "complete"} + + pending_task = tasks[pending_index] + pending_task_id = pending_task["id"] + pending_dispatch_text = build_tracking_message(pending_task, mention, todo_path) + pending_dispatch_message = find_task_dispatch_message(messages, bot_id, pending_dispatch_text) + if pending_dispatch_message is not None: + return { + "kind": "wait", + "wait_key": f"waiting-for-task-passes:{pending_task_id}", + "output": build_wait_event( + "waiting-for-task-passes", + todo_path=todo_path, + room_id=room_id, + retry_in_seconds=retry_in_seconds, + task_id=pending_task_id, + assignee=pending_task.get("assignee"), + dispatched_at=pending_dispatch_message.get("created_at"), + ), + } + + if pending_index == 0: + return { + "kind": "dispatch", + "task": pending_task, + "text": pending_dispatch_text, + } + + completed_task = tasks[pending_index - 1] + completed_task_id = completed_task["id"] + completed_dispatch_text = build_tracking_message(completed_task, mention, todo_path) + completed_dispatch_message = find_task_dispatch_message(messages, bot_id, completed_dispatch_text) + if completed_dispatch_message is None: + raise TrackingError( + f'Task {completed_task_id} is already marked passed, but no tracker dispatch message was found in room "{room_id}"' + ) + + completed_dispatch_at = parse_created_at(completed_dispatch_message.get("created_at")) + if completed_dispatch_at is None: + raise TrackingError( + f'Task {completed_task_id} has a tracker dispatch message with an invalid created_at timestamp' + ) + + assignee_user = resolve_room_assignee(bootstrap, room_id, completed_task.get("assignee")) + assignee_user_id = str(assignee_user.get("id") or "").strip() + if not assignee_user_id: + raise TrackingError( + f'Task assignee "{completed_task.get("assignee")}" in room "{room_id}" is missing a user id' + ) + + if not has_assignee_reply_after(messages, assignee_user_id, completed_dispatch_at): + return { + "kind": "wait", + "wait_key": f"waiting-for-assignee-reply:{completed_task_id}", + "output": build_wait_event( + "waiting-for-assignee-reply", + todo_path=todo_path, + room_id=room_id, + retry_in_seconds=retry_in_seconds, + task_id=completed_task_id, + assignee=completed_task.get("assignee"), + pending_task_id=pending_task_id, + dispatched_at=completed_dispatch_message.get("created_at"), + ), + } + + return { + "kind": "dispatch", + "task": pending_task, + "text": pending_dispatch_text, + } + + class CSGClawAPI: def __init__(self, base_url: str, token: str | None, timeout: float, dry_run: bool) -> None: self.base_url = base_url.rstrip("/") @@ -138,7 +371,7 @@ def _headers(self) -> dict[str, str]: headers["Authorization"] = f"Bearer {self.token}" return headers - def request_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + def request_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: url = build_url(self.base_url, path) body = None if payload is None else json.dumps(payload).encode("utf-8") @@ -173,6 +406,12 @@ def _mock_response( if method == "GET" and path == "/api/v1/workers": return [] + if method == "GET" and path == "/api/v1/im/bootstrap": + return {"current_user_id": "u-admin", "users": [], "rooms": []} + + if method == "GET" and path.startswith("/api/v1/messages?"): + return [] + result: dict[str, Any] = { "dry_run": True, "method": method, @@ -253,6 +492,19 @@ def send_bot_message(self, bot_id: str, room_id: str, text: str) -> dict[str, An {"room_id": room_id, "text": text}, ) + def get_bootstrap(self) -> dict[str, Any]: + data = self.request_json("GET", "/api/v1/im/bootstrap") + if isinstance(data, dict): + return data + raise SystemExit(f"Unexpected bootstrap response: {json.dumps(data, ensure_ascii=False)}") + + def list_messages(self, room_id: str) -> list[dict[str, Any]]: + query = parse.urlencode({"room_id": room_id}) + data = self.request_json("GET", f"/api/v1/messages?{query}") + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + raise SystemExit(f"Unexpected message list response: {json.dumps(data, ensure_ascii=False)}") + def build_assignment_text(worker_name: str, task: str, mention: str | None) -> str: custom_mention = (mention or "").strip() @@ -321,7 +573,8 @@ def summarize_task(task: dict[str, Any]) -> str: lines.append(f"Progress note: {progress_note}") if isinstance(passes, bool): lines.append(f"Passes: {'true' if passes else 'false'}") - lines.append("完成后请用 @manager 回复结果、阻塞或交接信息。") + lines.append("完成后请在房间内用 @manager 回复结果、阻塞或交接信息。") + lines.append("不要手动通知下一位执行者,tracker 会在你回复且 todo.json 更新后自动继续。") return "\n".join(lines) @@ -333,6 +586,8 @@ def build_tracking_message(task: dict[str, Any], mention: str | None, todo_path: task_message = ( f"请根据{todo_path}处理任务{task_id}。" "完成后请将对应任务的passes更新为true,并将进展备注写到progress_note字段。" + "同时请在当前房间回复 @manager 结果或阻塞说明。" + "不要手动通知下一位执行者;tracker 会在你回复且 todo.json 更新后自动分派后续任务。" ) return build_assignment_text(assignee, task_message, mention) @@ -454,9 +709,9 @@ def cmd_start_tracking(args: argparse.Namespace) -> int: def cmd_run_tracking(args: argparse.Namespace) -> int: api = load_api(args) - last_sent_task_id: str | None = None read_error_streak = 0 last_read_error: str | None = None + last_wait_key: str | None = None terminated = False def handle_termination(signum: int, _frame: Any) -> None: @@ -513,9 +768,18 @@ def handle_termination(signum: int, _frame: Any) -> None: read_error_streak = 0 last_read_error = None - pending_task = get_pending_task(tasks) + decision = decide_tracking_action( + tasks, + api.list_messages(args.room_id), + api.get_bootstrap(), + bot_id=args.bot_id, + room_id=args.room_id, + mention=args.mention, + todo_path=args.todo_path, + retry_in_seconds=args.interval, + ) - if pending_task is None: + if decision["kind"] == "complete": output = { "event": "all-complete", "todo_path": args.todo_path, @@ -530,25 +794,38 @@ def handle_termination(signum: int, _frame: Any) -> None: print(json.dumps(output, ensure_ascii=False, indent=2), flush=True) return 0 - pending_task_id = str(pending_task["id"]) - should_dispatch = pending_task_id != last_sent_task_id - if should_dispatch: - text = build_tracking_message(pending_task, args.mention, args.todo_path) - result = api.send_bot_message(args.bot_id, args.room_id, text) - output = { - "event": "dispatched", - "task_id": pending_task["id"], - "assignee": pending_task["assignee"], - "todo_path": args.todo_path, - "message": result, - "text": text, - } - print(json.dumps(output, ensure_ascii=False, indent=2), flush=True) - last_sent_task_id = pending_task_id + if decision["kind"] == "wait": + wait_key = str(decision["wait_key"]) + if wait_key != last_wait_key: + print(json.dumps(decision["output"], ensure_ascii=False, indent=2), flush=True) + last_wait_key = wait_key if args.once: return 0 + time.sleep(args.interval) + continue + + if decision["kind"] != "dispatch": + raise SystemExit(f'Unexpected tracking decision: {decision["kind"]}') + + pending_task = decision["task"] + text = str(decision["text"]) + result = api.send_bot_message(args.bot_id, args.room_id, text) + output = { + "event": "dispatched", + "task_id": pending_task["id"], + "assignee": pending_task["assignee"], + "todo_path": args.todo_path, + "message": result, + "text": text, + } + print(json.dumps(output, ensure_ascii=False, indent=2), flush=True) + last_wait_key = None + if args.once: + return 0 time.sleep(args.interval) + except TrackingError as exc: + raise SystemExit(str(exc)) from exc finally: remove_tracking_state(args.todo_path) diff --git a/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py b/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py new file mode 100644 index 0000000..3d17737 --- /dev/null +++ b/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py @@ -0,0 +1,167 @@ +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).resolve().parent / "manager_worker_api.py" +SPEC = importlib.util.spec_from_file_location("manager_worker_api", MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load module from {MODULE_PATH}") +manager_worker_api = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(manager_worker_api) + + +BOT_ID = "u-manager" +ROOM_ID = "room-123" +TODO_PATH = "/tmp/project/todo.json" + + +def make_task(task_id, assignee, *, passes): + return { + "id": task_id, + "assignee": assignee, + "category": "feature", + "description": f"task {task_id}", + "steps": ["do the work"], + "passes": passes, + "progress_note": "", + } + + +def make_message(sender_id, content, created_at): + return { + "id": f"msg-{sender_id}-{created_at}", + "sender_id": sender_id, + "content": content, + "created_at": created_at, + } + + +def make_bootstrap(): + return { + "current_user_id": "u-admin", + "users": [ + {"id": "u-manager", "handle": "manager", "name": "manager"}, + {"id": "u-ux", "handle": "ux", "name": "ux"}, + {"id": "u-dev", "handle": "dev", "name": "dev"}, + {"id": "u-qa", "handle": "qa", "name": "qa"}, + ], + "rooms": [ + { + "id": ROOM_ID, + "participants": ["u-admin", "u-manager", "u-ux", "u-dev", "u-qa"], + } + ], + } + + +def dispatch_message(task): + return manager_worker_api.build_tracking_message(task, None, TODO_PATH) + + +class TrackingDecisionTests(unittest.TestCase): + def decide(self, tasks, messages, bootstrap=None): + if bootstrap is None: + bootstrap = make_bootstrap() + return manager_worker_api.decide_tracking_action( + tasks, + messages, + bootstrap, + bot_id=BOT_ID, + room_id=ROOM_ID, + mention=None, + todo_path=TODO_PATH, + retry_in_seconds=2.0, + ) + + def test_first_task_dispatches_immediately(self): + task1 = make_task(1, "ux", passes=False) + + decision = self.decide([task1], []) + + self.assertEqual(decision["kind"], "dispatch") + self.assertEqual(decision["task"]["id"], 1) + self.assertEqual(decision["text"], dispatch_message(task1)) + + def test_waits_for_task_passes_when_current_task_already_dispatched(self): + task1 = make_task(1, "ux", passes=False) + messages = [ + make_message(BOT_ID, dispatch_message(task1), "2026-04-10T08:26:40Z"), + ] + + decision = self.decide([task1, make_task(2, "dev", passes=False)], messages) + + self.assertEqual(decision["kind"], "wait") + self.assertEqual(decision["output"]["event"], "waiting-for-task-passes") + self.assertEqual(decision["output"]["task_id"], 1) + + def test_waits_for_assignee_reply_after_previous_task_passes(self): + task1 = make_task(1, "ux", passes=True) + task2 = make_task(2, "dev", passes=False) + messages = [ + make_message(BOT_ID, dispatch_message(task1), "2026-04-10T08:26:40Z"), + ] + + decision = self.decide([task1, task2], messages) + + self.assertEqual(decision["kind"], "wait") + self.assertEqual(decision["output"]["event"], "waiting-for-assignee-reply") + self.assertEqual(decision["output"]["task_id"], 1) + self.assertEqual(decision["output"]["pending_task_id"], 2) + + def test_tool_trace_does_not_count_as_assignee_reply(self): + task1 = make_task(1, "ux", passes=True) + task2 = make_task(2, "dev", passes=False) + messages = [ + make_message(BOT_ID, dispatch_message(task1), "2026-04-10T08:26:40Z"), + make_message("u-ux", "🔧 `read_file`\n```\n{\"path\":\"/tmp/todo.json\"}\n```", "2026-04-10T08:26:44Z"), + ] + + decision = self.decide([task1, task2], messages) + + self.assertEqual(decision["kind"], "wait") + self.assertEqual(decision["output"]["event"], "waiting-for-assignee-reply") + + def test_dispatches_next_task_after_human_reply_and_pass(self): + task1 = make_task(1, "ux", passes=True) + task2 = make_task(2, "dev", passes=False) + messages = [ + make_message(BOT_ID, dispatch_message(task1), "2026-04-10T08:26:40Z"), + make_message("u-ux", "任务1已完成,设计文档已经交付。", "2026-04-10T08:32:49Z"), + ] + + decision = self.decide([task1, task2], messages) + + self.assertEqual(decision["kind"], "dispatch") + self.assertEqual(decision["task"]["id"], 2) + self.assertEqual(decision["text"], dispatch_message(task2)) + + def test_dispatches_when_reply_exists_before_current_poll_once_pass_is_true(self): + task1 = make_task(1, "ux", passes=True) + task2 = make_task(2, "dev", passes=False) + messages = [ + make_message(BOT_ID, dispatch_message(task1), "2026-04-10T08:26:40Z"), + make_message("u-ux", "设计工作完成,等待下一步。", "2026-04-10T08:27:10Z"), + ] + + decision = self.decide([task1, task2], messages) + + self.assertEqual(decision["kind"], "dispatch") + self.assertEqual(decision["task"]["id"], 2) + + def test_unresolved_assignee_raises_clear_error(self): + task1 = make_task(1, "ghost", passes=True) + task2 = make_task(2, "dev", passes=False) + messages = [ + make_message(BOT_ID, dispatch_message(task1), "2026-04-10T08:26:40Z"), + ] + + with self.assertRaises(manager_worker_api.TrackingError) as ctx: + self.decide([task1, task2], messages) + + self.assertIn('Task assignee "ghost"', str(ctx.exception)) + self.assertIn(ROOM_ID, str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/static/app.js b/web/static/app.js index 5952d57..bcff65c 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -406,6 +406,7 @@ function App() { const messageListRef = useRef(null); useEffect(() => { + // step 10.0 Browser bootstrap branch: fetch one full IM snapshot before subscribing to live updates. fetch("/api/v1/bootstrap") .then((resp) => resp.json()) .then((payload) => { @@ -420,6 +421,7 @@ function App() { }, []); useEffect(() => { + // step 10.1 Live-update branch: keep the UI synchronized by applying SSE events from the shared IM bus. const source = new EventSource("/api/v1/events"); source.onmessage = (event) => { @@ -532,6 +534,7 @@ function App() { }, [activeConversationId, visibleMessages.length]); async function sendMessage() { + // step 10.2 Send branch 1: write a user message through HTTP, then merge it into local state immediately. if (!data || !activeConversation || !draft.trim()) { return; } @@ -556,6 +559,7 @@ function App() { } async function createRoom() { + // step 10.3 Send branch 2: create a room through HTTP, then switch the UI to that room. if (!data || !roomTitle.trim()) { return; } @@ -585,6 +589,7 @@ function App() { } async function inviteUsers() { + // step 10.4 Send branch 3: invite more users into the active room through HTTP. if (!data || !activeConversation || inviteUserIDs.length === 0) { return; } @@ -1315,6 +1320,7 @@ function latestAt(conversation) { } function applyIMEvent(current, event) { + // step 10.5 Merge live events into the browser model by event type instead of refetching the whole workspace. if (!current || !event?.type) { return current; } @@ -1387,6 +1393,7 @@ function sortConversations(conversations) { } function normalizeIMData(payload) { + // step 10.0.1 The backend returns rooms; the frontend also aliases them as conversations for chat-oriented UI code. if (!payload) { return payload; } From 06105019703d1689c602110a0fba940848af9e95 Mon Sep 17 00:00:00 2001 From: Yun Long Date: Sat, 11 Apr 2026 17:47:49 +0800 Subject: [PATCH 2/5] Adjust config from llm to models --- README.md | 26 +-- README.zh.md | 26 +-- cli/onboard/onboard.go | 87 +++++++- cli/onboard/onboard_test.go | 46 +++- cli/serve/serve.go | 72 ++++-- cli/serve/serve_test.go | 11 +- cmd/csgclaw/main.go | 3 - docs/api.md | 8 +- internal/agent/box.go | 4 - internal/agent/runtime.go | 2 - internal/agent/service.go | 15 +- internal/agent/service_test.go | 4 +- internal/api/handler.go | 11 - internal/api/picoclaw.go | 9 - internal/api/router.go | 5 - internal/config/config.go | 236 +++++++++++++------- internal/config/config_test.go | 106 ++++++--- internal/config/model_validation.go | 332 ++++++++++++++++++++++++---- internal/im/events.go | 3 - internal/im/service.go | 14 -- internal/server/http.go | 3 - web/static/app.js | 7 - 22 files changed, 728 insertions(+), 302 deletions(-) diff --git a/README.md b/README.md index 91b44dc..7dfcffb 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,13 @@ go build ./cmd/csgclaw ## Quick Start ```bash -csgclaw onboard --profile default --default-profile default --base-url --api-key --model-id [--reasoning-effort ] +csgclaw onboard --provider default --model-id --base-url --api-key [--default ] csgclaw serve ``` Open the printed URL (e.g. `http://127.0.0.1:18080/`) in your browser to enter the IM workspace. -## LLM Profile Examples +## Model Provider Examples ### Remote LLM API @@ -50,15 +50,13 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" -[llm] -default_profile = "remote-main" +[models] +default = "remote.gpt-5.4" -[llm.profiles.remote-main] -provider = "llm-api" +[models.providers.remote] base_url = "https://api.openai.com/v1" api_key = "sk-your-api-key" -model_id = "gpt-5.4" -reasoning_effort = "medium" +models = ["gpt-5.4"] [bootstrap] manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" @@ -72,15 +70,13 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" -[llm] -default_profile = "codex-main" +[models] +default = "codex.gpt-5.4" -[llm.profiles.codex-main] -provider = "llm-api" +[models.providers.codex] base_url = "http://127.0.0.1:8317/v1" api_key = "local" -model_id = "gpt-5.4" -reasoning_effort = "medium" +models = ["gpt-5.4"] [bootstrap] manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" @@ -93,7 +89,7 @@ manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" "id": "u-reviewer", "name": "reviewer", "description": "code review worker", - "profile": "codex-main", + "profile": "codex.gpt-5.4", "role": "worker" } ``` diff --git a/README.zh.md b/README.zh.md index d8a56fb..d61077d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -34,13 +34,13 @@ go build ./cmd/csgclaw ## 快速开始 ```bash -csgclaw onboard --profile default --default-profile default --base-url --api-key --model-id [--reasoning-effort ] +csgclaw onboard --provider default --model-id --base-url --api-key [--default ] csgclaw serve ``` 执行后 CLI 会打印访问地址(例如 `http://127.0.0.1:18080/`),在浏览器中打开即可进入 IM 工作区。 -## LLM Profile 配置示例 +## Model Provider 配置示例 ### 远程 LLM API @@ -50,15 +50,13 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" -[llm] -default_profile = "remote-main" +[models] +default = "remote.gpt-5.4" -[llm.profiles.remote-main] -provider = "llm-api" +[models.providers.remote] base_url = "https://api.openai.com/v1" api_key = "sk-your-api-key" -model_id = "gpt-5.4" -reasoning_effort = "medium" +models = ["gpt-5.4"] [bootstrap] manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" @@ -72,15 +70,13 @@ listen_addr = "0.0.0.0:18080" advertise_base_url = "http://127.0.0.1:18080" access_token = "your_access_token" -[llm] -default_profile = "codex-main" +[models] +default = "codex.gpt-5.4" -[llm.profiles.codex-main] -provider = "llm-api" +[models.providers.codex] base_url = "http://127.0.0.1:8317/v1" api_key = "local" -model_id = "gpt-5.4" -reasoning_effort = "medium" +models = ["gpt-5.4"] [bootstrap] manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" @@ -93,7 +89,7 @@ manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1" "id": "u-reviewer", "name": "reviewer", "description": "code review worker", - "profile": "codex-main", + "profile": "codex.gpt-5.4", "role": "worker" } ``` diff --git a/cli/onboard/onboard.go b/cli/onboard/onboard.go index e7aa42f..4e86fb2 100644 --- a/cli/onboard/onboard.go +++ b/cli/onboard/onboard.go @@ -38,7 +38,8 @@ func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globa fs := run.NewFlagSet("onboard", run.Program+" onboard [flags]", c.Summary()) baseURL := fs.String("base-url", "", "LLM provider base URL") apiKey := fs.String("api-key", "", "LLM provider API key") - modelID := fs.String("model-id", "", "LLM model identifier") + modelsValue := fs.String("models", "", "comma-separated LLM model identifiers") + reasoningEffort := fs.String("reasoning-effort", "", "optional upstream reasoning_effort default") managerImage := fs.String("manager-image", "", "bootstrap manager image") forceRecreateManager := fs.Bool("force-recreate-manager", false, "remove and recreate the bootstrap manager box") if err := fs.Parse(args); err != nil { @@ -65,15 +66,32 @@ func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globa }, } } + llmCfg := effectiveLLMConfig(cfg) + targetProvider := configuredProviderName(llmCfg) + providerCfg := llmCfg.Providers[targetProvider] if *baseURL != "" { - cfg.Model.BaseURL = *baseURL + providerCfg.BaseURL = *baseURL } if *apiKey != "" { - cfg.Model.APIKey = *apiKey + providerCfg.APIKey = *apiKey } - if *modelID != "" { - cfg.Model.ModelID = *modelID + if *modelsValue != "" { + models, err := parseModelsFlag(*modelsValue) + if err != nil { + return err + } + providerCfg.Models = models + } + if *reasoningEffort != "" { + providerCfg.ReasoningEffort = *reasoningEffort + } + llmCfg.Providers[targetProvider] = providerCfg + if len(providerCfg.Models) > 0 && (*modelsValue != "" || strings.TrimSpace(llmCfg.Default) == "" || strings.TrimSpace(llmCfg.DefaultProfile) == "") { + defaultSelector := config.ModelSelector(targetProvider, providerCfg.Models[0]) + llmCfg.Default = defaultSelector + llmCfg.DefaultProfile = defaultSelector } + syncConfigWithLLM(&cfg, llmCfg) if *managerImage != "" { cfg.Bootstrap.ManagerImage = *managerImage } @@ -165,11 +183,11 @@ func validateModelConfig(cfg config.Config) error { var validationErr *config.ModelValidationError if errors.As(err, &validationErr) && len(validationErr.MissingFields) > 0 { return fmt.Errorf( - "llm config is incomplete (%s); run `csgclaw onboard --base-url --api-key --model-id `", + "models config is incomplete (%s); run `csgclaw onboard --base-url --api-key --models [--reasoning-effort ]`", strings.Join(missingModelFlags(validationErr.MissingFields), ", "), ) } - return fmt.Errorf("llm config is invalid: %w", err) + return fmt.Errorf("models config is invalid: %w", err) } return nil } @@ -183,7 +201,9 @@ func missingModelFlags(fields []string) []string { case "api_key": flags = append(flags, "--api-key") case "model_id": - flags = append(flags, "--model-id") + flags = append(flags, "--models") + case "default", "default_profile": + flags = append(flags, "models.default") default: flags = append(flags, field) } @@ -192,8 +212,55 @@ func missingModelFlags(fields []string) []string { } func effectiveLLMConfig(cfg config.Config) config.LLMConfig { - if len(cfg.LLM.Profiles) != 0 || strings.TrimSpace(cfg.LLM.DefaultProfile) != "" { + if !cfg.Models.IsZero() { + return cfg.Models.Normalized() + } + if !cfg.LLM.IsZero() { return cfg.LLM.Normalized() } - return config.SingleProfileLLM(cfg.Model) + return config.SingleProfileLLM(cfg.Model).Normalized() +} + +func configuredProviderName(llmCfg config.LLMConfig) string { + if name := strings.TrimSpace(llmCfg.EffectiveDefaultProvider()); name != "" { + return name + } + return config.DefaultLLMProfile +} + +func syncConfigWithLLM(cfg *config.Config, llmCfg config.LLMConfig) { + if cfg == nil { + return + } + llmCfg = llmCfg.Normalized() + cfg.Models = llmCfg + cfg.LLM = llmCfg + if selector, modelCfg, err := llmCfg.Resolve(""); err == nil { + cfg.Models.Default = selector + cfg.Models.DefaultProfile = selector + cfg.LLM = cfg.Models + cfg.Model = modelCfg.Resolved() + return + } + cfg.Model = cfg.Model.Resolved() +} + +func parseModelsFlag(raw string) ([]string, error) { + models := make([]string, 0) + seen := make(map[string]struct{}) + for _, value := range strings.Split(raw, ",") { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + models = append(models, value) + } + if len(models) == 0 { + return nil, fmt.Errorf("--models must include at least one model identifier") + } + return models, nil } diff --git a/cli/onboard/onboard_test.go b/cli/onboard/onboard_test.go index 73a5601..4f1b72a 100644 --- a/cli/onboard/onboard_test.go +++ b/cli/onboard/onboard_test.go @@ -3,6 +3,7 @@ package onboard import ( "bytes" "context" + "os" "path/filepath" "strings" "testing" @@ -34,7 +35,7 @@ func TestRunRequiresLLMFlagsForFirstTimeSetup(t *testing.T) { if err == nil { t.Fatal("Run() error = nil, want error") } - if !strings.Contains(err.Error(), "--base-url") || !strings.Contains(err.Error(), "--api-key") || !strings.Contains(err.Error(), "--model-id") { + if !strings.Contains(err.Error(), "--base-url") || !strings.Contains(err.Error(), "--api-key") || !strings.Contains(err.Error(), "--models") { t.Fatalf("Run() error = %q, want all required LLM flags", err) } } @@ -50,8 +51,24 @@ func TestRunReusesExistingLLMConfig(t *testing.T) { callCount := 0 CreateManagerBot = func(_ context.Context, _, _ string, cfg config.Config, _ bool) (bot.Bot, error) { callCount++ - if cfg.Model.BaseURL != "http://llm.test" || cfg.Model.APIKey != "secret" || cfg.Model.ModelID != "gpt-test" { - t.Fatalf("model config = %#v, want preserved values", cfg.Model) + if got, want := cfg.Models.Default, "default.gpt-test"; got != want { + t.Fatalf("cfg.Models.Default = %q, want %q", got, want) + } + provider, ok := cfg.Models.Providers["default"] + if !ok { + t.Fatal(`cfg.Models.Providers["default"] missing`) + } + if provider.BaseURL != "http://llm.test" || provider.APIKey != "secret" { + t.Fatalf("provider config = %#v, want preserved base_url/api_key", provider) + } + if got, want := strings.Join(provider.Models, ","), "gpt-test,gpt-test-mini"; got != want { + t.Fatalf("provider models = %q, want %q", got, want) + } + if got, want := provider.ReasoningEffort, "medium"; got != want { + t.Fatalf("provider reasoning_effort = %q, want %q", got, want) + } + if cfg.Model.BaseURL != "http://llm.test" || cfg.Model.APIKey != "secret" || cfg.Model.ModelID != "gpt-test" || cfg.Model.ReasoningEffort != "medium" { + t.Fatalf("model config = %#v, want resolved default values", cfg.Model) } return bot.Bot{}, nil } @@ -61,10 +78,31 @@ func TestRunReusesExistingLLMConfig(t *testing.T) { run := testContext() cmd := NewCmd() - args := []string{"--base-url", "http://llm.test", "--api-key", "secret", "--model-id", "gpt-test"} + args := []string{ + "--base-url", "http://llm.test", + "--api-key", "secret", + "--models", "gpt-test,gpt-test-mini", + "--reasoning-effort", "medium", + } if err := cmd.Run(context.Background(), run, args, command.GlobalOptions{Config: configPath}); err != nil { t.Fatalf("initial Run() error = %v", err) } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + content := string(data) + for _, want := range []string{ + `[models]`, + `default = "default.gpt-test"`, + `[models.providers.default]`, + `models = ["gpt-test", "gpt-test-mini"]`, + `reasoning_effort = "medium"`, + } { + if !strings.Contains(content, want) { + t.Fatalf("saved config missing %q:\n%s", want, content) + } + } if err := cmd.Run(context.Background(), run, nil, command.GlobalOptions{Config: configPath}); err != nil { t.Fatalf("second Run() error = %v", err) diff --git a/cli/serve/serve.go b/cli/serve/serve.go index 1ba7e39..7b5b6cc 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -399,12 +399,12 @@ listen_addr = %q advertise_base_url = %q access_token = %q -[llm] -default_profile = %q - [bootstrap] manager_image = %q -`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), llmCfg.EffectiveDefaultProfile(), cfg.Bootstrap.ManagerImage) + formatEffectiveProfiles(llmCfg) + +[models] +default = %q +`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, partiallyMaskSecret(cfg.Server.AccessToken), cfg.Bootstrap.ManagerImage, llmCfg.DefaultSelector()) + formatEffectiveProviders(llmCfg) if strings.TrimSpace(cfg.Channels.FeishuAdminOpenID) != "" { content += fmt.Sprintf(` @@ -454,11 +454,11 @@ func validateModelConfig(cfg config.Config) error { var validationErr *config.ModelValidationError if errors.As(err, &validationErr) && len(validationErr.MissingFields) > 0 { return fmt.Errorf( - "llm config is incomplete (%s); run `csgclaw onboard --profile --model-id [--base-url ] [--api-key ] [--reasoning-effort ] [--default-profile ]`", + "models config is incomplete (%s); run `csgclaw onboard --base-url --api-key --models [--reasoning-effort ]`", strings.Join(missingModelFlags(validationErr.MissingFields), ", "), ) } - return fmt.Errorf("llm config is invalid: %w", err) + return fmt.Errorf("models config is invalid: %w", err) } return nil } @@ -472,11 +472,9 @@ func missingModelFlags(fields []string) []string { case "api_key": flags = append(flags, "--api-key") case "model_id": - flags = append(flags, "--model-id") - case "provider": - flags = append(flags, "--provider") - case "default_profile": - flags = append(flags, "--default-profile") + flags = append(flags, "--models") + case "default", "default_profile": + flags = append(flags, "models.default") default: flags = append(flags, field) } @@ -532,38 +530,64 @@ func newLLMService(cfg config.Config, svc *agent.Service) (*llm.Service, error) if svc == nil { return nil, nil } - return llm.NewService(cfg.Model, svc), nil + _, modelCfg, err := effectiveLLMConfig(cfg).Resolve("") + if err != nil { + return nil, err + } + return llm.NewService(modelCfg, svc), nil } func effectiveLLMConfig(cfg config.Config) config.LLMConfig { - if len(cfg.LLM.Profiles) != 0 || strings.TrimSpace(cfg.LLM.DefaultProfile) != "" { + if !cfg.Models.IsZero() { + return cfg.Models.Normalized() + } + if !cfg.LLM.IsZero() { return cfg.LLM.Normalized() } return config.SingleProfileLLM(cfg.Model) } -func formatEffectiveProfiles(llmCfg config.LLMConfig) string { +func formatEffectiveProviders(llmCfg config.LLMConfig) string { llmCfg = llmCfg.Normalized() var b strings.Builder - for _, name := range sortedProfileNames(llmCfg.Profiles) { - profile := llmCfg.Profiles[name].Resolved() + for _, name := range sortedProviderNames(llmCfg.Providers) { + provider := llmCfg.Providers[name].Resolved() fmt.Fprintf(&b, ` -[llm.profiles.%s] -provider = %q +[models.providers.%s] base_url = %q api_key = %q -model_id = %q -reasoning_effort = %q -`, name, profile.EffectiveProvider(), profile.BaseURL, partiallyMaskSecret(profile.APIKey), profile.ModelID, profile.ReasoningEffort) +models = %s +`, name, provider.BaseURL, partiallyMaskSecret(provider.APIKey), formatModelList(provider.Models)) + if provider.ReasoningEffort != "" { + fmt.Fprintf(&b, "reasoning_effort = %q\n", provider.ReasoningEffort) + } } return b.String() } -func sortedProfileNames(profiles map[string]config.ModelConfig) []string { - names := make([]string, 0, len(profiles)) - for name := range profiles { +func sortedProviderNames(providers map[string]config.ProviderConfig) []string { + names := make([]string, 0, len(providers)) + for name := range providers { names = append(names, name) } sort.Strings(names) return names } + +func formatModelList(models []string) string { + if len(models) == 0 { + return "[]" + } + quoted := make([]string, 0, len(models)) + for _, modelID := range models { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + quoted = append(quoted, strconv.Quote(modelID)) + } + if len(quoted) == 0 { + return "[]" + } + return "[" + strings.Join(quoted, ", ") + "]" +} diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index aafb23d..e505514 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -96,6 +96,11 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { APIKey: "sk-secret", ModelID: "model-test", }, + Models: config.SingleProfileLLM(config.ModelConfig{ + BaseURL: "http://llm.test", + APIKey: "sk-secret", + ModelID: "model-test", + }), Bootstrap: config.BootstrapConfig{ ManagerImage: "ghcr.io/example/manager:latest", }, @@ -127,12 +132,14 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { `advertise_base_url = "http://example.test"`, `api_key = "sk*****et"`, `access_token = "pc*****et"`, + `[models]`, + `default = "default.model-test"`, + `[models.providers.default]`, `[channels.feishu]`, `admin_open_id = "ou_admin"`, `[channels.feishu.manager]`, `app_id = "cli_manager"`, `app_secret = "ma**********et"`, - `provider = "llm-api"`, "CSGClaw IM is available at: http://example.test/", } { if !strings.Contains(got, want) { @@ -201,7 +208,7 @@ func TestValidateModelConfigRequiresOnboardWhenIncomplete(t *testing.T) { if !strings.Contains(err.Error(), "csgclaw onboard") { t.Fatalf("validateModelConfig() error = %q, want onboard guidance", err) } - if !strings.Contains(err.Error(), "--base-url") || !strings.Contains(err.Error(), "--api-key") || !strings.Contains(err.Error(), "--model-id") { + if !strings.Contains(err.Error(), "--base-url") || !strings.Contains(err.Error(), "--api-key") || !strings.Contains(err.Error(), "--models") { t.Fatalf("validateModelConfig() error = %q, want missing model flags", err) } } diff --git a/cmd/csgclaw/main.go b/cmd/csgclaw/main.go index daea8e4..68d2598 100644 --- a/cmd/csgclaw/main.go +++ b/cmd/csgclaw/main.go @@ -13,7 +13,6 @@ import ( ) func main() { - // step 1.0 Start from a thin entrypoint and hand off almost everything to cli.App. log.SetFlags(0) if err := run(os.Args[1:]); err != nil { if errors.Is(err, flag.ErrHelp) { @@ -24,13 +23,11 @@ func main() { } func run(args []string) error { - // step 1.1 Build the CLI application object, which owns command dispatch and shared dependencies. app := cli.New() return executeWithSignalContext(args, app.Execute) } func executeWithSignalContext(args []string, execFn func(context.Context, []string) error) error { - // step 1.2 Give every command a cancellation context so serve/log streaming can shut down cleanly. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return execFn(ctx, args) diff --git a/docs/api.md b/docs/api.md index f175080..4efacb4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,7 +33,7 @@ ok "role": "worker", "status": "running", "created_at": "2026-03-28T12:00:03Z", - "profile": "cliproxy-codex", + "profile": "codex.gpt-5.4", "provider": "llm-api", "model_id": "gpt-5.4", "reasoning_effort": "medium", @@ -62,7 +62,7 @@ ok "id": "u-alice", "name": "alice", "description": "frontend dev", - "profile": "cliproxy-codex", + "profile": "codex.gpt-5.4", "role": "worker" } ``` @@ -77,7 +77,7 @@ ok "role": "worker", "status": "running", "created_at": "2026-03-28T12:00:03Z", - "profile": "cliproxy-codex", + "profile": "codex.gpt-5.4", "provider": "llm-api", "model_id": "gpt-5.4", "reasoning_effort": "medium", @@ -90,7 +90,7 @@ ok - `name` 必填 - `name` 不能是 `manager` - `id` 可选;未传时服务端会自动生成 -- `profile` 可选;它引用配置中的 `llm.profiles.`,未传时使用 `llm.default_profile` +- `profile` 可选;它引用配置中的 `models.default` 或显式 selector(例如 `codex.gpt-5.4`) - `provider`、`model_id`、`reasoning_effort` 是服务端解析后的快照字段,便于调试 - `status`、`created_at` 以实际 box 启动结果为准 - `manager` 嵌套字段已不再支持 diff --git a/internal/agent/box.go b/internal/agent/box.go index a6c8bdf..ba133f4 100644 --- a/internal/agent/box.go +++ b/internal/agent/box.go @@ -13,7 +13,6 @@ import ( ) func (s *Service) createGatewayBox(ctx context.Context, rt *boxlite.Runtime, image, name, botID string, modelCfg config.ModelConfig) (*boxlite.Box, *boxlite.BoxInfo, error) { - // step 8.2.2 Create and start one PicoClaw gateway box, then capture its Boxlite metadata for persistence. if testCreateGatewayBoxHook != nil { return testCreateGatewayBoxHook(s, ctx, rt, image, name, botID, modelCfg) } @@ -51,7 +50,6 @@ func (s *Service) forceRemoveBox(ctx context.Context, rt *boxlite.Runtime, idOrN } func (s *Service) gatewayBoxOptions(name, botID string, modelCfg config.ModelConfig) ([]boxlite.BoxOption, error) { - // step 8.2.2.1 The box environment carries both model config and CSGClaw channel config into PicoClaw. modelCfg = modelCfg.Resolved() if strings.TrimSpace(modelCfg.ModelID) == "" { modelCfg = s.model.Resolved() @@ -71,7 +69,6 @@ func (s *Service) gatewayBoxOptions(name, botID string, modelCfg config.ModelCon for key, value := range envVars { opts = append(opts, boxlite.WithEnv(key, value)) } - // step 8.2.2.2 The worker process is the PicoClaw gateway itself, writing logs to ~/.picoclaw/gateway.log. //entrypoint, cmd := gatewayStartCommand(managerDebugMode) opts = append(opts, //boxlite.WithEntrypoint(entrypoint...), @@ -84,7 +81,6 @@ func (s *Service) gatewayBoxOptions(name, botID string, modelCfg config.ModelCon if err != nil { return nil, err } - // step 8.2.2.3 Project files are shared through one host mount so workers can work on the same workspace tree. projectsRoot, err := ensureAgentProjectsRoot() if err != nil { return nil, err diff --git a/internal/agent/runtime.go b/internal/agent/runtime.go index dec1ba7..9bf2efa 100644 --- a/internal/agent/runtime.go +++ b/internal/agent/runtime.go @@ -15,7 +15,6 @@ import ( ) func (s *Service) ensureRuntime(agentName string) (*boxlite.Runtime, error) { - // step 8.2.1 Each agent name maps to its own Boxlite runtime home under ~/.csgclaw/agents//boxlite. if testEnsureRuntimeHook != nil { return testEnsureRuntimeHook(s, agentName) } @@ -59,7 +58,6 @@ func (s *Service) ensureRuntimeAtHome(homeDir string) (*boxlite.Runtime, error) } func (s *Service) lookupBootstrapManager(ctx context.Context) (*boxlite.Runtime, *boxlite.Box, error) { - // step 8.1.0 When bootstrapping the manager, try the saved BoxID first and fall back to the fixed name. homeDir, err := boxRuntimeHome(ManagerName) if err != nil { return nil, nil, err diff --git a/internal/agent/service.go b/internal/agent/service.go index f6f9fea..f621b7a 100644 --- a/internal/agent/service.go +++ b/internal/agent/service.go @@ -119,7 +119,10 @@ func NewServiceWithLLMAndChannels(llmCfg config.LLMConfig, server config.ServerC } defaultProfile, model, err := llmCfg.Resolve("") if err != nil { - defaultProfile = llmCfg.EffectiveDefaultProfile() + defaultProfile = strings.TrimSpace(llmCfg.Normalized().Default) + if defaultProfile == "" { + defaultProfile = strings.TrimSpace(llmCfg.Normalized().DefaultProfile) + } model = config.ModelConfig{}.Resolved() } svc := &Service{ @@ -499,9 +502,6 @@ func (s *Service) refreshAgentBoxID(id string, got Agent, resolvedKey string, bo } func (s *Service) Delete(ctx context.Context, id string) error { - // step 8.4 Deletion removes both layers: - // step 8.4.1 the Boxlite box/runtime home - // step 8.4.2 the persisted agent registry entry. id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("agent id is required") @@ -572,10 +572,6 @@ func (s *Service) List() []Agent { } func (s *Service) CreateWorker(ctx context.Context, req CreateRequest) (Agent, error) { - // step 8.2 Create the current worker form of an agent: - // step 8.2.1 reserve a unique id/name - // step 8.2.2 create a gateway box - // step 8.2.3 persist the worker metadata. id := strings.TrimSpace(req.ID) name := strings.TrimSpace(req.Name) description := strings.TrimSpace(req.Description) @@ -619,8 +615,6 @@ func (s *Service) CreateWorker(ctx context.Context, req CreateRequest) (Agent, e if err != nil { return Agent{}, err } - - // step 8.2.2 Workers currently reuse the manager image and run the same gateway-style runtime. box, info, err := s.createGatewayBox(ctx, rt, s.managerImage, name, id, resolvedModel) if err != nil { return Agent{}, fmt.Errorf("create worker box: %w", err) @@ -675,7 +669,6 @@ func (s *Service) ListWorkers() []Agent { } func (s *Service) StreamLogs(ctx context.Context, id string, follow bool, lines int, w io.Writer) error { - // step 8.3 Agent logs are not stored separately by CSGClaw; we tail the gateway log from inside the box. id = strings.TrimSpace(id) if id == "" { return fmt.Errorf("agent id is required") diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index 095524c..c6e5d78 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -482,8 +482,8 @@ func TestCreateWorkerStoresResolvedProfileSnapshot(t *testing.T) { if err != nil { t.Fatalf("CreateWorker() error = %v", err) } - if got.Profile != "remote-main" { - t.Fatalf("CreateWorker().Profile = %q, want %q", got.Profile, "remote-main") + if got.Profile != "remote-main.gpt-5.4" { + t.Fatalf("CreateWorker().Profile = %q, want %q", got.Profile, "remote-main.gpt-5.4") } if got.Provider != config.ProviderLLMAPI { t.Fatalf("CreateWorker().Provider = %q, want %q", got.Provider, config.ProviderLLMAPI) diff --git a/internal/api/handler.go b/internal/api/handler.go index 7e1fdc0..86be54b 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -105,8 +105,6 @@ func (h *Handler) handleWorkers(w http.ResponseWriter, r *http.Request) { http.Error(w, "agent service is not configured", http.StatusServiceUnavailable) return } - - // step 7.0 /api/v1/workers is the worker-focused HTTP branch used by current integrations. switch r.Method { case http.MethodGet: writeJSON(w, http.StatusOK, h.svc.ListWorkers()) @@ -153,8 +151,6 @@ func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { http.Error(w, "agent service is not configured", http.StatusServiceUnavailable) return } - - // step 7.1 /api/v1/agents currently shares the same create path as /workers, so creation means "create a worker". switch r.Method { case http.MethodGet: if err := h.svc.Reload(); err != nil { @@ -413,7 +409,6 @@ func (h *Handler) handleIMBootstrap(w http.ResponseWriter, r *http.Request) { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - // step 7.3 The browser starts here: one snapshot gives it current user, users, rooms, and initial messages. writeJSON(w, http.StatusOK, presentBootstrap(h.im.Bootstrap())) } @@ -635,8 +630,6 @@ func (h *Handler) handleIMEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - - // step 7.4 The browser keeps itself fresh through SSE by subscribing to the shared IM event bus. events, cancel := h.imBus.Subscribe() defer cancel() @@ -782,8 +775,6 @@ func (h *Handler) ensureWorkerIMState(created agent.Agent) error { if h.im == nil { return nil } - - // step 7.2.2 Create or reuse the IM identity for this worker, then create its admin-worker bootstrap room. user, room, err := h.im.EnsureAgentUser(im.EnsureAgentUserRequest{ ID: created.ID, Name: created.Name, @@ -795,7 +786,6 @@ func (h *Handler) ensureWorkerIMState(created agent.Agent) error { } h.publishUserEvent(im.EventTypeUserCreated, user) if room != nil { - // step 7.2.3 After the room exists, post a delayed bootstrap instruction so the worker gets its role prompt. h.publishRoomEvent(im.EventTypeRoomCreated, *room) imSvc := h.im roomID := room.ID @@ -831,7 +821,6 @@ func (h *Handler) publishMessageCreated(conversationID, senderID string, message if h.imBus == nil { return } - // step 7.5 Every persisted IM message becomes an in-memory event so UI clients and PicoClaw can react. sender, ok := h.im.User(senderID) if !ok { return diff --git a/internal/api/picoclaw.go b/internal/api/picoclaw.go index 7e156f0..48b7f61 100644 --- a/internal/api/picoclaw.go +++ b/internal/api/picoclaw.go @@ -11,7 +11,6 @@ import ( ) func (h *Handler) registerPicoClawRoutes(mux *http.ServeMux) { - // step 7.6 Bot-facing PicoClaw routes live outside /api/v1 because they follow PicoClaw's callback contract. mux.HandleFunc("/api/bots/", h.handlePicoClawBotRoutes) } @@ -27,15 +26,10 @@ func (h *Handler) PublishPicoClawEvent(evt im.Event) { if !ok { return } - // step 7.6.1 Only message.created events flow through this branch; room/user events stay browser-only. h.picoclaw.PublishMessageEvent(room, *evt.Sender, *evt.Message) } func (h *Handler) handlePicoClawBotRoutes(w http.ResponseWriter, r *http.Request) { - // step 7.6.2 A bot can do two things here: - // step 7.6.2.1 subscribe to its message event stream - // step 7.6.2.2 send a reply back into an IM room. - // step 7.6.2.3 reach the host-side LLM bridge through an OpenAI-compatible endpoint. botID, action, ok := parsePicoClawBotPath(r.URL.Path) if !ok { http.NotFound(w, r) @@ -65,7 +59,6 @@ func (h *Handler) handlePicoClawBotRoutes(w http.ResponseWriter, r *http.Request } func (h *Handler) handlePicoClawEvents(w http.ResponseWriter, r *http.Request, botID string) { - // step 7.6.2.1 Bots receive SSE here instead of the server actively calling out to them. flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming is not supported", http.StatusInternalServerError) @@ -102,8 +95,6 @@ func (h *Handler) handlePicoClawSendMessage(w http.ResponseWriter, r *http.Reque http.Error(w, "im service is not configured", http.StatusServiceUnavailable) return } - - // step 7.6.2.2 Bot replies are written back into IM as normal messages authored by that bot's user ID. var req im.PicoClawSendMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) diff --git a/internal/api/router.go b/internal/api/router.go index 0990d6d..3e1cd59 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -3,7 +3,6 @@ package api import "net/http" func (h *Handler) Routes() *http.ServeMux { - // step 6.0 Register one route tree for the browser/UI API and a second route tree for PicoClaw bot callbacks. mux := http.NewServeMux() h.registerCoreRoutes(mux) h.registerChannelRoutes(mux) @@ -12,10 +11,6 @@ func (h *Handler) Routes() *http.ServeMux { } func (h *Handler) registerCoreRoutes(mux *http.ServeMux) { - // step 6.1 Core routes serve three clients: - // step 6.1.1 CLI commands under /api/v1/agents, /rooms, /users - // step 6.1.2 browser bootstrap/SSE under /api/v1/bootstrap and /api/v1/events - // step 6.1.3 compatibility aliases under /api/v1/im/*. mux.HandleFunc("/healthz", h.handleHealthz) mux.HandleFunc("/api/v1/bots", h.handleBots) mux.HandleFunc("/api/v1/agents", h.handleAgents) diff --git a/internal/config/config.go b/internal/config/config.go index 2a9714d..800e2d6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ import ( type Config struct { Server ServerConfig + Models LLMConfig LLM LLMConfig Model ModelConfig Bootstrap BootstrapConfig @@ -36,6 +37,8 @@ type ModelConfig struct { } type LLMConfig struct { + Default string + Providers map[string]ProviderConfig DefaultProfile string Profiles map[string]ModelConfig } @@ -66,7 +69,6 @@ const ( DefaultHTTPPort = apiclient.DefaultHTTPPort DefaultAccessToken = "your_access_token" DefaultManagerImage = "ghcr.io/russellluo/picoclaw:2026.4.8.1" - DefaultLLMProfile = "default" ) func DefaultListenAddr() string { @@ -162,11 +164,12 @@ func Load(path string) (Config, error) { return Config{}, fmt.Errorf("read config: %w", err) } + modelsCfg := newLLMConfig() cfg := Config{ - LLM: LLMConfig{ - Profiles: make(map[string]ModelConfig), - }, + Models: modelsCfg, + LLM: newLLMConfig(), } + section := "" scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { @@ -176,16 +179,21 @@ func Load(path string) (Config, error) { } if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { section = strings.Trim(line, "[]") + if isLegacyConfigSection(section) { + return Config{}, fmt.Errorf("legacy config section [%s] is no longer supported; migrate to [models] and [models.providers.]", section) + } continue } - key, value, ok := strings.Cut(line, "=") + key, rawValue, ok := strings.Cut(line, "=") if !ok { return Config{}, fmt.Errorf("invalid line: %q", line) } key = strings.TrimSpace(key) - value = strings.Trim(strings.TrimSpace(value), `"`) + rawValue = strings.TrimSpace(rawValue) + value := parseStringValue(rawValue) + switch { case section == "server": switch key { @@ -196,40 +204,10 @@ func Load(path string) (Config, error) { case "access_token": cfg.Server.AccessToken = value } - case section == "llm": - switch key { - case "default_profile": - cfg.LLM.DefaultProfile = value - } - case section == "model": + case section == "models": switch key { - case "provider": - cfg.Model.Provider = value - case "base_url": - cfg.Model.BaseURL = value - case "api_key": - cfg.Model.APIKey = value - case "model_id": - cfg.Model.ModelID = value - case "reasoning_effort": - cfg.Model.ReasoningEffort = value - } - default: - if name, ok := llmProfileSectionName(section); ok { - profile := cfg.LLM.Profiles[name] - switch key { - case "provider": - profile.Provider = value - case "base_url": - profile.BaseURL = value - case "api_key": - profile.APIKey = value - case "model_id": - profile.ModelID = value - case "reasoning_effort": - profile.ReasoningEffort = value - } - cfg.LLM.Profiles[name] = profile + case "default": + modelsCfg.Default = value } case section == "bootstrap": switch key { @@ -257,6 +235,25 @@ func Load(path string) (Config, error) { feishu.AppSecret = value } cfg.Channels.Feishu[name] = feishu + default: + if name, ok := modelsProviderSectionName(section); ok { + provider := modelsCfg.Providers[name] + switch key { + case "base_url": + provider.BaseURL = value + case "api_key": + provider.APIKey = value + case "models": + models, parseErr := parseStringArray(rawValue) + if parseErr != nil { + return Config{}, fmt.Errorf("parse models.providers.%s.models: %w", name, parseErr) + } + provider.Models = models + case "reasoning_effort": + provider.ReasoningEffort = value + } + modelsCfg.Providers[name] = provider + } } } if err := scanner.Err(); err != nil { @@ -272,12 +269,13 @@ func Load(path string) (Config, error) { if cfg.Server.AccessToken == "" { cfg.Server.AccessToken = DefaultAccessToken } - cfg.Model = cfg.Model.Resolved() - if len(cfg.LLM.Profiles) == 0 { - cfg.LLM = SingleProfileLLM(cfg.Model) + + if !modelsCfg.IsZero() { + cfg.Models = modelsCfg.Normalized() } else { - cfg.LLM = cfg.LLM.Normalized() + cfg.Models = SingleProfileLLM(ModelConfig{}) } + cfg.LLM = cfg.Models cfg.syncModelFromLLM() return cfg, nil } @@ -289,14 +287,8 @@ func (c Config) Save(path string) error { cfg := c cfg.syncModelFromLLM() - llmCfg := cfg.LLM.Normalized() - if len(llmCfg.Profiles) == 0 { - llmCfg = SingleProfileLLM(cfg.Model) - } - defaultProfile := llmCfg.EffectiveDefaultProfile() - if defaultProfile == "" { - defaultProfile = DefaultLLMProfile - } + llmCfg := cfg.effectiveLLMConfig() + defaultSelector := llmCfg.DefaultSelector() var b strings.Builder fmt.Fprintf(&b, `# Generated by csgclaw onboard. @@ -306,23 +298,24 @@ listen_addr = %q advertise_base_url = %q access_token = %q -[llm] -default_profile = %q - [bootstrap] manager_image = %q -`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, cfg.Server.AccessToken, defaultProfile, cfg.Bootstrap.ManagerImage) - for _, name := range sortedProfileNames(llmCfg.Profiles) { - profile := llmCfg.Profiles[name].Resolved() +[models] +default = %q +`, cfg.Server.ListenAddr, cfg.Server.AdvertiseBaseURL, cfg.Server.AccessToken, cfg.Bootstrap.ManagerImage, defaultSelector) + + for _, name := range sortedProviderNames(llmCfg.Providers) { + provider := llmCfg.Providers[name].Resolved() fmt.Fprintf(&b, ` -[llm.profiles.%s] -provider = %q +[models.providers.%s] base_url = %q api_key = %q -model_id = %q -reasoning_effort = %q -`, name, profile.EffectiveProvider(), profile.BaseURL, profile.APIKey, profile.ModelID, profile.ReasoningEffort) +models = %s +`, name, provider.BaseURL, provider.APIKey, formatStringArray(provider.Models)) + if provider.ReasoningEffort != "" { + fmt.Fprintf(&b, "reasoning_effort = %q\n", provider.ReasoningEffort) + } } if strings.TrimSpace(c.Channels.FeishuAdminOpenID) != "" { @@ -354,8 +347,8 @@ app_secret = %q return nil } -func llmProfileSectionName(section string) (string, bool) { - const prefix = "llm.profiles." +func modelsProviderSectionName(section string) (string, bool) { + const prefix = "models.providers." if !strings.HasPrefix(section, prefix) { return "", false } @@ -367,11 +360,31 @@ func llmProfileSectionName(section string) (string, bool) { } func SingleProfileLLM(model ModelConfig) LLMConfig { + model = model.Resolved() + provider := ProviderConfig{ + BaseURL: model.BaseURL, + APIKey: model.APIKey, + ReasoningEffort: model.ReasoningEffort, + } + if model.ModelID != "" { + provider.Models = []string{model.ModelID} + } return LLMConfig{ + Default: DefaultLLMProfile, + Providers: map[string]ProviderConfig{DefaultLLMProfile: provider.Resolved()}, DefaultProfile: DefaultLLMProfile, - Profiles: map[string]ModelConfig{ - DefaultLLMProfile: model.Resolved(), - }, + Profiles: map[string]ModelConfig{DefaultLLMProfile: model}, + } +} + +func (c Config) effectiveLLMConfig() LLMConfig { + switch { + case !c.Models.IsZero(): + return c.Models.Normalized() + case !c.LLM.IsZero(): + return c.LLM.Normalized() + default: + return SingleProfileLLM(c.Model).Normalized() } } @@ -379,22 +392,93 @@ func (c *Config) syncModelFromLLM() { if c == nil { return } - c.LLM = c.LLM.Normalized() - if len(c.LLM.Profiles) == 0 { - c.LLM = SingleProfileLLM(c.Model) - } - name, model, err := c.LLM.Resolve("") + + llmCfg := c.effectiveLLMConfig() + c.Models = llmCfg + c.LLM = llmCfg + + name, model, err := llmCfg.Resolve("") if err != nil { c.Model = c.Model.Resolved() return } - c.LLM.DefaultProfile = name + + c.Models.Default = name + c.Models.DefaultProfile = name + c.LLM = c.Models c.Model = model.Resolved() } -func sortedProfileNames(profiles map[string]ModelConfig) []string { - names := make([]string, 0, len(profiles)) - for name := range profiles { +func newLLMConfig() LLMConfig { + return LLMConfig{ + Providers: make(map[string]ProviderConfig), + Profiles: make(map[string]ModelConfig), + } +} + +func isLegacyConfigSection(section string) bool { + section = strings.TrimSpace(section) + switch { + case section == "llm": + return true + case section == "model": + return true + case strings.HasPrefix(section, "llm.profiles."): + return true + default: + return false + } +} + +func parseStringValue(raw string) string { + return strings.Trim(strings.TrimSpace(raw), `"`) +} + +func parseStringArray(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + if !strings.HasPrefix(raw, "[") || !strings.HasSuffix(raw, "]") { + return nil, fmt.Errorf("expected TOML string array, got %q", raw) + } + inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(raw, "["), "]")) + if inner == "" { + return nil, nil + } + parts := strings.Split(inner, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = parseStringValue(part) + if part == "" { + continue + } + out = append(out, part) + } + return out, nil +} + +func formatStringArray(values []string) string { + if len(values) == 0 { + return "[]" + } + quoted := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + quoted = append(quoted, fmt.Sprintf("%q", value)) + } + if len(quoted) == 0 { + return "[]" + } + return "[" + strings.Join(quoted, ", ") + "]" +} + +func sortedProviderNames(providers map[string]ProviderConfig) []string { + names := make([]string, 0, len(providers)) + for name := range providers { names = append(names, name) } sort.Strings(names) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 78ddb08..707b003 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -53,10 +53,13 @@ func TestLoadAppliesDefaultManagerImage(t *testing.T) { listen_addr = "127.0.0.1:18080" advertise_base_url = "http://127.0.0.1:18080" -[model] +[models] +default = "default.minimax-m2.7" + +[models.providers.default] base_url = "http://127.0.0.1:4000" api_key = "sk" -model_id = "minimax-m2.7" +models = ["minimax-m2.7"] ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatalf("WriteFile() error = %v", err) @@ -75,25 +78,24 @@ model_id = "minimax-m2.7" if got, want := cfg.Model.Provider, ProviderLLMAPI; got != want { t.Fatalf("cfg.Model.Provider = %q, want %q", got, want) } - if got, want := cfg.LLM.DefaultProfile, DefaultLLMProfile; got != want { - t.Fatalf("cfg.LLM.DefaultProfile = %q, want %q", got, want) + if got, want := cfg.Models.Default, "default.minimax-m2.7"; got != want { + t.Fatalf("cfg.Models.Default = %q, want %q", got, want) } } -func TestLoadReadsLLMProfilePool(t *testing.T) { +func TestLoadReadsModelsProviderPool(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") content := `[server] listen_addr = "127.0.0.1:18080" -[llm] -default_profile = "remote-main" +[models] +default = "remote.gpt-5.4" -[llm.profiles.remote-main] -provider = "llm-api" +[models.providers.remote] base_url = "https://example.test/v1" api_key = "sk-test" -model_id = "gpt-5.4" +models = ["gpt-5.4", "gpt-5.4-mini"] reasoning_effort = "medium" ` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { @@ -104,20 +106,49 @@ reasoning_effort = "medium" if err != nil { t.Fatalf("Load() error = %v", err) } - if got, want := cfg.LLM.DefaultProfile, "remote-main"; got != want { - t.Fatalf("cfg.LLM.DefaultProfile = %q, want %q", got, want) - } - if got, want := cfg.Model.Provider, ProviderLLMAPI; got != want { - t.Fatalf("cfg.Model.Provider = %q, want %q", got, want) + if got, want := cfg.Models.Default, "remote.gpt-5.4"; got != want { + t.Fatalf("cfg.Models.Default = %q, want %q", got, want) } if got, want := cfg.Model.ModelID, "gpt-5.4"; got != want { t.Fatalf("cfg.Model.ModelID = %q, want %q", got, want) } - if got, want := cfg.LLM.Profiles["remote-main"].BaseURL, "https://example.test/v1"; got != want { - t.Fatalf("cfg.LLM.Profiles[remote-main].BaseURL = %q, want %q", got, want) + if got, want := cfg.Models.Providers["remote"].BaseURL, "https://example.test/v1"; got != want { + t.Fatalf("cfg.Models.Providers[remote].BaseURL = %q, want %q", got, want) + } + if got, want := strings.Join(cfg.Models.Providers["remote"].Models, ","), "gpt-5.4,gpt-5.4-mini"; got != want { + t.Fatalf("cfg.Models.Providers[remote].Models = %q, want %q", got, want) + } + if got, want := cfg.Models.Providers["remote"].ReasoningEffort, "medium"; got != want { + t.Fatalf("cfg.Models.Providers[remote].ReasoningEffort = %q, want %q", got, want) + } +} + +func TestLoadRejectsLegacyLLMSections(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := `[server] +listen_addr = "127.0.0.1:18080" + +[llm] +default_profile = "remote-main" + +[llm.profiles.remote-main] +provider = "llm-api" +base_url = "https://example.test/v1" +api_key = "sk-test" +model_id = "gpt-5.4" +reasoning_effort = "medium" +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := Load(path) + if err == nil { + t.Fatal("Load() error = nil, want legacy [llm] rejection") } - if got, want := cfg.LLM.Profiles["remote-main"].ReasoningEffort, "medium"; got != want { - t.Fatalf("cfg.LLM.Profiles[remote-main].ReasoningEffort = %q, want %q", got, want) + if !strings.Contains(err.Error(), "legacy config section [llm] is no longer supported") { + t.Fatalf("Load() error = %q, want legacy [llm] rejection", err) } } @@ -128,10 +159,13 @@ func TestLoadSupportsNamedFeishuChannelConfigs(t *testing.T) { listen_addr = "127.0.0.1:18080" advertise_base_url = "http://127.0.0.1:18080" -[model] +[models] +default = "default.minimax-m2.7" + +[models.providers.default] base_url = "http://127.0.0.1:4000" api_key = "sk" -model_id = "minimax-m2.7" +models = ["minimax-m2.7"] [channels.feishu] admin_open_id = "ou_admin" @@ -170,21 +204,22 @@ app_secret = "dev-secret" } } -func TestSaveWritesAccessTokenUnderServerSection(t *testing.T) { +func TestSaveWritesModelsSection(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") + models := SingleProfileLLM(ModelConfig{ + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk", + ModelID: "minimax-m2.7", + }) cfg := Config{ Server: ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", }, - LLM: SingleProfileLLM(ModelConfig{ - BaseURL: "http://127.0.0.1:4000", - APIKey: "sk", - ModelID: "minimax-m2.7", - ReasoningEffort: "medium", - }), + Models: models, + LLM: models, Bootstrap: BootstrapConfig{ ManagerImage: "img", }, @@ -215,14 +250,17 @@ func TestSaveWritesAccessTokenUnderServerSection(t *testing.T) { if !strings.Contains(content, "access_token = \"shared-token\"") { t.Fatalf("saved config missing server access token:\n%s", content) } - if !strings.Contains(content, "[llm]") || !strings.Contains(content, "[llm.profiles.default]") { - t.Fatalf("saved config missing llm profile sections:\n%s", content) + if !strings.Contains(content, "[models]") || !strings.Contains(content, "[models.providers.default]") { + t.Fatalf("saved config missing models sections:\n%s", content) + } + if !strings.Contains(content, `default = "default.minimax-m2.7"`) { + t.Fatalf("saved config missing canonical models.default:\n%s", content) } - if !strings.Contains(content, `reasoning_effort = "medium"`) { - t.Fatalf("saved config missing reasoning_effort:\n%s", content) + if !strings.Contains(content, `models = ["minimax-m2.7"]`) { + t.Fatalf("saved config missing models array:\n%s", content) } - if strings.Contains(content, "[picoclaw]") { - t.Fatalf("saved config should not contain [picoclaw] section:\n%s", content) + if strings.Contains(content, "[llm]") || strings.Contains(content, "model_id = ") { + t.Fatalf("saved config should not contain legacy llm/profile keys:\n%s", content) } for _, want := range []string{ "[channels.feishu.dev]", diff --git a/internal/config/model_validation.go b/internal/config/model_validation.go index 06638dc..2000a1d 100644 --- a/internal/config/model_validation.go +++ b/internal/config/model_validation.go @@ -7,9 +7,18 @@ import ( ) const ( - ProviderLLMAPI = "llm-api" + ProviderLLMAPI = "llm-api" + DefaultLLMProfile = "default" + DefaultModelFormat = "%s.%s" ) +type ProviderConfig struct { + BaseURL string + APIKey string + Models []string + ReasoningEffort string +} + type ModelValidationError struct { MissingFields []string Message string @@ -37,6 +46,15 @@ func NormalizeProvider(provider string) string { } } +func ModelSelector(providerName, modelID string) string { + providerName = strings.TrimSpace(providerName) + modelID = strings.TrimSpace(modelID) + if providerName == "" || modelID == "" { + return "" + } + return fmt.Sprintf(DefaultModelFormat, providerName, modelID) +} + func (c ModelConfig) EffectiveProvider() string { return NormalizeProvider(c.Provider) } @@ -80,35 +98,152 @@ func (c ModelConfig) Validate() error { return nil } +func (c ProviderConfig) Resolved() ProviderConfig { + out := c + out.BaseURL = strings.TrimRight(strings.TrimSpace(out.BaseURL), "/") + out.APIKey = strings.TrimSpace(out.APIKey) + out.ReasoningEffort = strings.ToLower(strings.TrimSpace(out.ReasoningEffort)) + out.Models = normalizeModelIDs(out.Models) + return out +} + +func (c ProviderConfig) MissingFields() []string { + cfg := c.Resolved() + var missing []string + if cfg.BaseURL == "" { + missing = append(missing, "base_url") + } + if cfg.APIKey == "" { + missing = append(missing, "api_key") + } + if len(cfg.Models) == 0 { + missing = append(missing, "model_id") + } + return missing +} + +func (c ProviderConfig) Validate() error { + cfg := c.Resolved() + if missing := cfg.MissingFields(); len(missing) > 0 { + return &ModelValidationError{ + MissingFields: missing, + Message: fmt.Sprintf("provider %q is missing required fields: %s", ProviderLLMAPI, strings.Join(missing, ", ")), + } + } + return nil +} + +func (c ProviderConfig) modelConfig(modelID string) ModelConfig { + cfg := c.Resolved() + return ModelConfig{ + Provider: ProviderLLMAPI, + BaseURL: cfg.BaseURL, + APIKey: cfg.APIKey, + ModelID: strings.TrimSpace(modelID), + ReasoningEffort: cfg.ReasoningEffort, + }.Resolved() +} + +func (c LLMConfig) IsZero() bool { + return len(c.Providers) == 0 && len(c.Profiles) == 0 && strings.TrimSpace(c.Default) == "" && strings.TrimSpace(c.DefaultProfile) == "" +} + func (c LLMConfig) Normalized() LLMConfig { out := LLMConfig{ + Default: strings.TrimSpace(c.Default), + Providers: make(map[string]ProviderConfig), DefaultProfile: strings.TrimSpace(c.DefaultProfile), - Profiles: make(map[string]ModelConfig, len(c.Profiles)), + Profiles: make(map[string]ModelConfig), + } + + for name, provider := range c.Providers { + name = strings.TrimSpace(name) + if name == "" { + continue + } + out.Providers[name] = provider.Resolved() } for name, profile := range c.Profiles { name = strings.TrimSpace(name) if name == "" { continue } + provider := out.Providers[name] + if provider.BaseURL == "" { + provider.BaseURL = strings.TrimRight(strings.TrimSpace(profile.BaseURL), "/") + } + if provider.APIKey == "" { + provider.APIKey = strings.TrimSpace(profile.APIKey) + } + if provider.ReasoningEffort == "" { + provider.ReasoningEffort = strings.ToLower(strings.TrimSpace(profile.ReasoningEffort)) + } + if modelID := strings.TrimSpace(profile.ModelID); modelID != "" { + provider.Models = append(provider.Models, modelID) + } + out.Providers[name] = provider.Resolved() out.Profiles[name] = profile.Resolved() } + + if out.Default == "" { + out.Default = out.DefaultProfile + } if out.DefaultProfile == "" { - out.DefaultProfile = out.EffectiveDefaultProfile() + out.DefaultProfile = out.Default + } + + for name, provider := range out.Providers { + if _, ok := out.Profiles[name]; ok { + continue + } + if len(provider.Models) == 1 { + out.Profiles[name] = provider.modelConfig(provider.Models[0]) + } + } + + if out.Default == "" && out.DefaultProfile != "" { + out.Default = out.DefaultProfile + } + if out.DefaultProfile == "" && out.Default != "" { + out.DefaultProfile = out.Default } return out } -func (c LLMConfig) EffectiveDefaultProfile() string { - defaultProfile := strings.TrimSpace(c.DefaultProfile) - if defaultProfile != "" { - return defaultProfile +func (c LLMConfig) DefaultSelector() string { + name, _, err := c.Resolve("") + if err != nil { + return "" } - if len(c.Profiles) == 1 { - for name := range c.Profiles { - return strings.TrimSpace(name) + return name +} + +func (c LLMConfig) EffectiveDefaultProvider() string { + cfg := c.Normalized() + if selector := cfg.DefaultSelector(); selector != "" { + providerName, _, ok := splitModelSelector(selector) + if ok { + return providerName } } - if _, ok := c.Profiles[DefaultLLMProfile]; ok { + defaultValue := strings.TrimSpace(cfg.Default) + if defaultValue == "" { + defaultValue = strings.TrimSpace(cfg.DefaultProfile) + } + if defaultValue != "" { + if providerName, _, ok := splitModelSelector(defaultValue); ok { + return providerName + } + if _, ok := cfg.Providers[defaultValue]; ok { + return defaultValue + } + } + if len(cfg.Providers) == 1 { + for name := range cfg.Providers { + return name + } + } + if _, ok := cfg.Providers[DefaultLLMProfile]; ok { return DefaultLLMProfile } return "" @@ -116,41 +251,104 @@ func (c LLMConfig) EffectiveDefaultProfile() string { func (c LLMConfig) Resolve(profile string) (string, ModelConfig, error) { cfg := c.Normalized() - name := strings.TrimSpace(profile) - if name == "" { - name = cfg.EffectiveDefaultProfile() + requested := strings.TrimSpace(profile) + if requested == "" { + requested = strings.TrimSpace(cfg.Default) + if requested == "" { + requested = strings.TrimSpace(cfg.DefaultProfile) + } + if requested == "" && len(cfg.Providers) == 1 { + for name := range cfg.Providers { + requested = name + } + } + } + if requested == "" { + return "", ModelConfig{}, &ModelValidationError{ + MissingFields: []string{"default"}, + Message: "models default is not configured", + } + } + return cfg.resolveSelector(requested) +} + +func (c LLMConfig) resolveSelector(selector string) (string, ModelConfig, error) { + cfg := c.Normalized() + selector = strings.TrimSpace(selector) + if selector == "" { + return "", ModelConfig{}, &ModelValidationError{ + MissingFields: []string{"default"}, + Message: "models default is not configured", + } } - if name == "" { - return "", ModelConfig{}, &ModelValidationError{Message: "llm default_profile is not configured"} + + providerName, modelID, hasModelID := splitModelSelector(selector) + if !hasModelID { + providerName = selector } - model, ok := cfg.Profiles[name] + provider, ok := cfg.Providers[providerName] if !ok { - return "", ModelConfig{}, &ModelValidationError{Message: fmt.Sprintf("llm profile %q was not found", name)} + return "", ModelConfig{}, &ModelValidationError{ + MissingFields: []string{"default"}, + Message: fmt.Sprintf("models provider %q was not found", providerName), + } + } + provider = provider.Resolved() + if !hasModelID { + switch len(provider.Models) { + case 0: + modelID = "" + case 1: + modelID = provider.Models[0] + default: + return "", ModelConfig{}, &ModelValidationError{ + MissingFields: []string{"default"}, + Message: fmt.Sprintf("models provider %q has multiple models; set models.default to %q", providerName, ModelSelector(providerName, provider.Models[0])), + } + } + } else if !containsString(provider.Models, modelID) { + return "", ModelConfig{}, &ModelValidationError{ + MissingFields: []string{"default"}, + Message: fmt.Sprintf("models default %q does not match any models.providers entry", selector), + } + } + + model := provider.modelConfig(modelID) + name := providerName + if modelID != "" { + name = ModelSelector(providerName, modelID) } - return name, model.Resolved(), nil + return name, model, nil } func (c LLMConfig) MatchProfile(candidate ModelConfig) (string, ModelConfig, bool) { cfg := c.Normalized() candidate = candidate.Resolved() - for _, name := range sortedProfileNames(cfg.Profiles) { - profile := cfg.Profiles[name].Resolved() - if !strings.EqualFold(profile.EffectiveProvider(), candidate.EffectiveProvider()) { + for _, name := range sortedProviderNames(cfg.Providers) { + provider := cfg.Providers[name].Resolved() + if !strings.EqualFold(ProviderLLMAPI, candidate.EffectiveProvider()) { continue } - if strings.TrimSpace(profile.ModelID) != strings.TrimSpace(candidate.ModelID) { + if candidate.ReasoningEffort != "" && strings.TrimSpace(provider.ReasoningEffort) != strings.TrimSpace(candidate.ReasoningEffort) { continue } - if candidate.ReasoningEffort != "" && strings.TrimSpace(profile.ReasoningEffort) != strings.TrimSpace(candidate.ReasoningEffort) { - continue + for _, modelID := range provider.Models { + if strings.TrimSpace(modelID) != strings.TrimSpace(candidate.ModelID) { + continue + } + model := provider.modelConfig(modelID) + return ModelSelector(name, modelID), model, true } - return name, profile, true } return "", ModelConfig{}, false } func (c LLMConfig) MissingFields() []string { - _, model, err := c.Resolve("") + cfg := c.Normalized() + if len(cfg.Providers) == 0 { + return ProviderConfig{}.MissingFields() + } + _, model, err := cfg.Resolve("") if err != nil { var validationErr *ModelValidationError if errors.As(err, &validationErr) { @@ -163,29 +361,23 @@ func (c LLMConfig) MissingFields() []string { func (c LLMConfig) Validate() error { cfg := c.Normalized() - if len(cfg.Profiles) == 0 { + if len(cfg.Providers) == 0 { return SingleProfileLLM(ModelConfig{}).Validate() } - defaultProfile := cfg.EffectiveDefaultProfile() - if defaultProfile == "" { - return &ModelValidationError{ - MissingFields: []string{"default_profile"}, - Message: "llm default_profile is required", - } + _, model, err := cfg.Resolve("") + if err != nil { + return err } - if _, ok := cfg.Profiles[defaultProfile]; !ok { + if missing := model.MissingFields(); len(missing) > 0 { return &ModelValidationError{ - MissingFields: []string{"default_profile"}, - Message: fmt.Sprintf("llm default_profile %q does not match any llm.profiles entry", defaultProfile), + MissingFields: missing, + Message: fmt.Sprintf("models default is missing required fields: %s", strings.Join(missing, ", ")), } } - for _, name := range sortedProfileNames(cfg.Profiles) { - profile := cfg.Profiles[name] - if err := profile.Validate(); err != nil { - if name == defaultProfile { - return err - } - return fmt.Errorf("llm profile %q is invalid: %w", name, err) + for _, name := range sortedProviderNames(cfg.Providers) { + provider := cfg.Providers[name] + if err := provider.Validate(); err != nil { + return fmt.Errorf("models provider %q is invalid: %w", name, err) } } return nil @@ -203,3 +395,55 @@ func (c ModelConfig) validateProvider() error { ), } } + +func splitModelSelector(selector string) (string, string, bool) { + selector = strings.TrimSpace(selector) + if selector == "" { + return "", "", false + } + dot := strings.Index(selector, ".") + colon := strings.Index(selector, ":") + switch { + case dot == -1 && colon == -1: + return "", "", false + case dot == -1: + providerName, modelID, ok := strings.Cut(selector, ":") + return strings.TrimSpace(providerName), strings.TrimSpace(modelID), ok && strings.TrimSpace(providerName) != "" && strings.TrimSpace(modelID) != "" + case colon == -1 || dot < colon: + providerName, modelID, ok := strings.Cut(selector, ".") + return strings.TrimSpace(providerName), strings.TrimSpace(modelID), ok && strings.TrimSpace(providerName) != "" && strings.TrimSpace(modelID) != "" + default: + providerName, modelID, ok := strings.Cut(selector, ":") + return strings.TrimSpace(providerName), strings.TrimSpace(modelID), ok && strings.TrimSpace(providerName) != "" && strings.TrimSpace(modelID) != "" + } +} + +func normalizeModelIDs(models []string) []string { + if len(models) == 0 { + return nil + } + seen := make(map[string]struct{}, len(models)) + out := make([]string, 0, len(models)) + for _, modelID := range models { + modelID = strings.TrimSpace(modelID) + if modelID == "" { + continue + } + if _, ok := seen[modelID]; ok { + continue + } + seen[modelID] = struct{}{} + out = append(out, modelID) + } + return out +} + +func containsString(values []string, want string) bool { + want = strings.TrimSpace(want) + for _, value := range values { + if strings.TrimSpace(value) == want { + return true + } + } + return false +} diff --git a/internal/im/events.go b/internal/im/events.go index 7111307..9098fc3 100644 --- a/internal/im/events.go +++ b/internal/im/events.go @@ -33,7 +33,6 @@ func NewBus() *Bus { } func (b *Bus) Subscribe() (<-chan Event, func()) { - // step 9.4 IM events are fanned out in-memory to browsers and the PicoClaw bridge without touching disk again. ch := make(chan Event, 16) b.mu.Lock() @@ -58,8 +57,6 @@ func (b *Bus) Publish(evt Event) { if b == nil { return } - - // step 9.4.1 Slow subscribers do not block the system; events are dropped when a subscriber channel is full. b.mu.Lock() targets := make([]chan Event, 0, len(b.subscribers)) for _, ch := range b.subscribers { diff --git a/internal/im/service.go b/internal/im/service.go index 3e63eda..13aff9e 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -111,10 +111,6 @@ func NewServiceFromPath(path string) (*Service, error) { } func NewServiceFromBootstrap(state Bootstrap) *Service { - // step 9.0 IM owns the collaboration model: - // step 9.0.1 users - // step 9.0.2 rooms/conversations - // step 9.0.3 persisted messages inside each room. state = normalizeBootstrap(state) users := state.Users @@ -373,7 +369,6 @@ func sessionRelativePath(roomID string) string { } func EnsureBootstrapState(path string) error { - // step 9.1 First-run IM state always guarantees admin, manager, and their bootstrap room. state, err := LoadBootstrap(path) if err != nil { return err @@ -542,7 +537,6 @@ func containsUserIDInConversation(conv Conversation, userID string) bool { } func (s *Service) Bootstrap() Bootstrap { - // step 9.2 The browser bootstrap snapshot is just a presentation of current in-memory IM state. s.mu.RLock() defer s.mu.RUnlock() @@ -678,7 +672,6 @@ func (s *Service) KickUser(userID string) error { } func (s *Service) EnsureAgentUser(req EnsureAgentUserRequest) (User, *Room, error) { - // step 9.3 Mirror an agent into IM as a user and ensure it has a direct bootstrap room with admin. id := strings.TrimSpace(req.ID) name := strings.ToLower(strings.TrimSpace(req.Name)) handle := strings.ToLower(strings.TrimSpace(req.Handle)) @@ -737,7 +730,6 @@ func (s *Service) EnsureWorkerUser(req EnsureWorkerUserRequest) (User, *Room, er } func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { - // step 9.5 User-originated messages enter IM here, are persisted, and later become SSE/PicoClaw events upstream. content := strings.TrimSpace(req.Content) roomID := strings.TrimSpace(req.RoomID) if roomID == "" { @@ -771,7 +763,6 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { } func (s *Service) DeliverMessage(req DeliverMessageRequest) (Message, error) { - // step 9.5.1 Bot-originated replies use a parallel branch that defaults sender_id when the bridge omits it. roomID := strings.TrimSpace(req.RoomID) senderID := strings.TrimSpace(req.SenderID) content := strings.TrimSpace(req.Content) @@ -805,7 +796,6 @@ func (s *Service) DeliverMessage(req DeliverMessageRequest) (Message, error) { } func (s *Service) CreateRoom(req CreateRoomRequest) (Room, error) { - // step 9.6 New rooms are created with an initial event message so the UI can explain how the room started. title := strings.TrimSpace(req.Title) description := strings.TrimSpace(req.Description) if title == "" { @@ -860,7 +850,6 @@ func (s *Service) CreateConversation(req CreateConversationRequest) (Conversatio } func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) { - // step 9.7 Member invites mutate participants and append an event message describing who was added. roomID := strings.TrimSpace(req.RoomID) if roomID == "" { return Room{}, fmt.Errorf("room_id is required") @@ -977,7 +966,6 @@ func (s *Service) User(userID string) (User, bool) { } func (s *Service) extractMentions(content string) []string { - // step 9.5.2 Mentions are resolved from @handle text into user IDs so group-room bot delivery can be selective. matches := mentionPattern.FindAllStringSubmatch(content, -1) if len(matches) == 0 { return nil @@ -1082,7 +1070,6 @@ func formatConversationSubtitle(count int) string { } func (s *Service) presentRoomLocked(room Room) Room { - // step 9.2.1 Direct rooms are presented using the "other participant" name to feel like a chat app. cloned := cloneRoom(room) if len(cloned.Participants) != 2 { return cloned @@ -1160,7 +1147,6 @@ func (s *Service) bootstrapLocked() Bootstrap { } func (s *Service) ensureAdminAgentRoomLocked(agentID, agentName string) (*Room, bool) { - // step 9.3.1 Every worker gets one admin-worker bootstrap direct room, created lazily the first time it appears. for _, room := range s.rooms { if len(room.Participants) != 2 { continue diff --git a/internal/server/http.go b/internal/server/http.go index ea8044f..7843263 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -30,7 +30,6 @@ func Run(opts Options) error { if opts.Context == nil { opts.Context = context.Background() } - handler := api.NewHandlerWithBotAndAccessToken(opts.Service, opts.Bot, opts.IM, opts.IMBus, opts.PicoClaw, opts.Feishu, opts.LLM, opts.AccessToken) mux := handler.Routes() mux.Handle("/", uiHandler()) @@ -42,7 +41,6 @@ func Run(opts Options) error { } if opts.IMBus != nil && opts.PicoClaw != nil { - // step 5.1 Bridge internal IM events to PicoClaw subscribers so bots can receive SSE message events. events, cancel := opts.IMBus.Subscribe() defer cancel() @@ -63,7 +61,6 @@ func Run(opts Options) error { errCh := make(chan error, 1) go func() { - // step 5.2 Use the shared context for graceful shutdown across CLI signal handling and HTTP serve loops. <-opts.Context.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/web/static/app.js b/web/static/app.js index bcff65c..5952d57 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -406,7 +406,6 @@ function App() { const messageListRef = useRef(null); useEffect(() => { - // step 10.0 Browser bootstrap branch: fetch one full IM snapshot before subscribing to live updates. fetch("/api/v1/bootstrap") .then((resp) => resp.json()) .then((payload) => { @@ -421,7 +420,6 @@ function App() { }, []); useEffect(() => { - // step 10.1 Live-update branch: keep the UI synchronized by applying SSE events from the shared IM bus. const source = new EventSource("/api/v1/events"); source.onmessage = (event) => { @@ -534,7 +532,6 @@ function App() { }, [activeConversationId, visibleMessages.length]); async function sendMessage() { - // step 10.2 Send branch 1: write a user message through HTTP, then merge it into local state immediately. if (!data || !activeConversation || !draft.trim()) { return; } @@ -559,7 +556,6 @@ function App() { } async function createRoom() { - // step 10.3 Send branch 2: create a room through HTTP, then switch the UI to that room. if (!data || !roomTitle.trim()) { return; } @@ -589,7 +585,6 @@ function App() { } async function inviteUsers() { - // step 10.4 Send branch 3: invite more users into the active room through HTTP. if (!data || !activeConversation || inviteUserIDs.length === 0) { return; } @@ -1320,7 +1315,6 @@ function latestAt(conversation) { } function applyIMEvent(current, event) { - // step 10.5 Merge live events into the browser model by event type instead of refetching the whole workspace. if (!current || !event?.type) { return current; } @@ -1393,7 +1387,6 @@ function sortConversations(conversations) { } function normalizeIMData(payload) { - // step 10.0.1 The backend returns rooms; the frontend also aliases them as conversations for chat-oriented UI code. if (!payload) { return payload; } From eb308e3fce6a9e5a7a647e38dde137f02881eaed Mon Sep 17 00:00:00 2001 From: Yun Long Date: Mon, 13 Apr 2026 09:04:22 +0800 Subject: [PATCH 3/5] Compact onboard arguments --- README.md | 3 ++- README.zh.md | 3 ++- cli/onboard/onboard.go | 2 +- cli/serve/serve.go | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7dfcffb..466fae1 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,12 @@ go build ./cmd/csgclaw ## Quick Start ```bash -csgclaw onboard --provider default --model-id --base-url --api-key [--default ] +csgclaw onboard --base-url --api-key --models [--reasoning-effort ] csgclaw serve ``` Open the printed URL (e.g. `http://127.0.0.1:18080/`) in your browser to enter the IM workspace. +For a fresh config, `onboard` creates a single `default` provider and sets `models.default` to `default.`. ## Model Provider Examples diff --git a/README.zh.md b/README.zh.md index d61077d..1fe2158 100644 --- a/README.zh.md +++ b/README.zh.md @@ -34,11 +34,12 @@ go build ./cmd/csgclaw ## 快速开始 ```bash -csgclaw onboard --provider default --model-id --base-url --api-key [--default ] +csgclaw onboard --base-url --api-key --models [--reasoning-effort ] csgclaw serve ``` 执行后 CLI 会打印访问地址(例如 `http://127.0.0.1:18080/`),在浏览器中打开即可进入 IM 工作区。 +对于全新配置,`onboard` 会创建一个名为 `default` 的 provider,并把 `models.default` 设为 `default.<第一个模型>`。 ## Model Provider 配置示例 diff --git a/cli/onboard/onboard.go b/cli/onboard/onboard.go index 4e86fb2..ea3a6d1 100644 --- a/cli/onboard/onboard.go +++ b/cli/onboard/onboard.go @@ -203,7 +203,7 @@ func missingModelFlags(fields []string) []string { case "model_id": flags = append(flags, "--models") case "default", "default_profile": - flags = append(flags, "models.default") + flags = append(flags, "--models") default: flags = append(flags, field) } diff --git a/cli/serve/serve.go b/cli/serve/serve.go index 7b5b6cc..b729668 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -474,7 +474,7 @@ func missingModelFlags(fields []string) []string { case "model_id": flags = append(flags, "--models") case "default", "default_profile": - flags = append(flags, "models.default") + flags = append(flags, "--models") default: flags = append(flags, field) } From cc0520d246938ed7df852d88a1499a512c15d200 Mon Sep 17 00:00:00 2001 From: Yun Long Date: Mon, 13 Apr 2026 16:00:53 +0800 Subject: [PATCH 4/5] Revert the manager-side PicoClaw packaging path. --- internal/agent/manager_config.go | 138 -------------------------- internal/agent/manager_config_test.go | 74 -------------- 2 files changed, 212 deletions(-) diff --git a/internal/agent/manager_config.go b/internal/agent/manager_config.go index 1ba500f..96db0f4 100644 --- a/internal/agent/manager_config.go +++ b/internal/agent/manager_config.go @@ -4,11 +4,9 @@ import ( _ "embed" "encoding/json" "fmt" - "io/fs" "net" "os" "path/filepath" - "runtime" "strings" "csgclaw/internal/config" @@ -22,24 +20,6 @@ var defaultManagerPicoClawConfig []byte //go:embed defaults/manager-security.yml var defaultManagerSecurityConfig string -var managerSkillSourceDirResolver = bundledManagerSkillSourceDir - -const managerMemoryContents = `# Manager Memory - -When an admin asks you to arrange or reuse workers such as ux, dev, and qa: - -- Do not do the implementation work yourself. -- Do not use message for status chatter or request restatement. -- Use the bundled manager-worker-dispatch workflow directly from ~/.picoclaw/workspace/skills/manager-worker-dispatch. -- Fast path: - 1. python scripts/manager_worker_api.py list-workers - 2. join the chosen workers to the room - 3. write todo.json under ~/.picoclaw/workspace/projects// - 4. python scripts/manager_worker_api.py start-tracking --room-id --todo-path -- Only open SKILL.md if the workflow must change or a required command is unclear. -- After tracking starts, send one concise assignment summary. -` - func ensureManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) (string, error) { return ensureAgentPicoClawConfig(ManagerName, "u-manager", server, model) } @@ -52,9 +32,6 @@ func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConf if err := os.MkdirAll(filepath.Join(hostRoot, hostPicoClawLogs), 0o755); err != nil { return "", fmt.Errorf("create manager picoclaw logs dir: %w", err) } - if err := ensureAgentWorkspace(hostRoot, agentName); err != nil { - return "", err - } data, err := renderAgentPicoClawConfig(botID, server, model) if err != nil { @@ -247,118 +224,3 @@ func renderManagerSecurityConfig(server config.ServerConfig, model config.ModelC } return content } - -func ensureAgentWorkspace(hostRoot, agentName string) error { - workspaceRoot := filepath.Join(hostRoot, "workspace") - for _, dir := range []string{ - filepath.Join(workspaceRoot, "memory"), - filepath.Join(workspaceRoot, "projects"), - filepath.Join(workspaceRoot, "sessions"), - filepath.Join(workspaceRoot, "state"), - filepath.Join(workspaceRoot, "skills"), - } { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("create agent workspace dir %q: %w", dir, err) - } - } - if !strings.EqualFold(strings.TrimSpace(agentName), ManagerName) { - return nil - } - - dstRoot := filepath.Join(workspaceRoot, "skills", "manager-worker-dispatch") - srcRoot, err := managerSkillSourceDirResolver() - if err != nil { - return fmt.Errorf("resolve bundled manager skill: %w", err) - } - if err := copyDirTree(srcRoot, dstRoot); err != nil { - return fmt.Errorf("seed bundled manager skill: %w", err) - } - memoryPath := filepath.Join(workspaceRoot, "memory", "MEMORY.md") - if err := os.WriteFile(memoryPath, []byte(managerMemoryContents), 0o644); err != nil { - return fmt.Errorf("write manager memory: %w", err) - } - return nil -} - -func bundledManagerSkillSourceDir() (string, error) { - candidates := make([]string, 0, 4) - if wd, err := os.Getwd(); err == nil { - candidates = append(candidates, wd) - } - if exe, err := os.Executable(); err == nil { - candidates = append(candidates, filepath.Dir(exe), filepath.Dir(filepath.Dir(exe))) - } - if _, file, _, ok := runtime.Caller(0); ok { - candidates = append(candidates, filepath.Dir(file), filepath.Dir(filepath.Dir(filepath.Dir(file)))) - } - - for _, base := range candidates { - if found := findManagerSkillUnder(base); found != "" { - return found, nil - } - } - return "", os.ErrNotExist -} - -func findManagerSkillUnder(base string) string { - base = strings.TrimSpace(base) - if base == "" { - return "" - } - for current := base; ; current = filepath.Dir(current) { - candidate := filepath.Join(current, "skills", "manager-worker-dispatch") - if info, err := os.Stat(filepath.Join(candidate, "SKILL.md")); err == nil && !info.IsDir() { - return candidate - } - parent := filepath.Dir(current) - if parent == current { - return "" - } - } -} - -func copyDirTree(srcRoot, dstRoot string) error { - srcRoot = filepath.Clean(srcRoot) - dstRoot = filepath.Clean(dstRoot) - if err := os.RemoveAll(dstRoot); err != nil { - return err - } - - return filepath.WalkDir(srcRoot, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - rel, err := filepath.Rel(srcRoot, path) - if err != nil { - return err - } - target := dstRoot - if rel != "." { - target = filepath.Join(dstRoot, rel) - } - - info, err := d.Info() - if err != nil { - return err - } - if d.IsDir() { - return os.MkdirAll(target, info.Mode().Perm()) - } - - data, err := os.ReadFile(path) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - mode := info.Mode().Perm() - if mode == 0 { - mode = 0o644 - } - if err := os.WriteFile(target, data, mode); err != nil { - return err - } - return nil - }) -} diff --git a/internal/agent/manager_config_test.go b/internal/agent/manager_config_test.go index a0e246f..b6e0ca1 100644 --- a/internal/agent/manager_config_test.go +++ b/internal/agent/manager_config_test.go @@ -69,20 +69,6 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { func TestEnsureAgentPicoClawConfigUsesDirectoryMountRoot(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - managerSkillSourceDirResolver = func() (string, error) { - src := filepath.Join(t.TempDir(), "skills", "manager-worker-dispatch") - if err := os.MkdirAll(filepath.Join(src, "scripts"), 0o755); err != nil { - t.Fatalf("os.MkdirAll(skill source) error = %v", err) - } - if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("name: test\n"), 0o644); err != nil { - t.Fatalf("os.WriteFile(SKILL.md) error = %v", err) - } - if err := os.WriteFile(filepath.Join(src, "scripts", "manager_worker_api.py"), []byte("print('ok')\n"), 0o755); err != nil { - t.Fatalf("os.WriteFile(manager_worker_api.py) error = %v", err) - } - return src, nil - } - defer func() { managerSkillSourceDirResolver = bundledManagerSkillSourceDir }() root, err := ensureAgentPicoClawConfig("ux", "u-ux", config.ServerConfig{ ListenAddr: "0.0.0.0:18080", @@ -122,66 +108,6 @@ func TestEnsureAgentPicoClawConfigUsesDirectoryMountRoot(t *testing.T) { } } -func TestEnsureAgentPicoClawConfigSeedsManagerSkill(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - - src := filepath.Join(t.TempDir(), "skills", "manager-worker-dispatch") - for _, dir := range []string{ - src, - filepath.Join(src, "scripts"), - filepath.Join(src, "agents"), - } { - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatalf("os.MkdirAll(%q) error = %v", dir, err) - } - } - for path, content := range map[string]string{ - filepath.Join(src, "SKILL.md"): "name: manager-worker-dispatch\n", - filepath.Join(src, "scripts", "manager_worker_api.py"): "#!/usr/bin/env python\nprint('ok')\n", - filepath.Join(src, "agents", "openai.yaml"): "interface: {}\n", - } { - mode := os.FileMode(0o644) - if strings.HasSuffix(path, ".py") { - mode = 0o755 - } - if err := os.WriteFile(path, []byte(content), mode); err != nil { - t.Fatalf("os.WriteFile(%q) error = %v", path, err) - } - } - - managerSkillSourceDirResolver = func() (string, error) { return src, nil } - defer func() { managerSkillSourceDirResolver = bundledManagerSkillSourceDir }() - - root, err := ensureAgentPicoClawConfig("manager", "u-manager", config.ServerConfig{ - ListenAddr: "0.0.0.0:18080", - AccessToken: "shared-token", - }, config.ModelConfig{ - ModelID: "gpt-5.4", - }) - if err != nil { - t.Fatalf("ensureAgentPicoClawConfig() error = %v", err) - } - - for _, rel := range []string{ - filepath.Join("workspace", "memory", "MEMORY.md"), - filepath.Join("workspace", "skills", "manager-worker-dispatch", "SKILL.md"), - filepath.Join("workspace", "skills", "manager-worker-dispatch", "scripts", "manager_worker_api.py"), - filepath.Join("workspace", "skills", "manager-worker-dispatch", "agents", "openai.yaml"), - } { - if _, err := os.Stat(filepath.Join(root, rel)); err != nil { - t.Fatalf("os.Stat(%q) error = %v", filepath.Join(root, rel), err) - } - } - data, err := os.ReadFile(filepath.Join(root, "workspace", "memory", "MEMORY.md")) - if err != nil { - t.Fatalf("os.ReadFile(MEMORY.md) error = %v", err) - } - if !strings.Contains(string(data), "python scripts/manager_worker_api.py list-workers") { - t.Fatalf("MEMORY.md = %q, want dispatch fast path", string(data)) - } -} - func TestIPv4FromAddr(t *testing.T) { tests := []struct { name string From 25ac9e748faa1db90a6e136ad22d50cc4553c820 Mon Sep 17 00:00:00 2001 From: Yun Long Date: Mon, 13 Apr 2026 16:28:42 +0800 Subject: [PATCH 5/5] Set default goal to build-all in Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a61f03d..1a0da97 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ IMAGE ?= ghcr.io/russellluo/picoclaw TAG ?= 2025.3.25 LOCAL_IMAGE ?= picoclaw:local -.DEFAULT_GOAL := build +.DEFAULT_GOAL := build-all .PHONY: help fmt test build build-csgclaw build-csgclaw-cli build-all run onboard clean package package-all release tag push publish boxlite-setup