The sentence that started this project was five words long:
"I've sent the email."
It 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.
If 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.
This is the story of the fix in my open-source project, Quasnooze, and the one design decision I'd defend hardest if you asked me why it's built the way it is.
The problem: hallucination isn't just wrong facts, it's wrong #
actions Most 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.
Agentic 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:
- The training signal rewards helpful-sounding completions. "I've sent it" is a more satisfying answer than "I can't confirm that."
- 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.
- The failure is invisible to the developer unless you deliberately check for it. The HTTP response was 200. The text streamed fine. The turn "succeeded." My 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. It mostly worked. 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. Prompting, in other words, was asking the same component that made the error to be the component that catches it.
The fix: audit the reply, don't trust it #
The insight I built around is small but it changes the architecture:
The model's reply is not output. It's a claim, and claims can be checked against evidence.
At the end of every turn, Quasnooze has two things the model doesn't fully control:
The tool trace — which tools actually ran, whether they returned ok, whether the user denied them.
The memory facts — what's genuinely in the store, not what the model wishes were there.
So 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.
Here's the actual API — this runs standalone, no server, no model:
import { auditReply, renderGuardNote } from "./src/guard.js";
const result = auditReply({
reply: "Done — I've sent the email to priya@example.com.",
trace: [], // nothing actually ran this turn
memoryFacts: [],
userText: "email priya about the launch",
});
console.log(result.flags);
console.log(renderGuardNote(result.flags));
Real output from that snippet:
[
{
"code": "action-without-evidence",
"message": "the reply says \"i've sent\" but no tool ran that could have done it"
},
{
"code": "invented-email",
"message": "an address appears that was never given to me: priya@example.com"
}
]
And the note the user actually sees:
---
🛡️ **Code check:** the reply above needs a second look.
• the reply says "i've sent" but no tool ran that could have done it
• an address appears that was never given to me: priya@example.com
_Quasnooze checks claims against what actually ran — this one did not line up._
The 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:
A silent correction is just a tidier way of lying about what happened.
If 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.
The same principle — never let one layer quietly speak for another — shaped the permission system.
When 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:
git clone https://github.com/Quasynvo/Quasnooze.git
cd Quasnooze
npm run setup # configure model, workspace, access key, optional channels
npm run doctor # check what's actually working, including what isn't
npm run brain # verify your model is reachable
npm start # panel on http://localhost:7777 by default
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.
If you want the voice visuals in your own project, the renderer is a plain ES module with no build step:
import { createVisualizer } from "/visualizer.js";
const motion = createVisualizer(document.getElementById("voiceCanvas"));
// Drive this from real application state, not from a decorative timer.
motion.setState("thinking");
I'd rather undersell this than have you find the gaps yourself:
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.
PRs are welcome; there's a CONTRIBUTING.md and the test suite is plain