From 0de3094d67f901656db49b47979e67ef47f8d6b0 Mon Sep 17 00:00:00 2001 From: yetone Date: Mon, 28 Sep 2026 19:24:39 +0800 Subject: [PATCH] =?UTF-8?q?agent:=20WorkBuddy=20takes=20magpie's=20models?= =?UTF-8?q?=20(=E8=83=BD=E5=90=A6=E4=BD=9C=E4=B8=BAAgent=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81WorkBuddy)=20=E2=80=94=20a=20WorkBuddy=20agent=20whose?= =?UTF-8?q?=20provider=20field=20puts=20every=20magpie=20model=20into=20Wo?= =?UTF-8?q?rkBuddy's=20picker:=20entries=20in=20~/.workbuddy/models.json?= =?UTF-8?q?=20($WORKBUDDY=5FCONFIG=5FDIR),=20the=20custom-model=20list=20W?= =?UTF-8?q?orkBuddy=20reads=20and=20watches=20(no=20restart),=20as=20OpenA?= =?UTF-8?q?I=20chat-completions=20models=20at=20the=20gateway's=20/v1/chat?= =?UTF-8?q?/completions=20under=20magpie-workbuddy,=20with=20each=20model'?= =?UTF-8?q?s=20context,=20output,=20images=20and=20reasoning=20levels=20(r?= =?UTF-8?q?easoning.supportedEfforts,=20canDisableThinking=20where=20it=20?= =?UTF-8?q?can=20think=20off).=20WorkBuddy's=20own=20entries=20(its=20loca?= =?UTF-8?q?l=20models)=20and=20the=20CLI's=20{models,=20availableModels}?= =?UTF-8?q?=20form=20are=20kept;=20a=20magpie=20model=20turned=20off=20in?= =?UTF-8?q?=20WorkBuddy=20stays=20off;=20off=20takes=20only=20magpie's=20o?= =?UTF-8?q?ut.=20Its=20requests=20say=20CLI/=20WorkBuddy/,=20so?= =?UTF-8?q?=20they're=20known=20by=20the=20key.=20Tested=20with=20WorkBudd?= =?UTF-8?q?y=205.5.6's=20own=20CLI=20against=20a=20sandbox=20gateway:=20de?= =?UTF-8?q?epseek/deepseek-v4-pro=20answered,=20reasoning=5Feffort=20sent,?= =?UTF-8?q?=20usage=20under=20WorkBuddy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + internal/agent/agents.go | 1 + internal/agent/workbuddy.go | 215 +++++++++++++++++++++++++++++++ internal/agent/workbuddy_test.go | 124 ++++++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 internal/agent/workbuddy.go create mode 100644 internal/agent/workbuddy_test.go diff --git a/README.md b/README.md index 767dea62..efa9791b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ and there is a terminal version (`magpie tui`) and a plain CLI. | Qoder CN (CLI) | `~/.qoder-cn/settings.json` (`$QODERCN_CONFIG_DIR`) | model, effort (as Qoder; its own accounts, a Qoder CN plan with BYOK) | | Grok Build | `~/.grok/config.toml` (`$GROK_HOME`) | model, effort | | ZCode | `~/.zcode/v2/config.json` | provider (magpie's models in ZCode's picker) | +| WorkBuddy | `~/.workbuddy/models.json` (`$WORKBUDDY_CONFIG_DIR`) | provider (magpie's models in WorkBuddy's picker) | | OpenHanako | `~/.hanako/provider-catalog.json` + `agents//config.yaml` (`$HANA_HOME`; its local API while it runs) | model (the primary agent's; magpie's models as a provider) | | Alma | Alma's local API (`localhost:23001`, while Alma runs) | model (Alma's default; magpie's models as a provider) | diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 9eec1715..96827d82 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -77,6 +77,7 @@ func All() []*Agent { qoderCN(home), grok(home), zcode(home), + workbuddy(home), hanako(home), alma(), cindy(), diff --git a/internal/agent/workbuddy.go b/internal/agent/workbuddy.go new file mode 100644 index 00000000..abbdc8e7 --- /dev/null +++ b/internal/agent/workbuddy.go @@ -0,0 +1,215 @@ +package agent + +// WorkBuddy (Tencent's CodeBuddy desktop app) takes models of one's own +// from ~/.workbuddy/models.json ($WORKBUDDY_CONFIG_DIR if set): a list of +// OpenAI chat-completions models, or {"models":[…],"availableModels":[…]} +// as CodeBuddy Code's CLI writes it. Its own "local models" are entries of +// the same list (local: true). +// +// [{"id":"deepseek/pro","name":…,"vendor":"magpie","apiKey":"magpie-workbuddy", +// "url":"http://127.0.0.1:3425/v1/chat/completions", +// "maxInputTokens":…,"maxOutputTokens":…, +// "supportsToolCall":true,"supportsImages":…,"supportsReasoning":true, +// "reasoning":{"supportedEfforts":[…],"canDisableThinking":…,"defaultEffort":…}}] +// +// WorkBuddy shows them in its picker as "custom-local:", and asks url +// for model , with reasoning_effort, under Bearer apiKey. It watches the +// file and picks a change up without a restart. The model is picked per +// task in WorkBuddy's window, not in a file, so what magpie sets is whether +// its models are in that picker. Its requests say "CLI/ WorkBuddy/", +// so they are told apart by their key. + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/gateway" +) + +func workbuddy(home string) *Agent { + dir := os.Getenv("WORKBUDDY_CONFIG_DIR") + if dir == "" { + dir = filepath.Join(home, ".workbuddy") + } + path := filepath.Join(dir, "models.json") + return &Agent{ + ID: "workbuddy", Name: "WorkBuddy", Icon: "workbuddy-color", Aliases: []string{"work-buddy"}, + UA: []string{"workbuddy"}, + Dir: dir, Path: path, + Sync: func() error { + if !workbuddyWired(path) { + return nil + } + return workbuddyWrite(path, true) + }, + Fields: []Field{{ + Key: "provider", Label: "provider", + Get: func() string { + if workbuddyWired(path) { + return magpieID + } + return "" + }, + Set: func(v string) error { return workbuddyWrite(path, v != "") }, + Options: func(map[string]string) []Option { + return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie", Note: "every magpie model in WorkBuddy's picker"}} + }, + }}, + } +} + +// workbuddyDoc is models.json as read: its entries, and the rest of the +// object when it is one (nil for a bare list). +type workbuddyDoc struct { + models []json.RawMessage + rest map[string]json.RawMessage +} + +func workbuddyRead(path string) (workbuddyDoc, error) { + var d workbuddyDoc + b, err := edit.Read(path) + if err != nil || len(bytes.TrimSpace(b)) == 0 { + return d, err + } + if bytes.TrimSpace(b)[0] == '[' { + return d, json.Unmarshal(b, &d.models) + } + if err := json.Unmarshal(b, &d.rest); err != nil { + return d, err + } + if raw, ok := d.rest["models"]; ok { + if err := json.Unmarshal(raw, &d.models); err != nil { + return d, err + } + } + return d, nil +} + +// workbuddyEntry is what magpie reads of an entry. +type workbuddyEntry struct { + ID string `json:"id"` + Vendor string `json:"vendor"` + Disabled *bool `json:"disabled"` +} + +func workbuddyMine(raw json.RawMessage) (workbuddyEntry, bool) { + var e workbuddyEntry + return e, json.Unmarshal(raw, &e) == nil && e.Vendor == magpieID +} + +// workbuddyWired reports whether models.json has magpie's models. +func workbuddyWired(path string) bool { + d, _ := workbuddyRead(path) + return slices.ContainsFunc(d.models, func(raw json.RawMessage) bool { _, ok := workbuddyMine(raw); return ok }) +} + +// workbuddyWrite puts magpie's models into models.json (on) or takes them +// out, leaving every other entry and key as WorkBuddy wrote it. A model +// turned off in WorkBuddy stays off. +func workbuddyWrite(path string, on bool) error { + d, err := workbuddyRead(path) + if err != nil { + return err + } + var kept []json.RawMessage + var ours []string + off := map[string]bool{} + at := -1 // where magpie's were, which they keep + for _, raw := range d.models { + if e, ok := workbuddyMine(raw); ok { + if at < 0 { + at = len(kept) + } + ours = append(ours, e.ID) + if e.Disabled != nil && *e.Disabled { + off[e.ID] = true + } + continue + } + kept = append(kept, raw) + } + if !on && len(ours) == 0 { + return nil + } + var add []json.RawMessage + var ids []string + if on { + for _, m := range magpieModels("workbuddy") { + e := workbuddyModel(m.ID, m.Name, m.Context, m.Output, m.Images, m.Efforts) + if off[m.ID] { + e["disabled"] = true + } + b, err := json.Marshal(e) + if err != nil { + return err + } + add = append(add, b) + ids = append(ids, m.ID) + } + } + if at < 0 { + at = len(kept) + } + models := slices.Concat(kept[:at], add, kept[at:]) + if models == nil { + models = []json.RawMessage{} + } + + var out any = models + if d.rest != nil { + // a list of the models to show keeps showing magpie's + if raw, ok := d.rest["availableModels"]; ok { + var avail []string + if json.Unmarshal(raw, &avail) == nil && len(avail) > 0 { + avail = slices.DeleteFunc(avail, func(id string) bool { return slices.Contains(ours, id) }) + avail = append(avail, ids...) + b, _ := json.Marshal(avail) + d.rest["availableModels"] = b + } + } + b, err := json.Marshal(models) + if err != nil { + return err + } + d.rest["models"] = b + out = d.rest + } + b, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return edit.WriteAtomic(path, append(b, '\n')) +} + +// workbuddyModel is one of magpie's models as a models.json entry. +func workbuddyModel(id, name string, context, output int, images bool, efforts []string) map[string]any { + if context == 0 { + context = 200000 + } + e := map[string]any{ + "id": id, "name": name, "vendor": magpieID, + "apiKey": gateway.TokenFor("workbuddy"), "url": gatewayV1() + "/chat/completions", + "maxInputTokens": context, + "supportsToolCall": true, "supportsImages": images, "supportsReasoning": false, + } + // without it WorkBuddy caps every reply at its own default + if out := zcodeOutput(output); out > 0 { + e["maxOutputTokens"] = out + } + var levels []string + for _, l := range efforts { + if l != "none" && !slices.Contains(levels, l) { + levels = append(levels, l) + } + } + if len(levels) > 0 { + e["supportsReasoning"] = true + e["reasoning"] = map[string]any{"supportedEfforts": levels, + "canDisableThinking": slices.Contains(efforts, "none"), "defaultEffort": zcodeDefaultLevel(levels)} + } + return e +} diff --git a/internal/agent/workbuddy_test.go b/internal/agent/workbuddy_test.go new file mode 100644 index 00000000..1021420f --- /dev/null +++ b/internal/agent/workbuddy_test.go @@ -0,0 +1,124 @@ +package agent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +func TestWorkBuddy(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("WORKBUDDY_CONFIG_DIR", "") + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro"}}); err != nil { + t.Fatal(err) + } + path := filepath.Join(home, ".workbuddy", "models.json") + os.MkdirAll(filepath.Dir(path), 0o755) + // WorkBuddy's own local model, which stays + os.WriteFile(path, []byte(`[{"id":"qwen-local","local":true,"url":"http://127.0.0.1:8080/v1/chat/completions"}]`), 0o600) + read := func() []map[string]any { + var ms []map[string]any + b, _ := os.ReadFile(path) + if err := json.Unmarshal(b, &ms); err != nil { + t.Fatalf("%v\n%s", err, b) + } + return ms + } + + a := workbuddy(home) + if !a.Detected() { + t.Fatal("not detected") + } + f := a.Field("provider") + if f.Get() != "" { + t.Fatalf("get: %q", f.Get()) + } + if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + ms := read() + if len(ms) != 2 || ms[0]["id"] != "qwen-local" || ms[0]["local"] != true { + t.Fatalf("models: %v", ms) + } + pro := ms[1] + if pro["id"] != "deepseek/pro" || pro["vendor"] != "magpie" || pro["apiKey"] != "magpie-workbuddy" || + pro["url"] != gatewayV1()+"/chat/completions" || pro["supportsToolCall"] != true || pro["maxInputTokens"] == nil { + t.Fatalf("magpie model: %v", pro) + } + if f.Get() != "magpie" { + t.Fatalf("get: %q", f.Get()) + } + + // turned off in WorkBuddy, it stays off through a sync + ms[1]["disabled"] = true + b, _ := json.Marshal(ms) + os.WriteFile(path, b, 0o600) + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if ms = read(); len(ms) != 2 || ms[1]["disabled"] != true { + t.Fatalf("after sync: %v", ms) + } + + if err := f.Set(""); err != nil { + t.Fatal(err) + } + if ms = read(); len(ms) != 1 || ms[0]["id"] != "qwen-local" { + t.Fatalf("after off: %v", ms) + } + if f.Get() != "" { + t.Fatalf("get: %q", f.Get()) + } + // off, a sync leaves it off + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if len(read()) != 1 { + t.Fatal("sync put magpie back") + } + + // the CLI's object form, with a list of the models to show + os.WriteFile(path, []byte(`{"models":[{"id":"mine","url":"https://x/v1/chat/completions"}],"availableModels":["mine"],"other":1}`), 0o600) + if err := f.Set("magpie"); err != nil { + t.Fatal(err) + } + var doc struct { + Models []map[string]any `json:"models"` + Available []string `json:"availableModels"` + Other int `json:"other"` + } + b, _ = os.ReadFile(path) + if err := json.Unmarshal(b, &doc); err != nil { + t.Fatal(err) + } + if len(doc.Models) != 2 || doc.Other != 1 || len(doc.Available) != 2 || doc.Available[1] != "deepseek/pro" { + t.Fatalf("object form: %s", b) + } + if err := f.Set(""); err != nil { + t.Fatal(err) + } + b, _ = os.ReadFile(path) + doc.Available = nil + json.Unmarshal(b, &doc) + if len(doc.Models) != 1 || len(doc.Available) != 1 { + t.Fatalf("object form off: %s", b) + } +} + +func TestWorkBuddyEfforts(t *testing.T) { + e := workbuddyModel("x/y", "Y", 0, 500000, true, []string{"none", "low", "high"}) + r, _ := e["reasoning"].(map[string]any) + if e["maxInputTokens"] != 200000 || e["maxOutputTokens"] != zcodeMaxOutput || e["supportsReasoning"] != true || + r["canDisableThinking"] != true || r["defaultEffort"] != "high" || len(r["supportedEfforts"].([]string)) != 2 { + t.Fatalf("%v", e) + } + if e := workbuddyModel("x/z", "Z", 1000, 0, false, nil); e["reasoning"] != nil || e["supportsReasoning"] != false || e["maxOutputTokens"] != nil { + t.Fatalf("%v", e) + } +}