Files
magpie/internal/agent/applied.go
T
cyxer000andyetone 1ec5495b26 agents: omp's roles pick their own models (subagents, smol, slow), keep their thinking level, and get back what they had before magpie (#325)
* agents: omp's subagents and small role pick their own models, through magpie too

* agents: omp keeps a role's thinking level and the model it had before magpie, and offers the slow and designer roles

A role set to "anthropic/claude-opus-5:max" and switched to a magpie model lost its ":max", and resetting it deleted the key rather than putting the user's model back. The level now goes with the new model (omp clamps one the model lacks, never fails), the model the role had before magpie is stashed and restored, and the reviewer's @slow and the designer's @designer roles are fields beside subagents and small.

* gui: omp's slow and designer roles follow the model, as its subagents do

Unset, both roles take the session's model, yet the picker offered them omp's Default and no typed model. They are now squares beside subagents, with Same as model first and a model typed in taken as it is.

* agents: omp's thinking level no longer hides a role's model from magpie, and the model alone clears it — magpie omp magpie/x/y:high is taken, a role moved back to omp's own is drift, a list of models comes back on reset, and designer (gone in omp 18.1.5) goes

A role on "magpie/deepseek/pro:max" matched the picker only through the
option offered for its level beside the model, and only for the value in
the file now. So the CLI refused a model typed at a level, a recorded
value at a level the current one didn't share wasn't known for magpie's
(no "replaced" drift), and a level could never be taken away: a model
picked without one took the old one on.

