diff --git a/groups_cli.go b/groups_cli.go index fb7d28db..af058ec5 100644 --- a/groups_cli.go +++ b/groups_cli.go @@ -22,8 +22,8 @@ const groupUsage = `usage: a name in use replaces that group magpie group set k=v… change one: name, models (the whole list, in order, patterns too), models+=[,m2…] (append), models-=[,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": diff --git a/groups_cli_test.go b/groups_cli_test.go index 1e176909..f9ae50b6 100644 --- a/groups_cli_test.go +++ b/groups_cli_test.go @@ -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) diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index dc3a73a4..ea8a6a64 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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.", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index 4f78d51e..18e17004 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -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; } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 49beae7d..f23d27fc 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -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 diff --git a/internal/gui/tests/group-context.test.cjs b/internal/gui/tests/group-context.test.cjs new file mode 100644 index 00000000..55cf8e89 --- /dev/null +++ b/internal/gui/tests/group-context.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/gui/tests/group-first-token.test.cjs b/internal/gui/tests/group-first-token.test.cjs index 95e50952..1954a03b 100644 --- a/internal/gui/tests/group-first-token.test.cjs +++ b/internal/gui/tests/group-first-token.test.cjs @@ -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 }); diff --git a/internal/gui/tests/group-patterns.test.cjs b/internal/gui/tests/group-patterns.test.cjs index e3d33b24..32a48983 100644 --- a/internal/gui/tests/group-patterns.test.cjs +++ b/internal/gui/tests/group-patterns.test.cjs @@ -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 = []; diff --git a/internal/provider/context_test.go b/internal/provider/context_test.go index 0ad40877..41a38e2f 100644 --- a/internal/provider/context_test.go +++ b/internal/provider/context_test.go @@ -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, diff --git a/internal/provider/group.go b/internal/provider/group.go index 8ca41c0e..9544f095 100644 --- a/internal/provider/group.go +++ b/internal/provider/group.go @@ -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