v0.11.0 — Agentic-loop trust + reliable web search
Agentic-loop hardening and prompt trust: every fix in this release closes a way a long session
could hang, thrash, silently lose its mind, or roll past the user's "stop" — each found by
dogfooding and verified live. Every interactive menu now speaks one visual language, arrow keys
are deterministic, and plan cancellation is enforced by the app instead of requested of the
model. Under the hood, the largest file in the codebase was decomposed and warnings are now
build errors — so this release also makes the next one safer to build. Web search also stops
depending on DuckDuckGo's goodwill: an optional free Tavily key makes it reliable.
Fixed
-
Models no longer recite their knowledge cutoff instead of using the web search they have — observed live on minimax-m3: asked for current weather, the model claimed "I don't have any tool to fetch live weather feeds" (false — search_web was registered), recommended the user try Google and weather.com, and asked permission to search instead of searching; the same setup on minimax-m2.7 searched fine. Two causes. First, the prompt's web guidance was a passive "use search_web when you need current information" buried at guideline #9 — no match for a trained cutoff-disclaimer reflex, and once a model disclaims live access once, self-consistency keeps it disclaiming for the rest of the conversation (the bad session opened with a from-memory-answerable question, which set the no-search precedent). Second, the static prompt advertised search even when
EnableWebSearchwas off.SystemPrompts.MandoCodeAssistantis nowBuildMandoCodeAssistant(webSearchEnabled): enabled, an assertive LIVE WEB ACCESS section instructs search-FIRST for anything recent (no asking permission), forbids claiming a lack of internet access, and forbids punting the user to Google; disabled, the prompt stops advertising tools that aren't registered and points at/config set websearch trueinstead.AIServicerebuilds the prompt on every settings path, andRefreshSettingsAsyncswaps the in-history system message in place so toggling websearch mid-conversation actually reaches the model instead of waiting for the next/clear. -
"Cancel the plan" now actually stops the work — immediately — cancelling at a diff approval previously only set a flag that the plan runner checked after the whole step finished. A step that wrote three files kept right on presenting file #2 and file #3 for approval after the user had already cancelled at file #1, because Semantic Kernel's auto-invoke loop was still executing the response's remaining tool calls — and the "stop all further work" message returned to the model is a polite request that models demonstrably ignore (the same lesson as the plan-prompt cancel fix below). New plan-cancellation circuit in
FunctionInvocationFilter, first in priority above the budget and dedup circuits: once a scope is flagged cancelled, every subsequent tool call — writes, reads, commands — is refused mechanically, before any approval UI is reached. Cancellation went from a stop sign the model could roll through to a stop gate. Covered by regression tests reproducing the exact observed 3-file scenario, including that a fresh turn isn't poisoned by a previously cancelled scope. -
Arrow keys at the plan-approval menu no longer get eaten ("press twice" lag) — two console readers were racing: Spectre's blocking
SelectionPromptand RazorConsole's framework keyboard pump (KeyboardEventManagerpollsConsole.KeyAvailableevery 50ms and reads unconditionally — no focus check, no pause API, and itsIConsoleInputseam isinternalso it can't be substituted). An arrow pressed while Spectre was mid-repaint sat in the buffer just long enough for the pump to steal it and drop it (no focused VDOM element exists mid-turn). The plan-approval prompt is now a VDOM component (ApprovalSelect) fed by the pump itself — one reader, no race, by construction. The remaining Spectre menus (step-failure prompt, the fourDiffApprovalHandlerapprovals) carry the same latent race at lower traffic and migrate next using the same component. -
Changing settings mid-task can no longer freeze the stall watchdog permanently —
/config setrebuilds the kernel; the rebuild detached event handlers from the oldFunctionInvocationFiltereven when a tool call was still in flight on it, so that call's completion never reached the pending-function tracker. The count stayed pinned > 0 and the stall watchdog — which only resumes at count 0 — stayed paused for the rest of the session (the same failure surface as the pending-count leak below, through a different door).BuildKernelnow deliberately leaves the old filter's handlers attached: in-flight calls complete and decrement correctly, nothing fires twice (new calls route through the new kernel), and the old filter becomes collectible once its calls finish. -
Step-failure "How would you like to proceed?" prompt no longer fires as a fake-async method —
HandleProgressEventAsyncwas declaredasyncwith a fully synchronous body (CS1998); now an honest synchronousHandleProgressEvent. Surfaced by the new warnings-as-errors policy below. -
Assorted hardening from a full-codebase review —
ValidateModelAsyncdisposes its HTTP response;RequestTimeoutMinutesis clamped byValidateAndClamplike every other numeric setting (a hand-edited"requestTimeoutMinutes": 999previously loaded unclamped);FileSystemPlugin's grep/search loops only swallow file-access exceptions (IOException/UnauthorizedAccessException/NotSupportedException) instead of everything;MusicPlayerServiceteardown disposes each NAudio object in its own guarded step so one failingDisposecan't skip the rest. -
Concurrent approval prompts can no longer deadlock the session — new
ApprovalPromptGate(aSemaphoreSlim(1,1)async mutex) serializes every interactive approval surface: diff, delete, command, MCP, and plan approval. The kernel runs withAllowConcurrentInvocation = true, so a model response containing two approval-gated tool calls opened two blocking Spectre prompts at once — both looping onConsole.ReadKey, stealing each other's keys. The visible prompt completed; the invisible one blocked its function forever, the turn never ended, and the input prompt never returned. This was the primary cause of the "agent suddenly stops and won't display the prompt" hang on long agentic loops. The plan-approval prompt holds the gate only around the prompt itself, never around execution — plan steps' own approvals take the same gate, so holding it acrossExecutePlanAsyncwould deadlock the first step. -
Pending-function-count leak permanently paused the stall watchdog —
FunctionInvocationFilter.OnFunctionInvocationAsyncincremented the count and firedOnFunctionStarted~250 lines before thetry/finallythat decremented it, with seven hand-rolled cleanup copies on the early-return paths. An exception anywhere in that window (UI event handler, approval callback) skipped the decrement; the filter is long-lived, soPendingFunctionCountstayed pinned > 0 for the rest of the session and the stall watchdog — which only resumes at count 0 — never resumed again. Any later genuine model stall then hung silently until the 15-minute request ceiling. Restructured: onetry/finallyowns the lifecycle (InvokeCoreAsyncsplit), all seven manual copies deleted. The MCP approval gate also moved inside the lifecycle so the watchdog pauses while the user deliberates at an MCP prompt, like every other approval. -
Approval awaits now observe cancellation — all five approval awaits (MCP, edit, write, delete, command) are wrapped in
WaitAsync(context.CancellationToken). A wedged prompt previously blocked an await that observed no token, which defeated Esc, the stall watchdog, AND the request ceiling simultaneously — the turn could never unwind. -
"Cancel request" at the plan-approval prompt actually cancels — it previously returned a "stop here" string to the model as a tool result, a polite request that a frontier cloud model was observed simply ignoring (it executed the cancelled plan's steps itself via direct tool calls). Now it cancels
_requestCts— the same mechanical path as Esc — so the turn unwinds regardless of what the model thinks of the instruction. -
One plan per turn, enforced mechanically — observed runaway: a model completed a 5-step plan, immediately started building an uninvited duplicate of the project, then proposed a third round of work.
PlanHandoff's recursion guard only covers a plan that's still running. NewInvocationScope.PlanAlreadyProcessedcircuit: once a real plan (≥1 step) is handled — executed, rejected, or cancelled — any furtherpropose_planin the same turn is short-circuited with a stop directive. Malformed/empty proposals don't consume the slot, so a legitimate retry with corrected steps still works. The plan-completion summary returned to the model now also ends with an explicit wrap-up directive ("respond with a brief summary; do NOT create more files or propose another plan"). -
Plugin-level
edit_filefailures now count toward the N=3 edit-failure circuit — the circuit previously only counted preview-level failures. The preview validatesold_textagainst a snapshot captured at interception; when an earlier edit in the same concurrent batch landed in between, the plugin's re-validation failed AFTER the preview passed — and those failures bypassed the circuit entirely. Observed live: 9+ consecutive "Could not find the specified text" misses on one path with no trip.UpdateScopeForCompletedCallnow records error-resultedit_filecalls into the same counter. -
Per-scope bookkeeping keys are normalized across path aliases — the model was observed addressing one file as both
Games/index.htmlandsrc/MandoCode/bin/Debug/net8.0/Games/index.html(the plugin'sStripRedundantRootPrefixresolves both to the same file). Edit-failure counts, read-dedup keys, and modified-since-read tracking keyed on the raw strings, so an alias switch split the circuit counters and dodged trip thresholds. The plugin's resolution is now extracted asFileSystemPlugin.ResolvePathand the filter normalizes every bookkeeping key through it. Pre-execution content capture (diff previews) uses the same resolution, so aliased edit/write/delete targets now capture the real file instead of silently skipping the preview. -
cd X && <command>no longer swallowed by the cd interceptor —execute_command's cd handling treated the entire compound string as a directory path and failed withNo such directory: "X" && <command>. Compound commands (&&,;,|) now fall through to the real shell; barecd <dir>still retargets the project root. -
qwen3-coder:480b-cloudwas misclassified as a local model — every cloud check wasContains(":cloud"), but size-variant cloud tags end in-cloud, not:cloud. Worst impact:CheckCloudSignInAsynctold users whose only cloud model was qwen3-coder that they weren't signed in. Centralized intoMandoCodeConfig.IsCloudModel(matches both suffix forms) and adopted at every former call site. -
A completed plan's work can no longer be bulldozed by the model that requested it — plan steps execute in isolated chat histories, so the outer model never sees them run; all it got back was "Successfully completed N of N steps," and models routinely treated that verdict as "not started yet." Observed live: after a 4-step plan finished a working 740-line build, the outer model re-created the project folder and replaced the finished file with a fresh 63-line skeleton — every write auto-approved because the user had picked "don't ask again" during the plan, until a server error killed the turn mid-rewrite. Two layers, same doctrine as the cancellation fixes above (a stop gate, not a stop sign). Persuasion:
PlanHandoffnow returns an evidence manifest instead of a verdict — per-step statuses with capped result digests (~500 chars each, the whole manifest bounded so it doesn't eat small local context windows), the file operations recorded at the invocation-filter choke point (ground truth that the call ran and succeeded, not a model self-report), and an explicit stop directive. Enforcement: a new post-plan mutation circuit inFunctionInvocationFilterrefuseswrite_file/edit_file/delete_file/delete_folder/create_folderfor the rest of the turn once a plan has completed ≥1 step. Reads and shell stay allowed so the model can inspect results; rejected plans never arm the gate (the model must stay free to do the work directly); the gate is turn-scoped, so the next user message edits freely. Verified live: the same scenario now ends with a 278-token summary referencing the manifest and zero post-plan tool calls. -
Plan steps now see the user's actual request, not the model's summary of it — steps were seeded with only the
propose_plangoal, a lossy distillation. Observed live: "in @STarfox/ create a starfox64 inspired game…" became goal "create a starfox64 inspired game in three.js" — the folder reference vanished, every step wrote to the project root (editing a leftoverindex.htmlfrom an earlier run), and the final summary then claimed the game was "in the STarfox folder."ChatStreamAsyncnow captures the turn's verbatim user message — including the @file/@folder expansions — and every step's context includes it under an "Original Request" header declared authoritative for target folders and paths. Head-capped at 4k chars so a pasted @file can't flood every step's context on small local models (paths and intent lead a prompt, so they always survive truncation). Step-context construction extracted to a static, testedAIService.BuildStepContext. Verified live: the next run's first action wasCreateFolder(StarFox)and every diff landed inStarFox/. -
The
done_reason: "length"cutoff warning no longer misdiagnoses — one stop reason has three causes needing different cures, and the old message always said "increase max tokens with /config", which is exactly the wrong knob in two of them. NewBuildLengthCutoffNotice: output ≈maxTokens→ genuine response-cap advice; output far below the cap on a local model → the context window filled (points at the Ollama desktop app's Context length slider — which defaults to ~4k — or the/setupdaemon restart); same on a cloud model → server-side window,/clear/smaller-request advice with no local-daemon nonsense. Empty-content cases note that thinking models (qwen3, minimax) can spend the whole budget on internal reasoning with nothing visible to show.
Added
- Tavily web search with DuckDuckGo fallback — DuckDuckGo's free HTML endpoint rate-limits and temporarily blocks IPs under normal agentic use, so
search_webcould randomly fail with an opaque error.WebSearchPluginnow prefers Tavily (an LLM-optimized search API with a free ~1,000-searches/month tier) whenever a key is configured, keeping DuckDuckGo as the zero-config default and as the fallback when Tavily itself fails. Onboarding is deliberately just-in-time rather than upfront: when DuckDuckGo blocks a search (403/429/503 or its bot-challenge page), the tool result returned to the model explains why, where to get a free key, and the exact/config set tavilyKey <key>command — so the assistant teaches the fix in context at the exact moment search fails, which is when the user actually cares. Setting the key fires one live verification probe against Tavily and reports✓ verified/✗ rejectedimmediately (the key saves either way — the user may be offline). The key is masked everywhere it's displayed (tvly-…7890in/config show,DescribeKeys, and set confirmations), clearable with/config set tavilyKey clear, and overridable via theTAVILY_API_KEYenvironment variable — which deliberately bypasses the Program.cs env-override pattern so a laterSave()never persists an env-provided secret to disk. The/setupwizard gains a skippable step 7 (default: skip) explaining the trade-off, with keep/replace/remove handling for an existing key. NewSetResult.PostSetValidationdelegate letsConfigKeySetterstay synchronous and shared between the CLI and in-app command (below) while both run the async key probe post-save. - In-app
/config set <key> <value>— change any setting without leaving the session (or losing the conversation). When an error message says "raise the watchdog: /config set modelResponseTimeout 300", the user can now do exactly that, in place. With no arguments it lists every key with its current value and valid range. Backed by a new sharedConfigKeySetterused by BOTH the in-app command and the CLI's--config set, so key names and validation can never drift apart again — they already had: the stall-watchdog error recommended amodelResponseTimeoutkey the CLI didn't actually have (now it does, withwatchdogas an alias). Kernel-baked settings (temperature, maxTokens, toolBudget, plugin toggles) apply via a new history-preserving kernel rebuild (RefreshSettingsAsync); live-read settings take effect on the next message; restart-scoped keys say so explicitly. - Ranged reads:
read_file_contents(relativePath, startLine?, endLine?)— the ~10K output cap is unchanged, but truncation now cuts at a line boundary and names the exact resume point:[truncated at line 312 of 1051 — call read_file_contents with startLine=313 to continue]. Closes the "editing blind" failure on large generated files: the model could only ever see the first ~third of a 1,000-line file, composedold_textfrom memory of code it wrote below the truncation horizon, and thrash-looped on "Could not find" errors. The read-dedup key includes the range so paging is never mistaken for a redundant re-read; CRLF files round-trip byte-exact so what the model sees is whatedit_filematches. NewLARGE FILESsystem-prompt section teaches the model to page to the section it's editing and never composeold_textfrom memory. contextLengthconfig →OLLAMA_CONTEXT_LENGTHon daemon spawn — the SK Ollama connector exposes nonum_ctx, so MandoCode never set the context window; local models ran at Ollama's ~4k default, silently truncating the system prompt and earlier reads as agentic conversations grew (the root cause behind "model forgot its instructions" and several stall patterns). When MandoCode starts the daemon it now setsOLLAMA_CONTEXT_LENGTHfrom config (default 8192,0= leave Ollama alone, clamped 2048–262144,--config set contextLength <n>). Only applies when MandoCode launches the daemon — desktop-app users are steered to the app's own Context length slider by the new diagnostics.- Hardware-tier context auto-sizing — picking a local model in
/setupor/modelsizes the window from the parameter count in the tag (<3b → 8k, 3–6b → 16k, 7b+ → 32k; unparseable tags → 8k floor): the user self-selected the model knowing their GPU, so the tag is the best hardware signal available without probing VRAM. Cloud picks leave the setting alone (context is managed server-side at the model's full window).MandoCodeConfig.RecommendedContextLengthis the single mapping. /learnteaches the context window — new section with the "model's desk" analogy, the silent-forgetting failure mode, the ~0.5–1.5 GB VRAM per 8k rule of thumb, a warning that the Ollama desktop app's slider defaults to a small 4k, and an advanced note onOLLAMA_KV_CACHE_TYPE=q8_0.
Changed
/modelis now an actual quick switch — pick a model, done. The max-tokens picker step is removed from the flow (response length lives in/config); local picks auto-size the context window per the tier mapping. The command was also mislabeled everywhere as picking "context size" —MaxTokensmaps to Ollama'sNumPredict, the per-reply output cap, not the context window. All labels now say what the knob does.- Max-response-tokens tiers trimmed to 2k–64k — the 128k/200k tiers were context-window numbers wearing the wrong hat; no single reply is 200k tokens, agentic work is chunked across tool calls by design, and on local models a reply can't exceed the context window anyway.
MaxMaxTokenslowered to 65536;ValidateAndClampheals existing configs saved above it. Picker explainer now states explicitly that this is not the context window. - Recover from "request body too large" / HTTP 413 errors —
RetryPolicy.IsContextOverflowErrornow matches"request body too large","request entity too large","payload too large","413", andHttpRequestException.StatusCode == 413. Previously these transport-level rejections (Ollama's Go HTTP server enforces a request-body size limit before the model ever sees the prompt) fell through toFormatHttpFailure, which printed "Make sure Ollama is running: ollama serve" — wrong advice since the daemon was fine and the payload was too big. Now they route throughCompactChatHistoryAsyncand auto-retry the same way model-side context overflows do, so a long agentic session that blows the HTTP body limit recovers transparently instead of dying with a misleading sign-in nag. - Shell-based file reads via
execute_commandare refused — new circuit inFunctionInvocationFilter.OnFunctionInvocationAsyncshort-circuits commands whose first verb matchestype|cat|head|tail|more|less|nl|gc|Get-Content|findstr|grep|sls|Select-String|sed|awkand steers the model back toread_file_contents. Models that hitread_file_contents's 10K cap on large files were falling back totype foo.js | more +555orfindstr /n "x" *.cs, which bypassed the duplicate-read circuit and dumped fresh file content into chat history on every call — a major contributor to runaway request-body growth. The check uses a word-boundary regex sotypescript/category/etc. don't trigger, and piped filters likegit status | grep modifiedstill pass through (only the leading verb counts). edit_file"Could not find old_text" error includes the current file content —BuildEditPreviewin the filter andEditFilein the plugin both attach a content hint (capped at 5K chars with a truncation marker) when the match fails. Previously the model's only recovery path was to fire a separateread_file_contentstrip, which contributed to the cascade of redundant reads in long sessions. With the content inline, the model can correct itsold_textand retry in the next call without the round trip.- Edit-failure content hint is deduped per (scope, path) —
InvocationScope.HasEmittedEditHint/MarkEditHintEmittedtrack which paths have already had their content shipped this turn. First failure emits the 5K hint; subsequent failures emit a one-line pointer back ("the content was attached to an earlier failure — re-examine it"). Without this dedup, a model stuck in an exploratory loop (e.g. 20 failed edits on the same file as it hunts for the right region) shipped the same 5K blob 20 times, defeating the cascade-prevention the hint was meant to provide.RecordWriteclears the flag because the file's content has changed and any prior hint is stale. - N=3 edit-failure circuit per path —
InvocationScope.RecordEditFailure/GetEditFailureCounttrack consecutive failededit_fileattempts per path. AfterInvocationScope.EditFailureCircuitThreshold(3) failures without an intervening write, the filter refuses further edits on that path and steers the model towardread_file_contents(with a targeted line range) orwrite_file(whole-region rewrite). Caps the wasted-call cost of a stuck loop independently of the content-hint dedup — three retries is enough to absorb a normal "fix old_text and try again" pattern, but short enough to short-circuit a thrash loop before it eats the turn. - "Cancel the plan" from the step-failure prompt actually cancels the plan —
TaskPlannerService.ExecutePlanAsync's generic exception catch was decidingshouldCancelBEFORE theyieldthat prompted the user. So a "Cancel" pick setplan.Status = Cancelledafter the catch had already committed to "skip and continue", and the loop silently moved to the next step. Decision moved to after the yield; added aplan.Status == Cancelledguard at the foreach loop top as defense in depth (the previous version only checkedcancellationToken.IsCancellationRequested, which the user-prompt path doesn't trigger). AIServicedecomposed (~1,550 → ~1,100 lines) — the ~450-line text-based function-call fallback (for local models that emit tool calls as JSON text) moved to its ownFallbackFunctionCallExecutorservice, and the duplicated timeout/stall-watchdog/retry/token-recording scaffolding that existed once in the chat-turn path and again in the plan-step path is now a single sharedExecuteModelCallAsyncwith typed cancellation classification (ModelStallException/ModelCallTimeoutException). The two copies had already started drifting; a watchdog fix now lands once and applies everywhere.- Warnings are now build errors —
TreatWarningsAsErrorsenabled in both projects after fixing all five outstanding nullability warnings properly ([MemberNotNull]onBuildKernel, nonull!suppressions). The warning count is locked at zero permanently. - Default stall watchdog raised from 180s to 420s — model calls are non-streaming, so the watchdog receives no signal while the model generates and cannot distinguish "healthily generating a long reply" from "hung." With the response cap now at honest values, long single-round generations are possible — and on a busy cloud provider (~10 tok/s observed), a several-thousand-token reply legitimately runs past 3 minutes. Observed live: a plan step composing a design document was killed mid-generation by the 180s watchdog. 420s keeps genuine-stall detection bounded while giving slow generations room; existing configs keep their saved value (
/config set modelResponseTimeout 420to adopt). The durable fix — streaming model calls with per-chunk watchdog resets, so the watchdog measures silence rather than duration — is tracked for a future release. - Folder creation reviewed, deliberately left unprompted — audited whether
create_foldershould gate like writes/deletes. Verdict: it can't overwrite or destroy anything, it's confined to the project root,write_file(which is gated) already creates parent directories anyway, and git doesn't track empty dirs — while prompting for it would erode the attention users give to the approvals that matter. Documented decision, zero code.
UI
- One palette across every menu in the app — green = proceed, warm gold = redirect/decline, red = destructive, black-on-deepskyblue1 = current selection, deepskyblue1 = prompt titles. Applied to all five approval prompts (diff, command, delete, MCP, plan), the step-failure prompt (gold Skip / red Cancel the plan),
/config, all four/setupwizard menus, every onboarding menu,/model,/skill, and the music genre picker. Neutral pickers (models, genres, skills) get the title + highlight only — option colors stay reserved for decision prompts so the green/gold/red semantics keep their meaning. Supersedes 0.10.0's green-highlight convention. BothApp.razorandConfigurationWizardexpose a sharedSelectionHighlightstyle so future prompts inherit the standard by reference. - New
ApprovalSelectRazor component — arrow-key decision menu rendered through the VDOM with per-option colors (something RazorConsole's stockSelectcan't do), the standard highlight treatment, and a>indicator matching the Spectre look. Awaits observe the cancellation token, so Esc/timeouts unwind a turn stuck at the prompt. Component docs capture the hard-won rendering rule: element structure must stay identical across renders (vary attributes, never swap element shapes) or RazorConsole's VDOM differ collapses the menu.
Test coverage
393/393 tests passing. New: SystemPromptsTests (LIVE WEB ACCESS section present with anti-disclaimer rules when web search is on, no tool advertising when off, core identity intact in both variants), tavilyKey cases in ConfigKeySetterTests (set/clear aliases, masking in every message and listing, the wrong-prefix warning, the post-set validation hook), FallbackFunctionCallExecutorTests (36 cases — all three text-call JSON formats, escaped-string arguments, nested braces, name/parameter normalization), PlanCancellationCircuitTests (the exact observed cancel-leak scenario, driven through a real Kernel), PostPlanMutationGateTests (gate refusal without invocation across all five mutating functions, reads still allowed, fresh-scope reset, rejected plans not arming the gate, manifest content and capping, partial-completion arming), and PlanStepContextTests (verbatim-request inclusion, head-truncation survival of folder references, last-2 previous-results window).