Skip to content

Commit c88471d

Browse files
authored
feat: add profile-based LLM routing and improve skill (#2)
- Add profile-based LLM routing - improve manager-worker-dispatch skill to tighten the tracking
1 parent ded028c commit c88471d

37 files changed

Lines changed: 2887 additions & 283 deletions

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ IMAGE ?= ghcr.io/russellluo/picoclaw
2222
TAG ?= 2025.3.25
2323
LOCAL_IMAGE ?= picoclaw:local
2424

25-
.DEFAULT_GOAL := build
25+
.DEFAULT_GOAL := build-all
2626

2727
.PHONY: help fmt test build build-csgclaw build-csgclaw-cli build-all run onboard clean package package-all release tag push publish boxlite-setup
2828

README.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,66 @@ go build ./cmd/csgclaw
3434
## Quick Start
3535

3636
```bash
37-
csgclaw onboard --base-url <url> --api-key <key> --model-id <model>
37+
csgclaw onboard --base-url <url> --api-key <key> --models <model[,model...]> [--reasoning-effort <effort>]
3838
csgclaw serve
3939
```
4040

4141
Open the printed URL (e.g. `http://127.0.0.1:18080/`) in your browser to enter the IM workspace.
42+
For a fresh config, `onboard` creates a single `default` provider and sets `models.default` to `default.<first-model>`.
43+
44+
## Model Provider Examples
45+
46+
### Remote LLM API
47+
48+
```toml
49+
[server]
50+
listen_addr = "0.0.0.0:18080"
51+
advertise_base_url = "http://127.0.0.1:18080"
52+
access_token = "your_access_token"
53+
54+
[models]
55+
default = "remote.gpt-5.4"
56+
57+
[models.providers.remote]
58+
base_url = "https://api.openai.com/v1"
59+
api_key = "sk-your-api-key"
60+
models = ["gpt-5.4"]
61+
62+
[bootstrap]
63+
manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1"
64+
```
65+
66+
### Local Codex via CLIProxyAPI
67+
68+
```toml
69+
[server]
70+
listen_addr = "0.0.0.0:18080"
71+
advertise_base_url = "http://127.0.0.1:18080"
72+
access_token = "your_access_token"
73+
74+
[models]
75+
default = "codex.gpt-5.4"
76+
77+
[models.providers.codex]
78+
base_url = "http://127.0.0.1:8317/v1"
79+
api_key = "local"
80+
models = ["gpt-5.4"]
81+
82+
[bootstrap]
83+
manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1"
84+
```
85+
86+
### Worker Override Example
87+
88+
```json
89+
{
90+
"id": "u-reviewer",
91+
"name": "reviewer",
92+
"description": "code review worker",
93+
"profile": "codex.gpt-5.4",
94+
"role": "worker"
95+
}
96+
```
4297

4398
## Features
4499

README.zh.md

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,66 @@ go build ./cmd/csgclaw
3434
## 快速开始
3535

3636
```bash
37-
csgclaw onboard --base-url <url> --api-key <key> --model-id <model>
37+
csgclaw onboard --base-url <url> --api-key <key> --models <model[,model...]> [--reasoning-effort <effort>]
3838
csgclaw serve
3939
```
4040

4141
执行后 CLI 会打印访问地址(例如 `http://127.0.0.1:18080/`),在浏览器中打开即可进入 IM 工作区。
42+
对于全新配置,`onboard` 会创建一个名为 `default` 的 provider,并把 `models.default` 设为 `default.<第一个模型>`
43+
44+
## Model Provider 配置示例
45+
46+
### 远程 LLM API
47+
48+
```toml
49+
[server]
50+
listen_addr = "0.0.0.0:18080"
51+
advertise_base_url = "http://127.0.0.1:18080"
52+
access_token = "your_access_token"
53+
54+
[models]
55+
default = "remote.gpt-5.4"
56+
57+
[models.providers.remote]
58+
base_url = "https://api.openai.com/v1"
59+
api_key = "sk-your-api-key"
60+
models = ["gpt-5.4"]
61+
62+
[bootstrap]
63+
manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1"
64+
```
65+
66+
### 通过 CLIProxyAPI 接入本地 Codex
67+
68+
```toml
69+
[server]
70+
listen_addr = "0.0.0.0:18080"
71+
advertise_base_url = "http://127.0.0.1:18080"
72+
access_token = "your_access_token"
73+
74+
[models]
75+
default = "codex.gpt-5.4"
76+
77+
[models.providers.codex]
78+
base_url = "http://127.0.0.1:8317/v1"
79+
api_key = "local"
80+
models = ["gpt-5.4"]
81+
82+
[bootstrap]
83+
manager_image = "ghcr.io/russellluo/picoclaw:2026.4.8.1"
84+
```
85+
86+
### Worker 覆盖示例
87+
88+
```json
89+
{
90+
"id": "u-reviewer",
91+
"name": "reviewer",
92+
"description": "code review worker",
93+
"profile": "codex.gpt-5.4",
94+
"role": "worker"
95+
}
96+
```
4297

4398
## 功能特性
4499

cli/agent/agent.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string,
8989
id := fs.String("id", "", "agent id")
9090
name := fs.String("name", "", "agent name")
9191
description := fs.String("description", "", "agent description")
92-
modelID := fs.String("model-id", "", "agent model identifier")
92+
profile := fs.String("profile", "", "agent llm profile")
9393
if err := fs.Parse(args); err != nil {
9494
return err
9595
}
@@ -101,7 +101,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string,
101101
ID: *id,
102102
Name: *name,
103103
Description: *description,
104-
ModelID: *modelID,
104+
Profile: *profile,
105105
})
106106
if err != nil {
107107
return err

cli/app_test.go

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ func TestExecuteAgentListUsesHTTPClient(t *testing.T) {
5252
if req.URL.String() != "http://example.test/api/v1/agents" {
5353
t.Fatalf("url = %q, want %q", req.URL.String(), "http://example.test/api/v1/agents")
5454
}
55-
return jsonResponse(http.StatusOK, `[{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z"}]`), nil
55+
return jsonResponse(http.StatusOK, `[{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z","profile":"codex-main"}]`), nil
5656
}),
5757
}
5858

