group: a rule can be for a kind of message — intent="writing or fixing tests": as a user's turn begins, the group's classifier (any model magpie has, best a small fast one) is asked once which of the intents that may match the message is, and the turn stays with the answer through its tool rounds; it isn't asked when no intent rule could decide, the same message isn't asked about twice, and when it fails or is slow (8s) no intent matches and it rests 30s. The trace says what it was asked and said; Routing's rule editor has "asks for" and the classifier picker, and magpie group rule add … intent=… classifier=… / magpie group rule classifier from the terminal. A sub-millisecond reply no longer shows as NaN s in the trace

This commit is contained in:
yetone committed 2026-09-26 11:09:13 +08:00
1 parent b9b412bb0e
commit bc720c8d87
16 files changed
+917 -49

No files matched your search

+3
View File
@@ -651,6 +651,9 @@ func showGroup(g provider.Group) error {
}
kv(k, fmt.Sprintf("%d %s", i+1, ruleLine(r)))
}
if g.Classifier != "" {
kv("classifier", g.Classifier+muted.Render(" tells which intent a message is"))
}
if u := groupUses()[g.ID]; len(u) > 0 {
kv("used by", green.Render(strings.Join(u, ", ")))
}
+44 -14
View File
@@ -14,10 +14,13 @@ import (
const ruleUsage = `usage:
magpie group rule <group> the group's rules
magpie group rule add <group> use=<model> [tokens=<n>] [images] [effort=on|low|medium|high|xhigh|max] [agents=a,b…] [at=<n>]
magpie group rule add <group> use=<model> [tokens=<n>] [images] [effort=on|low|medium|high|xhigh|max] [agents=a,b…]
[intent="<what the message asks for>"] [classifier=<model>] [at=<n>]
a rule: a turn that matches it goes to <model>, one of the group's, first
magpie group rule rm <group> <n> remove rule n
magpie group rule mv <group> <n> <to> move rule n to place <to>
magpie group rule classifier <group> <model>
the model that tells which intent a message is
Rules are looked at top first when you send a message (a new turn); the first that
matches puts its model first, and the group's others stay behind it if it fails.
@@ -28,9 +31,14 @@ const ruleUsage = `usage:
images it carries an image, now or earlier in the conversation
effort the agent asked for reasoning: on (any), or at least this level
agents it comes from one of these agents (claude, codex, opencode, … as magpie usage names them)
intent the user's message is of this kind, in your words ("writing or fixing tests", "a quick
question"): as the turn begins, the group's classifier — any model magpie has, best a small
fast one without reasoning — is asked which of the intents that may match the message is,
once; if it fails or can't say, no intent matches. Its call shows in the usage as magpie's own
e.g. magpie group rule add opus-anywhere use=openrouter/google/gemini-3-pro tokens=200k
magpie group rule add opus-anywhere use=a/vision-model images`
magpie group rule add opus-anywhere use=a/vision-model images
magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash intent="a quick question" classifier=groq/llama-3.1-8b-instant`
// parseTokens reads 200000, 200k, 1.5m.
func parseTokens(v string) (int, error) {
@@ -50,10 +58,9 @@ func parseTokens(v string) (int, error) {
}
// parseRule makes a rule of k=v words; the model is resolved among the
// group's members.
func parseRule(g provider.Group, words []string) (provider.Rule, int, error) {
var r provider.Rule
at := 0
// group's members. classifier is the group's classifier when one was
// given.
func parseRule(g provider.Group, words []string) (r provider.Rule, at int, classifier string, err error) {
for _, w := range words {
k, v, hasV := strings.Cut(w, "=")
k = strings.ToLower(strings.TrimSpace(k))
@@ -61,13 +68,13 @@ func parseRule(g provider.Group, words []string) (provider.Rule, int, error) {
case "use", "model", "to":
id, err := groupMember(g, v)
if err != nil {
return r, 0, err
return r, 0, "", err
}
r.Use = id
case "tokens", "context", "longer":
n, err := parseTokens(v)
if err != nil {
return r, 0, err
return r, 0, "", err
}
r.Tokens = n
case "images", "image":
@@ -77,7 +84,7 @@ func parseRule(g provider.Group, words []string) (provider.Rule, int, error) {
case "no", "false", "off", "0":
r.Images = false
default:
return r, 0, fmt.Errorf("images takes no value (or yes/no), not %q", v)
return r, 0, "", fmt.Errorf("images takes no value (or yes/no), not %q", v)
}
case "effort", "reasoning", "thinking":
if !hasV {
@@ -86,20 +93,32 @@ func parseRule(g provider.Group, words []string) (provider.Rule, int, error) {
r.Effort = strings.ToLower(strings.TrimSpace(v))
case "agents", "agent":
r.Agents = splitList(v)
case "intent", "asks", "about":
r.Intent = strings.TrimSpace(v)
if r.Intent == "" {
return r, 0, "", fmt.Errorf(`intent says what the message asks for: intent="writing or fixing tests"`)
}
case "classifier", "classify", "by":
if classifier = strings.TrimSpace(v); classifier == "" {
return r, 0, "", fmt.Errorf("classifier=<model>: the model that tells which intent a message is")
}
case "at":
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return r, 0, fmt.Errorf("at is a place from 1, not %q", v)
return r, 0, "", fmt.Errorf("at is a place from 1, not %q", v)
}
at = n
default:
return r, 0, fmt.Errorf("unknown %q (use, tokens, images, effort, agents, at)\n\n%s", w, ruleUsage)
return r, 0, "", fmt.Errorf("unknown %q (use, tokens, images, effort, agents, intent, classifier, at)\n\n%s", w, ruleUsage)
}
}
if r.Use == "" {
return r, 0, fmt.Errorf("use=<model> is missing: one of %s", strings.Join(g.Members, ", "))
return r, 0, "", fmt.Errorf("use=<model> is missing: one of %s", strings.Join(g.Members, ", "))
}
return r, at, nil
if r.Intent != "" && classifier == "" && g.Classifier == "" {
return r, 0, "", fmt.Errorf("a rule with an intent needs the group's classifier, the model that tells which intent a message is: add classifier=<model>, best a small fast one")
}
return r, at, classifier, nil
}
// groupMember is the group's member a typed model names: its id, its
@@ -167,10 +186,13 @@ func ruleCmd(args []string) error {
}
switch verb {
case "add", "new":
r, at, err := parseRule(g, rest[1:])
r, at, classifier, err := parseRule(g, rest[1:])
if err != nil {
return err
}
if classifier != "" {
g.Classifier = classifier
}
if at == 0 || at > len(g.Rules) {
g.Rules = append(g.Rules, r)
} else {
@@ -199,6 +221,14 @@ func ruleCmd(args []string) error {
}
r := g.Rules[i]
g.Rules = slices.Insert(slices.Delete(g.Rules, i, i+1), j, r)
case "classifier", "classify":
if len(rest) != 2 {
return fmt.Errorf("magpie group rule classifier <group> <model>")
}
if !slices.ContainsFunc(g.Rules, func(r provider.Rule) bool { return r.Intent != "" }) {
return fmt.Errorf("%s has no rule with an intent to classify for", g.ID)
}
g.Classifier = rest[1]
default:
return fmt.Errorf("magpie group rule has no %q\n\n%s", verb, ruleUsage)
}
+59
View File
@@ -111,6 +111,65 @@ func TestGroupRuleCmd(t *testing.T) {
}
}
// A rule with an intent, and the group's classifier with it.
func TestGroupRuleIntentCmd(t *testing.T) {
groupsHome(t)
if _, err := addGroup("Opus", []string{"models=a/claude-opus-5-5,b/gpt-5.5,a/only-a"}); err != nil {
t.Fatal(err)
}
group := func() provider.Group {
t.Helper()
g, err := findGroup("opus")
if err != nil {
t.Fatal(err)
}
return g
}
for _, bad := range [][]string{
{"add", "opus", "use=gpt-5.5", "intent=a quick question"}, // no classifier
{"add", "opus", "use=gpt-5.5", "intent=", "classifier=a/only-a"}, // no words
{"add", "opus", "use=gpt-5.5", "intent=a quick question", "classifier=z/nothing"}, // unknown model
{"add", "opus", "use=gpt-5.5", "intent=a quick question", "classifier=group/opus"},
{"classifier", "opus", "a/only-a"}, // no intent yet
} {
if err := ruleCmd(bad); err == nil {
t.Errorf("%v taken", bad)
}
}
if err := ruleCmd([]string{"add", "opus", "use=gpt-5.5", "intent= a quick question ", "classifier=magpie/a/only-a"}); err != nil {
t.Fatal(err)
}
g := group()
if len(g.Rules) != 1 || g.Rules[0].Intent != "a quick question" || g.Classifier != "a/only-a" {
t.Fatalf("%+v", g)
}
// a second needs no classifier again
if err := ruleCmd([]string{"add", "opus", "use=claude-opus-5-5", "intent=planning", "agents=codex"}); err != nil {
t.Fatal(err)
}
if err := ruleCmd([]string{"classifier", "opus", "b/gpt-5.5"}); err != nil || group().Classifier != "b/gpt-5.5" {
t.Fatalf("%v %+v", err, group())
}
if err := ruleCmd([]string{"opus"}); err != nil {
t.Fatal(err)
}
if got := ruleLine(group().Rules[1]); !strings.Contains(got, `agent codex · intent "planning"`) {
t.Fatalf("%q", got)
}
// with the intents gone, so is the classifier
for range 2 {
if err := ruleCmd([]string{"rm", "opus", "1"}); err != nil {
t.Fatal(err)
}
}
if g := group(); len(g.Rules) != 0 || g.Classifier != "" {
t.Fatalf("%+v", g)
}
if gs := storedGroups(t); gs[0]["classifier"] != nil {
t.Fatalf("stored: %v", gs)
}
}
func TestGroupMemberNames(t *testing.T) {
g := provider.Group{ID: "x", Members: []string{"a/m", "b/vendor/m", "a/Big", "c/deepseek/deepseek-v4-pro"}}
for in, want := range map[string]string{"a/m": "a/m", "magpie/a/m": "a/m", "A/BIG": "a/Big", "big": "a/Big", "vendor/m": "b/vendor/m",
+208
View File
@@ -0,0 +1,208 @@
package gateway
// A rule with an intent (provider.Rule.Intent) is for a kind of message:
// as a user's turn begins, the group's classifier model is asked which of
// the intents the user's message is — once, before the turn goes anywhere,
// and never again within it. The call goes through magpie itself, so the
// classifier is any model magpie has, with its keys, failover and usage;
// it shows in the usage as magpie's own call. When it can't say
// (it fails, is slow, or answers something else) no intent matches, and
// the trace tells why.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/provider"
)
// RouterAgent is the User-Agent magpie's own classifier calls carry.
const RouterAgent = "magpie-router/1"
var (
classifyTimeout = 8 * time.Second
classifyKeep = 10 * time.Minute // an answer, for the same message and intents
classifyRest = 30 * time.Second // after a failure, before the classifier is asked again
)
// classifier asks a model which of intents text is: the intent, or "" for
// none of them.
type classifier func(model string, intents []string, text string) (string, error)
var classified = struct {
sync.Mutex
m map[string]classifiedAs
failed map[string]classifyFailure // classifier model → its last failure
}{m: map[string]classifiedAs{}, failed: map[string]classifyFailure{}}
type classifiedAs struct {
intent string
at time.Time
}
type classifyFailure struct {
err string
at time.Time
}
// classify is ask's answer for the message, from what was asked before
// when it can be: the same message and intents within classifyKeep. A
// classifier that failed is left to rest for classifyRest rather than
// making every turn wait out its timeout.
func classify(ask classifier, model string, intents []string, text string) (intent string, cached bool, err error) {
h := sha256.New()
h.Write([]byte(model + "\x00" + strings.ToLower(strings.Join(intents, "\x00")) + "\x00" + text))
key := hex.EncodeToString(h.Sum(nil))
now := time.Now()
classified.Lock()
if c, ok := classified.m[key]; ok && now.Sub(c.at) < classifyKeep {
classified.Unlock()
return c.intent, true, nil
}
if f, ok := classified.failed[model]; ok && now.Sub(f.at) < classifyRest {
classified.Unlock()
return "", false, fmt.Errorf("%s failed %s ago (%s); not asked again for now", model, now.Sub(f.at).Round(time.Second), f.err)
}
classified.Unlock()
intent, err = ask(model, intents, text)
classified.Lock()
defer classified.Unlock()
if err != nil {
classified.failed[model] = classifyFailure{err: err.Error(), at: time.Now()}
return "", false, err
}
delete(classified.failed, model)
classified.m[key] = classifiedAs{intent: intent, at: time.Now()}
if len(classified.m) > 4096 {
for k, c := range classified.m {
if time.Since(c.at) > classifyKeep {
delete(classified.m, k)
}
}
}
return intent, false, nil
}
var reminders = regexp.MustCompile(`(?s)<system-reminder>.*?</system-reminder>`)
// userText is what the user said to begin the turn: the last user
// message's text, without what agents add to it for the model
// (<system-reminder>…), its middle left out when long.
func userText(req *Request) string {
var parts []string
for i := len(req.Messages) - 1; i >= 0; i-- {
m := req.Messages[i]
if m.Role != "user" {
continue
}
for _, p := range m.Parts {
if p.Kind == Text {
parts = append(parts, p.Text)
}
}
break
}
text := strings.TrimSpace(reminders.ReplaceAllString(strings.Join(parts, "\n"), ""))
const head, tail = 3000, 1000
if r := []rune(text); len(r) > head+tail {
text = string(r[:head]) + "\n…\n" + string(r[len(r)-tail:])
}
return text
}
const classifyPrompt = "You route a user's message to a coding assistant by what it asks for. " +
"Given numbered kinds of request and the user's message, answer with the number of the kind the message is, " +
"or 0 if it is none of them. Answer with the number only."
// classifyBody is the Chat request asking model which of intents text is.
func classifyBody(model string, intents []string, text string) []byte {
var b strings.Builder
b.WriteString("Kinds:\n")
for i, in := range intents {
fmt.Fprintf(&b, "%d. %s\n", i+1, in)
}
b.WriteString("\nThe user's message:\n<message>\n")
b.WriteString(text)
b.WriteString("\n</message>\n\nThe number of its kind (0 for none):")
body, _ := json.Marshal(map[string]any{
"model": model,
"messages": []map[string]string{
{"role": "system", "content": classifyPrompt},
{"role": "user", "content": b.String()},
},
"stream": false,
"temperature": 0,
"max_tokens": 512,
})
return body
}
var firstNumber = regexp.MustCompile(`\d+`)
// readIntent is the intent a classifier's answer names: its first number,
// 0 for none.
func readIntent(answer string, intents []string) (string, error) {
n, err := strconv.Atoi(firstNumber.FindString(answer))
if err != nil || n < 0 || n > len(intents) {
a := []rune(strings.TrimSpace(answer))
if len(a) > 80 {
a = append(a[:80], '…')
}
return "", fmt.Errorf("it answered %q, not a number from 0 to %d", string(a), len(intents))
}
if n == 0 {
return "", nil
}
return intents[n-1], nil
}
// askClassifier asks model through the gateway itself, as a client would.
func (s *Server) askClassifier(model string, intents []string, text string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), classifyTimeout)
defer cancel()
r, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://magpie/v1/chat/completions", nil)
if err != nil {
return "", err
}
r.Header.Set("Content-Type", "application/json")
r.Header.Set("User-Agent", RouterAgent)
w := httptest.NewRecorder()
s.serve(w, r, provider.Chat, classifyBody(model, intents, text))
if ctx.Err() != nil {
return "", fmt.Errorf("%s gave no answer in %s", model, classifyTimeout)
}
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
return "", fmt.Errorf("%s: %d, not an answer", model, w.Code)
}
if w.Code >= 300 || out.Error != nil {
msg := http.StatusText(w.Code)
if out.Error != nil && out.Error.Message != "" {
msg = out.Error.Message
}
return "", fmt.Errorf("%s: %s", model, msg)
}
if len(out.Choices) == 0 {
return "", fmt.Errorf("%s gave no answer", model)
}
return readIntent(out.Choices[0].Message.Content, intents)
}
+294
View File
@@ -0,0 +1,294 @@
package gateway
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/provider"
)
// clsUp is a Chat vendor that classifies: it answers what answer says,
// after wait, and keeps what it was asked.
type clsUp struct {
mu sync.Mutex
answer string
fail int
wait time.Duration
asked []string
agents []string
}
func (u *clsUp) ServeHTTP(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
u.mu.Lock()
u.asked = append(u.asked, string(b))
answer, fail, wait := u.answer, u.fail, u.wait
u.mu.Unlock()
if wait > 0 {
select {
case <-time.After(wait):
case <-r.Context().Done():
return
}
}
w.Header().Set("Content-Type", "application/json")
if fail != 0 {
w.WriteHeader(fail)
io.WriteString(w, `{"error":{"message":"classifier down"}}`)
return
}
io.WriteString(w, `{"id":"c","choices":[{"index":0,"message":{"role":"assistant","content":`+quote(answer)+`},"finish_reason":"stop"}],"usage":{"prompt_tokens":50,"completion_tokens":1}}`)
}
func (u *clsUp) n() int {
u.mu.Lock()
defer u.mu.Unlock()
return len(u.asked)
}
func (u *clsUp) last() string {
u.mu.Lock()
defer u.mu.Unlock()
return u.asked[len(u.asked)-1]
}
func (u *clsUp) set(answer string, fail int) {
u.mu.Lock()
u.answer, u.fail = answer, fail
u.mu.Unlock()
}
// intented is ruled's group with a classifier, c/cls, and the rules given.
func intented(t *testing.T, rules ...provider.Rule) (*Server, *ruleUp, *ruleUp, *clsUp) {
t.Helper()
s, a, b := ruled(t)
c := &clsUp{answer: "0"}
srv := httptest.NewServer(c)
t.Cleanup(srv.Close)
if err := provider.Save(provider.Provider{ID: "c", Name: "C", Key: "kc", Models: []string{"cls"}, Chat: srv.URL + "/v1"}); err != nil {
t.Fatal(err)
}
if err := catalog.SaveLive("c", srv.URL+"/v1", []catalog.Model{{ID: "cls", Context: 32000}}); err != nil {
t.Fatal(err)
}
g, _, _ := provider.FindGroup("group/r")
g.Rules, g.Classifier = rules, "c/cls"
if err := provider.SaveGroup(g); err != nil {
t.Fatal(err)
}
return s, a, b, c
}
func postOK(t *testing.T, s *Server, session, body string) (string, Route) {
t.Helper()
code, out := postAs(t, s, session, body)
if code != 200 {
t.Fatalf("%d %s", code, out)
}
return out, lastRoute(s)
}
// A rule with an intent: the classifier is asked as the turn begins, with
// the user's words only, and the turn goes where its answer says; the
// turn's tool rounds don't ask again.
func TestIntentRoutesATurn(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "writing or fixing tests"})
c.set("1", 0)
msg := "add a unit test for the parser <system-reminder>secret agent context</system-reminder>"
out, r := postOK(t, s, "s1", chat(msg, nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule == nil || r.Rule.N != 1 || r.Rule.Use != "b/big" {
t.Fatalf("turn 1: %s %+v", out, r.Rule)
}
cl := r.Rule.Classified
if cl == nil || cl.By != "c/cls" || cl.Intent != "writing or fixing tests" || cl.Error != "" || cl.Cached || len(cl.Intents) != 1 {
t.Fatalf("classified %+v", cl)
}
if len(r.Rule.When) != 1 || r.Rule.When[0] != `intent "writing or fixing tests"` {
t.Fatalf("when %v", r.Rule.When)
}
asked := c.last()
if !strings.Contains(asked, `"model":"cls"`) || !strings.Contains(asked, `1. writing or fixing tests`) ||
!strings.Contains(asked, "add a unit test for the parser") || strings.Contains(asked, "secret agent context") ||
!strings.Contains(asked, `"temperature":0`) {
t.Fatalf("asked %s", asked)
}
// its tool rounds stay, and the classifier isn't asked again
out, r = postOK(t, s, "s1", chat(msg, nil, 2, ""))
if !strings.Contains(out, "from kb") || !r.Rule.Held || r.Rule.N != 1 || r.Rule.Classified != nil || c.n() != 1 {
t.Fatalf("within: %s %+v %d", out, r.Rule, c.n())
}
// the next turn is another kind: no rule, so it stays where it was
// (the group's affinity), with the classifier's answer in the trace
c.set("0", 0)
_, r = postOK(t, s, "s1", chat(msg, []string{"what does this function return?"}, 0, ""))
if r.Rule.N != 0 || r.Rule.Classified == nil || r.Rule.Classified.Intent != "" || c.n() != 2 {
t.Fatalf("turn 2: %+v %d", r.Rule, c.n())
}
if !strings.Contains(c.last(), "what does this function return?") || strings.Contains(c.last(), "add a unit test") {
t.Fatalf("turn 2 asked about %s", c.last())
}
// the classifier's call is magpie's own, in the usage
var seen bool
for _, call := range s.Recent() {
if call.Model == "c/cls" && call.Agent == "magpie-router" {
seen = true
}
}
if !seen {
t.Fatal("the classifier's call isn't recorded as magpie-router's")
}
}
// Several intents: the classifier chooses among those of the rules that
// may match, each once, and the first rule with its answer decides.
func TestIntentChoosesAmongRules(t *testing.T) {
s, _, _, c := intented(t,
provider.Rule{Use: "a/small", Intent: "a quick question"},
provider.Rule{Use: "b/big", Intent: "Planning a large change"},
provider.Rule{Use: "b/big", Intent: "planning a large change", Agents: []string{"codex"}},
provider.Rule{Use: "b/big", Intent: "reviewing code", Images: true}, // no image: not offered
)
c.set("2", 0)
out, r := postOK(t, s, "s2", chat("design the new sync engine", nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule.N != 2 {
t.Fatalf("%s %+v", out, r.Rule)
}
if got := r.Rule.Classified.Intents; len(got) != 2 || got[0] != "a quick question" || got[1] != "Planning a large change" {
t.Fatalf("intents %q", got)
}
if strings.Contains(c.last(), "reviewing code") || strings.Count(strings.ToLower(c.last()), "planning a large change") != 1 {
t.Fatalf("asked %s", c.last())
}
// an answer that is no number of the list matches nothing
for _, bad := range []string{"3", "maybe", ""} {
c.set(bad, 0)
out, r = postOK(t, s, "s2-"+bad, chat("tell me about "+bad+" things", nil, 0, ""))
if !strings.Contains(out, "from ka") || r.Rule.N != 0 || r.Rule.Classified.Error == "" {
t.Fatalf("answer %q: %s %+v", bad, out, r.Rule)
}
classified.Lock()
delete(classified.failed, "c/cls") // an answer that isn't one rests it too; let it be asked again
classified.Unlock()
}
// an answer with words around the number is read
c.set("Kind 1.", 0)
_, r = postOK(t, s, "s2-words", chat("what's a goroutine", nil, 0, ""))
if r.Rule.N != 1 || r.Rule.Classified.Intent != "a quick question" {
t.Fatalf("%+v", r.Rule)
}
}
// The classifier isn't asked when no rule that could be the first to
// match waits on an intent.
func TestIntentNotAskedWhenItCantMatter(t *testing.T) {
s, _, _, c := intented(t,
provider.Rule{Use: "b/big", Tokens: 1000},
provider.Rule{Use: "b/big", Intent: "debugging", Agents: []string{"codex"}},
provider.Rule{Use: "a/small", Intent: "debugging"},
)
// a long request: the tokens rule is first to match whatever it says
_, r := postOK(t, s, "n1", chat(long(2000), nil, 0, ""))
if r.Rule.N != 1 || r.Rule.Classified != nil || c.n() != 0 {
t.Fatalf("long: %+v %d", r.Rule, c.n())
}
// a short one: rule 3 may match, rule 2 isn't for this agent
c.set("1", 0)
_, r = postOK(t, s, "n2", chat("why does this panic", nil, 0, ""))
if r.Rule.N != 3 || r.Rule.Classified == nil || len(r.Rule.Classified.Intents) != 1 || c.n() != 1 {
t.Fatalf("short: %+v %d", r.Rule, c.n())
}
// an image alone has no words to classify: not asked, nothing matches
g, ms, _ := provider.FindGroup("group/r")
img := &Request{Messages: []Message{{Role: "user", Parts: []Part{{Kind: Image}}}}}
asked := 0
hit := ruleFor("img", g, ms, img, "claude", func(string, []string, string) (string, error) { asked++; return "debugging", nil })
if hit.N != 0 || hit.Classified == nil || !strings.Contains(hit.Classified.Error, "no words") || asked != 0 {
t.Fatalf("image: %+v %+v", hit, hit.Classified)
}
}
// A classifier that fails or is slow leaves the turn to the rest of the
// rules; after a failure it rests rather than making each turn wait.
func TestIntentClassifierFails(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "refactoring"})
c.set("", 500)
out, r := postOK(t, s, "f1", chat("rename this package", nil, 0, ""))
if !strings.Contains(out, "from ka") || r.Rule.N != 0 || !strings.Contains(r.Rule.Classified.Error, "classifier down") {
t.Fatalf("down: %s %+v", out, r.Rule)
}
asked := c.n()
c.set("1", 0)
out, r = postOK(t, s, "f2", chat("rename that package", nil, 0, ""))
if !strings.Contains(out, "from ka") || !strings.Contains(r.Rule.Classified.Error, "not asked again") || c.n() != asked {
t.Fatalf("resting: %s %+v %d/%d", out, r.Rule, c.n(), asked)
}
// once it has rested, it's asked again
classified.Lock()
f := classified.failed["c/cls"]
f.at = time.Now().Add(-classifyRest - time.Second)
classified.failed["c/cls"] = f
classified.Unlock()
out, r = postOK(t, s, "f3", chat("rename it back", nil, 0, ""))
if !strings.Contains(out, "from kb") || r.Rule.N != 1 || r.Rule.Classified.Error != "" {
t.Fatalf("rested: %s %+v", out, r.Rule)
}
// slow: past the timeout it's no answer
was := classifyTimeout
classifyTimeout = 200 * time.Millisecond
t.Cleanup(func() { classifyTimeout = was })
c.mu.Lock()
c.wait = 2 * time.Second
c.mu.Unlock()
t0 := time.Now()
out, r = postOK(t, s, "f4", chat("rename everything", nil, 0, ""))
if !strings.Contains(out, "from ka") || !strings.Contains(r.Rule.Classified.Error, "no answer") || time.Since(t0) > 1500*time.Millisecond {
t.Fatalf("slow: %s %+v %s", out, r.Rule, time.Since(t0))
}
}
// The same message is classified once: another conversation starting with
// it (an agent retrying, a subagent) reads the answer kept.
func TestIntentAnswerKept(t *testing.T) {
s, _, _, c := intented(t, provider.Rule{Use: "b/big", Intent: "refactoring"})
c.set("1", 0)
_, r := postOK(t, s, "k1", chat("split main.go", nil, 0, ""))
if r.Rule.N != 1 || r.Rule.Classified.Cached {
t.Fatalf("%+v", r.Rule)
}
_, r = postOK(t, s, "k2", chat("split main.go", nil, 0, ""))
if r.Rule.N != 1 || !r.Rule.Classified.Cached || c.n() != 1 {
t.Fatalf("%+v %d", r.Rule, c.n())
}
}
func TestUserText(t *testing.T) {
req := &Request{Messages: []Message{
{Role: "user", Parts: []Part{{Kind: Text, Text: "first"}}},
{Role: "assistant", Parts: []Part{{Kind: Text, Text: "ok"}}},
{Role: "user", Parts: []Part{{Kind: Text, Text: "<system-reminder>\nx\n</system-reminder>"}, {Kind: Text, Text: " second "}}},
}}
if got := userText(req); got != "second" {
t.Fatalf("%q", got)
}
big := strings.Repeat("a", 3000) + strings.Repeat("m", 5000) + strings.Repeat("z", 1000)
got := userText(&Request{Messages: []Message{{Role: "user", Parts: []Part{{Kind: Text, Text: big}}}}})
if !strings.HasPrefix(got, strings.Repeat("a", 3000)+"\n…\n") || !strings.HasSuffix(got, strings.Repeat("z", 1000)) || strings.Contains(got, "m") {
t.Fatalf("%d", len(got))
}
for in, want := range map[string]string{"1": "x", "2": "y", " 0 ": "", "2\n": "y"} {
if got, err := readIntent(in, []string{"x", "y"}); err != nil || got != want {
t.Fatalf("%q: %q %v", in, got, err)
}
}
for _, in := range []string{"3", "-", "none", ""} {
if _, err := readIntent(in, []string{"x", "y"}); err == nil {
t.Fatalf("%q read", in)
}
}
}
+1 -1
View File
@@ -388,7 +388,7 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro
if isGroup && len(g.Rules) > 0 {
if req, err := parse(from, body); err == nil {
ruleAt, words = ruleKey(g, r.Header, req), firstWords(req)
hit = ruleFor(ruleAt, g, ms, req, call.Agent)
hit = ruleFor(ruleAt, g, ms, req, call.Agent, s.askClassifier)
ruled, ruleOK = ruleMember(hit, ms)
}
}
+3
View File
@@ -46,6 +46,9 @@ func fresh(t *testing.T) {
turnRules.Lock()
turnRules.m = map[string]turnRule{}
turnRules.Unlock()
classified.Lock()
classified.m, classified.failed = map[string]classifiedAs{}, map[string]classifyFailure{}
classified.Unlock()
}
func serveOn(t *testing.T, id, key string, models []string, v http.Handler, keys ...string) {
+64 -14
View File
@@ -38,6 +38,19 @@ type RuleHit struct {
// Unready: the member the rule names has nobody to take it now, and
// the group routes the request as it would without the rule.
Unready bool `json:"unready,omitempty"`
// Classified: the group's classifier was asked which intent the
// turn's first message is
Classified *Classified `json:"classified,omitempty"`
}
// Classified is what the classifier was asked as a turn began, and said.
type Classified struct {
By string `json:"by"` // the classifier model
Intents []string `json:"intents"` // what it chose among
Intent string `json:"intent,omitempty"` // what it said the message is; "" for none
Cached bool `json:"cached,omitempty"` // said before, for the same message
Ms int `json:"ms,omitempty"` // how long asking it took
Error string `json:"error,omitempty"` // why it couldn't say: no intent matches then
}
type turnRule struct {
@@ -46,6 +59,8 @@ type turnRule struct {
n int
at time.Time
input int // the tokens the vendor counted the conversation's last request as
// intent is what the classifier said the turn's first message is
intent string
}
var turnRules = struct {
@@ -80,11 +95,13 @@ func firstWords(req *Request) string {
}
// ruleFor is the rule a group request goes by: at a turn's start, the
// first of the group's rules it matches; within the turn — the agent
// handing tool results back — what was decided when it began, unless the
// conversation has grown past what that model can take and a rule sends
// it to one with more room. It returns nil for a group without rules.
func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, agent string) *RuleHit {
// first of the group's rules it matches — asking the group's classifier
// first when a rule that may match has an intent; within the turn — the
// agent handing tool results back — what was decided when it began, unless
// the conversation has grown past what that model can take and a rule
// sends it to one with more room. It returns nil for a group without
// rules.
func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, agent string, ask classifier) *RuleHit {
if len(g.Rules) == 0 || req == nil {
return nil
}
@@ -96,14 +113,10 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
break
}
}
var ctx map[string]int
if within {
ctx = memberContexts(ms) // before the lock: it reads the catalog
}
now := time.Now()
turnRules.Lock()
defer turnRules.Unlock()
tr, had := turnRules.m[key]
turnRules.Unlock()
if had && now.Sub(tr.at) > stickKeep {
had = false
}
@@ -117,6 +130,12 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
if within {
// the same turn: what was decided as it began, while the group
// still has that rule; else nothing moves it
ctx := memberContexts(ms) // before the lock: it reads the catalog
if had {
q.Intent = tr.intent // as the classifier said when the turn began
}
turnRules.Lock()
defer turnRules.Unlock()
switch {
case !had || tr.turn != turn && turn > 0:
hit.Waits = true
@@ -129,19 +148,50 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a
}
if grown, ok := outgrown(g, ctx, hit, q); ok {
hit = grown
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: tr.input}
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: tr.input, intent: q.Intent}
return hit
}
if had {
tr.at = now
turnRules.m[key] = tr
if cur, ok := turnRules.m[key]; ok {
cur.at = now
turnRules.m[key] = cur
}
}
return hit
}
// a new turn: the classifier is asked only when a rule that could be
// the first to match waits on its intent
if intents := provider.Intents(g.Rules, q); len(intents) > 0 {
c := &Classified{By: g.Classifier, Intents: intents}
text := userText(req)
switch {
case g.Classifier == "":
c.Error = "the group has no classifier"
case ask == nil:
c.Error = "nothing to ask the classifier with"
case text == "":
c.Error = "the message has no words to classify"
default:
t0 := time.Now()
intent, cached, err := classify(ask, g.Classifier, intents, text)
c.Intent, c.Cached, c.Ms = intent, cached, int(time.Since(t0).Milliseconds())
if err != nil {
c.Error = err.Error()
}
}
q.Intent = c.Intent
hit.Classified = c
}
if i := provider.MatchRule(g.Rules, q); i >= 0 {
hit.N, hit.Use, hit.When = i+1, g.Rules[i].Use, g.Rules[i].Conditions()
}
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: tr.input}
turnRules.Lock()
defer turnRules.Unlock()
input := tr.input
if cur, ok := turnRules.m[key]; ok {
input = cur.input // answered while the classifier was asked
}
turnRules.m[key] = turnRule{turn: turn, use: hit.Use, n: hit.N, at: now, input: input, intent: q.Intent}
if len(turnRules.m) > 4096 {
for k, tr := range turnRules.m {
if now.Sub(tr.at) > stickKeep {
+13
View File
@@ -609,6 +609,19 @@ const I18N = {
"1 rule": "1 条规则",
"{n} rules": "{n} 条规则",
"Rule {n} needs a condition": "第 {n} 条规则还没有条件",
"asks for": "意图是",
"asks for “{intent}”": "意图是“{intent}”",
"what it asks for, e.g. writing tests": "用户想做什么,例如:写测试",
"Intent told by": "意图判断模型",
"choose a model": "选择模型",
"As a turn begins, this model is asked which of the intents the message is — once; a small, fast one without reasoning is best. If it fails or can't say, no intent matches. Its calls show in the usage as magpie’s own.": "每一轮开始时,问这个模型一次:用户这条消息属于哪个意图。建议选小而快、不带推理的模型。它出错或给不出答案时,所有意图条件都不命中。它的调用在用量里显示为 magpie 自己的调用。",
"Choose the model that tells which intent a message is": "请选择用来判断消息意图的模型",
"the classifier": "意图判断模型",
"said before, for the same message": "同一条消息之前已判断过",
"in {ms}": "用时 {ms}",
"{by} was to tell which of {kinds} turn {turn} is, but couldn't — {err} — so no rule with an intent matches it.": "{by} 本应判断第 {turn} 轮属于 {kinds} 中的哪一个,但没能给出答案({err}),所以带意图的规则都不命中。",
"{by} was asked which of {kinds} turn {turn} is, and said none ({took}).": "问 {by} 第 {turn} 轮属于 {kinds} 中的哪一个,它说都不是({took})。",
"{by} was asked which of {kinds} turn {turn} is, and said “{intent}” ({took}).": "问 {by} 第 {turn} 轮属于 {kinds} 中的哪一个,它说是“{intent}”({took})。",
"With two models or more, a rule can send some turns to one of them first.": "组里有两个及以上模型时,可以用规则把某些轮次先发给其中一个。",
"Checked top first when you send a message: the first that matches sends that turn to its model first; the rest stay behind it if it fails. A turn under way is never moved.": "每次你发新消息时从上往下检查:第一条命中的规则把这一轮先发给它指定的模型,其余成员排在后面做故障转移。进行中的一轮不会中途换模型。",
"Rule {n} ({when}) sent turn {turn} to {use} as it began; the turn stays there.": "第 {turn} 轮开始时命中规则 {n}({when}),发给了 {use};这一轮一直留在那里。",
+6
View File
@@ -250,6 +250,12 @@
.rt-cond .ic { width: 14px; height: 14px; flex: none; }
.rt-cond > span { overflow: hidden; text-overflow: ellipsis; }
.rt-cond.tk { cursor: text; }
.rt-cond.in { cursor: text; }
.rt-cond.in input { width: 24ch; }
.rt-classifier { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin-top: 8px; }
.rt-classifier[hidden] { display: none; }
.rt-classifier .w { color: var(--muted); font-size: 12px; }
.rt-classifier .hint { flex-basis: 100%; }
.rt-cond input { width: 7ch; height: 20px; padding: 0 2px; border: 0; background: transparent; color: inherit; font: inherit; font-variant-numeric: tabular-nums; outline: none; -moz-appearance: textfield; }
.rt-cond input::-webkit-outer-spin-button, .rt-cond input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.rt-rwarn { color: var(--amber, var(--muted)); font-size: 11.5px; }
+48 -7
View File
@@ -98,7 +98,7 @@
if (h < 48) return t("{n} h", { n: Math.round(m / 60) });
return t("{n} d", { n: Math.round(m / 1440) });
}
const took = (ms) => ms < 1000 ? t("{n} ms", { n: ms }) : t("{n} s", { n: (ms / 1000).toFixed(ms < 10e3 ? 1 : 0) });
const took = (ms = 0) => ms < 1000 ? t("{n} ms", { n: ms }) : t("{n} s", { n: (ms / 1000).toFixed(ms < 10e3 ? 1 : 0) });
function clock(s) {
const d = new Date(s), n = new Date();
const hm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
@@ -217,6 +217,8 @@
const out = [];
const aff = affWhy(r, true) ? null : affWhy(r, false);
if (aff) out.push(aff);
const cls = classWhy(r);
if (cls) out.push(cls);
const rule = ruleWhy(r, false);
if (rule) out.push(rule);
const smart = (x) => !x.routing && x.kind === "account";
@@ -283,9 +285,20 @@
if (x.held) return null;
return t("No rule matches turn {turn} (about {n} tokens{img}), so the group routes it as usual.", { turn: x.turn, n: tokens(x.tokens), img: x.images ? t(", with an image") : "" });
}
// classWhy tells what the group's classifier said of the turn's message
function classWhy(r) {
const c = r.rule?.classified;
if (!c) return null;
const by = c.by || t("the classifier"), kinds = (c.intents || []).map((x) => `“${x}”`).join(", ");
if (c.error) return t("{by} was to tell which of {kinds} turn {turn} is, but couldn't — {err} — so no rule with an intent matches it.", { by, kinds, turn: r.rule.turn, err: c.error });
const when = c.cached ? t("said before, for the same message") : t("in {ms}", { ms: took(c.ms) });
if (!c.intent) return t("{by} was asked which of {kinds} turn {turn} is, and said none ({took}).", { by, kinds, turn: r.rule.turn, took: when });
return t("{by} was asked which of {kinds} turn {turn} is, and said “{intent}” ({took}).", { by, kinds, turn: r.rule.turn, intent: c.intent, took: when });
}
// a rule's condition as the gateway writes it, in the page's words
function condText(c) {
let m;
if ((m = /^intent "(.*)"$/.exec(c))) return t("asks for “{intent}”", { intent: m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\") });
if ((m = /^tokens ≥ (\d+)$/.exec(c))) return t("≥ {n} tokens", { n: Number(m[1]).toLocaleString() });
if (c === "images") return t("has an image");
if (c === "reasoning") return t("reasoning on");
@@ -1124,6 +1137,7 @@
if (r.images) bits.push(t("has an image"));
if (r.effort) bits.push(r.effort === "on" ? t("reasoning on") : t("reasoning ≥ {level}", { level: r.effort }));
if (r.agents?.length) bits.push(r.agents.map((id) => (state.clients || state.agents || []).find((a) => a.id === id)?.name || id).join(" / "));
if (r.intent) bits.push(t("asks for “{intent}”", { intent: r.intent }));
return bits.join(" · ");
}
const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -1196,7 +1210,7 @@
if (!g.ready) tags.append(el("span", "tag bad", t("no member ready")));
const edit = el("button", "text", t("Edit"));
edit.onclick = (e) => { e.stopPropagation(); open(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", affinity: g.affinity || "", rules: (g.rules || []).map((r) => ({ ...r, agents: [...(r.agents || [])] })) } }; renderGroups(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], routing: g.routing || "", affinity: g.affinity || "", classifier: g.classifier || "", rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])] })) } }; renderGroups(); };
row.onclick = open;
row.append(ics, main, tags, edit);
return row;
@@ -1318,7 +1332,15 @@
r.agents = !v ? [] : r.agents.includes(v) ? r.agents.filter((x) => x !== v) : [...r.agents, v];
ag.textContent = agentsName(); ag.classList.toggle("on", r.agents.length > 0);
});
when.append(tk, im, ef, ag);
// intent: what the message asks for, as the classifier judges it
const it = el("span", "rt-cond in" + (r.intent ? " on" : ""));
it.onclick = () => ii.focus();
const ii = input(r.intent || "", t("what it asks for, e.g. writing tests"));
ii.maxLength = 200;
ii.oninput = () => { r.intent = ii.value; it.classList.toggle("on", !!r.intent.trim()); drawClassifier(); };
ii.onkeydown = (e) => e.stopPropagation();
it.append(el("span", "", t("asks for")), ii);
when.append(tk, im, ef, ag, it);
// the member it sends to
const use = el("div", "rt-use");
const ub = el("button", "rt-cond on");
@@ -1343,11 +1365,28 @@
row.append(el("span", "i", String(i + 1)), when, use, ctl);
rlist.append(row);
});
drawClassifier();
};
rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "" }); drawRules(); };
// the classifier, once a rule has an intent: the model asked which
// intent a turn's message is
const cls = el("div", "rt-classifier");
const drawClassifier = () => {
const on = d.rules.some((r) => r.intent?.trim());
cls.hidden = !on;
if (!on) return;
const m = groups.models.find((x) => x.id === d.classifier);
const cb = el("button", "rt-cond" + (d.classifier ? " on" : ""));
if (d.classifier) cb.append(icon(m?.icon || "generic"), el("span", "", m ? `${m.name || m.id} · ${m.providerName}` : d.classifier));
else cb.append(el("span", "", t("choose a model")));
cb.onclick = (ev) => openPicker({ id: "", name: "", fields: [] }, { key: "classifier", label: "model", value: d.classifier, options: groups.models.map((x) => ({ value: x.id, label: x.name || x.id, note: x.providerName, icon: x.icon, group: x.providerName, ref: x.id })),
onPick: (id) => { if (id) d.classifier = id; drawClassifier(); } }, cb, ev);
cls.replaceChildren(el("span", "w", t("Intent told by")), cb,
el("div", "hint", t("As a turn begins, this model is asked which of the intents the message is — once; a small, fast one without reasoning is best. If it fails or can't say, no intent matches. Its calls show in the usage as magpie’s own.")));
};
rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [] }); drawRules(); };
rbox.append(rlist, rAdd);
const rw2 = el("div");
rw2.append(rbox, rHint2);
rw2.append(rbox, cls, rHint2);
ed.append(el("label", "", t("Rules")), rw2);
drawRules();
@@ -1363,10 +1402,12 @@
const saveBtn = el("button", "text primary", t(g ? "Save" : "Add"));
const save = () => {
if (!d.members.length) { addBtn.focus({ preventScroll: true }); return status(t("A group needs a model in it"), "warn"); }
const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length);
d.rules.forEach((r) => { r.intent = (r.intent || "").trim(); });
const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent);
if (bare >= 0) return status(t("Rule {n} needs a condition", { n: bare + 1 }), "warn");
if (d.rules.some((r) => r.intent) && !d.classifier) return status(t("Choose the model that tells which intent a message is"), "warn");
saveBtn.classList.add("busy");
groupAction("save", { id: idOf(), name: d.name.trim() || idOf(), members: d.members, routing: d.routing, affinity: d.affinity, rules: d.rules }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }));
groupAction("save", { id: idOf(), name: d.name.trim() || idOf(), members: d.members, routing: d.routing, affinity: d.affinity, rules: d.rules, classifier: d.rules.some((r) => r.intent) ? d.classifier : "" }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }));
};
saveBtn.onclick = save;
bar.append(cancel, saveBtn);
+19
View File
@@ -44,6 +44,10 @@ type Group struct {
// Rules send the requests they match to one member first, in order:
// the first that matches decides (see Rule).
Rules []Rule `json:"rules,omitempty"`
// Classifier is the model ("provider/model", not a group) asked which
// of the rules' intents a user's message is. Rules with an intent need
// one; a small, fast model without reasoning does.
Classifier string `json:"classifier,omitempty"`
// Auto is set on a group magpie found: one model served by several
// providers. It is derived, never stored.
Auto bool `json:"auto,omitempty"`
@@ -254,6 +258,21 @@ func SaveGroup(g Group) error {
return err
}
g.Rules = rules
g.Classifier = strings.TrimPrefix(strings.TrimSpace(g.Classifier), "magpie/")
intents := slices.ContainsFunc(g.Rules, func(r Rule) bool { return r.Intent != "" })
switch {
case strings.HasPrefix(g.Classifier, GroupPrefix):
return fmt.Errorf("the classifier is a model, not a group (%s)", g.Classifier)
case intents && g.Classifier == "":
return errors.New("a rule with an intent needs the group's classifier: the model that tells which intent a message is")
case !intents:
g.Classifier = "" // nothing to ask it
}
if g.Classifier != "" {
if _, _, ok := Resolve(g.Classifier); !ok {
return fmt.Errorf("magpie knows no model %q to classify with", g.Classifier)
}
}
g.Auto, g.Hidden = false, false
f := load()
for i := range f.Groups {
+54 -5
View File
@@ -3,8 +3,11 @@ package provider
// A group's rules: which member a request goes to first, by what can be
// seen in the request itself — how long it is, whether it carries an
// image, how hard the agent asked the model to think, which agent sent it.
// Nothing is guessed from what the request says: the same request is
// always routed the same way, and the trace tells which rule did it.
// Those are never guessed: the same request is always routed the same way,
// and the trace tells which rule did it. The one exception is a rule with
// an intent (what the user asks for, in the user's words): as a turn
// begins, a model the group names (its Classifier) is asked which of the
// intents the user's message is, once, and the trace tells what it said.
//
// A rule decides when a user's turn begins; the agent's requests within
// that turn — tool results sent back — stay with what it decided, so a
@@ -36,8 +39,15 @@ type Rule struct {
Effort string `json:"effort,omitempty"`
// Agents: the request is from one of these agents (usage.AgentOf ids).
Agents []string `json:"agents,omitempty"`
// Intent: the user's message at the turn's start is of this kind, as
// the group's Classifier judges it — "writing or fixing tests",
// "a quick question". When the classifier can't say, it doesn't match.
Intent string `json:"intent,omitempty"`
}
// MaxIntent is how long an intent may be, in characters.
const MaxIntent = 200
// Conditions says what a rule matches, for a list or a trace.
func (r Rule) Conditions() []string {
var out []string
@@ -57,6 +67,9 @@ func (r Rule) Conditions() []string {
if len(r.Agents) > 0 {
out = append(out, "agent "+strings.Join(r.Agents, "|"))
}
if r.Intent != "" {
out = append(out, fmt.Sprintf("intent %q", r.Intent))
}
return out
}
@@ -66,6 +79,7 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) {
r.Use = strings.TrimSpace(r.Use)
r.Effort = strings.ToLower(strings.TrimSpace(r.Effort))
r.Agents = cleanList(r.Agents)
r.Intent = strings.Join(strings.Fields(r.Intent), " ")
for j := range r.Agents {
r.Agents[j] = strings.ToLower(r.Agents[j])
}
@@ -79,8 +93,10 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) {
return nil, fmt.Errorf("rule %d: tokens can't be negative", n)
case r.Effort != "" && r.Effort != "on" && !slices.Contains(Efforts, r.Effort):
return nil, fmt.Errorf("rule %d: effort is on or one of %s, not %q", n, strings.Join(Efforts, ", "), r.Effort)
case len([]rune(r.Intent)) > MaxIntent:
return nil, fmt.Errorf("rule %d: an intent is at most %d characters", n, MaxIntent)
case len(r.Conditions()) == 0:
return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort or agents)", n)
return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort, agents or intent)", n)
}
if len(r.Agents) == 0 {
r.Agents = nil
@@ -97,10 +113,22 @@ type RuleRequest struct {
Thinking bool // the agent asked for reasoning
Effort string // at what level, when it said
Agent string
// Intent is the one of the rules' intents the classifier said the
// user's message is; "" when none, or it wasn't asked.
Intent string
}
// Matches reports whether the request is one the rule is for.
func (r Rule) Matches(q RuleRequest) bool {
if r.Intent != "" && !strings.EqualFold(r.Intent, q.Intent) {
return false
}
return r.MatchesBesidesIntent(q)
}
// MatchesBesidesIntent is Matches but for the rule's intent: whether the
// classifier's answer is all it waits on.
func (r Rule) MatchesBesidesIntent(q RuleRequest) bool {
if r.Tokens > 0 && q.Tokens < r.Tokens {
return false
}
@@ -129,6 +157,27 @@ func MatchRule(rules []Rule, q RuleRequest) int {
return slices.IndexFunc(rules, func(r Rule) bool { return r.Matches(q) })
}
// Intents are the intents the classifier is to choose among for q: those
// of the rules that match it but for their intent, up to the first that
// matches outright (a rule after it could never be the first to match).
// None when no rule before that one has an intent — the classifier then
// isn't asked. Each is given once, as the first rule has it.
func Intents(rules []Rule, q RuleRequest) []string {
var out []string
for _, r := range rules {
if !r.MatchesBesidesIntent(q) {
continue
}
if r.Intent == "" {
break
}
if !slices.ContainsFunc(out, func(s string) bool { return strings.EqualFold(s, r.Intent) }) {
out = append(out, r.Intent)
}
}
return out
}
// ruledEntry tells agents what a group with rules can do: images, when a
// rule of images alone sends every request with one to a member that takes
// them (and any rule before it, which may take such a request first, sends
@@ -155,11 +204,11 @@ func ruledEntry(e *Entry, g Group, ms []Member, entries []Entry) {
if !ok {
continue
}
if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && sees(x) && !e.Images &&
if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && r.Intent == "" && sees(x) && !e.Images &&
!slices.ContainsFunc(g.Rules[:i], func(b Rule) bool { y, ok := of(b.Use); return !ok || !sees(y) }) {
e.Images, e.ImageInput = true, x.ImageInput
}
if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || x.Context <= e.Context {
if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || r.Intent != "" || x.Context <= e.Context {
continue // only a rule of length alone takes every long request
}
// every request up to the rule's length must fit whoever may get
+91 -2
View File
@@ -18,6 +18,8 @@ func TestCleanRules(t *testing.T) {
{Rule{Use: "a/m", Effort: "huge"}, "effort is on or one of"},
{Rule{Use: "a/m"}, "needs a condition"},
{Rule{Use: "a/m", Agents: []string{" ", ""}}, "needs a condition"},
{Rule{Use: "a/m", Intent: " \n\t "}, "needs a condition"},
{Rule{Use: "a/m", Intent: strings.Repeat("字", MaxIntent+1)}, "at most"},
} {
_, err := cleanRules([]Rule{{Use: "b/big", Images: true}, tc.rule}, members)
if err == nil || !strings.Contains(err.Error(), tc.err) || !strings.HasPrefix(err.Error(), "rule 2:") {
@@ -26,7 +28,7 @@ func TestCleanRules(t *testing.T) {
}
got, err := cleanRules([]Rule{
{Use: " b/big ", Effort: " HIGH ", Agents: []string{" Claude-Code ", "codex", "codex", ""}},
{Use: "a/m", Effort: "On", Agents: []string{}},
{Use: "a/m", Effort: "On", Agents: []string{}, Intent: " writing\n tests "},
}, members)
if err != nil {
t.Fatal(err)
@@ -34,7 +36,7 @@ func TestCleanRules(t *testing.T) {
if got[0].Use != "b/big" || got[0].Effort != "high" || !slices.Equal(got[0].Agents, []string{"claude-code", "codex"}) {
t.Errorf("%+v", got[0])
}
if got[1].Effort != "on" || got[1].Agents != nil {
if got[1].Effort != "on" || got[1].Agents != nil || got[1].Intent != "writing tests" {
t.Errorf("%+v", got[1])
}
if got, err := cleanRules(nil, members); err != nil || got != nil {
@@ -103,6 +105,53 @@ func TestMatchRuleFirstWins(t *testing.T) {
}
}
// An intent matches the classifier's answer, whatever its case; it holds
// with the rule's other conditions.
func TestRuleIntent(t *testing.T) {
r := Rule{Use: "a/m", Intent: "Writing tests", Agents: []string{"codex"}}
for _, tc := range []struct {
q RuleRequest
match, bi bool
}{
{RuleRequest{Agent: "codex", Intent: "writing tests"}, true, true},
{RuleRequest{Agent: "codex", Intent: "WRITING TESTS"}, true, true},
{RuleRequest{Agent: "codex"}, false, true},
{RuleRequest{Agent: "codex", Intent: "debugging"}, false, true},
{RuleRequest{Agent: "claude", Intent: "writing tests"}, false, false},
} {
if r.Matches(tc.q) != tc.match || r.MatchesBesidesIntent(tc.q) != tc.bi {
t.Errorf("%+v: %v %v", tc.q, r.Matches(tc.q), r.MatchesBesidesIntent(tc.q))
}
}
if c := r.Conditions(); len(c) != 2 || c[1] != `intent "Writing tests"` {
t.Errorf("%q", c)
}
rules := []Rule{
{Use: "a", Intent: "quick question"},
{Use: "b", Intent: "debugging", Images: true},
{Use: "b", Intent: "Quick Question", Agents: []string{"codex"}},
{Use: "c", Intent: "planning"},
{Use: "d", Tokens: 100},
{Use: "e", Intent: "after"},
}
for _, tc := range []struct {
q RuleRequest
want []string
}{
{RuleRequest{Tokens: 500, Agent: "codex"}, []string{"quick question", "planning"}},
{RuleRequest{Tokens: 500, Images: true}, []string{"quick question", "debugging", "planning"}},
{RuleRequest{Tokens: 500}, []string{"quick question", "planning"}},
{RuleRequest{Tokens: 5}, []string{"quick question", "planning", "after"}},
} {
if got := Intents(rules, tc.q); !slices.Equal(got, tc.want) {
t.Errorf("%+v: %q, want %q", tc.q, got, tc.want)
}
}
if got := Intents([]Rule{{Use: "d", Tokens: 1}, {Use: "a", Intent: "x"}}, RuleRequest{Tokens: 5}); got != nil {
t.Errorf("a plain rule first: %q", got)
}
}
func TestRuleConditions(t *testing.T) {
got := Rule{Tokens: 200000, Images: true, Effort: "high", Agents: []string{"codex", "claude-code"}}.Conditions()
want := []string{"tokens ≥ 200000", "images", "effort ≥ high", "agent codex|claude-code"}
@@ -160,6 +209,8 @@ func TestRuledEntry(t *testing.T) {
{"long rule", []string{"a/text", "b/huge"}, []Rule{{Use: "b/huge", Tokens: 100000}}, false, 1000000},
{"long rule past a member", []string{"a/text", "b/huge"}, []Rule{{Use: "b/huge", Tokens: 150000}}, false, 128000},
{"long rule, a smaller member", []string{"a/text", "b/small", "b/huge"}, []Rule{{Use: "b/huge", Tokens: 100000}}, false, 32000},
{"images and an intent", []string{"a/text", "a/vision"}, []Rule{{Use: "a/vision", Images: true, Intent: "screenshots"}}, false, 128000},
{"long rule and an intent", []string{"a/text", "b/huge"}, []Rule{{Use: "b/huge", Tokens: 100000, Intent: "big refactors"}}, false, 128000},
{"long rule and more", []string{"a/text", "b/huge"}, []Rule{{Use: "b/huge", Tokens: 100000, Effort: "on"}}, false, 128000},
{"capped by a rule before", []string{"a/text", "a/vision", "b/huge"}, []Rule{{Use: "a/vision", Agents: []string{"codex"}}, {Use: "b/huge", Tokens: 100000}}, false, 200000},
{"long rule to less", []string{"a/text", "a/vision"}, []Rule{{Use: "a/text", Tokens: 1000}}, false, 128000},
@@ -195,3 +246,41 @@ func TestSaveGroupRules(t *testing.T) {
t.Fatalf("%v %+v", ok, g)
}
}
// A group with an intent needs a classifier: a model magpie knows, not a
// group. Without intents it keeps none.
func TestSaveGroupClassifier(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
for _, id := range []string{"a", "b"} {
if err := Save(Provider{ID: id, Name: id, Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"m", "big"}}); err != nil {
t.Fatal(err)
}
}
intent := []Rule{{Use: "b/big", Intent: "planning"}}
for _, tc := range []struct {
classifier, err string
}{
{"", "needs the group's classifier"},
{"group/other", "not a group"},
{"z/nothing", "knows no model"},
} {
err := SaveGroup(Group{Name: "R", Members: []string{"a/m", "b/big"}, Rules: intent, Classifier: tc.classifier})
if err == nil || !strings.Contains(err.Error(), tc.err) {
t.Errorf("%q: %v, want …%s", tc.classifier, err, tc.err)
}
}
if err := SaveGroup(Group{Name: "R", Members: []string{"a/m", "b/big"}, Rules: intent, Classifier: " magpie/a/m "}); err != nil {
t.Fatal(err)
}
g, _, _ := FindGroup(GroupPrefix + "r")
if g.Classifier != "a/m" || g.Rules[0].Intent != "planning" {
t.Fatalf("%+v", g)
}
if err := SaveGroup(Group{Name: "R", Members: []string{"a/m", "b/big"}, Rules: []Rule{{Use: "b/big", Tokens: 5}}, Classifier: "a/m"}); err != nil {
t.Fatal(err)
}
if g, _, _ := FindGroup(GroupPrefix + "r"); g.Classifier != "" {
t.Fatalf("kept %q without an intent", g.Classifier)
}
}
+5 -3
View File
@@ -167,12 +167,13 @@
<tr><td>Off <code>off</code></td><td>Every request is routed afresh.</td></tr>
</table></div>
<h3 id="rules">Rules · a turn to a chosen model</h3>
<p>A group’s <span class="ui">Rules</span> send a turn to one of its models when the turn is of a kind you name: long (<code>tokens ≥ N</code>), carrying an image, asking for reasoning (on, or at least a level), or from certain agents. They are looked at top first when you send a message; the first that matches puts its model first, with the group’s others behind it if it fails. A turn no rule matches is routed as the group always routes, and keeps to where it was.</p>
<p>A group’s <span class="ui">Rules</span> send a turn to one of its models when the turn is of a kind you name: long (<code>tokens ≥ N</code>), carrying an image, asking for reasoning (on, or at least a level), from certain agents, or asking for something you describe (an <em>intent</em>). They are looked at top first when you send a message; the first that matches puts its model first, with the group’s others behind it if it fails. A turn no rule matches is routed as the group always routes, and keeps to where it was.</p>
<ul>
<li>A rule decides as a turn begins, and the turn stays on that model while the agent sends tool results back, so what the vendor cached isn’t lost halfway. The one exception: a turn that grows past its model’s context moves to a member that takes it.</li>
<li>Tokens are estimated from the request’s size, or what the vendor counted for the conversation’s last request, whichever is more. Leave some margin: a rule at 150k for a 200k model, not 199k.</li>
<li>A subagent is a conversation of its own, so a rule can send it elsewhere while its agent keeps where it is.</li>
<li>The Routing tab says which rule sent each turn where.</li>
<li>An intent is a few words for what the user asks for — “writing or fixing tests”, “a quick question”. As a turn begins, the group’s classifier — any model magpie has; a small, fast one without reasoning is best — is asked once which of the intents that could match the user’s message is. Only the message is sent, not the conversation. If it fails, is slow (8 s) or can’t say, no intent matches and the turn goes by the other rules. It isn’t asked when a rule without an intent would decide anyway, and the same message isn’t asked about twice. Its calls show in Usage as magpie’s own.</li>
<li>The Routing tab says which rule sent each turn where, and what the classifier said.</li>
</ul>
<p>The <span class="ui">Routing</span> tab shows the gateway’s decisions live: who answered each request, and why. The same groups from a terminal:</p>
<div class="code">
@@ -182,7 +183,8 @@
magpie group add <span class="s">"Opus anywhere"</span> models=claude/claude-opus-5-5,copilot/claude-opus-5.5 routing=order stays=session
magpie group set opus-anywhere models+=openrouter/anthropic/claude-opus-5.5
magpie group rule add opus-anywhere use=openrouter/anthropic/claude-opus-5.5 tokens=150k <span class="c"># long turns to it first</span>
magpie group rule opus-anywhere <span class="c"># its rules; rule rm|mv &lt;n&gt;</span>
magpie group rule add opus-anywhere use=copilot/claude-opus-5.5 intent=<span class="s">"a quick question"</span> classifier=groq/llama-3.1-8b-instant
magpie group rule opus-anywhere <span class="c"># its rules; rule rm|mv &lt;n&gt;, rule classifier &lt;group&gt; &lt;model&gt;</span>
magpie group rm opus-anywhere <span class="c"># one magpie found is hidden; magpie group restore &lt;id&gt;</span>
magpie claude group/opus-anywhere <span class="c"># use it</span></pre>
</div>
+5 -3
View File
@@ -167,12 +167,13 @@
<tr><td>关闭 <code>off</code></td><td>每个请求都重新路由。</td></tr>
</table></div>
<h3 id="rules">规则 · 把某类对话交给指定模型</h3>
<p>路由组的 <span class="ui">规则</span> 把符合条件的一轮对话交给组里指定的模型:够长(<code>tokens ≥ N</code>)、带图片、要求推理(开启,或不低于某个强度)、来自某些 Agent。每次你发新消息时从上往下检查,第一条命中的规则把它的模型排在最前,其余成员排在后面做故障转移。没有命中任何规则的一轮照组本来的方式路由,并保持在原来的账号上。</p>
<p>路由组的 <span class="ui">规则</span> 把符合条件的一轮对话交给组里指定的模型:够长(<code>tokens ≥ N</code>)、带图片、要求推理(开启,或不低于某个强度)、来自某些 Agent,或者用户想做的事符合你描述的<em>意图</em>。每次你发新消息时从上往下检查,第一条命中的规则把它的模型排在最前,其余成员排在后面做故障转移。没有命中任何规则的一轮照组本来的方式路由,并保持在原来的账号上。</p>
<ul>
<li>规则只在一轮开始时决定;Agent 回传工具结果的过程中一直留在那个模型上,不会半途换掉而丢掉厂商的缓存。唯一例外:这一轮涨到超过所在模型的上下文,会换到装得下的成员。</li>
<li>token 数取请求大小的估算与厂商对这个会话上一个请求的计数中较大的一个。请留出余量:给 200k 的模型设 150k,而不是 199k。</li>
<li>子 Agent 是单独的会话,规则可以把它发到别处,主 Agent 仍留在原处。</li>
<li>路由标签页会显示每一轮是被哪条规则送到哪里的。</li>
<li>意图是用几个词描述用户想做什么,例如“写或修测试”“一个简单问题”。每一轮开始时,magpie 会问一次组里指定的意图判断模型:用户这条消息属于哪个可能命中的意图。这个模型可以是 magpie 里的任意模型,建议选小而快、不带推理的。发过去的只有这条消息,不包括整段对话。它出错、超时(8 秒)或给不出答案时,意图条件都不命中,这一轮按其他规则走。如果前面有不带意图的规则已经能决定,就不会去问;同一条消息也不会问两次。它的调用在用量里显示为 magpie 自己的调用。</li>
<li>路由标签页会显示每一轮是被哪条规则送到哪里的,以及意图判断模型给出的答案。</li>
</ul>
<p><span class="ui">路由</span> 标签页实时显示网关的决定:每个请求由谁回答、为什么。在终端里也能管理路由组:</p>
<div class="code">
@@ -182,7 +183,8 @@
magpie group add <span class="s">"Opus anywhere"</span> models=claude/claude-opus-5-5,copilot/claude-opus-5.5 routing=order stays=session
magpie group set opus-anywhere models+=openrouter/anthropic/claude-opus-5.5
magpie group rule add opus-anywhere use=openrouter/anthropic/claude-opus-5.5 tokens=150k <span class="c"># 长对话优先发给它</span>
magpie group rule opus-anywhere <span class="c"># 查看规则;rule rm|mv &lt;n&gt;</span>
magpie group rule add opus-anywhere use=copilot/claude-opus-5.5 intent=<span class="s">"一个简单问题"</span> classifier=groq/llama-3.1-8b-instant
magpie group rule opus-anywhere <span class="c"># 查看规则;rule rm|mv &lt;n&gt;,rule classifier &lt;组&gt; &lt;模型&gt; 换判断模型</span>
magpie group rm opus-anywhere <span class="c"># 自动发现的组会被隐藏;magpie group restore &lt;id&gt; 恢复</span>
magpie claude group/opus-anywhere <span class="c"># 使用它</span></pre>
</div>