Files
magpie/internal/agent/agent.go
T

219 lines
6.5 KiB
Go

// Package agent describes every coding agent magpie can drive: where its config
// lives, which fields matter (model, effort, …) and which values to offer.
package agent
import (
"fmt"
"os"
"os/exec"
"runtime"
"sort"
"strings"
"time"
"github.com/yetone/magpie/internal/proc"
"github.com/yetone/magpie/internal/provider"
)
// Option is one value the picker offers for a field.
type Option struct {
Value string `json:"value"`
Label string `json:"label,omitempty"` // display name, when the value is an id
Note string `json:"note"`
Icon string `json:"icon,omitempty"` // bundled icon name
Icons []string `json:"icons,omitempty"` // a routing group's providers' icons, stacked
Group string `json:"group,omitempty"` // section header in the picker
// GroupIcon is the group's own logo in the picker's rail, when it is
// not the first model's (a provider serving other vendors' models)
GroupIcon string `json:"groupIcon,omitempty"`
Ref string `json:"ref,omitempty"` // the catalog model, the same in every agent
}
// Field is one tunable setting of an agent. Set with an empty value puts
// the field back to the agent's own default: anything magpie wired in (the
// gateway as a provider, its model catalog) comes out and the key is removed.
type Field struct {
Key string
Label string
Get func() string
Set func(string) error
Options func(cur map[string]string) []Option
// Quiet fields are left out of listings while empty: they follow
// another field until set (Claude Code's per-tier models).
Quiet bool
}
// Agent is one supported coding agent.
type Agent struct {
ID string
Name string
Icon string // bundled icon name (internal/gui/assets/icons)
Aliases []string
Bin string // executable name, used for detection
Dir string // config directory, used for detection
Path string // config file magpie edits
Fields []Field
// Notice, if set, is advice worth showing after a change: agents that
// read their config once at start-up need a restart to see it.
Notice func() string
// UA is what the agent's User-Agent begins with, lower-case: how the
// gateway tells its requests from others'
UA []string
// Sync, for an agent that reads magpie's models from a file of its own
// rather than asking the gateway, rewrites that list as the catalog is
// now — where magpie wrote one; nothing else changes (see SyncCatalog).
Sync func() error
// Check, for an agent magpie wires in beyond its model field, says what
// of that wiring is gone while the model is still one of magpie's —
// something else rewrote the config — or "" when it is all there
// (see Drift).
Check func() string
// LastUsed, for an agent that keeps a log of its own prompts, is when
// it was last used — each prompt a request the gateway should have
// seen, so one it didn't went round magpie (see Drift). Zero if unknown.
LastUsed func() time.Time
// Reached, for an agent that logs where its model requests go, is its
// newest one since a time: when, the address it went to, and whether
// nothing answered there. Zero if there is none.
Reached func(since time.Time) (at time.Time, to string, refused bool)
}
// Running reports whether a process whose command line matches any pattern
// (an extended regexp, as for pgrep -f) is alive. Unknown on Windows.
func Running(patterns ...string) bool {
if runtime.GOOS == "windows" {
return false
}
for _, pat := range patterns {
if err := proc.Command("pgrep", "-f", pat).Run(); err == nil {
return true
}
}
return false
}
// Detected reports whether the agent seems to be installed or configured.
func (a *Agent) Detected() bool {
if _, err := os.Stat(a.Path); err == nil {
return true
}
if a.Dir != "" {
if _, err := os.Stat(a.Dir); err == nil {
return true
}
}
if a.Bin != "" {
if _, err := exec.LookPath(a.Bin); err == nil {
return true
}
}
return false
}
// Field looks a field up by key.
func (a *Agent) Field(key string) *Field {
for i := range a.Fields {
if a.Fields[i].Key == key {
return &a.Fields[i]
}
}
for i := range a.Fields {
if a.Fields[i].Label == key { // `magpie gemini auth …`: the label as shown
return &a.Fields[i]
}
}
return nil
}
// Values reads every field.
func (a *Agent) Values() map[string]string {
m := make(map[string]string, len(a.Fields))
for _, f := range a.Fields {
m[f.Key] = f.Get()
}
return m
}
// Detected returns the agents present on this machine, in display order.
func Detected() []*Agent {
var out []*Agent
for _, a := range All() {
if a.Detected() {
out = append(out, a)
}
}
return out
}
// Find resolves a user-typed name (id, alias, or unique prefix).
func Find(q string) (*Agent, error) {
q = strings.ToLower(strings.TrimSpace(q))
all := All()
var prefix []*Agent
for _, a := range all {
if a.ID == q {
return a, nil
}
for _, al := range a.Aliases {
if al == q {
return a, nil
}
}
if strings.HasPrefix(a.ID, q) || strings.HasPrefix(strings.ToLower(a.Name), q) {
prefix = append(prefix, a)
}
}
switch len(prefix) {
case 1:
return prefix[0], nil
case 0:
return nil, fmt.Errorf("unknown agent %q (try: %s)", q, strings.Join(ids(all), ", "))
}
return nil, fmt.Errorf("%q is ambiguous: %s", q, strings.Join(ids(prefix), ", "))
}
func ids(as []*Agent) []string {
s := make([]string, len(as))
for i, a := range as {
s[i] = a.ID
}
sort.Strings(s)
return s
}
// Spell puts a value typed for field key the way this agent spells it: a
// catalog model is "copilot/gpt-6" in some agents and "magpie/copilot/gpt-6"
// in others, and either is taken in both. A value the picker offers as is
// stays, and so does one it doesn't know (a model the agent reaches on its
// own that isn't listed); a "magpie/…" value the catalog doesn't have is an
// error rather than a model the agent would ask its own vendor for.
func (a *Agent) Spell(key, v string) (string, error) {
f := a.Field(key)
if f == nil || f.Options == nil || v == "" {
return v, nil
}
opts := f.Options(a.Values())
for _, o := range opts {
if o.Value == v {
return v, nil
}
}
ref, prefixed := strings.CutPrefix(v, magpieID+"/")
// a provider renamed since (a profile saved before) is the same one
refs := []string{ref}
if r := provider.RenamedRef(ref); r != ref {
refs = append(refs, r)
}
for _, r := range refs {
for _, o := range opts {
if o.Ref != "" && o.Ref == r {
return o.Value, nil
}
}
}
if prefixed {
return "", fmt.Errorf("%s isn't a model in magpie's catalog (magpie models lists them)", ref)
}
return v, nil
}