5959
if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "agent", "list"}); err != nil {
6060
t.Fatalf("Execute() error = %v", err)
6161
}
62-
assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running")
62+
assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running", "codex-main")
6363
}
6464

6565
func TestExecuteBotListUsesDefaultChannel(t *testing.T) {
@@ -253,8 +253,8 @@ func TestExecuteMessageSendsToFeishuChannel(t *testing.T) {
253253
func TestRenderAgentsTableAlignsLongColumns(t *testing.T) {
254254
var buf bytes.Buffer
255255
agents := []agent.Agent{
256-
{ID: "u-manager", Name: "manager", Role: "manager", Status: "running"},
257-
{ID: "u-dev", Name: "dev", Role: "worker", Status: "running"},
256+
{ID: "u-manager", Name: "manager", Role: "manager", Status: "running", Profile: "codex-main"},
257+
{ID: "u-dev", Name: "dev", Role: "worker", Status: "running", Profile: "claude-main"},
258258
{ID: "u-alex", Name: "alex", Role: "worker", Status: "running"},
259259
}
260260

@@ -267,7 +267,7 @@ func TestRenderAgentsTableAlignsLongColumns(t *testing.T) {
267267
t.Fatalf("line count = %d, want 4; output=%q", len(lines), buf.String())
268268
}
269269

270-
re := regexp.MustCompile(`^(\S+)(\s{2,})(\S+)(\s{2,})(\S+)(\s{2,})(\S+)$`)
270+
re := regexp.MustCompile(`^(\S+)(\s{2,})(\S+)(\s{2,})(\S+)(\s{2,})(\S+)(\s{2,})(\S+)$`)
271271
if re.FindStringSubmatchIndex(lines[0]) == nil {
272272
t.Fatalf("header not aligned: %q", lines[0])
273273
}
@@ -282,6 +282,16 @@ func TestRenderAgentsTableAlignsLongColumns(t *testing.T) {
282282
}
283283
}
284284

285+
func TestRenderAgentsTableUsesDashForMissingProfile(t *testing.T) {
286+
var buf bytes.Buffer
287+
288+
if err := renderAgentsTable(&buf, []agent.Agent{{ID: "u-alice", Name: "alice", Role: "worker", Status: "running"}}); err != nil {
289+
t.Fatalf("renderAgentsTable() error = %v", err)
290+
}
291+
292+
assertTableHasRow(t, buf.String(), "u-alice", "alice", "worker", "running", "-")
293+
}
294+
285295
func TestExecuteAgentCreateUsesHTTPClient(t *testing.T) {
286296
var stdout bytes.Buffer
287297
app := &App{
@@ -308,19 +318,19 @@ func TestExecuteAgentCreateUsesHTTPClient(t *testing.T) {
308318
if payload["description"] != "worker" {
309319
t.Fatalf("payload[description] = %#v, want %q", payload["description"], "worker")
310320
}
311-
if payload["model_id"] != "gpt-test" {
312-
t.Fatalf("payload[model_id] = %#v, want %q", payload["model_id"], "gpt-test")
321+
if payload["profile"] != "cliproxy-codex" {
322+
t.Fatalf("payload[profile] = %#v, want %q", payload["profile"], "cliproxy-codex")
313323
}
314324

315-
return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z"}`), nil
325+
return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z","profile":"codex-main"}`), nil
316326
}),
317327
}
318328

319-
err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--token", "secret-token", "agent", "create", "--name", "alice", "--description", "worker", "--model-id", "gpt-test"})
329+
err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--token", "secret-token", "agent", "create", "--name", "alice", "--description", "worker", "--profile", "cliproxy-codex"})
320330
if err != nil {
321331
t.Fatalf("Execute() error = %v", err)
322332
}
323-
assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running")
333+
assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running", "codex-main")
324334
}
325335

326336
func TestExecuteAgentDeleteUsesHTTPClient(t *testing.T) {
@@ -357,14 +367,14 @@ func TestExecuteAgentStatusByIDUsesHTTPClient(t *testing.T) {
357367
if req.URL.String() != "http://example.test/api/v1/agents/u-alice" {
358368
t.Fatalf("url = %q, want %q", req.URL.String(), "http://example.test/api/v1/agents/u-alice")
359369
}
360-
return jsonResponse(http.StatusOK, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z"}`), nil
370+
return jsonResponse(http.StatusOK, `{"id":"u-alice","name":"alice","role":"worker","status":"running","created_at":"2026-04-01T12:00:00Z","profile":"codex-main"}`), nil
361371
}),
362372
}
363373

364374
if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "agent", "status", "u-alice"}); err != nil {
365375
t.Fatalf("Execute() error = %v", err)
366376
}
367-
assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running")
377+
assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "running", "codex-main")
368378
}
369379

