Files
yetone 5361a2178e agent, gateway: Cursor Private Inference's row has an effort, which magpie's gateway asks its requests for (#1003, gjf7) — other agents' rows pick a reasoning effort that magpie writes into their config, but Cursor Private Inference reads only its base URL and key from the environment, and its app offers no Reasoning control for most of magpie's models, so there was no way to set one short of a routing group with a member fixed at a level, which the reporter used as a workaround. Its row now has an effort field like the others': the default (as Cursor asks) first, then every level the models it lists have (cursorLocalEfforts: their levels in MemberEfforts order, low/medium/high where a model reasons with levels unknown, none at all when nothing it lists reasons or it isn't connected). The pick is kept in magpie, settings' agentEfforts by agent (provider.AgentEffort / SetAgentEffort, a level of MemberEfforts or "" for none), and the gateway's candidate loop asks a cursor-local request for it with withFixedEffort, fitted to the model's levels and bounded by a group's, on a model that reasons (the new Provider.Thinks: levels, its list's Reasoning, or models.dev's). Precedence: a group member's fixed effort, then a :effort suffix, then a routing rule's turn pick, then the agent's pick, then the request's own; the pick wins over a level Cursor sent, as Cursor sends its own default as often as a choice, and Default hands the choice back to Cursor. A model that doesn't reason and every other agent's requests go as they came. The Settings page's save carries agentEfforts over as it does fastPicks (TestSettingsSaveKeepsWhatItDoesNotSend's fixture names it). In the window the field sits under "Settings" rather than "New sessions" for this agent alone, as it applies to every request from then on, not to what a session starts on; no new t() strings (Settings, effort and default are translated in zh, ja and de already). TestAgentEffortOnCursorLocal (Chat and Responses with and without a level of their own, Claude on Messages made to think, OpenCode's request untouched, a non-reasoning model untouched, a member fixed at low kept, the pick taken off) fails without the gateway change and passes; TestCursorLocalEffort covers the options (none with no reasoning model, the union of levels, the low/medium/high fallback, none while disconnected), Apply and a bad level refused; both pass -race (-count=20 gateway, -count=10 agent). cursor-local-effort.test.cjs (the window at 560px and the tray panel at 440px: the field under Settings, the slider's five stops with Default first, a pick posting the field, no sideways scroll) passes on Chromium and WebKit in en, zh, ja and de; claude-effort, subagent-effort, agent-field-names, agent-connect, agent-layout, agent-tweak-fold, agent-models, cursor-local-launch and i18n pass on both. go test -tags nogui ./internal/..., go vet, gofmt -l, linux (nogui) and windows builds pass. Not tried with the real Cursor Private Inference: whether it sends a reasoning level of its own on models it shows a Reasoning control for is unverified, which the precedence makes moot. docs/subsystems/gateway-routing.md and agent-wiring.md say it.
2026-10-06 17:29:28 +08:00

312 lines
11 KiB
Go

package agent
// Cursor Private Inference is a build of Cursor whose agent runs on this
// machine (its cursor-local-agent-runtime extension) and asks a model
// endpoint of the user's rather than Cursor's backend (#299). It is
// installed as Cursor is — the same app name, bundle id and ~/.cursor —
// and tells itself apart only by its product.json ("nameShort": "Cursor
// Private Inference"). The endpoint it takes from a model's own settings,
// else its Open configuration dialog's (kept in the state.vscdb regular
// Cursor shares), else from its environment: CURSOR_LOCAL_AGENT_BASE_URL
// and CURSOR_LOCAL_AGENT_API_KEY. magpie writes nothing of Cursor's — the
// Open configuration's base URL and key are regular Cursor's own override
// settings too, so writing them would move regular Cursor as well. It sets
// the two variables, which only this build reads, for the user instead
// (cursorLocalUserEnv: launchctl on the Mac, the registry's Environment on
// Windows, environment.d on Linux), so an app opened from the Dock or the
// Start menu has them, and the row is connected like any other agent's;
// the command that starts it with them (Agent.Launch) stays for a start
// from a shell. The gateway's /models tells it, for each model, the APIs
// it is served on (api_types) and its limits (capabilities), which it
// reads to pick Anthropic Messages, Responses or Chat and its context.
import (
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
// CursorLocalID is the agent's id, which its key names (TokenFor).
const CursorLocalID = "cursor-local"
// cursorLocalName is product.json's nameShort for the build.
const cursorLocalName = "Cursor Private Inference"
// cursorLocalApp is where the build is installed: its program, to start,
// and "" when it isn't. A var so tests can point it elsewhere.
var cursorLocalApp = func() string {
cursorLocalSeen.Lock()
defer cursorLocalSeen.Unlock()
if time.Since(cursorLocalSeen.at) > 30*time.Second {
cursorLocalSeen.app, cursorLocalSeen.at = findCursorLocal(cursorLocalRoots()), time.Now()
}
return cursorLocalSeen.app
}
// cursorLocalSeen is where the build was last found, looked for again
// after half a minute: the apps folder is read every time the agents are.
var cursorLocalSeen struct {
sync.Mutex
app string
at time.Time
}
// cursorLocalRoots are the folders apps are installed in on this system.
func cursorLocalRoots() []string {
home, _ := os.UserHomeDir()
switch runtime.GOOS {
case "darwin":
return []string{"/Applications", filepath.Join(home, "Applications")}
case "windows":
var out []string
for _, v := range []string{"LOCALAPPDATA", "ProgramFiles"} {
if d := os.Getenv(v); d != "" {
out = append(out, filepath.Join(d, "Programs"), d)
}
}
return out
}
return []string{"/opt", "/usr/share", "/usr/lib", filepath.Join(home, ".local", "share"), filepath.Join(home, "Applications")}
}
// findCursorLocal is the program of the Cursor Private Inference among the
// apps in roots: one whose product.json names it, a Mac's bundle by its
// Info.plist's CFBundleExecutable, else by the applicationName
// product.json gives (cursor, Cursor.exe).
func findCursorLocal(roots []string) string {
for _, root := range roots {
ents, err := os.ReadDir(root)
if err != nil {
continue
}
for _, e := range ents {
dir := filepath.Join(root, e.Name())
if strings.HasSuffix(e.Name(), ".app") {
res := filepath.Join(dir, "Contents", "Resources", "app")
if p, ok := cursorLocalProduct(res); ok {
if exe := plistExecutable(filepath.Join(dir, "Contents", "Info.plist")); exe != "" {
return filepath.Join(dir, "Contents", "MacOS", exe)
}
return filepath.Join(dir, "Contents", "MacOS", p.macExe())
}
continue
}
if p, ok := cursorLocalProduct(filepath.Join(dir, "resources", "app")); ok {
name := p.exe()
if runtime.GOOS == "windows" {
name = p.NameShort + ".exe"
if !isFile(filepath.Join(dir, name)) {
name = p.exe() + ".exe"
}
}
return filepath.Join(dir, name)
}
}
}
return ""
}
type cursorProduct struct {
NameShort string `json:"nameShort"`
ApplicationName string `json:"applicationName"`
}
// exe is the program's name as product.json gives it: Cursor for cursor.
func (p cursorProduct) exe() string {
n := p.ApplicationName
if n == "" {
return "Cursor"
}
if runtime.GOOS == "linux" {
return n
}
return p.macExe()
}
// macExe is the program's name in a .app, which is a Mac's on any system.
func (p cursorProduct) macExe() string {
n := p.ApplicationName
if n == "" {
return "Cursor"
}
return strings.ToUpper(n[:1]) + n[1:]
}
// cursorLocalProduct reads the product.json in an app's resources, which
// is the build's when it names it.
func cursorLocalProduct(res string) (cursorProduct, bool) {
var p cursorProduct
b, err := os.ReadFile(filepath.Join(res, "product.json"))
if err != nil || json.Unmarshal(b, &p) != nil {
return p, false
}
return p, strings.EqualFold(strings.TrimSpace(p.NameShort), cursorLocalName)
}
var plistExeRe = regexp.MustCompile(`<key>CFBundleExecutable</key>\s*<string>([^<]+)</string>`)
// plistExecutable is CFBundleExecutable of an XML Info.plist, "" when it
// can't be read.
func plistExecutable(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
if m := plistExeRe.FindSubmatch(b); m != nil {
return strings.TrimSpace(string(m[1]))
}
return ""
}
// CursorLocalLaunch is the command that starts the build at app on the
// gateway at gw, in the shell of this system. Started so, from a shell,
// the app has the variables; opened from the Dock or Finder it has only
// the login shell's.
func CursorLocalLaunch(app, gw string) string {
key, base := gateway.TokenFor(CursorLocalID), gw+"/v1"
if runtime.GOOS == "windows" {
return `$env:CURSOR_LOCAL_AGENT_BASE_URL="` + base + `"; $env:CURSOR_LOCAL_AGENT_API_KEY="` + key + `"; & '` + strings.ReplaceAll(app, "'", "''") + `'`
}
return "CURSOR_LOCAL_AGENT_BASE_URL=" + base + " CURSOR_LOCAL_AGENT_API_KEY=" + key + " '" + strings.ReplaceAll(app, "'", `'\''`) + "'"
}
// cursorLocalVars are the variables the build reads its endpoint from.
var cursorLocalVars = []string{"CURSOR_LOCAL_AGENT_BASE_URL", "CURSOR_LOCAL_AGENT_API_KEY"}
// cursorLocalEnv is what they are set to for the gateway at gw.
func cursorLocalEnv(gw string) map[string]string {
return map[string]string{cursorLocalVars[0]: gw + "/v1", cursorLocalVars[1]: gateway.TokenFor(CursorLocalID)}
}
// cursorLocalMark is magpie's own note that the variables are set for the
// user, which the row reads as connected: the Mac forgets launchctl's at a
// restart, and magpie sets them again when it starts (KeepCursorLocalEnv).
func cursorLocalMark() string { return filepath.Join(appdir.Config(), "cursor-local.env") }
func cursorLocalWired() bool { return isFile(cursorLocalMark()) }
// cursorLocalUserEnv sets the variables for the user's apps started from
// now on (nil clears them). The system's own (cursorlocal_env_*.go), a var
// so tests set nothing outside their folder.
var cursorLocalUserEnv = setUserEnv
// cursorLocalOn sets the variables for the gateway, and notes it; off
// clears them.
func cursorLocalOn(on bool) error {
if !on {
if err := cursorLocalUserEnv(nil); err != nil {
return err
}
if err := os.Remove(cursorLocalMark()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
env := cursorLocalEnv(gateway.URL())
if err := cursorLocalUserEnv(env); err != nil {
return err
}
var b strings.Builder
for _, k := range cursorLocalVars {
b.WriteString(k + "=" + env[k] + "\n")
}
if err := os.MkdirAll(filepath.Dir(cursorLocalMark()), 0o700); err != nil {
return err
}
return os.WriteFile(cursorLocalMark(), []byte(b.String()), 0o600)
}
// KeepCursorLocalEnv sets the variables again when the gateway is served,
// with its address now, while Cursor Private Inference is connected: the
// Mac's launchctl ones are gone after a restart, and the port may have
// changed. The app takes them when it is next opened.
func KeepCursorLocalEnv(context.Context) {
if !cursorLocalWired() {
return
}
if err := cursorLocalOn(true); err != nil {
log.Printf("%s: setting CURSOR_LOCAL_AGENT_BASE_URL again: %v", cursorLocalName, err)
}
}
func cursorLocal() *Agent {
return &Agent{
ID: CursorLocalID, Name: cursorLocalName, Icon: "cursor", Aliases: []string{"cursor-private-inference"},
// its model picker lists the gateway's /models as its key is shown
// them, which the row's Models button picks
ListsModels: true,
detect: func() bool { return cursorLocalApp() != "" },
Launch: func() string {
if app := cursorLocalApp(); app != "" {
return CursorLocalLaunch(app, gateway.URL())
}
return ""
},
Fields: []Field{{
Key: "provider", Label: "provider",
Get: func() string {
if cursorLocalWired() {
return magpieID
}
return ""
},
Set: func(v string) error { return cursorLocalOn(v != "") },
Options: func(map[string]string) []Option {
return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie",
Note: "CURSOR_LOCAL_AGENT_BASE_URL and _API_KEY set for your user, which only this build reads (quit it and open it again)"}}
},
}, {
// how hard its models reason, which neither its environment nor,
// for most models, its app can say (#1003): the gateway asks
// its requests for it (provider.AgentEffort)
Key: "effort", Label: "effort",
Get: func() string { return provider.AgentEffort(CursorLocalID) },
Set: func(v string) error { return provider.SetAgentEffort(CursorLocalID, v) },
Options: func(map[string]string) []Option {
if !cursorLocalWired() {
return nil
}
return cursorLocalEfforts()
},
}},
Notice: func() string {
return cursorLocalName + " reads magpie's gateway from CURSOR_LOCAL_AGENT_BASE_URL and CURSOR_LOCAL_AGENT_API_KEY, now set for your user: quit it (the app, not only its window) and open it again, as it keeps the model list it was first given until it quits. A base URL or API key set in its Open configuration or a model's settings comes first, so leave both empty (or set the base URL to " + gateway.URL() + "/v1 and the key to " + gateway.TokenFor(CursorLocalID) + ")."
},
}
}
// cursorLocalEfforts are the levels Cursor Private Inference's effort is
// picked among: the default, its requests going as it asks, then every
// level a model its list shows has (low, medium and high for models that
// reason with levels unknown). None when no model it lists reasons.
func cursorLocalEfforts() []Option {
shown, _ := provider.CatalogFor(CursorLocalID)
has, reasons := map[string]bool{}, false
for _, e := range shown {
reasons = reasons || e.Reasoning
for _, l := range e.Efforts {
has[l] = true
}
}
levels := slices.DeleteFunc(slices.Clone(provider.MemberEfforts), func(l string) bool { return !has[l] })
if len(levels) == 0 && reasons {
levels = []string{"low", "medium", "high"}
}
if len(levels) == 0 {
return nil
}
return append([]Option{{Value: ""}}, static(levels...)...)
}