{"slug": "how-i-built-a-local-ai-assistant-that-admits-when-it-didn-t-actually-do-the", "title": "How I built a local AI assistant that admits when it didn't actually do the thing", "summary": "A developer built Quasnooze, an open-source local AI assistant that audits each model reply against the turn's actual tool trace and memory facts rather than trusting the prose. The guard module, src/guard.js, is a pure function that flags claims like \"I've sent the email\" when no tool ran and invented details such as email addresses never provided, addressing agentic hallucination where small models narrate actions that never occurred.", "body_md": "The sentence that started this project was five words long:\n\n\"I've sent the email.\"\n\nIt hadn't. Nothing had run. There was no email tool call in that turn, no SMTP connection, no record of anything. Just a small local model, a confident tone, and a sentence that was completely false.\n\nIf you've wired a 7B model up to function calling, you've probably met the same failure. It's not a crash. It's not an exception in your logs. It's worse: **it's a fluent, well-formed paragraph that silently disagrees with reality.** Your UI shows a checkmark, the user moves on with their day, and the thing never happened.\n\nThis is the story of the fix in my open-source project, [Quasnooze](https://github.com/Quasynvo/Quasnooze), and the one design decision I'd defend hardest if you asked me why it's built the way it is.\n\n## The problem: hallucination isn't just wrong facts, it's wrong\n\n*actions*\nMost discussion of hallucination is about knowledge. The model says the library was released in 2015 when it was 2017. Annoying, but visible — the user can catch it.\n\nAgentic hallucination is different in kind. When a model has tools, it doesn't just describe the world, it *narrates its own effects on the world*. And small models are especially prone to narrating effects that didn't happen, because:\n\n- The training signal rewards helpful-sounding completions. \"I've sent it\" is a more satisfying answer than \"I can't confirm that.\"\n- A tool call can fail, get denied, time out, or never be emitted at all — but the model's next token prediction doesn't care about any of that.\n- The failure is\n**invisible to the developer** unless you deliberately check for it. The HTTP response was 200. The text streamed fine. The turn \"succeeded.\"\nMy first attempt was the obvious one: prompt engineering. A system prompt with strong instructions — never claim an action you didn't take, only refer to tool output, don't invent contact details. I wrote it carefully. I iterated on it.\nIt mostly worked.\n**That's what made it dangerous.** A 90%-effective prompt doesn't fail randomly, it fails *confidently* — and it fails exactly on the turns where the model feels most helpful, which are the turns where you're least likely to double-check. Worse, a stronger model doesn't fix it, it just produces a more convincing paragraph. The lie gets better grammar.\nPrompting, in other words, was asking the same component that made the error to be the component that catches it.\n## The fix: audit the reply, don't trust it\nThe insight I built around is small but it changes the architecture:\nThe model's reply is **not output**. It's a **claim**, and claims can be checked against evidence.\nAt the end of every turn, Quasnooze has two things the model doesn't fully control:\n**The tool trace** — which tools actually ran, whether they returned `ok`, whether the user *denied* them.\n**The memory facts** — what's genuinely in the store, not what the model wishes were there.\nSo instead of trusting the prose, the app compares the prose to the evidence. `src/guard.js` is a pure function that takes the reply, the trace, the memory facts, the user's text and the tool output, and returns a list of flags.\nHere's the actual API — this runs standalone, no server, no model:\n\n``` js\nimport { auditReply, renderGuardNote } from \"./src/guard.js\";\nconst result = auditReply({\n  reply: \"Done — I've sent the email to priya@example.com.\",\n  trace: [],                       // nothing actually ran this turn\n  memoryFacts: [],\n  userText: \"email priya about the launch\",\n});\nconsole.log(result.flags);\nconsole.log(renderGuardNote(result.flags));\n```\n\nReal output from that snippet:\n\n```\n[\n  {\n    \"code\": \"action-without-evidence\",\n    \"message\": \"the reply says \\\"i've sent\\\" but no tool ran that could have done it\"\n  },\n  {\n    \"code\": \"invented-email\",\n    \"message\": \"an address appears that was never given to me: priya@example.com\"\n  }\n]\n```\n\nAnd the note the user actually sees:\n\n```\n---\n🛡️ **Code check:** the reply above needs a second look.\n  • the reply says \"i've sent\" but no tool ran that could have done it\n  • an address appears that was never given to me: priya@example.com\n_Quasnooze checks claims against what actually ran — this one did not line up._\n```\n\nThe tempting version of this feature silently corrects the text, or regenerates the response, or quietly drops the offending sentence. I deliberately didn't do any of that, for a reason I now feel strongly about:\n\n**A silent correction is just a tidier way of lying about what happened.**\n\nIf the code edits the model's words, the user cannot tell whether the assistant genuinely succeeded or whether a regex papered over a failure. So the guard appends a visible note and never touches the model's text. The transparency *is* the product. A flag is a nudge, not an alarm — the styling is deliberately calm, because false positives are real and I don't want to train people to panic.\n\nThe same principle — *never let one layer quietly speak for another* — shaped the permission system.\n\nWhen a tool is sensitive, the model can't approve it. Neither can a spoken \"yes.\" Neither can an animation. Approval is a distinct path from the visual state machine, which means:\n\n```\ngit clone https://github.com/Quasynvo/Quasnooze.git\ncd Quasnooze\nnpm run setup   # configure model, workspace, access key, optional channels\nnpm run doctor  # check what's actually working, including what isn't\nnpm run brain   # verify your model is reachable\nnpm start       # panel on http://localhost:7777 by default\n```\n\n`npm run doctor` deserves a mention: it only probes loopback origins, and it will happily tell you that a feature is *not* ready rather than pretend. It's the most-used script in the repo for a reason — most \"it doesn't work\" issues are configuration, not code.\n\nIf you want the voice visuals in your own project, the renderer is a plain ES module with no build step:\n\n``` js\nimport { createVisualizer } from \"/visualizer.js\";\nconst motion = createVisualizer(document.getElementById(\"voiceCanvas\"));\n// Drive this from real application state, not from a decorative timer.\nmotion.setState(\"thinking\");\n```\n\nI'd rather undersell this than have you find the gaps yourself:\n\n`node --test`, and not one of them can tell you whether a microphone works.`auditReply`. Those cases are worth more than stars, and I'll credit them.`npm run doctor` usually points at the culprit.\nPRs are welcome; there's a `CONTRIBUTING.md` and the test suite is plain", "url": "https://wpnews.pro/news/how-i-built-a-local-ai-assistant-that-admits-when-it-didn-t-actually-do-the", "canonical_source": "https://dev.to/kayato123_fca8d3c04a54849/how-i-built-a-local-ai-assistant-that-admits-when-it-didnt-actually-do-the-thing-25bh", "published_at": "2026-11-04 18:48:39+00:00", "updated_at": "2026-11-04 18:53:58.146748+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-tools", "large-language-models", "developer-tools"], "entities": ["Quasnooze", "Quasynvo"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-i-built-a-local-ai-assistant-that-admits-when-it-didn-t-actually-do-the", "markdown": "https://wpnews.pro/news/how-i-built-a-local-ai-assistant-that-admits-when-it-didn-t-actually-do-the.md", "text": "https://wpnews.pro/news/how-i-built-a-local-ai-assistant-that-admits-when-it-didn-t-actually-do-the.txt", "jsonld": "https://wpnews.pro/news/how-i-built-a-local-ai-assistant-that-admits-when-it-didn-t-actually-do-the.jsonld"}}