diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 610e7011..44a4f6a8 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -419,6 +419,20 @@ func goose(home, cfg string) *Agent { Options: func(cur map[string]string) []Option { return ownOptions("", cur["model"], "anthropic", "openai", "google", "openrouter") }, + }, { + // GOOSE_THINKING_EFFORT, the effort goose asks of a model that + // thinks, for every provider + Key: "effort", Label: "effort", + Get: func() string { v, _ := get("GOOSE_THINKING_EFFORT"); return v }, + Set: func(v string) error { + if v == "" { + return edit.DelYAMLTop(path, "GOOSE_THINKING_EFFORT") + } + return set(edit.KV{Path: "GOOSE_THINKING_EFFORT", Value: v}) + }, + Options: func(map[string]string) []Option { + return static("off", "low", "medium", "high", "max") + }, }}, } } @@ -470,6 +484,21 @@ func copilot(home string) *Agent { } return out }, + }, { + // effortLevel, which Copilot saves beside the model and clears + // when its own /model changes the model; the levels are the + // model's, low to xhigh when Copilot's list does not say + Key: "effort", Label: "effort", + Get: jsonGet(path, "effortLevel"), + Set: jsonSet(path, "effortLevel"), + Options: func(cur map[string]string) []Option { + if live, _, ok := catalog.Live("copilot"); ok { + if e := catalog.Efforts(live, cur["model"]); len(e) > 0 { + return static(e...) + } + } + return static("low", "medium", "high", "xhigh") + }, }}, } } @@ -541,6 +570,22 @@ func crush(home, cfg string) *Agent { Fields: []Field{ {Key: "model", Label: "large", Get: pairGet(get, "models.large.provider", "models.large.model"), Set: setter("models.large.provider", "models.large.model"), Options: opts("model")}, {Key: "small", Label: "small", Get: pairGet(get, "models.small.provider", "models.small.model"), Set: setter("models.small.provider", "models.small.model"), Options: opts("small")}, + { + // the large model's reasoning_effort, which Crush's schema + // takes as low, medium or high (for OpenAI-style models) + Key: "effort", Label: "effort", + Get: func() string { v, _ := get("models.large.reasoning_effort"); return v }, + Set: func(v string) error { + if v == "" { + return edit.DelJSON(path, "models.large.reasoning_effort") + } + if m, _ := get("models.large.model"); m == "" { + return fmt.Errorf("pick Crush's large model first; the effort is kept with it") + } + return set(edit.KV{Path: "models.large.reasoning_effort", Value: v}) + }, + Options: func(map[string]string) []Option { return static("low", "medium", "high") }, + }, }, } } diff --git a/internal/agent/claude.go b/internal/agent/claude.go index 52609eb4..8bdd4677 100644 --- a/internal/agent/claude.go +++ b/internal/agent/claude.go @@ -30,6 +30,9 @@ var claudeEnv = []string{ // A tier that has none follows the main model. var claudeTiers = []string{"opus", "sonnet", "haiku", "fable"} +// claudeEfforts are the levels settings.json's effortLevel takes. +var claudeEfforts = []string{"low", "medium", "high", "xhigh"} + func tierEnv(tier string) string { return "ANTHROPIC_DEFAULT_" + strings.ToUpper(tier) + "_MODEL" } func claude(home string) *Agent { @@ -148,6 +151,20 @@ func claude(home string) *Agent { // go stale. return append(group(name, own), claudeViaMagpie()...) }, + }, { + // the effort Claude Code starts with, as its /effort saves it; + // settings.json keeps low to xhigh (max lasts a session only) + Key: "effort", Label: "effort", + Get: jsonGet(path, "effortLevel"), + Set: func(v string) error { + if v != "" && !contains(claudeEfforts, v) { + return fmt.Errorf("Claude Code keeps an effort of %s, not %q", strings.Join(claudeEfforts, ", "), v) + } + return jsonSet(path, "effortLevel")(v) + }, + Options: func(map[string]string) []Option { + return static(claudeEfforts...) + }, }} for _, tier := range claudeTiers { fields = append(fields, Field{ diff --git a/internal/agent/commandcode.go b/internal/agent/commandcode.go index 37ba071d..75b221c4 100644 --- a/internal/agent/commandcode.go +++ b/internal/agent/commandcode.go @@ -8,6 +8,8 @@ package agent // at the first "/". Command Code still wants its own sign-in for these. import ( + "encoding/json" + "fmt" "os" "path/filepath" "slices" @@ -91,6 +93,47 @@ func commandCode(home string) *Agent { } return append(out, viaMagpie("commandcode", magpieID+"/")...) }, + }, { + // settings.json's reasoningEffort keeps an effort for each model + // (Command Code's /effort saves it); this is the current model's + Key: "effort", Label: "effort", + Get: func() string { return ccEffortMap(path)[get("model")] }, + Set: func(v string) error { + model := get("model") + if model == "" { + return fmt.Errorf("pick Command Code's model first; it keeps an effort for each model") + } + efforts := ccEffortMap(path) + if v == "" { + delete(efforts, model) + } else { + efforts[model] = v + } + if len(efforts) == 0 { + return edit.DelJSON(path, "reasoningEffort") + } + // written whole, as model ids hold dots + return edit.SetJSON(path, edit.KV{Path: "reasoningEffort", Value: efforts}) + }, + Options: func(cur map[string]string) []Option { + if ref, ok := strings.CutPrefix(cur["model"], magpieID+"/"); ok { + for _, m := range magpieModels("commandcode") { + if m.ID != ref { + continue + } + var efforts []string + for _, x := range ccEfforts { + if slices.Contains(m.Efforts, x) { + efforts = append(efforts, x) + } + } + if len(efforts) > 0 { + return static(efforts...) + } + } + } + return static(ccEfforts...) + }, }}, } } @@ -115,3 +158,24 @@ func ccProviderJSON() any { } return map[string]any{"name": "magpie", "api": "openai-completions", "baseURL": gatewayV1(), "apiKey": false, "models": ms} } + +// ccEffortMap is settings.json's reasoningEffort, model id to effort. +func ccEffortMap(path string) map[string]string { + out := map[string]string{} + b, err := os.ReadFile(path) + if err != nil { + return out + } + var c struct { + Efforts map[string]any `json:"reasoningEffort"` + } + if json.Unmarshal(b, &c) != nil { + return out + } + for k, v := range c.Efforts { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} diff --git a/internal/agent/dsh.go b/internal/agent/dsh.go index 791cba6c..6e97c1c4 100644 --- a/internal/agent/dsh.go +++ b/internal/agent/dsh.go @@ -74,6 +74,13 @@ func dsh(home string) *Agent { Options: func(map[string]string) []Option { return append(append([]Option{}, dshModels...), viaMagpie("dsh", magpieID+"/")...) }, + }, { + // llm-deepseek's reasoningEffort, the thinking effort sessions + // start with (off, high or max; dsh's own default is high) + Key: "effort", Label: "thinking", + Get: func() string { return dshGetEffort(dir) }, + Set: func(v string) error { return dshSetEffort(dir, v) }, + Options: func(map[string]string) []Option { return static(dshEfforts...) }, }}, } } @@ -320,13 +327,13 @@ func dshSetFile(path, v string, modern bool) error { drop("agent-loop") drop("llm-deepseek") case modern && viaGateway: - put("llm-deepseek", dshProviderLines(true)) + put("llm-deepseek", dshProviderLines(true, dshEffortIn(items))) put("agent-default-model", dshDefaultLines(ref)) case modern: drop("llm-deepseek") put("agent-default-model", dshDefaultLines(v)) case viaGateway: - put("llm-deepseek", dshProviderLines(false)) + put("llm-deepseek", dshProviderLines(false, dshEffortIn(items))) put("agent-loop", dshLoopLines(ref)) put("api-gateway", dshRouteLines(ref)) default: @@ -356,19 +363,23 @@ func yamlQuote(s string) string { // dshProviderLines is the llm-deepseek entry pointing dsh at the gateway, // with the catalog as the models its /model offers. Since 0.1.5 the key is a -// credential the entry names rather than holds. -func dshProviderLines(modern bool) []string { +// credential the entry names rather than holds. effort is the thinking +// effort sessions start with, high (dsh's own default) when "". +func dshProviderLines(modern bool, effort string) []string { key := " apiKey: " + yamlQuote(gateway.Token) if modern { key = " apiKeyEnv: " + dshKeyRef } + if effort == "" { + effort = "high" + } lines := []string{ "- id: llm-deepseek " + dshMark, " config:", key, " baseURL: " + yamlQuote(gatewayV1()), " thinking: enabled", - " reasoningEffort: high", + " reasoningEffort: " + effort, " models:", } ms := magpieModels("dsh") @@ -447,7 +458,7 @@ func dshSync(dir string) error { for _, l := range items[i].lines { modern = modern || strings.HasPrefix(strings.TrimSpace(l), "apiKeyEnv:") } - lines := dshProviderLines(modern) + lines := dshProviderLines(modern, dshEffortIn(items)) if strings.Join(lines, "\n") == strings.Join(items[i].lines, "\n") { continue } @@ -463,6 +474,79 @@ func dshSync(dir string) error { return nil } +// dshEfforts are the thinking efforts llm-deepseek takes. +var dshEfforts = []string{"off", "high", "max"} + +// dshEffortIn is the reasoningEffort of the llm-deepseek entry magpie +// wrote, "" when there is none. +func dshEffortIn(items []dshItem) string { + i := dshFind(items, "llm-deepseek") + if i < 0 || !items[i].magpie { + return "" + } + for _, l := range items[i].lines { + if k, v, ok := strings.Cut(strings.TrimSpace(l), ":"); ok && k == "reasoningEffort" { + return yamlScalar(strings.TrimSpace(v)) + } + } + return "" +} + +// dshFiles are the patch lists magpie writes: every profile's, else +// config.yaml. +func dshFiles(dir string) []string { + if files := dshProfiles(dir); len(files) > 0 { + return files + } + return []string{filepath.Join(dir, "config.yaml")} +} + +// dshGetEffort reads the effort of magpie's llm-deepseek entry. +func dshGetEffort(dir string) string { + _, items, err := dshRead(dshFiles(dir)[0]) + if err != nil { + return "" + } + return dshEffortIn(items) +} + +// dshSetEffort writes v into each llm-deepseek entry magpie wrote. dsh's +// own row is used whole when there is none, so the effort goes with a +// model through magpie. +func dshSetEffort(dir, v string) error { + if v != "" && !contains(dshEfforts, v) { + return fmt.Errorf("DeepSeek Harness takes an effort of %s, not %q", strings.Join(dshEfforts, ", "), v) + } + done := false + for _, f := range dshFiles(dir) { + head, items, err := dshRead(f) + if err != nil { + continue + } + i := dshFind(items, "llm-deepseek") + if i < 0 || !items[i].magpie { + continue + } + modern := false + for _, l := range items[i].lines { + modern = modern || strings.HasPrefix(strings.TrimSpace(l), "apiKeyEnv:") + } + items[i].lines = dshProviderLines(modern, v) + out := append([]string{}, head...) + for _, it := range items { + out = append(out, it.lines...) + } + if err := edit.WriteAtomic(f, []byte(strings.Join(out, "\n")+"\n")); err != nil { + return err + } + done = true + } + if !done && v != "" { + return fmt.Errorf("pick a model through magpie for DeepSeek Harness first; the effort is kept with magpie's DeepSeek entry") + } + return nil +} + // dshSettingsEndpoint reports whether dsh's own settings carry an // llm-deepseek section with an endpoint or key, which dsh puts over the // patch list. diff --git a/internal/agent/dsh_test.go b/internal/agent/dsh_test.go index b771c0e6..a35665ec 100644 --- a/internal/agent/dsh_test.go +++ b/internal/agent/dsh_test.go @@ -185,7 +185,7 @@ func TestDshModelLimits(t *testing.T) { }); err != nil { t.Fatal(err) } - s := strings.Join(dshProviderLines(true), "\n") + s := strings.Join(dshProviderLines(true, ""), "\n") want := ` - id: "v/see" name: "see ยท V" contextWindow: 400000 diff --git a/internal/agent/effort_test.go b/internal/agent/effort_test.go new file mode 100644 index 00000000..8e3b6718 --- /dev/null +++ b/internal/agent/effort_test.go @@ -0,0 +1,187 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/edit" + "github.com/yetone/magpie/internal/provider" +) + +// effortHome is a sandbox HOME with a provider for magpie to serve. +func effortHome(t *testing.T) string { + t.Helper() + 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("HERMES_HOME", "") + t.Setenv("DSH_HOME", "") + if err := provider.Save(provider.Provider{ID: "deepseek", Name: "DeepSeek", Chat: "https://api.deepseek.com/v1", Key: "k", Models: []string{"pro", "flash"}}); err != nil { + t.Fatal(err) + } + return home +} + +// setEffort sets, reads back and resets an agent's effort, checking what +// else is in its file stays. +func setEffort(t *testing.T, a *Agent, v, keep string) { + t.Helper() + f := a.Field("effort") + if f == nil { + t.Fatalf("%s has no effort field", a.ID) + } + if err := f.Set(v); err != nil { + t.Fatalf("%s: %v", a.ID, err) + } + if got := f.Get(); got != v { + t.Fatalf("%s: effort %q, want %q\n%s", a.ID, got, v, readFile(a.Path)) + } + found := false + for _, o := range f.Options(map[string]string{}) { + found = found || o.Value == v + } + if !found { + t.Fatalf("%s: %q not among the options", a.ID, v) + } + if !strings.Contains(readFile(a.Path), keep) { + t.Fatalf("%s lost %q:\n%s", a.ID, keep, readFile(a.Path)) + } + if err := f.Set(""); err != nil { + t.Fatal(err) + } + if got := f.Get(); got != "" { + t.Fatalf("%s: reset left %q\n%s", a.ID, got, readFile(a.Path)) + } + if !strings.Contains(readFile(a.Path), keep) { + t.Fatalf("%s lost %q on reset:\n%s", a.ID, keep, readFile(a.Path)) + } +} + +func TestEffortFields(t *testing.T) { + home := effortHome(t) + cfg := filepath.Join(home, ".config") + + writeFile(t, filepath.Join(home, ".claude", "settings.json"), `{"theme":"dark","model":"opus"}`) + c := claude(home) + setEffort(t, c, "xhigh", `"theme"`) + // max lasts a session in Claude Code; settings.json drops it + if err := c.Field("effort").Set("max"); err == nil { + t.Fatal("claude took max") + } + + writeFile(t, filepath.Join(home, ".hermes", "config.yaml"), "model:\n default: x\nagent:\n max_turns: 9\n") + h := hermes(home) + setEffort(t, h, "none", "max_turns: 9") + if v, _ := edit.GetYAML(h.Path, "model.default"); v != "x" { + t.Fatalf("hermes model: %q", v) + } + + writeFile(t, filepath.Join(home, ".omp", "agent", "config.yml"), "modelRoles:\n default: a/b\n") + setEffort(t, omp(home), "xhigh", "default: a/b") + + writeFile(t, filepath.Join(cfg, "goose", "config.yaml"), "GOOSE_MODEL: m\nGOOSE_PROVIDER: p\n") + g := goose(home, cfg) + setEffort(t, g, "max", "GOOSE_MODEL: m") + if v, _ := edit.GetYAMLTop(g.Path, "GOOSE_PROVIDER"); v != "p" { + t.Fatalf("goose provider: %q", v) + } + + writeFile(t, filepath.Join(home, ".copilot", "settings.json"), `{"model":"gpt-5.5","contextTier":"long"}`) + setEffort(t, copilot(home), "xhigh", `"contextTier"`) + + cr := crush(home, cfg) + os.MkdirAll(filepath.Dir(cr.Path), 0o755) + os.WriteFile(cr.Path, []byte(`{"options":{"debug":true}}`), 0o644) + // Crush keeps the effort with the large model + if err := cr.Field("effort").Set("high"); err == nil { + t.Fatal("crush took an effort without a large model") + } + if err := cr.Field("model").Set("magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + setEffort(t, cr, "medium", `"debug"`) + if cr.Field("model").Get() != "magpie/deepseek/pro" { + t.Fatalf("crush model: %s", readFile(cr.Path)) + } +} + +func TestCommandCodeEffort(t *testing.T) { + home := effortHome(t) + path := filepath.Join(home, ".commandcode", "settings.json") + writeFile(t, path, `{"theme":"dark","reasoningEffort":{"gpt-5.5":"low"}}`) + a := commandCode(home) + f := a.Field("effort") + if err := f.Set("high"); err == nil { + t.Fatal("took an effort without a model") + } + if err := a.Field("model").Set("magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + setEffort(t, a, "max", `"theme"`) + // each model keeps its own; the other one's stays + if m := ccEffortMap(path); m["gpt-5.5"] != "low" || len(m) != 1 { + t.Fatalf("efforts: %v\n%s", m, readFile(path)) + } + // a model id with dots is one key, not a path + if err := a.Field("model").Set("anthropic/claude-4.5"); err != nil { + t.Fatal(err) + } + if err := f.Set("xhigh"); err != nil { + t.Fatal(err) + } + if m := ccEffortMap(path); m["anthropic/claude-4.5"] != "xhigh" || m["gpt-5.5"] != "low" { + t.Fatalf("efforts: %v\n%s", m, readFile(path)) + } + // the last one gone, the map goes too + os.WriteFile(path, []byte(`{"model":"x","reasoningEffort":{"x":"low"}}`), 0o644) + if err := f.Set(""); err != nil { + t.Fatal(err) + } + if strings.Contains(readFile(path), "reasoningEffort") { + t.Fatalf("left the map:\n%s", readFile(path)) + } +} + +func TestDshEffort(t *testing.T) { + home := effortHome(t) + patch := filepath.Join(home, ".dsh", "profiles", "web", "cordis.patch.yml") + writeFile(t, patch, "[]\n") + a := dsh(home) + f := a.Field("effort") + // dsh's own row is used whole; the effort goes with magpie's + if err := f.Set("max"); err == nil { + t.Fatal("took an effort on dsh's own row") + } + if err := a.Field("model").Set("magpie/deepseek/pro"); err != nil { + t.Fatal(err) + } + if f.Get() != "high" { + t.Fatalf("effort: %q\n%s", f.Get(), readFile(patch)) + } + if err := f.Set("max"); err != nil { + t.Fatal(err) + } + if err := f.Set("low"); err == nil { + t.Fatal("took low") + } + // kept when the model changes and when the catalog is synced + if err := a.Field("model").Set("magpie/deepseek/flash"); err != nil { + t.Fatal(err) + } + if err := a.Sync(); err != nil { + t.Fatal(err) + } + raw := readFile(patch) + if f.Get() != "max" || strings.Count(raw, "reasoningEffort:") != 1 || !strings.Contains(raw, `model: "deepseek/flash"`) { + t.Fatalf("patch:\n%s", raw) + } + if err := f.Set(""); err != nil { + t.Fatal(err) + } + if f.Get() != "high" { + t.Fatalf("reset: %q", f.Get()) + } +} diff --git a/internal/agent/hermes.go b/internal/agent/hermes.go index 38cada33..2c208f3c 100644 --- a/internal/agent/hermes.go +++ b/internal/agent/hermes.go @@ -108,6 +108,20 @@ func hermes(home string) *Agent { Options: func(cur map[string]string) []Option { return append(ownOptions("", cur["model"]), viaMagpie("hermes", magpieID+"/")...) }, + }, { + // agent.reasoning_effort, which Hermes's /reasoning saves; none + // turns reasoning off, and unset Hermes asks for medium + Key: "effort", Label: "effort", + Get: func() string { return getKey("agent.reasoning_effort") }, + Set: func(v string) error { + if v == "" { + return edit.DelYAML(path, "agent.reasoning_effort") + } + return edit.SetYAML(path, edit.KV{Path: "agent.reasoning_effort", Value: v}) + }, + Options: func(map[string]string) []Option { + return static("none", "minimal", "low", "medium", "high", "xhigh") + }, }}, } } diff --git a/internal/agent/omp.go b/internal/agent/omp.go index 29b9dce1..6a17b17d 100644 --- a/internal/agent/omp.go +++ b/internal/agent/omp.go @@ -98,6 +98,18 @@ func omp(home string) *Agent { Options: func(cur map[string]string) []Option { return append(ownOptions("", cur["model"]), viaMagpie("omp", magpieID+"/")...) }, + }, { + // the thinking level sessions start with, as omp's settings save + // it; unset omp takes high (it also knows auto, its own pick) + Key: "effort", Label: "thinking", + Get: func() string { v, _ := edit.GetYAML(path, "defaultThinkingLevel"); return v }, + Set: func(v string) error { + if v == "" { + return edit.DelYAML(path, "defaultThinkingLevel") + } + return edit.SetYAML(path, edit.KV{Path: "defaultThinkingLevel", Value: v}) + }, + Options: func(map[string]string) []Option { return static(ompEfforts...) }, }}, } }