Files
magpie/internal/agent/devin.go
T
yetone 6d69272732 agents: a model picked for Claude Code (or any agent) shows on its row at once, in the window and the tray panel, and /api/set answers in ~0.1s instead of seconds (Discord: switching Claude Code's model took 5–10s to show, magpie 0.1.410+) — /api/set writes the config and answers with the whole state, and the frontend drew the pick only from that answer. Every state asked the devin CLI (devin models list, ~1.5–3s a run) again several times whenever Devin was installed: its model's family, its options, its effort's options and Drift's re-read, and a CLI that gave no list (signed out, offline) was asked anew on every state, since only a list was cached. With a 4s CLI one set took 28s (measured, before). Now DevinFamilies keeps a failure for a minute, serves a list past its 5 minutes while it is read again in the background, and only a caller with nothing known waits — never one marked DevinNoWait, which Devin's field reads and options are (its Set still waits, so what is written is unchanged). Goose's detection read a binary's whole build info on every state (~150ms); the answer is kept while the file's size and time are the same. In the page, commit() draws the picked model and its flash before /api/set is sent, then draws the answer (a later pick's answer winning over an earlier one's); a refused pick puts the old model back and gives the reason. What is written to the agent's config is as before. Measured in a sandbox HOME with magpie web: /api/state 2.8–3.1s and /api/set 2.9s before, 0.1–0.5s after (state() ~110ms steady in-process); a provider whose endpoint never answers made no difference either way. Tested with internal/gui/set_fast_unix_test.go (devin CLI that sleeps 4s and a provider that never answers: three sets and the states after them each under 1.5s, ~5ms, settings.json as before; fails before the fix at 28s a set) and internal/gui/tests/model-pick.test.cjs (a set held 2.5s: the pick shown within 600ms in the window scrolled and in the tray panel's opened row and line, the page not moved, the answer keeping it, a refusal restoring the old model, en and zh, Chromium and WebKit; fails on main). click-scroll, panel-fold, menu-scroll, agy-launch, cli-update and old-webkit pass. go build -tags nogui (and GOOS=linux, GOOS=windows), go vet and go test -tags nogui ./... pass (TestClaudeAccountNamedAsItsLogin failed under load and passes alone). Not tried in the real Wails window or tray, nor with a real signed-in Devin CLI.
2026-09-30 03:21:24 +08:00

156 lines
4.9 KiB
Go

package agent
// Devin keeps its settings in ~/.config/devin/config.json (%APPDATA%\devin on
// Windows), the model under agent.model. Devin has no endpoint magpie can
// stand in for — its models are its own — so the options are the families
// `devin models list --format json` reports, not the magpie catalog, offered
// as the provider offers them: each family one model (and its fast run one
// more, claude-opus-5-5-fast), with an effort beside it. Devin's config has
// no effort of its own — a variant id is the effort it runs at — so the pair
// is written as the one id: a family's id with no effort (it follows the
// family's newest), its variant at the effort picked (claude-opus-5-5-high,
// claude-opus-5-5-high-fast) otherwise, and read back the same way.
import (
"context"
"errors"
"os"
"path/filepath"
"runtime"
"github.com/yetone/magpie/internal/provider"
)
func devin(home, cfg string) *Agent {
dir := filepath.Join(cfg, "devin")
if runtime.GOOS == "windows" {
if app := os.Getenv("APPDATA"); app != "" {
dir = filepath.Join(app, "devin")
}
}
path := filepath.Join(dir, "config.json")
get, set := jsonGet(path, "agent.model"), jsonSet(path, "agent.model")
return &Agent{
ID: "devin", Name: "Devin", Icon: "devin",
Bin: "devin", Dir: dir, Path: path,
Notice: func() string {
if Running(`(^|/)devin( |$)`) {
return "Devin reads its model at start-up — restart open Devin sessions to use this."
}
return ""
},
Fields: []Field{{
Key: "model", Label: "model",
// the family the id Devin has is of, or the id itself when no
// family has it at an effort
Get: func() string {
m, _ := devinSplit(get())
return m
},
// a family picked keeps the effort the old id was at, as near
// as the family has it; with none, its own id; an id that is no
// family's (swe-2-max, typed in) goes as it is
Set: func(v string) error {
if v == "" {
return set("")
}
families, err := providerDevinFamilies(context.Background())
if err != nil {
return set(v)
}
_, effort := provider.DevinSplit(families, get())
return set(provider.DevinPick(families, v, effort))
},
Options: func(cur map[string]string) []Option {
return devinOptions(cur["model"])
},
}, {
// not a key of Devin's: the effort the model's id is at, and
// picking one writes the model's variant at it
Key: "effort", Label: "effort",
Get: func() string {
_, e := devinSplit(get())
return e
},
Set: func(v string) error {
cur := get()
if cur == "" {
return errors.New("pick Devin's model first: its effort is part of the model's id")
}
families, err := providerDevinFamilies(context.Background())
if err != nil {
return err
}
model, _ := provider.DevinSplit(families, cur)
return set(provider.DevinPick(families, model, v))
},
Options: func(cur map[string]string) []Option {
return static(devinEfforts(cur["model"])...)
},
}},
}
}
// providerDevinFamilies lists Devin's models; a var so tests can fake it.
var providerDevinFamilies = func(ctx context.Context) ([]provider.DevinFamily, error) {
return provider.DevinFamilies(ctx)
}
// devinSplit is the id Devin's config holds as the picker shows it: the
// model and the effort it is at (provider.DevinSplit), or the id at none
// while Devin's list can't be read.
func devinSplit(id string) (model, effort string) {
if id == "" {
return "", ""
}
families, err := providerDevinFamilies(provider.DevinNoWait(context.Background()))
if err != nil {
return id, ""
}
return provider.DevinSplit(families, id)
}
// devinEfforts are the levels the model the picker shows has, none for one
// at no effort (glm-5-2-1m) or one Devin's list doesn't have.
func devinEfforts(model string) []string {
families, err := providerDevinFamilies(provider.DevinNoWait(context.Background()))
if err != nil {
return nil
}
for _, m := range provider.DevinOffered(families) {
if m.ID == model {
return m.Efforts
}
}
return nil
}
// devinOptions offers the families as the provider lists them — each one
// model, its fast run another, the effort picked beside it — and the
// variants at no effort (glm-5-2-1m). They are Devin's own models, not
// borrowed ones, so like every agent's own list they sit flat — a group
// each would only crowd the picker's rail with one icon per family.
func devinOptions(cur string) []Option {
families, err := providerDevinFamilies(provider.DevinNoWait(context.Background()))
if err != nil {
if cur == "" {
return nil
}
return []Option{{Value: cur}}
}
ms := provider.DevinOffered(families)
out := make([]Option, 0, len(ms))
for _, m := range ms {
out = append(out, Option{Value: m.ID, Label: m.Name, Icon: "devin", Context: m.Context})
}
if cur != "" {
for _, o := range out {
if o.Value == cur {
return out
}
}
out = append([]Option{{Value: cur, Note: "current"}}, out...)
}
return out
}