An agent may now say what it keeps after the model (Agent.SplitSuffix;
omp's is its level). Drift, Reseat, RenameProvider, Spell and the CLI's
field-for-value match the model and put the suffix back after the one
they move to; agents without it are unchanged. The option at the level
stays: it keeps the level. Another model keeps it too; the same model
picked alone clears it.

A role omp falls back through (a YAML list, or the comma list omp joins
it into, settings.ts modelRoleValueFromUnknown) read as nothing, so it
wasn't stashed and a reset deleted it. It reads as that comma list now,
is stashed and comes back; a list has no level of its own.

omp 18.1.5 removed the designer role (CHANGELOG), so its field goes. The
smol role, like task and slow, gives its agents the session's model when
unset; its field is labelled smol, as omp names it, so the GUI can show it
as the others without touching other agents' "small".

* gui: omp's smol role follows the model as its subagents and slow role do, and designer goes

smol unset gives omp's scout and sonic the session's model, so it is a
square after the pickers with "Same as model" first, as subagents and
slow are (a feather). Its label is smol, not small: OpenCode's, Crush's
and OpenChamber's small model stays a picker. designer, a role omp 18.1.5
removed, leaves FOLLOWS_MODEL, the glyphs and i18n.

* agents: the note on omp's fallback chains says their keys are roles, and from omp 18 models too

ompRefKeys' comment had retry.fallbackChains keyed by model. omp 16.3.5 takes only a role's name there (session/agent-session.ts: "a mapping of role names to selector arrays"); omp 18.4.4 takes a role's name or a model selector (session/retry-fallback-chains.ts). Walking the keys stays: a role's name is no magpie model and is passed over. Follow-up to the review of #340.

* agents: omp reads whether it is on magpie one way, and a role at a level that a fallback chain names too is renamed once in each

Rebased on #340, one scan over omp's config (ompRefs) answers both questions: magpie's provider stays in models.yml while anything under ompRefKeys is on magpie, as #340 has it, and Check looks at the wiring while any role under modelRoles is, as this PR had it; a fallback chain or enabledModels alone keeps the provider without being checked.

A provider renamed moves a role at a thinking level through SplitSuffix first, then RenameRefs the rest, which finds no old name left in the roles: each place moves once, the level kept.

* agents: a fallback chain alone on magpie has omp's wiring checked, and a role set off magpie mends it

Check now reads the same keys dropMagpie does (ompRefKeys: every role, retry.fallbackChains, enabledModels, task.agentModelOverrides): whatever keeps magpie's provider in models.yml has its baseUrl checked, so a chain on magpie whose wiring something else moved no longer fails its fallback silently. Applying again sets the model role, which may be omp's own; a role set off magpie while something else still names it now writes magpie's provider afresh instead of leaving it as it is, so that mends the wiring. Review of #325.

* agents: an omp role that is a list of models stays the user's own, even starting on one of magpie's, and comes back as it was written

A role omp falls back through ([magpie/deepseek/flash, openai/gpt-6], or the same as a comma-separated string) was taken for one of magpie's models because it started with magpie/: magpie taking the role over stashed nothing, and a reset deleted it. Only one model, no comma, is magpie's now: SplitSuffix answers whether a value is one model, and Spell, drift (magpieValue), Reseat, RenameProvider and the CLI's fieldForValue leave a list alone. The stash keeps a YAML list as the YAML it was written in (edit.GetYAMLText), and a reset puts it back so, flow or block; a comma-separated string comes back a string. A provider renamed moves the names inside a list through RenameRefs, once, and in the lists the stash keeps too. Review of #325.

* gui: a test holds omp's subagents, smol and slow roles as squares that follow the model, and OpenCode's small model a picker

omp-roles.test.cjs, in Chromium and WebKit, English and Chinese, API faked: the three roles are squares after the model's picker, unset named "same as model" (「同主模型」) and set naming their model; smol's picker opens on "Same as model" with the model beside it, and a pick sends it and shows it. OpenCode's small model stays a picker. With smol and slow taken out of FOLLOWS_MODEL it fails at "small is a square". Review of #325.

* gateway: a Claude Code run whose subagent ended on StructuredOutput is let go, and runs left waiting on tool calls are bounded (#345) — a workflow's subagents end on StructuredOutput, which the client answers itself, so the bridge's claude -p waited 30 minutes for a result that never came, one process per subagent. Such a run now waits 30s (for a schema error the client sends at once) and ends; of other runs waiting 5 minutes or more on their caller's tools, 16 are kept, the longest waiting let go first (one that then gets its results starts anew with the whole conversation). TestClaudeRunEndsAfterStructuredOutput and TestClaudeParkedRunsAreBounded fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

* plugins: a built-in moved onto its plugin looks and works as it did (你确保插件版的订阅 provider 和其对应的内置的…都保持一模一样了吗?) — moved onto its plugin, a built-in lost pieces: its tile's name, icon, plans and risk note went to the plugin's own, "Add account" still opened the built-in's sign-in (in the app, on the server and in `magpie accounts add`), an agent's own sign-in the built-in still found showed a second usage card of the same account, the account's plan badge went, WorkBuddy's daily check-in stopped, the provider's icon and site were the plugin's, WorkBuddy AI's card was named WorkBuddy, and Devin, which had no card of its own, lost the plugin's. Now a moved built-in keeps its tile and risk sheet and signs in through its plugin; its cards take the built-in's place and name, and it has none of its own; the plan the plugin tells stays with the account; WorkBuddy checks in through the plugin, which signs the call; a card is left out only where its built-in's slot showed it. TestMovedBuiltinQuiet, TestWorkBuddyCheckinThroughPlugin, TestMoveToPlugin (Devin-like card) and TestPluginUsage (plan kept) fail without the change. Command Code and Devin moved and back in a sandbox HOME: one card each, plan, name. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; plugin-move, plugin-signin, commandcode-risk, old-webkit pass; click-scroll's WebKit fails the same on origin/main under this load.

* provider: the Providers and Gateway pages no longer wait on `claude --version` (deluxe on Discord: on Omarchy the two pages never loaded) — on Omarchy `claude` is a mise wrapper that installs or updates Claude Code before it answers, which on a slow network takes minutes or never ends; magpie asked it with no limit and with a lock held, so /api/providers (fetching the Claude account's models) and every Claude request waited with it. The CLI is now asked in the background, given a minute, and a caller waits at most 3s before the version it has stands in; a late answer is taken once it comes. Reproduced on an Omarchy VM (/api/providers unanswered after 100s on v0.1.516, 3.7s with the change). TestClaudeVersionDoesNotWaitForAStalledCLI and TestClaudeVersionTakenWhenItComes fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

* plugins: a plugin's account behaves as its built-in's (the user: 插件版的订阅 provider 和内置的…保持一模一样了吗) — a plugin's failed sign-in now says why ("the sign-in failed: the sign-in page expired") instead of a bare failure; a plugin account the provider refuses with 401 is marked lapsed, "<user>'s <provider> sign-in has expired — sign in again", beside the account and on its usage card as a built-in's is, and the mark goes when it answers again or is signed in anew; a Devin or Command Code account taken from the agent's CLI moves to the plugin marked as the CLI's and back as the agent's own, so the one it follows stays the one the CLI is signed in to. TestPluginProvider (lapsed), TestMoveSideState (cli) and TestPluginSignIn (reason) fail without the change. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass.

* agents: "Hide all" in an agent's model list keeps its line, and an uninstalled Gemini CLI goes from the Agents page (#356, #230) — the line under an agent ("Showing 0 / N models") came only when a picker still listed one of magpie's models, so hiding them all took away the one way back; it now stays while any is hidden. Gemini CLI was taken as present from what it leaves in ~/.gemini (settings.json, .env, installation_id…), which stays after it is uninstalled, so its row stayed, offering to set up a CLI that can't run; it is now its gemini binary. TestAgentModelLineAllHidden and TestGeminiNotAntigravity fail without the change; agent-models-all-hidden.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./internal/agent ./internal/gui ./internal/library ./internal/provider pass; agent-models, click-scroll, old-webkit pass.

* gateway: Codex's Responses Lite tools reach SuperGrok and every other backend, and a group moves on when a member can't read a request (#350) — Codex sends a Lite model's tools as an additional_tools input item, its own functions in a "functions" namespace; magpie relayed the item to xAI, which 422s on it (unknown item type), and translated routes (Cursor, Anthropic, Chat…) dropped it, so the model had no tools. Outside OpenAI the item's tools now go in tools, the functions namespace opened into plain tools and earlier calls un-namespaced; OpenAI and the ChatGPT account still get Codex's own form. A 400/422 over the request's shape (failed to deserialize, unknown item type/parameter…) now moves to the group's next member, each asked once, the member not resting; the Routing page says so. TestAdditionalToolsRelayed, TestAdditionalToolsTranslated and TestGroupFailsOverARequestShape fail without the change; routing-shape.test.cjs (Chromium, WebKit, en/zh) added. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass; click-scroll, old-webkit, routing-wb-refused pass.

---------

Co-authored-by: yetone <yetoneful@gmail.com>
2026-10-01 01:15:55 +08:00

340 lines
11 KiB
Go

package agent
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
"github.com/yetone/magpie/internal/usage"
)
// An agent's config is a file anyone can write: another switcher, an
// installer, the agent's own setup. One that takes magpie out leaves the
// row showing a magpie model while the agent asks its own vendor for it, and
// the user blames magpie. So magpie remembers what it last set on each agent
// (applied.json, beside the stash) and says when that no longer holds —
// Drift — with the way to set it again.
var appliedMu sync.Mutex
// applied is what magpie last set on one agent, and when.
type applied struct {
At time.Time `json:"at"`
Fields map[string]string `json:"fields"` // field key → value
}
func appliedPath() string { return filepath.Join(filepath.Dir(provider.Path()), "applied.json") }
// appliedLoad is agent id → what magpie set on it.
func appliedLoad() map[string]applied {
out := map[string]applied{}
if b, err := os.ReadFile(appliedPath()); err == nil {
json.Unmarshal(b, &out)
}
return out
}
func appliedSave(m map[string]applied) {
b, _ := json.MarshalIndent(m, "", " ")
os.MkdirAll(filepath.Dir(appliedPath()), 0o755)
os.WriteFile(appliedPath(), b, 0o600)
}
func appliedOf(id string) applied {
appliedMu.Lock()
defer appliedMu.Unlock()
return appliedLoad()[id]
}
// record keeps what a field reads after magpie set it; a field put back to
// the agent's default is magpie's no longer.
func record(id, key, v string) {
appliedMu.Lock()
defer appliedMu.Unlock()
m := appliedLoad()
a := m[id]
if a.Fields == nil {
a.Fields = map[string]string{}
}
if v == "" {
delete(a.Fields, key)
} else {
a.Fields[key] = v
}
a.At = time.Now()
m[id] = a
appliedSave(m)
}
// Apply sets one of the agent's fields and remembers it as magpie's, so it
// can be told apart from what something else writes there later. Every
// setting of a field by the user goes through here.
func (a *Agent) Apply(key, v string) error {
f := a.Field(key)
if f == nil {
return nil
}
if err := f.Set(v); err != nil {
return err
}
record(a.ID, f.Key, f.Get())
return nil
}
// Drift is how an agent differs from what magpie set on it.
type Drift struct {
// Kind says what is off:
// "unwired" the model is magpie's but the config no longer sends it
// through magpie (Check);
// "replaced" a magpie model magpie set was replaced by the agent's own;
// "bypassed" the config is right, yet the agent was used since and
// nothing of it reached the gateway — it runs on an old
// config, or something outside the file overrides it.
Kind string `json:"kind"`
Field string `json:"field"` // the field it shows on
Now string `json:"now,omitempty"` // what that field says now
Want string `json:"want"` // what setting it again sets
Detail string `json:"detail"` // what exactly is off, for a tooltip
}
// started is when this process — and the gateway in it — came up: before
// then, a request the gateway missed says nothing about the agent.
var started = time.Now()
// Drift says what, if anything, keeps the agent off what magpie set: its
// config first (wiring, then each field against magpie's record), then —
// the config being right — whether its latest use actually came through.
// A field moved from one magpie model to another (the agent's own picker)
// isn't drift; one moved off magpie is.
func (a *Agent) Drift() *Drift {
if len(a.Fields) == 0 || a.theInstalled() {
return nil
}
vals := a.Values()
// the field on one of magpie's models: where drift shows, and what
// setting it again sets
on, onMagpie := a.Fields[0], false
for _, f := range a.Fields {
if magpieValue(a, f, vals[f.Key], vals) {
on, onMagpie = f, true
break
}
}
if a.Check != nil {
if d := a.Check(); d != "" {
return &Drift{Kind: "unwired", Field: on.Key, Now: vals[on.Key], Want: vals[on.Key], Detail: d}
}
}
rec := appliedOf(a.ID)
for _, f := range a.Fields {
want, ok := rec.Fields[f.Key]
if !ok || vals[f.Key] == want || !magpieValue(a, f, want, vals) || magpieValue(a, f, vals[f.Key], vals) {
continue
}
return &Drift{Kind: "replaced", Field: f.Key, Now: vals[f.Key], Want: want,
Detail: a.Name + "'s config was changed outside magpie: " + f.Label + " is " + orDefault(vals[f.Key]) + ", not " + want + " as magpie set it"}
}
if a.Reached != nil && onMagpie {
if at, to, refused := a.Reached(rec.At); !at.IsZero() {
switch {
case !sameHost(to, gateway.URL()):
return &Drift{Kind: "bypassed", Field: on.Key, Now: vals[on.Key], Want: vals[on.Key],
Detail: a.Name + "'s last request (" + at.Format("15:04") + ") went to " + hostOf(to) + ", not magpie — it was started before magpie set it up and still runs on its old config: quit and reopen it"}
case refused:
return &Drift{Kind: "bypassed", Field: on.Key, Now: vals[on.Key], Want: vals[on.Key],
Detail: a.Name + " couldn't reach magpie at " + at.Format("15:04") + " — magpie wasn't running then, so it has none of magpie's models: quit and reopen it"}
}
}
}
if a.LastUsed != nil && onMagpie {
if used := a.LastUsed(); bypassed(used, rec.At, usage.LastSeen(a.ID)) {
return &Drift{Kind: "bypassed", Field: on.Key, Now: vals[on.Key], Want: vals[on.Key],
Detail: a.Name + " was used at " + used.Format("15:04") + " but none of its requests reached magpie — one started before magpie set it up still runs on its old config: restart it"}
}
}
return nil
}
// installedURL is the gateway of the magpie people install, on its own
// port; a magpie on another one (magpie-dev, a sandbox) is run beside it.
var installedURL = "http://" + gateway.DefaultAddr
// elsewhere: this magpie is not on the installed one's port.
func elsewhere() bool { return !sameHost(gateway.URL(), installedURL) }
// theInstalled: this magpie runs beside the installed one, and the agent is
// wired to that one's gateway, not this one's — the installed magpie set it
// up, so it is that one's to check, not drift here. Otherwise the two would
// take turns flagging each other's wiring.
func (a *Agent) theInstalled() bool {
if !elsewhere() || a.Path == "" {
return false
}
b, err := os.ReadFile(a.Path)
if err != nil {
return false
}
return strings.Contains(string(b), gateway.DefaultAddr) && !strings.Contains(string(b), hostOf(gateway.URL()))
}
// bypassed: the agent was used — while this gateway was up and after magpie
// last set it — and no request of it arrived since. A request leaves within
// moments of the prompt; a little grace keeps one in flight from counting.
func bypassed(used, applied, seen time.Time) bool {
const grace = 30 * time.Second
return !used.IsZero() && used.After(started) && used.After(applied) &&
time.Since(used) > grace && seen.Before(used.Add(-2*time.Second))
}
// magpieValue: the value is one of magpie's models as this agent spells it,
// its suffix after the model (SplitSuffix) aside; a list of models is the
// user's own.
func magpieValue(a *Agent, f Field, v string, vals map[string]string) bool {
if v == "" {
return false
}
v, _, one := a.split(v)
if !one {
return false
}
if isMagpie(v) {
return true
}
if f.Options == nil {
return false
}
for _, o := range f.Options(vals) {
if o.Value == v {
return o.Ref != ""
}
}
return false
}
func orDefault(v string) string {
if v == "" {
return "the agent's default"
}
return v
}
// Reapply sets again what magpie set on the agent: what drifted, else its
// fields as they read now — for a config taken off magpie in a way no check
// catches. A replaced field brings back the others magpie set with it.
// Either way the record is renewed, so a use before now no longer counts.
func (a *Agent) Reapply() error {
d := a.Drift()
if d != nil && d.Kind == "replaced" {
rec := appliedOf(a.ID)
// the model first: the others (an effort) are checked against it
if err := a.Apply(d.Field, d.Want); err != nil {
return err
}
for _, f := range a.Fields {
if v, ok := rec.Fields[f.Key]; ok && f.Key != d.Field && f.Get() != v {
if err := a.Apply(f.Key, v); err != nil {
return err
}
}
}
return nil
}
vals := a.Values()
for _, f := range a.Fields {
if v := vals[f.Key]; v != "" && (d != nil && f.Key == d.Field || magpieValue(a, f, v, vals)) {
if err := a.Apply(f.Key, v); err != nil {
return err
}
}
}
return nil
}
// Keep takes the agent's config as it is now: what magpie set before is
// forgotten, and no longer said to have been changed.
func (a *Agent) Keep() {
appliedMu.Lock()
defer appliedMu.Unlock()
m := appliedLoad()
if _, ok := m[a.ID]; !ok {
return
}
delete(m, a.ID)
appliedSave(m)
}
// lastJSONLTime reads the newest Unix timestamp (seconds or milliseconds)
// under key in the last lines of a prompt log, passing over the lines whose
// text (under textKey) is a slash command: those an agent answers itself.
// Zero if there is none.
func lastJSONLTime(path, key, textKey string) time.Time {
f, err := os.Open(path)
if err != nil {
return time.Time{}
}
defer f.Close()
const tail = 64 << 10
if st, err := f.Stat(); err == nil && st.Size() > tail {
f.Seek(st.Size()-tail, 0)
}
var last int64
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64<<10), 4<<20)
for sc.Scan() {
var m map[string]json.RawMessage
if json.Unmarshal(sc.Bytes(), &m) != nil {
continue
}
var text string
if json.Unmarshal(m[textKey], &text) == nil && strings.HasPrefix(strings.TrimSpace(text), "/") {
continue
}
n, err := strconv.ParseInt(string(m[key]), 10, 64)
if err != nil {
continue
}
if n > 1e12 {
n /= 1000
}
last = max(last, n)
}
if last == 0 {
return time.Time{}
}
return time.Unix(last, 0)
}
// sameHost: two URLs name the same server, whatever their scheme (Codex
// asks magpie's http gateway over ws) or path.
func sameHost(a, b string) bool { return hostOf(a) == hostOf(b) }
// wiringOff checks what magpie wrote into an agent's config to reach the
// gateway — pairs of key and value, read with get — and says which no longer
// holds, "" when all do. A URL is quoted; a key's value never is.
func wiringOff(name, file string, get func(string) (string, bool), kvs ...string) string {
for i := 0; i+1 < len(kvs); i += 2 {
k, want := kvs[i], kvs[i+1]
v, _ := get(k)
if v == want {
continue
}
where := name + "'s " + k + " (" + filepath.Base(file) + ")"
switch {
case v == "":
return where + " is gone, so it no longer reaches magpie"
case strings.Contains(strings.ToLower(k), "url"):
return where + " is " + v + ", not magpie's gateway at " + want
default:
return where + " was changed, so magpie's gateway won't take its requests"
}
}
return ""
}