gui, groups: a group's editor sets the context agents are told — its largest model's, its smallest model's, or one typed (Mikan on Discord: 路由组可以支持自定义上下文长短或者支持用户选择按照上下文长度最短的供应商还是上下文长度最長的供应商吗?) — a group told agents its largest member's window (#712), and another could only be set from the CLI. The group editor now has a Context row under Slow to start: Largest model's (as before), Smallest model's, or Custom with a box (200k, 1m, 128000), its hint naming the window agents will be told from the members' own, redrawn as members are added or taken off; a length that isn't one says so and isn't saved. Smallest is kept as smallest (Context -1, ContextSmallest), worked out as the members change, so the agent compacts before any member would turn a conversation away; a conversation too long for one member still goes on to one with room. magpie group set <id> context=smallest|largest|272k does the same. zh, ja, de strings. TestContextSetByUser (smallest, then smallest after a member leaves) fails on the old code; TestApplyGroupPairs covers context=. group-context.test.cjs (WebKit, en and zh): Largest by default with no box, Smallest saves -1 and reopens so, Custom starts at the smallest member's, a bad length shows its error, 300k saves 300000 and reopens, Largest saves 0. group-first-token and group-patterns get a taller window, the editor having grown under the fixed footer; they, group-arrange, fold, member-off, member-fast, rate-limited-sink, routing-compact, manual, levels, steady, time, unlisted-models and settings-groups pass on WebKit. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass.

This commit is contained in:
yetone committed 2026-10-05 03:00:34 +08:00
1 parent 6159129f26
commit 399e5db63d
10 files changed
+250 -9

No files matched your search

+8 -4
View File
@@ -22,8 +22,8 @@ const groupUsage = `usage:
a name in use replaces that group
magpie group set <id> k=v… change one: name, models (the whole list, in order, patterns too),
models+=<m>[,m2…] (append), models-=<m>[,m2…] (drop), routing, stays,
context (how long a request agents are told it takes: 272k; empty is
its largest model's), levels (the reasoning levels agents are offered:
context (how long a request agents are told it takes: 272k; smallest
is its smallest model's; empty is its largest model's), levels (the reasoning levels agents are offered:
levels=none,low,medium,high,xhigh,max; empty is those every model has —
a model without the one asked is sent its nearest),
family (a tag: magpie visible shows agents families, not each group),
@@ -402,8 +402,12 @@ func applyGroupPairs(g *provider.Group, pairs []string, resolve func(string) (st
case "context":
// what agents are told the group takes; empty or 0 is its
// largest model's again
g.Context = 0
if strings.TrimSpace(v) != "" {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "largest", "max":
g.Context = 0
case "smallest", "min":
g.Context = provider.ContextSmallest
default:
g.Context, err = parseTokens(v)
}
case "levels", "level":
+9 -1
View File
@@ -87,7 +87,15 @@ func TestApplyGroupPairs(t *testing.T) {
if err := applyGroupPairs(&g, []string{"models-=x"}, resolve, false); err != nil || strings.Join(g.Members, " ") != "b/y" {
t.Fatalf("drop by bare id: %v %v", err, g.Members)
}
for _, bad := range [][]string{{"models"}, {"colour=red"}, {"id=x"}, {"models=nope"}, {"routing=fast"}, {"models-=a/m"}} {
for _, c := range []struct {
v string
want int
}{{"272k", 272000}, {"smallest", provider.ContextSmallest}, {"largest", 0}, {"1m", 1000000}, {"", 0}} {
if err := applyGroupPairs(&g, []string{"context=" + c.v}, resolve, false); err != nil || g.Context != c.want {
t.Errorf("context=%s: %d %v", c.v, g.Context, err)
}
}
for _, bad := range [][]string{{"models"}, {"colour=red"}, {"id=x"}, {"models=nope"}, {"routing=fast"}, {"models-=a/m"}, {"context=lots"}} {
h := g
if err := applyGroupPairs(&h, bad, resolve, false); err == nil {
t.Errorf("%v: no error", bad)
+27
View File
@@ -2032,6 +2032,15 @@ const I18N = {
"Wait": "一直等",
"2 min": "2 分钟",
"Slow to start": "迟迟不出字",
"Largest model's": "最大模型的",
"Smallest model's": "最小模型的",
"e.g. 200k": "如 200k",
"Agents are told the group takes {n} tokens; a longer conversation still goes on to a member with room for it.": "告诉 agent 这个组的上下文是 {n} tokens;更长的对话仍会交给放得下它的成员。",
"Agents are told {n} tokens, its smallest model's, so they compact before any member would turn the conversation away.": "告诉 agent {n} tokens(组里最小模型的),这样 agent 会在任何成员拒收之前先压缩对话。",
"Agents are told its smallest model's window, so they compact before any member would turn the conversation away.": "告诉 agent 组里最小模型的上下文,这样 agent 会在任何成员拒收之前先压缩对话。",
"Agents are told {n} tokens, its largest model's; a conversation too long for one member goes on to one with room for it.": "告诉 agent {n} tokens(组里最大模型的);某个成员放不下的对话会交给放得下的成员。",
"Agents are told its largest model's window; a conversation too long for one member goes on to one with room for it.": "告诉 agent 组里最大模型的上下文;某个成员放不下的对话会交给放得下的成员。",
"{v} is not a length of tokens like 200k or 1m": "{v} 不是有效的 token 长度,例如 200k 或 1m",
"slow to start": "迟迟未开始",
"Every member is waited for, however long it takes to begin answering.": "每个成员都会一直等到它开始回答,不论多久。",
"A member that hasn't begun answering after {n} — no text, reasoning or tool call yet — is let go and the next one asked, before any of it reaches the agent. It doesn't rest; the last one left is always waited for. Only for streamed requests.": "成员过了 {n} 还没开始回答(没有文字、思考或工具调用),就放弃它、改问下一个,这时它的回复还没有任何内容发给 agent。它不会被休息;最后剩下的那个总会一直等。只对流式请求生效。",
@@ -5042,6 +5051,15 @@ const I18N = {
"Wait": "待つ",
"2 min": "2 分",
"Slow to start": "応答開始が遅いとき",
"Largest model's": "最大のモデル",
"Smallest model's": "最小のモデル",
"e.g. 200k": "例: 200k",
"Agents are told the group takes {n} tokens; a longer conversation still goes on to a member with room for it.": "エージェントにはこのグループのコンテキストを {n} トークンと伝えます。より長い会話は収まるメンバーに回されます。",
"Agents are told {n} tokens, its smallest model's, so they compact before any member would turn the conversation away.": "エージェントには最小モデルの {n} トークンを伝えるので、どのメンバーにも断られる前に会話を圧縮します。",
"Agents are told its smallest model's window, so they compact before any member would turn the conversation away.": "エージェントには最小モデルのコンテキストを伝えるので、どのメンバーにも断られる前に会話を圧縮します。",
"Agents are told {n} tokens, its largest model's; a conversation too long for one member goes on to one with room for it.": "エージェントには最大モデルの {n} トークンを伝えます。あるメンバーに長すぎる会話は収まるメンバーに回されます。",
"Agents are told its largest model's window; a conversation too long for one member goes on to one with room for it.": "エージェントには最大モデルのコンテキストを伝えます。あるメンバーに長すぎる会話は収まるメンバーに回されます。",
"{v} is not a length of tokens like 200k or 1m": "{v} は 200k や 1m のようなトークン数ではありません",
"slow to start": "応答開始が遅い",
"Every member is waited for, however long it takes to begin answering.": "どのメンバーも、応答を始めるまでどれだけかかっても待ちます。",
"A member that hasn't begun answering after {n} — no text, reasoning or tool call yet — is let go and the next one asked, before any of it reaches the agent. It doesn't rest; the last one left is always waited for. Only for streamed requests.": "{n} 経っても応答を始めない(テキスト、推論、ツール呼び出しがまだない)メンバーは、何もエージェントに届く前に打ち切られ、次のメンバーに問い合わせます。そのメンバーは休止しません。最後に残ったメンバーは常に待ちます。ストリーミングリクエストのみ対象です。",
@@ -8044,6 +8062,15 @@ const I18N = {
"Wait": "Warten",
"2 min": "2 Min.",
"Slow to start": "Langsamer Start",
"Largest model's": "Größtes Modell",
"Smallest model's": "Kleinstes Modell",
"e.g. 200k": "z. B. 200k",
"Agents are told the group takes {n} tokens; a longer conversation still goes on to a member with room for it.": "Agenten wird gesagt, die Gruppe fasst {n} Tokens; ein längeres Gespräch geht weiter an ein Mitglied mit Platz dafür.",
"Agents are told {n} tokens, its smallest model's, so they compact before any member would turn the conversation away.": "Agenten wird {n} Tokens gesagt, das kleinste Modell, sodass sie verdichten, bevor ein Mitglied das Gespräch ablehnt.",
"Agents are told its smallest model's window, so they compact before any member would turn the conversation away.": "Agenten wird das Fenster des kleinsten Modells gesagt, sodass sie verdichten, bevor ein Mitglied das Gespräch ablehnt.",
"Agents are told {n} tokens, its largest model's; a conversation too long for one member goes on to one with room for it.": "Agenten wird {n} Tokens gesagt, das größte Modell; ein für ein Mitglied zu langes Gespräch geht an eines mit Platz dafür.",
"Agents are told its largest model's window; a conversation too long for one member goes on to one with room for it.": "Agenten wird das Fenster des größten Modells gesagt; ein für ein Mitglied zu langes Gespräch geht an eines mit Platz dafür.",
"{v} is not a length of tokens like 200k or 1m": "{v} ist keine Tokenlänge wie 200k oder 1m",
"slow to start": "langsamer Start",
"Every member is waited for, however long it takes to begin answering.": "Auf jedes Mitglied wird gewartet, egal wie lange es bis zum Antwortbeginn braucht.",
"A member that hasn't begun answering after {n} — no text, reasoning or tool call yet — is let go and the next one asked, before any of it reaches the agent. It doesn't rest; the last one left is always waited for. Only for streamed requests.": "Ein Mitglied, das nach {n} noch nicht zu antworten begonnen hat — noch kein Text, kein Reasoning, kein Tool-Aufruf —, wird aufgegeben und das nächste gefragt, bevor etwas davon den Agenten erreicht. Es pausiert nicht; auf das letzte verbleibende wird immer gewartet. Nur für gestreamte Anfragen.",
+3
View File
@@ -331,6 +331,9 @@ button.rt-session { cursor: pointer; }
.rt-gedit { cursor: default; }
.rt-gedit .ehead.fold { cursor: pointer; }
.rt-gedit .segs { max-width: 100%; }
.rt-gedit .ctx-wrap { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rt-gedit .ctx-wrap .hint { flex-basis: 100%; }
.rt-gedit .ctx-wrap input.ctx-in { width: 96px; flex: none; }
.rt-pool { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 10px 12px 10px 14px; }
.rt-pool .nm { flex: 1 1 12em; display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13px; }
.rt-pool .nm .ic { width: 16px; height: 16px; }
+39 -2
View File
@@ -2479,7 +2479,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], match: [...(g.match || [])], matched: [...(g.matched || [])], fast: [...(g.fast || [])], off: [...(g.off || [])], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", sink: !!g.sink, firstToken: g.firstToken || 0, classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); };
const open = () => { gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], match: [...(g.match || [])], matched: [...(g.matched || [])], fast: [...(g.fast || [])], off: [...(g.off || [])], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", sink: !!g.sink, firstToken: g.firstToken || 0, context: g.context || 0, classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } }; renderGroups(); };
row.onclick = open;
row.oncontextmenu = (e) => { e.preventDefault(); groupMenu(ics, g); };
row.append(ics, main, tags, edit);
@@ -2833,6 +2833,42 @@
fw.append(segs(FIRST_OPTS.map(([n, l]) => [n, t(l)]), d.firstToken || 0, (v) => { d.firstToken = +v; drawFirst(); }), fHint);
ed.append(el("label", "", t("Slow to start")), fw);
drawFirst();
// the window agents are told: the largest member's, the smallest's, or
// one the user names (Mikan on Discord)
const ctxHint = el("div", "hint"), ctxW = el("div", "ctx-wrap");
const ctxIn = keys(input(d.context > 0 ? ctxShort(d.context).toLowerCase() : "", t("e.g. 200k")));
ctxIn.classList.add("ctx-in");
const ctxs = () => d.members.map((id) => groups.models.find((x) => x.id === id)?.context || infoOf(id)?.context || 0).filter((n) => n > 0);
const ctxMode = () => d.context === -1 ? "smallest" : d.context > 0 ? "custom" : "largest";
let ctxErr = "";
const drawCtx = () => {
const ns = ctxs(), mode = ctxMode();
ctxIn.hidden = mode !== "custom";
const big = ns.length ? Math.max(...ns) : 0, small = ns.length ? Math.min(...ns) : 0;
ctxHint.textContent = ctxErr || (mode === "custom"
? t("Agents are told the group takes {n} tokens; a longer conversation still goes on to a member with room for it.", { n: (d.context || 0).toLocaleString() })
: mode === "smallest"
? (small ? t("Agents are told {n} tokens, its smallest model's, so they compact before any member would turn the conversation away.", { n: small.toLocaleString() }) : t("Agents are told its smallest model's window, so they compact before any member would turn the conversation away."))
: (big ? t("Agents are told {n} tokens, its largest model's; a conversation too long for one member goes on to one with room for it.", { n: big.toLocaleString() }) : t("Agents are told its largest model's window; a conversation too long for one member goes on to one with room for it.")));
};
const CTX_OPTS = [["largest", "Largest model's"], ["smallest", "Smallest model's"], ["custom", "Custom"]];
ctxW.append(segs(CTX_OPTS.map(([id, n]) => [id, t(n)]), ctxMode(), (v) => {
ctxErr = "";
if (v === "largest") d.context = 0;
else if (v === "smallest") d.context = -1;
else { const ns = ctxs(); d.context = d.context > 0 ? d.context : (ns.length ? Math.min(...ns) : 200000); ctxIn.value = ctxShort(d.context).toLowerCase(); }
drawCtx();
if (v === "custom") ctxIn.focus();
}), ctxIn, ctxHint);
ctxIn.oninput = () => {
const m = ctxIn.value.trim().toLowerCase().replace(/_/g, "").match(/^(\d+(?:\.\d+)?)\s*([km]?)$/);
const n = m ? Math.round(+m[1] * (m[2] === "k" ? 1e3 : m[2] === "m" ? 1e6 : 1)) : 0;
ctxErr = n > 0 ? "" : t("{v} is not a length of tokens like 200k or 1m", { v: ctxIn.value.trim() });
if (n > 0) d.context = n;
drawCtx();
};
ed.append(el("label", "", t("Context")), ctxW);
drawCtx();
const aHint = el("div", "hint", t(AFF_HINT[d.affinity] || AFF_HINT[""]));
const aw = el("div");
aw.append(segs(AFF_OPTS.map(([id, n]) => [id, t(n)]), d.affinity, (v) => { d.affinity = v; aHint.textContent = t(AFF_HINT[v] || AFF_HINT[""]); }), aHint);
@@ -2845,6 +2881,7 @@
rAdd.append(svg(PLUS, 11, 1.8), el("span", "", t("Add a rule")));
const rHint2 = el("div", "hint");
const drawRules = () => {
drawCtx(); // the members' windows changed with them
rlist.replaceChildren();
rAdd.hidden = d.members.length < 2;
rHint2.textContent = t(d.members.length < 2 ? "With two models or more, a rule can send some turns to one of them first."
@@ -3107,7 +3144,7 @@
if (own && !d.levels.length) return status(t("Pick a level to offer, or leave them to its models"), "warn");
saveBtn.classList.add("busy");
// refused, Add can be pressed again (busy, it takes no clicks)
groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: namedOf(d), match: d.match, routing: d.routing, pick: d.pick || "", affinity: d.affinity, sink: !!d.sink && sinkable(d.routing), firstToken: d.firstToken || 0, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: g?.context || 0, levels: own ? d.levels : [], family: g?.family || "", fast: d.fast.filter((x) => d.members.includes(x)), off: d.off.filter((x) => d.members.includes(x)) }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }))
groupAction("save", { id: idOf(), from: g?.id, name: d.name.trim() || idOf(), members: namedOf(d), match: d.match, routing: d.routing, pick: d.pick || "", affinity: d.affinity, sink: !!d.sink && sinkable(d.routing), firstToken: d.firstToken || 0, rules: d.rules, effort: d.effort, classifier: d.rules.some((r) => r.intent) || d.effort === "auto" ? d.classifier : "", context: d.context || 0, levels: own ? d.levels : [], family: g?.family || "", fast: d.fast.filter((x) => d.members.includes(x)), off: d.off.filter((x) => d.members.includes(x)) }, t(g ? "{name} saved" : "{name} added", { name: d.name.trim() || idOf() }))
.then(() => saveBtn.classList.remove("busy"));
};
// what goes wrong is said where it is seen, never a click that does nothing
+132
View File
@@ -0,0 +1,132 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// A routing group says which context window agents are told (Mikan on
// Discord: a custom length, or the shortest or longest member's). The
// group editor's "Context" row picks Largest model's (0, as before),
// Smallest model's (-1, followed as the members change) or Custom, with a
// box that takes 300k or 1m; its hint says the length agents are told.
// In English and Chinese, Chromium and WebKit.
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: "oa/gpt-6.1-sol", name: "gpt-6.1-sol", providerName: "OpenAI", icon: "generic", context: 1000000 },
{ id: "an/claude-opus-5-5", name: "claude-opus-5-5", providerName: "Anthropic", icon: "generic", context: 200000 },
{ id: "ds/deepseek-flash", name: "deepseek-flash", providerName: "DeepSeek", icon: "generic", context: 128000 },
];
const members = models.map((m) => m.id);
const saved = { context: 0 };
const groups = () => ({
models, pools: [],
groups: [{ id: "sol", name: "Sol", members, routing: "order", ready: true, context: saved.context,
memberInfo: members.map((id) => ({ id, ready: true })) }],
});
const words = {
en: { edit: "Edit", save: "Save", label: "Context", largest: "Largest model's", smallest: "Smallest model's", custom: "Custom", n1m: "1,000,000", n128: "128,000", n300: "300,000", bad: "not a length" },
zh: { edit: "编辑", save: "保存", label: "上下文", largest: "最大模型的", smallest: "最小模型的", custom: "自定义", n1m: "1,000,000", n128: "128,000", n300: "300,000", bad: "不是有效的" },
};
function serve(lang, posts) {
const state = { agents: [{ id: "codex", name: "Codex", path: "/test/codex", fields: [] }], profiles: [], settings: { lang, theme: "light" } };
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/plugins") return json({ plugins: [] });
if (url.pathname === "/api/gateway/trace") {
if (url.searchParams.get("wait")) return new Promise(() => {});
return json({ mine: true, now: new Date().toISOString(), seq: 1, totals: { requests: 0, 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/")) {
const body = JSON.parse(r.request().postData() || "{}");
posts.push({ path: url.pathname, body });
saved.context = body.context || 0;
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 group's context is its largest, smallest or a named length`, async (t) => {
saved.context = 0;
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 1100, height: 2000 }, reducedMotion: "reduce" })).newPage();
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-group-context.png`) });
}
await browser.close();
});
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 card = page.locator(".rt-group", { hasText: "Sol" });
await card.waitFor();
const ed = page.locator(".rt-gedit");
const row = ed.locator("label", { hasText: new RegExp(`^${w.label}$`) }).locator("xpath=following-sibling::div[1]");
const hint = () => row.locator(".hint").textContent();
const save = async () => {
await ed.locator("button.primary", { hasText: w.save }).click();
await page.waitForFunction(() => !document.querySelector(".rt-gedit"));
return posts.filter((p) => p.path === "/api/groups/save").at(-1).body;
};
const open = async () => { await card.locator("button", { hasText: w.edit }).click(); await row.waitFor().catch((e) => { throw new Error(errors.join(" / ") || e.message); }); };
await open();
assert.equal(await row.locator(".opt.on").textContent(), w.largest);
assert(await row.locator("input").isHidden(), "no box for the largest");
assert((await hint()).includes(w.n1m), await hint());
await row.locator(".opt", { hasText: w.smallest }).click();
assert((await hint()).includes(w.n128), await hint());
assert.equal((await save()).context, -1);
await open();
assert.equal(await row.locator(".opt.on").textContent(), w.smallest, "the editor opens at what was saved");
await row.locator(".opt", { hasText: w.custom }).click();
const box = row.locator("input");
assert.equal(await box.inputValue(), "128k", "custom starts at the smallest");
await box.fill("abc");
assert((await hint()).includes(w.bad), await hint());
await box.fill("300k");
assert((await hint()).includes(w.n300), await hint());
assert.equal((await save()).context, 300000);
await open();
assert.equal(await row.locator(".opt.on").textContent(), w.custom);
assert.equal(await row.locator("input").inputValue(), "300k");
await row.locator(".opt", { hasText: w.largest }).click();
assert.equal((await save()).context, 0);
const missing = await page.evaluate(() => [
"Largest model's", "Smallest model's", "Custom", "e.g. 200k",
"Agents are told the group takes {n} tokens; a longer conversation still goes on to a member with room for it.",
"Agents are told {n} tokens, its smallest model's, so they compact before any member would turn the conversation away.",
"Agents are told its smallest model's window, so they compact before any member would turn the conversation away.",
"Agents are told {n} tokens, its largest model's; a conversation too long for one member goes on to one with room for it.",
"Agents are told its largest model's window; a conversation too long for one member goes on to one with room for it.",
"{v} is not a length of tokens like 200k or 1m",
].filter((k) => !I18N.zh[k]));
assert.deepEqual(missing, [], "every string has its Chinese");
assert.deepEqual(errors, []);
});
}
}
@@ -65,7 +65,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
test(`${engine} ${lang}: a group lets a slow-to-start member go`, async (t) => {
saved.firstToken = 0;
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 }, reducedMotion: "reduce" })).newPage();
const page = await (await browser.newContext({ viewport: { width: 1100, height: 2000 }, reducedMotion: "reduce" })).newPage();
t.after(async () => {
if (process.env.ARTIFACT_DIR) {
await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true });
+1 -1
View File
@@ -65,7 +65,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const w = words[lang];
test(`${engine} ${lang}: a group finds models by patterns`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const page = await (await browser.newContext({ viewport: { width: 1100, height: 1400 }, reducedMotion: "reduce" })).newPage();
const page = await (await browser.newContext({ viewport: { width: 1100, height: 2000 }, reducedMotion: "reduce" })).newPage();
t.after(() => browser.close());
page.setDefaultTimeout(5000);
const errors = [], posts = [];
+14
View File
@@ -55,6 +55,20 @@ func TestContextSetByUser(t *testing.T) {
if got := of(); got != 100000 {
t.Errorf("set lower: %d", got)
}
// Mikan on Discord: told the smallest member's, kept as smallest when
// the members change
if err := SaveGroup(Group{ID: "g", Name: "g", Members: []string{"a/m1", "b/m2"}, Context: ContextSmallest}); err != nil {
t.Fatal(err)
}
if got := of(); got != 128000 {
t.Errorf("smallest member's: %d", got)
}
if err := SaveGroup(Group{ID: "g", Name: "g", Members: []string{"b/m2"}, Context: -7}); err != nil {
t.Fatal(err)
}
if got := of(); got != 200000 {
t.Errorf("smallest after a member left: %d", got)
}
}
// #712: a group of DeepSeek V4.1 Flash (1M) at WorkBuddy and OpenCode Go,
+16
View File
@@ -97,6 +97,10 @@ func (g Group) Live() Group {
}
// Group is a routing group.
// ContextSmallest is a group's Context when agents are told the window of
// its smallest member.
const ContextSmallest = -1
type Group struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -146,6 +150,9 @@ type Group struct {
Effort string `json:"effort,omitempty"`
// Context is how long a request the user says the group takes, in
// tokens: agents are told it rather than its largest member's.
// ContextSmallest tells them its smallest member's, whichever that is
// as the members change (Mikan on Discord), so the agent compacts
// before any member would turn the conversation away.
Context int `json:"context,omitempty"`
// Levels are the reasoning levels agents are offered for the group,
// lowest first, when the user names them (#295): rather than those
@@ -554,6 +561,7 @@ func groupEntries(entries []Entry) []Entry {
// gateway sends max to the members that have no ultra
ultra := false
chatgpt := false // a ChatGPT account answers for a member
smallest := 0 // the smallest window a member is known to have
for i, m := range ms {
chatgpt = chatgpt || m.Provider.Account != nil && m.Provider.Account.Agent == "codex"
if !slices.ContainsFunc(ms[:i], func(o Member) bool { return o.Provider.ID == m.Provider.ID }) {
@@ -594,6 +602,9 @@ func groupEntries(entries []Entry) []Entry {
if ctx > e.Context {
e.Context = ctx
}
if ctx > 0 && (smallest == 0 || ctx < smallest) {
smallest = ctx
}
if i == 0 {
e.ImageInput = imageInput
} else {
@@ -654,6 +665,8 @@ func groupEntries(entries []Entry) []Entry {
ruledEntry(&e, g.Live(), ms, entries)
if g.Context > 0 {
e.Context = g.Context
} else if g.Context == ContextSmallest && smallest > 0 {
e.Context = smallest
}
e.Family = g.Family
out = append(out, e)
@@ -693,6 +706,9 @@ func SaveGroup(g Group) error {
g.Name = g.ID
}
g.FirstToken = max(g.FirstToken, 0)
if g.Context < 0 {
g.Context = ContextSmallest
}
// the members it names and its patterns, without what the patterns
// matched when it was read: those are found again below, and stored
// never