370380
func TestExecuteAgentLogsUsesHTTPClient(t *testing.T) {

cli/command/command.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,21 @@ func RenderMessages(output string, w io.Writer, messages []apitypes.Message) err
143143

144144
func RenderAgentsTable(w io.Writer, agents []agent.Agent) error {
145145
tw := NewTableWriter(w)
146-
fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS")
146+
fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS\tPROFILE")
147147
for _, a := range agents {
148-
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status)
148+
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status, displayAgentProfile(a.Profile))
149149
}
150150
return tw.Flush()
151151
}
152152

153+
func displayAgentProfile(profile string) string {
154+
profile = strings.TrimSpace(profile)
155+
if profile == "" {
156+
return "-"
157+
}
158+
return profile
159+
}
160+
153161
func RenderBotsTable(w io.Writer, bots []apitypes.Bot) error {
154162
tw := NewTableWriter(w)
155163
fmt.Fprintln(tw, "ID\tNAME\tROLE\tCHANNEL\tAGENT\tUSER")

cli/http_client.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"net/http"
88
"net/url"
9+
"strings"
910
"text/tabwriter"
1011

1112
"csgclaw/cli/command"
@@ -88,9 +89,9 @@ func writeJSON(w io.Writer, v any) error {
8889

8990
func renderAgentsTable(w io.Writer, agents []agent.Agent) error {
9091
tw := newTableWriter(w)
91-
fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS")
92+
fmt.Fprintln(tw, "ID\tNAME\tROLE\tSTATUS\tPROFILE")
9293
for _, a := range agents {
93-
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status)
94+
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", a.ID, a.Name, a.Role, a.Status, displayAgentProfile(a.Profile))
9495
}
9596
return tw.Flush()
9697
}
@@ -99,6 +100,14 @@ func renderBotsTable(w io.Writer, bots []apitypes.Bot) error {
99100
return command.RenderBotsTable(w, bots)
100101
}
101102

103+
func displayAgentProfile(profile string) string {
104+
profile = strings.TrimSpace(profile)
105+
if profile == "" {
106+
return "-"
107+
}
108+
return profile
109+
}
110+
102111
func renderRoomsTable(w io.Writer, rooms []apitypes.Room) error {
103112
return command.RenderRoomsTable(w, rooms)
104113
}

0 commit comments

Comments
 (0)