diff --git a/groups_rules_cli.go b/groups_rules_cli.go index e334dd73..a7869eb4 100644 --- a/groups_rules_cli.go +++ b/groups_rules_cli.go @@ -15,7 +15,7 @@ import ( const ruleUsage = `usage: magpie group rule the group's rules magpie group rule add use= [tokens=] [images] [effort=on|low|medium|high|xhigh|max] [agents=a,b…] - [intent=""] [classifier=] [at=] + [intent=""] [compact] [classifier=] [at=] a rule: a turn that matches it goes to , one of the group's (or group/, a group in it), first; a model in the group at an effort of its own is named with it (use=glm/glm-5.3-flash:high) @@ -38,10 +38,15 @@ const ruleUsage = `usage: question"): as the turn begins, the group's classifier — any model magpie has, best a small fast one without reasoning, or another group of them to fail over — 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 + compact the agent is compacting the conversation (Claude Code's /compact or auto-compact, Codex's, + OpenCode's, Pi's, Gemini CLI's, Qwen Code's, Kimi's): a cheaper, faster model can write the + summary. The request is as long as the conversation, so a model known to take less is passed + over; it is not the turn's model, and the requests after it go on as before 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 fast use=codex/gpt-5.6-sol:xhigh effort=high + magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact 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. diff --git a/internal/gateway/compacting.go b/internal/gateway/compacting.go new file mode 100644 index 00000000..d6954b1e --- /dev/null +++ b/internal/gateway/compacting.go @@ -0,0 +1,62 @@ +package gateway + +import "strings" + +// An agent compacting its conversation asks a model to summarize it, with +// a prompt of its own in the system prompt or as the last user message; a +// rule may send that request to a cheaper, faster model (provider.Rule's +// Compact). The prompts are the agents' own, read from each one's release. + +// compactSystems open (or are) the system prompt of a compaction request. +var compactSystems = []string{ + // Claude Code 2.1, and OpenCode's before its own agent + "You are a helpful AI assistant tasked with summarizing conversations.", + // OpenCode's compaction agent (agent/prompt/compaction.txt) + "You are a context summarization agent.", + // Pi + "You are a context summarization assistant.", + // Gemini CLI + "You are a specialized system component responsible for distilling chat history into a structured XML ", + // Qwen Code + "You are the component that summarizes a conversation when its context window is about to overflow.", +} + +// compactAsks are in the last user message of a compaction request. +var compactAsks = []string{ + // Claude Code's /compact and auto-compact + "Your task is to create a detailed summary of the conversation so far", + // Codex (and magpie's own for a Codex compaction_trigger) + "You are performing a CONTEXT CHECKPOINT COMPACTION.", + // Kimi Code (prompts/compact.md) + "You are now given a task to compact this conversation context", +} + +// compacting reports whether req is an agent compacting its conversation. +func compacting(req *Request) bool { + if req == nil { + return false + } + for _, p := range compactSystems { + if strings.Contains(req.System, p) { + return true + } + } + 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 { + continue + } + for _, a := range compactAsks { + if strings.Contains(p.Text, a) { + return true + } + } + } + break + } + return false +} diff --git a/internal/gateway/compacting_test.go b/internal/gateway/compacting_test.go new file mode 100644 index 00000000..45422020 --- /dev/null +++ b/internal/gateway/compacting_test.go @@ -0,0 +1,108 @@ +package gateway + +import ( + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// Each agent's compaction request is told from its prompt; an ordinary +// request, or one only quoting a prompt earlier on, isn't. +func TestCompacting(t *testing.T) { + user := func(text string) Message { return Message{Role: "user", Parts: []Part{{Kind: Text, Text: text}}} } + said := Message{Role: "assistant", Parts: []Part{{Kind: Text, Text: "done"}}} + for name, req := range map[string]*Request{ + "claude code": {System: "You are Claude Code, Anthropic's official CLI for Claude.\nYou are a helpful AI assistant tasked with summarizing conversations.", + Messages: []Message{user("fix it"), said, user("Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests")}}, + "codex": {Messages: []Message{user("fix it"), said, user(codexCompactPrompt)}}, + "opencode": {System: "You are a context summarization agent. You are given a conversation between a user and an agent.", Messages: []Message{user("fix it")}}, + "pi": {System: "You are a context summarization assistant. Your task is to read a conversation", Messages: []Message{user("…")}}, + "gemini cli": {System: "You are a specialized system component responsible for distilling chat history into a structured XML .", Messages: []Message{user("fix it")}}, + "qwen code": {System: "You are the component that summarizes a conversation when its context window is about to overflow. The summary", Messages: []Message{user("fix it")}}, + "kimi": {Messages: []Message{user("fix it"), said, user("---\n\nThe above is a list of messages in an agent conversation. You are now given a task to compact this conversation context according to specific priorities and rules.")}}, + } { + if !compacting(req) { + t.Errorf("%s: not taken for compacting", name) + } + } + for name, req := range map[string]*Request{ + "plain": {System: "You are a coding agent.", Messages: []Message{user("fix it")}}, + "quoted": {Messages: []Message{user(codexCompactPrompt), said, user("thanks, go on")}}, + "nil": nil, + } { + if compacting(req) { + t.Errorf("%s: taken for compacting", name) + } + } +} + +// A rule for compacting sends an agent's compaction to its model on its +// own: the turn it happens in stays where it was, before and after; a +// compaction longer than the rule's model takes goes by the next rule that +// matches; a group with no such rule routes one as before. +func TestRuleCompact(t *testing.T) { + s, a, b := ruled(t, provider.Rule{Use: "a/small", Compact: true}, provider.Rule{Use: "b/big", Tokens: 2000}) + type sent struct { + body string + want string + n int + held bool + } + ccSystem := `{"role":"system","content":"You are a helpful AI assistant tasked with summarizing conversations."},` + compaction := func(first string) string { + return `{"model":"group/r","messages":[` + ccSystem + `{"role":"user","content":` + quote(first) + `},{"role":"assistant","content":"done"},` + + `{"role":"user","content":"Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests."}]}` + } + first := long(5000) + for i, tc := range []sent{ + {chat(first, nil, 0, ""), "kb", 2, false}, // the turn begins long: to b + {chat(first, nil, 1, ""), "kb", 2, true}, // its tool round stays + {compaction(first), "ka", 1, false}, // Claude Code compacts it: to a + {chat(first, nil, 2, ""), "kb", 2, true}, // and the turn goes on on b + {strings.Replace(chat(first, nil, 0, ""), `]}`, `,{"role":"assistant","content":"ok"},{"role":"user","content":`+quote(codexCompactPrompt)+`}]}`, 1), "ka", 1, false}, // Codex's + {chat(first, nil, 3, ""), "kb", 2, true}, + } { + code, out := postAs(t, s, "sess", tc.body) + r := lastRoute(s) + if code != 200 || !strings.Contains(out, "from "+tc.want) || r.Rule == nil || r.Rule.N != tc.n || r.Rule.Held != tc.held || r.Rule.Compact != (tc.want == "ka") { + t.Fatalf("request %d: %d %s %+v", i+1, code, out, r.Rule) + } + } + if a.n() != 2 || b.n() != 4 { + t.Fatalf("a %d b %d", a.n(), b.n()) + } + + // longer than a's 64k: passed over for the next rule + code, out := postAs(t, s, "big", compaction(long(70000))) + r := lastRoute(s) + if code != 200 || !strings.Contains(out, "from kb") || !r.Rule.Compact || r.Rule.N != 2 || len(r.Rule.Small) != 1 || r.Rule.Small[0] != "a/small" { + t.Fatalf("too long: %d %s %+v", code, out, r.Rule) + } + + // no rule for compacting: as before + s, _, _ = ruled(t, provider.Rule{Use: "b/big", Tokens: 2000}) + postAs(t, s, "sess", chat(first, nil, 0, "")) + code, out = postAs(t, s, "sess", compaction(first)) + if r := lastRoute(s); code != 200 || !strings.Contains(out, "from kb") || r.Rule.Compact || r.Rule.N != 1 { + t.Fatalf("no compact rule: %d %s %+v", code, out, r.Rule) + } +} + +// compact is a condition of its own, typed and read back. +func TestCompactRuleWords(t *testing.T) { + g := provider.Group{ID: "g", Members: []string{"a/small", "b/big"}} + r, _, _, err := provider.ParseRule(g, []string{"use=small", "compact"}) + if err != nil || !r.Compact || r.Use != "a/small" { + t.Fatalf("%+v %v", r, err) + } + if r.Line() != "use=a/small compact" || strings.Join(r.Conditions(), ",") != "compacting" { + t.Fatalf("%q %v", r.Line(), r.Conditions()) + } + if _, _, _, err := provider.ParseRule(g, []string{"use=small", "compact=maybe"}); err == nil { + t.Fatal("compact=maybe was taken") + } + if !r.Matches(provider.RuleRequest{Compact: true}) || r.Matches(provider.RuleRequest{Tokens: 1 << 20}) { + t.Fatal("Matches") + } +} diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 5d119bec..1efab9e1 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -1054,8 +1054,12 @@ func (s *Server) serve(w http.ResponseWriter, r *http.Request, from provider.Pro model = c.model if call.Status < 400 { servedCandidate(c, call.Usage.Input+call.Usage.Output+call.Usage.CacheRead+call.Usage.CacheWrite) - answered(stuck, c, aff.Turn, call.Usage.CacheRead) - if hit != nil { + // a compaction a rule sent to a model of its own leaves the + // conversation, and the turn's size, where they were + if hit == nil || !hit.Compact { + answered(stuck, c, aff.Turn, call.Usage.CacheRead) + } + if hit != nil && !hit.Compact { ruleAnswered(ruleAt, call.Usage) } for _, at := range nestedAt { diff --git a/internal/gateway/rules.go b/internal/gateway/rules.go index a0cc86b8..7d112ba6 100644 --- a/internal/gateway/rules.go +++ b/internal/gateway/rules.go @@ -49,6 +49,11 @@ type RuleHit struct { // Pick: the reasoning the group's decision model picked for the turn, // which its requests ask their model for (provider.EffortAuto) Pick string `json:"pick,omitempty"` + // Compact: the request is the agent compacting its conversation, looked + // at on its own; Small, the members of rules it matched that take less + // than it is long, passed over + Compact bool `json:"compact,omitempty"` + Small []string `json:"small,omitempty"` // Bare: the group has no rules, only its effort picked Bare bool `json:"bare,omitempty"` } @@ -154,6 +159,26 @@ func ruleFor(key string, g provider.Group, ms []provider.Member, req *Request, a if hit.Effort == "" && q.Thinking { hit.Effort = "on" } + if slices.ContainsFunc(g.Rules, func(r provider.Rule) bool { return r.Compact }) && compacting(req) { + // the agent summarizing its conversation: the first rule it matches, + // on its own — not the turn's decision, so the requests after it go + // on as the turn would. The request is as long as the conversation: + // a model known to take less would only refuse it, and is passed over. + q.Compact, hit.Compact = true, true + ctx := memberContexts(ms) + for i, r := range g.Rules { + if !r.Matches(q) { + continue + } + if c := ctx[r.Use]; c > 0 && c < q.Tokens { + hit.Small = append(hit.Small, r.Use) + continue + } + hit.N, hit.Use, hit.When = i+1, r.Use, r.Conditions() + break + } + return hit + } if within { // the same turn: what was decided as it began, while the group // still has that rule; else nothing moves it diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index c1629a84..44dbc11b 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1064,6 +1064,11 @@ const I18N = { "When": "当", "Time": "时间", "has an image": "带图片", + "compacting": "压缩上下文时", + "The agent summarizes the conversation to go on in less room (Claude Code's /compact, Codex, OpenCode…): a cheaper, faster model can do it": "Agent 总结对话以腾出上下文(Claude Code 的 /compact、Codex、OpenCode…):可交给更便宜、更快的模型来做", + "longer conversations skip it: it takes {n} tokens": "更长的对话会跳过它:它的上下文只有 {n} tokens", + "The agent is compacting the conversation, so rule {n} sends the summary to {use}; the conversation stays on the model it was on.": "Agent 正在压缩上下文,规则 {n} 把这次总结交给 {use};对话本身仍留在原来的模型上。", + "Passed over, as the conversation is longer than they take: {models}.": "对话比它们的上下文长,已跳过:{models}。", "any reasoning": "不限推理", "reasoning on": "开启推理", "reasoning ≥ {level}": "推理 ≥ {level}", diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index a4cf317c..d4d85a78 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -319,6 +319,7 @@ const when = (x.when || []).map(condText).join(", "); if (x.use && !x.unready) { const held = t("Rule {n} ({when}) sent turn {turn} to {use} as it began; the turn stays with whoever took it then.", { n: x.n, when, turn: x.turn, use: x.use }); + if (x.compact) return lead ? t("The agent is compacting the conversation, so rule {n} sends the summary to {use}; the conversation stays on the model it was on.", { n: x.n, use: x.use }) + (x.small?.length ? " " + t("Passed over, as the conversation is longer than they take: {models}.", { models: x.small.map((id) => useName(r, id)).join(", ") }) : "") : null; if (x.held) return lead || affWhy(r, true) ? held : null; if (x.grown) return lead ? t("Within turn {turn} the conversation grew to about {tokens} tokens, more than the model it was on takes, so rule {n} ({when}) moves it to {use}.", { turn: x.turn, tokens: tokens(x.tokens), n: x.n, when, use: x.use }) : null; if (!lead) return null; @@ -401,6 +402,7 @@ 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"); + if (c === "compacting") return t("compacting"); if ((m = /^effort ≥ (\w+)$/.exec(c))) return t("reasoning ≥ {level}", { level: m[1] }); if ((m = /^agent (.+)$/.exec(c))) return m[1].split("|").map(agentName).join(" / "); return c; @@ -1706,6 +1708,7 @@ 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 })); + if (r.compact) bits.push(t("compacting")); return bits.join(" · "); } const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); @@ -1969,7 +1972,12 @@ 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); + // compacting: the agent summarizing its conversation (/compact), + // which a cheaper, faster model can do + const cp = el("button", "rt-cond" + (r.compact ? " on" : ""), t("compacting")); + cp.title = t("The agent summarizes the conversation to go on in less room (Claude Code's /compact, Codex, OpenCode…): a cheaper, faster model can do it"); + cp.onclick = () => { r.compact = !r.compact; cp.classList.toggle("on", r.compact); warn(); }; + when.append(tk, im, ef, ag, it, cp); // the member it sends to const use = el("div", "rt-use"); const ub = el("button", "rt-cond on"); @@ -1982,6 +1990,8 @@ const m = infoOf(r.use), bits = []; if (r.images && m && m.ready && !m.images) bits.push(t("it doesn't take images")); if (r.tokens && m?.context && m.context < r.tokens) bits.push(t("it takes {n} tokens", { n: m.context.toLocaleString() })); + // a summary longer than it takes goes by the next rule, or the group + if (r.compact && m?.context && d.members.some((id) => (infoOf(id)?.context || 0) > m.context)) bits.push(t("longer conversations skip it: it takes {n} tokens", { n: m.context.toLocaleString() })); hint.textContent = bits.join(" · "); }; warn(); @@ -1996,7 +2006,7 @@ }); drawClassifier(); }; - rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "" }); drawRules(); }; + rAdd.onclick = () => { d.rules.push({ use: d.members[d.members.length - 1], tokens: 0, images: false, effort: "", agents: [], intent: "", compact: false }); drawRules(); }; // the classifier, once a rule has an intent or the effort is picked // per turn: the model asked which intent a turn's message is and how // hard it is. Jev (a decision provider's model) answers both in one @@ -2074,7 +2084,7 @@ const save = () => { if (!d.members.length) { addBtn.focus({ preventScroll: true }); return status(t("A group needs a model in it"), "warn"); } 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); + const bare = d.rules.findIndex((r) => !r.tokens && !r.images && !r.effort && !r.agents.length && !r.intent && !r.compact); 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"); if (d.effort === "auto" && !d.classifier) return status(t("Choose the model that rates how hard a turn is"), "warn"); diff --git a/internal/gui/tests/routing-compact.test.cjs b/internal/gui/tests/routing-compact.test.cjs new file mode 100644 index 00000000..2877928c --- /dev/null +++ b/internal/gui/tests/routing-compact.test.cjs @@ -0,0 +1,135 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A routing group's rule may be for the agent compacting its conversation +// (MR.Guo on X: a model of its own for context compression, cheaper and +// faster than the one the conversation is on). A group's rule for it reads +// "compacting"; in the editor it is a condition of its own, toggled with +// nothing moved, a rule with only it isn't refused as having none, and it +// is saved as compact:true; a member with less room than another is noted; +// a compaction in the trace is told as one, the models passed over named. +// In English and Chinese. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const models = [ + { id: "a/opus", name: "opus", providerName: "A", icon: "generic", context: 1000000, ready: true }, + { id: "b/flash", name: "flash", providerName: "B", icon: "generic", context: 128000, ready: true }, +]; +const groups = () => ({ + models, + groups: [{ id: "main", name: "Main", members: ["a/opus", "b/flash"], routing: "order", ready: true, + memberInfo: [{ id: "a/opus", ready: true, context: 1000000 }, { id: "b/flash", ready: true, context: 128000 }], rules: [{ use: "b/flash", compact: true }] }], + pools: [], +}); +const now = Date.now(); +const route = { + id: 7, seq: 7, time: new Date(now - 2000).toISOString(), agent: "claude", model: "main", provider: "b", done: true, status: 200, ms: 900, tokens: 90000, + group: { id: "main", name: "Main", members: ["a/opus", "b/flash"] }, + order: [{ id: "b", provider: "b", name: "B", kind: "key", model: "flash", known: true }, { id: "a", provider: "a", name: "A", kind: "key", model: "opus", known: true }], + tries: [{ id: "b", model: "flash", start: new Date(now - 2000).toISOString(), done: true, ms: 800, status: 200 }], + rule: { n: 1, use: "b/flash", when: ["compacting"], turn: 3, tokens: 90000, compact: true, small: ["c/mini"] }, +}; + +const words = { + en: { cond: "compacting", story: "The agent is compacting the conversation, so rule 1 sends the summary to", small: "Passed over, as the conversation is longer than they take: c/mini.", skip: "longer conversations skip it: it takes 128,000 tokens", edit: "Edit", add: "Add a rule", save: "Save" }, + zh: { cond: "压缩上下文时", story: "Agent 正在压缩上下文,规则 1 把这次总结交给", small: "对话比它们的上下文长,已跳过:c/mini。", skip: "更长的对话会跳过它:它的上下文只有 128,000 tokens", edit: "编辑", add: "添加规则", save: "保存" }, +}; + +function serve(lang, posts) { + const state = { agents: [{ id: "claude", name: "Claude Code", path: "/test/claude", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + let first = true; + return async (r) => { + const url = new URL(r.request().url()); + const json = (data) => r.fulfill({ json: data }); + if (url.pathname === "/boot.js") return r.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return r.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) return new Promise(() => {}); // nothing more comes + const routes = first ? [route] : []; + first = false; + return json({ mine: true, now: new Date().toISOString(), seq: 7, totals: { requests: 1, rerouted: 0, errors: 0 }, routes }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json(groups()); + if (url.pathname.startsWith("/api/groups/")) { + posts.push({ path: url.pathname, body: JSON.parse(r.request().postData() || "{}") }); + return json(groups()); + } + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true, url: "http://127.0.0.1:3999" } }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await r.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + const w = words[lang]; + test(`${engine} ${lang}: a rule for compacting`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 } })).newPage(); + page.setDefaultTimeout(5000); + const errors = [], posts = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, posts)); + await page.goto("http://magpie.test/?view=routing"); + const group = page.locator(".rt-group").first(); + await group.waitFor(); + + // the compaction in the trace, told as one + await page.waitForFunction((s) => document.documentElement.innerText.includes(s), w.story); + const said = await page.evaluate(() => document.documentElement.innerText); + assert(said.includes(w.small), "the model passed over is named"); + + // the group's rule, in words + const title = await group.evaluate((g) => [...g.querySelectorAll("[title]")].map((e) => e.title).join("\n")); + assert(title.includes(`1. ${w.cond} →`), `the rule reads as compacting: ${title}`); + + // the editor: the rule's condition on, and the note on its room + await group.locator("button", { hasText: w.edit }).click(); + const ed = page.locator(".rt-gedit"); + const rows = ed.locator(".rt-rule"); + await rows.first().waitFor(); + const cp = rows.first().locator("button.rt-cond", { hasText: w.cond }); + assert.equal(await cp.getAttribute("class"), "rt-cond on"); + assert.equal(await rows.first().locator(".rt-rwarn").textContent(), w.skip); + + // a second rule, compacting alone: toggled with nothing moved + await ed.locator("button", { hasText: w.add }).click(); + const second = rows.nth(1).locator("button.rt-cond", { hasText: w.cond }); + await second.waitFor(); + const at = () => page.evaluate(() => [...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => [e.id || e.className, e.scrollTop]).join(";") + "|" + Math.round(document.querySelector(".rt-gedit").getBoundingClientRect().top)); + await second.scrollIntoViewIfNeeded(); // by the test, as the reader would + await page.waitForTimeout(200); + const before = await at(); + await second.click(); + assert.equal(await second.getAttribute("class"), "rt-cond on"); + assert.equal(await at(), before, "the click moved nothing"); + await second.click(); + assert.equal(await second.getAttribute("class"), "rt-cond"); + await second.click(); + + // saved: compact:true, neither rule refused as having no condition + const saveBtn = ed.locator("button.primary", { hasText: w.save }); + // (the view wheeled down to it, as the page keeps still for any + // other scroll, and the mouse where it is: the browsers' scroll + // into view before a click can leave it under the footer) + await page.mouse.move(550, 600); + await page.mouse.wheel(0, 600); + await page.waitForTimeout(300); + const sb = await saveBtn.boundingBox(); + await page.mouse.click(sb.x + sb.width / 2, sb.y + sb.height / 2); + await page.waitForFunction(() => !document.querySelector(".rt-gedit")); + const save = posts.find((p) => p.path === "/api/groups/save"); + assert(save, `saved: ${JSON.stringify(posts)}`); + assert.deepEqual(save.body.rules.map((r) => [r.use, r.compact]), [["b/flash", true], ["b/flash", true]]); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/provider/grouprule.go b/internal/provider/grouprule.go index 18f8b2f5..3fa57e7d 100644 --- a/internal/provider/grouprule.go +++ b/internal/provider/grouprule.go @@ -43,6 +43,11 @@ type Rule struct { // 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"` + // Compact: the agent is compacting its conversation — asking a model to + // summarize it so it can go on in less room (Claude Code's /compact, + // Codex's, OpenCode's, Pi's…) — which a cheaper, faster model can do + // in place of the one the conversation is on. + Compact bool `json:"compact,omitempty"` } // MaxIntent is how long an intent may be, in characters. @@ -70,6 +75,9 @@ func (r Rule) Conditions() []string { if r.Intent != "" { out = append(out, fmt.Sprintf("intent %q", r.Intent)) } + if r.Compact { + out = append(out, "compacting") + } return out } @@ -96,7 +104,7 @@ func cleanRules(rules []Rule, members []string) ([]Rule, error) { 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, agents or intent)", n) + return nil, fmt.Errorf("rule %d: it needs a condition (tokens, images, effort, agents, intent or compacting)", n) } if len(r.Agents) == 0 { r.Agents = nil @@ -116,6 +124,8 @@ type RuleRequest struct { // 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 + // Compact: the request is the agent compacting its conversation. + Compact bool } // Matches reports whether the request is one the rule is for. @@ -135,6 +145,9 @@ func (r Rule) MatchesBesidesIntent(q RuleRequest) bool { if r.Images && !q.Images { return false } + if r.Compact && !q.Compact { + return false + } switch r.Effort { case "": case "on": @@ -231,11 +244,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 && r.Intent == "" && sees(x) && !e.Images && + if r.Images && r.Tokens == 0 && r.Effort == "" && len(r.Agents) == 0 && r.Intent == "" && !r.Compact && 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 || r.Intent != "" || x.Context <= e.Context { + if r.Tokens == 0 || r.Images || r.Effort != "" || len(r.Agents) > 0 || r.Intent != "" || r.Compact || 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 diff --git a/internal/provider/ruleparse.go b/internal/provider/ruleparse.go index de9edf13..663200de 100644 --- a/internal/provider/ruleparse.go +++ b/internal/provider/ruleparse.go @@ -1,7 +1,7 @@ package provider // A rule as it is typed: use= tokens=200k images effort=high -// agents=a,b intent="…" — by magpie group rule and the TUI's routing page. +// agents=a,b intent="…" compact — by magpie group rule and the TUI's routing page. import ( "fmt" @@ -58,6 +58,15 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err default: return r, 0, "", fmt.Errorf("images takes no value (or yes/no), not %q", v) } + case "compact", "compacting", "compaction": + switch strings.ToLower(v) { + case "", "yes", "true", "on", "1": + r.Compact = true + case "no", "false", "off", "0": + r.Compact = false + default: + return r, 0, "", fmt.Errorf("compact takes no value (or yes/no), not %q", v) + } case "effort", "reasoning", "thinking": if !hasV { v = "on" @@ -81,7 +90,7 @@ func ParseRule(g Group, words []string) (r Rule, at int, classifier string, err } at = n default: - return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, tokens, images, effort, agents, intent, classifier, at)", w) + return r, 0, "", fmt.Errorf(UnknownRuleWord+"%q (use, tokens, images, effort, agents, intent, compact, classifier, at)", w) } } if r.Use == "" { @@ -171,5 +180,8 @@ func (r Rule) Line() string { if r.Intent != "" { out = append(out, `intent="`+strings.ReplaceAll(r.Intent, `"`, "")+`"`) } + if r.Compact { + out = append(out, "compact") + } return strings.Join(out, " ") } diff --git a/internal/tui/routing.go b/internal/tui/routing.go index 11be9a71..53601565 100644 --- a/internal/tui/routing.go +++ b/internal/tui/routing.go @@ -253,7 +253,7 @@ func (m *model) openClassifier(g provider.Group) { // ruleHint is what a rule is typed as. const ruleHint = `use=, and any of: tokens=200k · images · effort=on|low|medium|high|xhigh|max · agents=codex,claude -intent="a quick question" (the group's classifier= tells it) · at= for its place` +intent="a quick question" (the group's classifier= tells it) · compact · at= for its place` // openRule asks for a rule: a new one, or rule i (from 0) typed again. func (m *model) openRule(g provider.Group, i int) { diff --git a/site/public/docs/start.html b/site/public/docs/start.html index 0f96b48b..c58ecde3 100644 --- a/site/public/docs/start.html +++ b/site/public/docs/start.html @@ -168,12 +168,13 @@ Off offEvery request is routed afresh.

Rules · a turn to a chosen model

-

A group’s Rules send a turn to one of its models when the turn is of a kind you name: long (tokens ≥ N), carrying an image, asking for reasoning (on, or at least a level), from certain agents, or asking for something you describe (an intent). 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.

+

A group’s Rules send a turn to one of its models when the turn is of a kind you name: long (tokens ≥ N), carrying an image, asking for reasoning (on, or at least a level), from certain agents, asking for something you describe (an intent), or the agent compacting its context. 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.

  • 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.
  • 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.
  • A subagent is a conversation of its own, so a rule can send it elsewhere while its agent keeps where it is.
  • 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. Exactly what it is asked, how its answer is used and what happens when it fails: Intent routing.
  • +
  • compacting matches an agent compacting its conversation — Claude Code’s /compact and auto-compact, Codex, OpenCode, Pi, Gemini CLI, Qwen Code, Kimi Code — so a cheaper, faster model writes the summary while the conversation stays on its own. A compaction is decided on its own: the turn it happens in, and its cache, stay where they were. When the conversation is longer than the rule’s model takes, the next rule that matches, or the group’s order, decides instead.
  • The Routing tab says which rule sent each turn where, and what the classifier said.

Groups in groups

@@ -197,6 +198,7 @@ magpie group add "Opus anywhere" models=claude/claude-opu 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 # long turns to it first magpie group rule add opus-anywhere use=copilot/claude-opus-5.5 intent="a quick question" classifier=groq/llama-3.1-8b-instant +magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact # /compact summaries to a cheap model magpie group set coder models+=group/opus-anywhere # a group in a group magpie group rule opus-anywhere # its rules; rule rm|mv <n>, rule classifier <group> <model> magpie group rm opus-anywhere # one magpie found is hidden; magpie group restore <id> diff --git a/site/public/docs/zh/start.html b/site/public/docs/zh/start.html index a350c092..d47abd34 100644 --- a/site/public/docs/zh/start.html +++ b/site/public/docs/zh/start.html @@ -168,12 +168,13 @@ 关闭 off每个请求都重新路由。

规则 · 把某类对话交给指定模型

-

路由组的 规则 把符合条件的一轮对话交给组里指定的模型:够长(tokens ≥ N)、带图片、要求推理(开启,或不低于某个强度)、来自某些 Agent,或者用户想做的事符合你描述的意图。每次你发新消息时从上往下检查,第一条命中的规则把它的模型排在最前,其余成员排在后面做故障转移。没有命中任何规则的一轮照组本来的方式路由,并保持在原来的账号上。

+

路由组的 规则 把符合条件的一轮对话交给组里指定的模型:够长(tokens ≥ N)、带图片、要求推理(开启,或不低于某个强度)、来自某些 Agent,用户想做的事符合你描述的意图,或者 Agent 正在压缩上下文。每次你发新消息时从上往下检查,第一条命中的规则把它的模型排在最前,其余成员排在后面做故障转移。没有命中任何规则的一轮照组本来的方式路由,并保持在原来的账号上。

  • 规则只在一轮开始时决定;Agent 回传工具结果的过程中一直留在那个模型上,不会半途换掉而丢掉厂商的缓存。唯一例外:这一轮涨到超过所在模型的上下文,会换到装得下的成员。
  • token 数取请求大小的估算与厂商对这个会话上一个请求的计数中较大的一个。请留出余量:给 200k 的模型设 150k,而不是 199k。
  • 子 Agent 是单独的会话,规则可以把它发到别处,主 Agent 仍留在原处。
  • 意图是用几个词描述用户想做什么,例如“写或修测试”“一个简单问题”。每一轮开始时,magpie 会问一次组里指定的意图判断模型:用户这条消息属于哪个可能命中的意图。这个模型可以是 magpie 里的任意模型,建议选小而快、不带推理的。发过去的只有这条消息,不包括整段对话。它出错、超时(8 秒)或给不出答案时,意图条件都不命中,这一轮按其他规则走。如果前面有不带意图的规则已经能决定,就不会去问;同一条消息也不会问两次。它的调用在用量里显示为 magpie 自己的调用。它具体被问了什么、回答怎么用、失败时怎么办,详见意图路由。
  • +
  • 压缩上下文时:Agent 压缩对话上下文时命中——Claude Code 的 /compact 和自动压缩、Codex、OpenCode、Pi、Gemini CLI、Qwen Code、Kimi Code——让更便宜、更快的模型来写总结,对话本身仍留在原来的模型上。压缩请求单独决定:它所在的这一轮和缓存都留在原处。对话比规则里的模型能装下的还长时,改由下一条命中的规则或组本来的顺序决定。
  • 路由标签页会显示每一轮是被哪条规则送到哪里的,以及意图判断模型给出的答案。

路由组里放路由组

@@ -197,6 +198,7 @@ magpie group add "Opus anywhere" models=claude/claude-opu 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 # 长对话优先发给它 magpie group rule add opus-anywhere use=copilot/claude-opus-5.5 intent="一个简单问题" classifier=groq/llama-3.1-8b-instant +magpie group rule add opus-anywhere use=deepseek/deepseek-v4-flash compact # /compact 的总结交给便宜的模型 magpie group set coder models+=group/opus-anywhere # 路由组里放路由组 magpie group rule opus-anywhere # 查看规则;rule rm|mv <n>,rule classifier <组> <模型> 换判断模型 magpie group rm opus-anywhere # 自动发现的组会被隐藏;magpie group restore <id> 恢复