From ab7b39f8490227379f347f306bacc57ac0e5cd4e Mon Sep 17 00:00:00 2001 From: Solenta Date: Fri, 6 Nov 2026 17:31:47 +0100 Subject: [PATCH 1/2] Show what tools and memory take of the context; warn when Ollama's window is too small (#211) - mimir doctor and the bot page: tools offered, about how many tokens, and standing memory. Warn above ~8k tool tokens, naming the biggest tool servers. - Ollama bots: read the window from /api/ps (loaded) or the model's num_ctx, and warn when tools + memory leave too little for the conversation. - routine_create's description: 2,851 -> 1,667 characters. - A test keeps the built-in tools' definitions under 28,000 characters (26,433 now). Co-Authored-By: Claude Opus 5.5 --- crates/mimir-core/src/doctor.rs | 57 +++++++++++++++++++++++++ crates/mimir-core/src/host.rs | 6 +++ crates/mimir-core/src/provider.rs | 29 +++++++++++++ crates/mimir-core/src/tools/mod.rs | 8 ++++ crates/mimir-core/src/tools/specs.rs | 64 +++++++++++++++++----------- docs/ai-accounts.md | 5 +++ ui/src/views/bot.tsx | 4 ++ 7 files changed, 149 insertions(+), 24 deletions(-) diff --git a/crates/mimir-core/src/doctor.rs b/crates/mimir-core/src/doctor.rs index 9e10a73..3e5dbe3 100644 --- a/crates/mimir-core/src/doctor.rs +++ b/crates/mimir-core/src/doctor.rs @@ -74,6 +74,10 @@ pub async fn run(home: &Path) -> Vec { } out.push(account(&b.name, &b.provider, &seen[&b.provider])); } + for b in &bots { + let (line, warnings) = context(&db, b).await; + out.push(if warnings.is_empty() { ok(format!("{}: {line}", b.name)) } else { bad(State::Warn, format!("{}: {line}", b.name), warnings.join("\n ")) }); + } for b in &bots { out.extend(channels(&b.name, crate::host::channels_of(b.id)).await); } @@ -223,6 +227,40 @@ fn account(bot: &str, kind: &str, v: &serde_json::Value) -> Check { } } +/// How much of every turn's context a bot's tools and standing memory take, and what's wrong with that: tools past +/// about 8k tokens, or a local model whose window they (nearly) fill. For `mimir doctor` and the bot page. +pub async fn context(db: &Db, bot: &crate::db::Bot) -> (String, Vec) { + let ctx = crate::tools::ToolCtx::new(db.clone(), bot.id, crate::host::LOCAL_CHAT); + let (count, tools, servers) = crate::tools::weight(&ctx); + let memory = crate::tools::tokens(db.standing_usage(&ctx.scope()).map_or(0, |u| u.0)); + let line = format!("tools offered: {count}, about {}; standing memory about {}", k(tools), k(memory)); + let mut warnings = vec![]; + if tools > 8000 { + let big: Vec = servers.iter().take(3).map(|(s, t)| format!("{s} ({})", k(*t))).collect(); + let big = if big.is_empty() { String::new() } else { format!(" The biggest tool servers: {}.", big.join(", ")) }; + warnings.push(format!("That's a lot of tools for every message.{big} Switch off what it doesn't need under What it can do.")); + } + if bot.provider == "local" { + let base = Some(bot.base_url.trim()).filter(|b| !b.is_empty()).unwrap_or(crate::provider::OLLAMA); + // Leave about 4k for its instructions and the conversation itself. + let need = tools + memory + 4096; + if let Some(window) = crate::provider::ollama_context(base, &bot.model).await.filter(|w| (*w as usize) < need) { + let want = need.next_power_of_two().max(16384); + warnings.push(format!( + "Ollama is set to {window} tokens for {}; Mimir's tools and memory take about {} of that before the conversation starts. Start Ollama with OLLAMA_CONTEXT_LENGTH={want}, or switch off tools it doesn't need.", + bot.model, + k(tools + memory) + )); + } + } + (line, warnings) +} + +/// Tokens as "3.7k tokens". +fn k(tokens: usize) -> String { + format!("{:.1}k tokens", tokens as f64 / 1000.0) +} + /// Each connected channel answers with its token (who the bot is there). async fn channels(bot: &str, ch: crate::runtime::Channels) -> Vec { async fn within(f: impl std::future::Future>) -> anyhow::Result { @@ -334,4 +372,23 @@ mod tests { assert_eq!(account("Ava", "claude", &v(r#"{"installed":true,"signed_in":null}"#)).state, State::Warn); assert_eq!(account("Ava", "openai", &v(r#"{"installed":true,"signed_in":true}"#)).state, State::Ok); } + + #[tokio::test] + async fn context_counts_tools_and_memory() { + let p = std::env::temp_dir().join(format!("mimir-doctor-context-{}.db", std::process::id())); + let _ = std::fs::remove_file(&p); + let db = Db::open(&p).unwrap(); + // A local bot whose Ollama doesn't answer: no window known, so no warning about it. + let bot = serde_json::json!({"id":0,"name":"Ava","persona":"","provider":"local","model":"qwen3","base_url":"http://127.0.0.1:9/v1","tools":""}); + let id = db.bot_save(&serde_json::from_value(bot).unwrap()).unwrap(); + let (line, warnings) = context(&db, &db.bot(id).unwrap()).await; + assert!(line.starts_with("tools offered: ") && line.contains("k tokens; standing memory about 0.0k tokens"), "{line}"); + assert!(warnings.is_empty(), "{warnings:?}"); + // Only a few tools: fewer tokens. + let few = serde_json::json!({"id":id,"name":"Ava","persona":"","provider":"local","model":"qwen3","base_url":"http://127.0.0.1:9/v1","tools":"web_fetch"}); + db.bot_save(&serde_json::from_value(few).unwrap()).unwrap(); + let (fewer, _) = context(&db, &db.bot(id).unwrap()).await; + assert!(fewer.starts_with("tools offered: 1, about 0."), "{fewer}"); + let _ = std::fs::remove_file(&p); + } } diff --git a/crates/mimir-core/src/host.rs b/crates/mimir-core/src/host.rs index e1acbe2..1a6b9f3 100644 --- a/crates/mimir-core/src/host.rs +++ b/crates/mimir-core/src/host.rs @@ -447,6 +447,12 @@ impl Host { let (reply, secs) = boxed(|| p.check(&self.db)).await?; ok(json!({"reply": crate::tools::truncate(&reply, 200), "seconds": (secs * 10.0).round() / 10.0, "provider": p.name()})) } + // How much of each turn its tools and standing memory take, with any warning (the bot page). + "bot_context" => { + let bot = self.db.bot(arg(a, "bot_id")?)?; + let (line, warnings) = boxed(|| crate::doctor::context(&self.db, &bot)).await; + ok(json!({"line": line, "warnings": warnings})) + } "statuses" => { // A bot another bot made (bot_create, in the AI CLI's own tool process) starts here, so its routines run. // Not while this app's bots are switched off (it shows a server's). diff --git a/crates/mimir-core/src/provider.rs b/crates/mimir-core/src/provider.rs index daa363d..8dbdf86 100644 --- a/crates/mimir-core/src/provider.rs +++ b/crates/mimir-core/src/provider.rs @@ -1013,6 +1013,29 @@ async fn ollama_tools(http: &reqwest::Client, base: &str, model: &str) -> Option Some(v["capabilities"].as_array()?.iter().any(|c| c == "tools")) } +/// The context window Ollama gives `model`, in tokens: what it's loaded with now (`/api/ps`), else what its Modelfile +/// sets (`num_ctx`). None when it can't say: not Ollama, or not loaded and not set (Ollama then picks by GPU memory, +/// 4k under 24 GB, or OLLAMA_CONTEXT_LENGTH). +pub async fn ollama_context(base: &str, model: &str) -> Option { + let root = base.trim_end_matches('/').strip_suffix("/v1")?; + let http = reqwest::Client::builder().timeout(Duration::from_secs(2)).build().ok()?; + let same = |n: &str| n == model || n.strip_suffix(":latest") == Some(model); + if let Ok(r) = http.get(format!("{root}/api/ps")).send().await { + let ps: Value = r.json().await.unwrap_or_default(); + let loaded = ps["models"].as_array().into_iter().flatten().find(|m| m["name"].as_str().is_some_and(same) || m["model"].as_str().is_some_and(same)); + if let Some(n) = loaded.and_then(|m| m["context_length"].as_u64()) { + return Some(n); + } + } + let show: Value = http.post(format!("{root}/api/show")).json(&json!({"model": model})).send().await.ok()?.json().await.ok()?; + num_ctx(show["parameters"].as_str()?) +} + +/// `num_ctx` from `/api/show`'s parameters ("num_ctx 8192\nstop ..."). +fn num_ctx(params: &str) -> Option { + params.lines().find_map(|l| l.trim().strip_prefix("num_ctx")?.trim().parse().ok()) +} + impl Provider { /// One tiny real round-trip, for "Test" buttons. Returns (reply, seconds). pub async fn check(&self, db: &crate::db::Db) -> Result<(String, f64)> { @@ -1588,6 +1611,12 @@ mod tests { use super::*; + #[test] + fn ollama_num_ctx() { + assert_eq!(num_ctx("stop \"<|im_end|>\"\nnum_ctx 8192\ntemperature 0.7"), Some(8192)); + assert_eq!(num_ctx("temperature 0.7"), None); + } + #[test] fn api_accounts() { let home = PathBuf::from("/tmp"); diff --git a/crates/mimir-core/src/tools/mod.rs b/crates/mimir-core/src/tools/mod.rs index c481edb..6f8842d 100644 --- a/crates/mimir-core/src/tools/mod.rs +++ b/crates/mimir-core/src/tools/mod.rs @@ -645,6 +645,14 @@ pub(crate) fn looks_secret(db: &Db, text: &str) -> Option<&'static str> { #[cfg(test)] mod tests { + /// Every built-in tool's definition goes to the model each turn (a local model's whole context can be 4k tokens): + /// growing past this is a choice, made by raising it here. + #[test] + fn builtin_tools_stay_within_budget() { + let size = serde_json::to_string(&super::specs()).unwrap().len(); + assert!(size < 28_000, "the built-in tools' definitions are {size} characters, over the 28,000 budget"); + } + #[test] fn needs_you_suggests_a_rule_after_five_plain_allows_and_stops_when_dismissed() { let p = std::env::temp_dir().join(format!("mimir-rule-hints-{}.db", std::process::id())); diff --git a/crates/mimir-core/src/tools/specs.rs b/crates/mimir-core/src/tools/specs.rs index aa0139b..4a95fc0 100644 --- a/crates/mimir-core/src/tools/specs.rs +++ b/crates/mimir-core/src/tools/specs.rs @@ -246,30 +246,24 @@ pub fn specs() -> Vec<(&'static str, &'static str, Value)> { ( "routine_create", "Schedule a task: recurring with cron (5-field, the user's local time, e.g. '0 8 * * Mon-Fri'), or once with at \ - ('2026-10-12 15:00', the user's local time) for reminders and anything for later; a one-off is gone after it runs. \ - For something tied to the user's calendar, cron can be calendar:, optionally :meetings (only \ - events with someone else invited) or :outside (only ones with someone from another company): \ - 'calendar:60:outside' runs an hour before each meeting with someone outside, and each run is told the event. \ - For \"tell me when X arrives\" by email, cron can be mail: instead: from:
, \ - subject:, and any other words the email must contain, e.g. 'mail:from:acme.com subject:invoice'; \ - a|b is either one (subject:invoice|quote). \ - Mimir checks new mail every 2 minutes itself and runs you only for a match, with that email. It ends after the \ - first match unless keep=true (\"every time…\"). For a message in a Slack channel you're in, cron can be \ - slack:# with optional from:@ and words the message must contain, e.g. 'slack:#deploys failed'; \ - it works the same way (each run is told the message). \ - prompt is what you will be asked to do each time it fires; the result is sent to this chat. \ - Set quiet=true for watches/check-ins (\"keep an eye on…\", \"only tell me if…\"): it runs read-only and \ - messages the user only when something needs their attention. A quiet check about one web page or feed, or \ - about new email, should set watch (the page's address, or \"mail\"): Mimir then looks itself before each \ - run and only runs you when it changed, with what changed, which costs far less. \ - For someone else in the household (\"remind Sam at 6 to…\"), set for to their name: it runs in their own chat \ - (write prompt as what to tell them), and the first time for each person the user is asked. \ - A repeating one with an end (\"every morning until the 30th\", \"for two weeks\") sets until: after its last run \ - before then, it's gone. \ - When you tell the person you'll check back on something yourself (\"I'll check Friday whether they replied\"), \ - book it: at, with followup=true, and prompt as what to check and tell them. Say in your reply when, and that \ - they can cancel it. It counts like any routine.", - json!({"type":"object","properties":{"until":{"type":"string","description":"YYYY-MM-DD (through that day) or YYYY-MM-DD HH:MM: when a repeating routine ends"},"followup":{"type":"boolean","description":"with at: a check-back you booked on your own, not one the person asked for"},"cron":{"type":"string"},"at":{"type":"string","description":"YYYY-MM-DD HH:MM, for a one-off instead of cron"},"prompt":{"type":"string","description":"up to 1000 characters"},"quiet":{"type":"boolean"},"watch":{"type":"string","description":"quiet checks only: a page or feed URL, or \"mail\""},"keep":{"type":"boolean","description":"mail: and slack: only: run for every match, not just the first"},"for":{"type":"string","description":"a household member's name, to send it to their chat instead of this one"}},"required":["prompt"]}), + for reminders and anything for later (gone after it runs). prompt is what you'll be asked each time; the result \ + comes to this chat. cron can also be an event: calendar:[:meetings|:outside] ('calendar:60:outside': \ + an hour before each meeting with someone from another company); mail: for \"tell me when X arrives\" \ + (from:
, subject:, other words it must contain, a|b for either; e.g. \ + 'mail:from:acme.com subject:invoice|quote'); slack:# [from:@] [words]. Mimir watches for these \ + itself and runs you only on a match, told what matched. quiet=true for watches and check-ins (\"only tell me \ + if…\"): runs read-only and messages only when something needs attention.", + json!({"type":"object","properties":{ + "prompt":{"type":"string","description":"up to 1000 characters"}, + "cron":{"type":"string"}, + "at":{"type":"string","description":"YYYY-MM-DD HH:MM, for a one-off instead of cron"}, + "quiet":{"type":"boolean"}, + "watch":{"type":"string","description":"quiet, about one page/feed URL or \"mail\": runs only when it changed (far cheaper)"}, + "keep":{"type":"boolean","description":"mail:/slack: run on every match, not just the first"}, + "for":{"type":"string","description":"a household member's name: runs in their chat (prompt = what to tell them)"}, + "until":{"type":"string","description":"YYYY-MM-DD or YYYY-MM-DD HH:MM: a repeating one ends then"}, + "followup":{"type":"boolean","description":"with at: a check-back you promised (\"I'll check Friday\"); say when, and that they can cancel"} + },"required":["prompt"]}), ), ("routine_list", "List this chat's routines.", json!({"type":"object","properties":{}})), ( @@ -499,6 +493,28 @@ pub fn specs_for(ctx: &ToolCtx) -> Vec<(String, String, Value)> { all_specs(&ctx.db).into_iter().filter(|(n, ..)| bot.as_ref().is_none_or(|b| b.allows(n)) && usable(ctx, n)).collect() } +/// About how many tokens a text takes: ~4 characters each. +pub fn tokens(chars: usize) -> usize { + chars.div_ceil(4) +} + +/// What the tools offered to this bot take of the context: (how many, about how many tokens, each tool server's share +/// as (server, tokens), biggest first). +pub fn weight(ctx: &ToolCtx) -> (usize, usize, Vec<(String, usize)>) { + let tools = specs_for(ctx); + let (mut total, mut servers) = (0, std::collections::BTreeMap::::new()); + for (n, d, s) in &tools { + let t = tokens(n.len() + d.len() + s.to_string().len()); + total += t; + if let Some((server, _)) = n.split_once("__") { + *servers.entry(server.to_string()).or_default() += t; + } + } + let mut servers: Vec<_> = servers.into_iter().collect(); + servers.sort_by_key(|s| std::cmp::Reverse(s.1)); + (tools.len(), total, servers) +} + #[cfg(test)] mod tests { #[test] diff --git a/docs/ai-accounts.md b/docs/ai-accounts.md index 3c5155d..5943f41 100644 --- a/docs/ai-accounts.md +++ b/docs/ai-accounts.md @@ -100,6 +100,11 @@ The app finds a running Ollama or LM Studio and lists its models. Models that ca chat but can't remember things or run routines. Ollama's default 4k-token context drops older messages in a long conversation (instructions and memory are kept); start it with `OLLAMA_CONTEXT_LENGTH=16384` for more. +Mimir's tools and standing memory go with every message: the bot page (under What it can do) and `mimir doctor` +show about how many tokens they take. On an Ollama bot they also warn when Ollama's window, as `/api/ps` or the +model's `num_ctx` reports it, leaves too little for the conversation. Switching off tools a bot doesn't need makes +room too. + ## What a run costs The run log shows each reply's model, tokens and cost. For Claude it's the API list-price equivalent that `claude -p` diff --git a/ui/src/views/bot.tsx b/ui/src/views/bot.tsx index 2351ca3..e44353f 100644 --- a/ui/src/views/bot.tsx +++ b/ui/src/views/bot.tsx @@ -165,6 +165,8 @@ export function BotPage({ bot, status, canDelete, onSaved, onDeleted, onChat, on invoke('set_hands_off', { botId: bot!.id, on }).then(handsOff.reload, e => setErr(String(e))) } const computer = useLoad(() => invoke('computer'), [], 60000) + // Reloads when the saved bot changes (its tools or model), not as the form is edited. + const context = useLoad(() => bot ? invoke<{ line: string; warnings: string[] }>('bot_context', { botId: bot.id }) : Promise.resolve(null), [bot?.id, bot?.tools, bot?.model, bot?.base_url, bot?.provider], 60000) const serverTools: [string, string][] = (toolServers.data?.servers ?? []).filter(s => !s.preset).map(s => [`${s.name}__*`, `Use ${s.name}`]) const ALL = [...Object.keys(TOOL_LABEL), 'browser__*', ...serverTools.map(([t]) => t)] // What each tool does for this bot once it's on: Settings → Permissions (the same for every bot), defaults included. @@ -337,6 +339,8 @@ export function BotPage({ bot, status, canDelete, onSaved, onDeleted, onChat, on toggle(tool)} /> ))} + {context.data &&
{context.data.line[0].toUpperCase() + context.data.line.slice(1)}, as of the last save. + {context.data.warnings.map(w =>

{w}

)}
} Where its files go (file_list, file_read, file_write), on {pickHere ? 'this computer' : folder.data?.machine ?? 'the machine Mimir runs on'}. Pick a folder of yours to see and edit them in {platform === 'mac' ? 'Finder' : 'your file manager'}; paths stay inside it (no ../, no links out). Empty: Mimir's own workspace for each chat.}> From 05e0aff0b8398bea6bfc73181dad6bf9cf0c041c Mon Sep 17 00:00:00 2001 From: Willem Date: Fri, 6 Nov 2026 18:25:00 +0100 Subject: [PATCH 2/2] Context estimate: count the standing memory the bot's prompt actually gets Only this bot's memories (not ones kept for another bot), plus the household's when the chat is in one, as lib.rs builds the prompt. Co-Authored-By: Claude Opus 5.5 --- crates/mimir-core/src/doctor.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/mimir-core/src/doctor.rs b/crates/mimir-core/src/doctor.rs index 3e5dbe3..0db62bb 100644 --- a/crates/mimir-core/src/doctor.rs +++ b/crates/mimir-core/src/doctor.rs @@ -232,7 +232,13 @@ fn account(bot: &str, kind: &str, v: &serde_json::Value) -> Check { pub async fn context(db: &Db, bot: &crate::db::Bot) -> (String, Vec) { let ctx = crate::tools::ToolCtx::new(db.clone(), bot.id, crate::host::LOCAL_CHAT); let (count, tools, servers) = crate::tools::weight(&ctx); - let memory = crate::tools::tokens(db.standing_usage(&ctx.scope()).map_or(0, |u| u.0)); + // The standing memory this bot's prompt gets (as in lib.rs): this chat's, and the household's when it's in one. + let mut scopes = vec![ctx.scope()]; + if db.in_household(ctx.chat_id) { + scopes.push(crate::HOUSEHOLD.to_string()); + } + let chars = scopes.iter().flat_map(|s| db.bootstrap(s, bot.id).unwrap_or_default()).map(|e| e.line().len()).sum(); + let memory = crate::tools::tokens(chars); let line = format!("tools offered: {count}, about {}; standing memory about {}", k(tools), k(memory)); let mut warnings = vec![]; if tools > 8000 { @@ -384,6 +390,12 @@ mod tests { let (line, warnings) = context(&db, &db.bot(id).unwrap()).await; assert!(line.starts_with("tools offered: ") && line.contains("k tokens; standing memory about 0.0k tokens"), "{line}"); assert!(warnings.is_empty(), "{warnings:?}"); + // Another bot's own memory isn't in this bot's prompt, so it isn't counted. + let m = db.store("chat:0", "knowledge", "Theirs", &"x".repeat(400), "", None).unwrap(); + assert!(db.set_for_bot("chat:0", m, id + 1).unwrap()); + assert_eq!(context(&db, &db.bot(id).unwrap()).await.0, line); + assert!(db.set_for_bot("chat:0", m, id).unwrap()); + assert!(context(&db, &db.bot(id).unwrap()).await.0.contains("standing memory about 0.1k tokens")); // Only a few tools: fewer tokens. let few = serde_json::json!({"id":id,"name":"Ava","persona":"","provider":"local","model":"qwen3","base_url":"http://127.0.0.1:9/v1","tools":"web_fetch"}); db.bot_save(&serde_json::from_value(few).unwrap()).unwrap();