Twin CV
Engineering

Stopping an LLM From Narrating Changes It Never Made

Our CV agent told a user “saved” for records that were never written. An intent gate and a two-model split didn't fix it. One loop, read-after-write checks and state the model can't argue with did.

Stopping an LLM From Narrating Changes It Never Made

The worst bug an agent with write access can have doesn't throw. In production, Twin CV's assistant twice told a user, in their own language, that a record had been saved. Both turns were logged as successful. The tables were empty.

This failure has a name, false success: the gap between an assistant's claim that something is done and the actual state of the system. In a write-heavy agent it's the failure that matters most, and in our case it had little to do with how good the model was. This post is the history of how we tried to stop it: a prompt rule, then an intent gate with an advisor/worker split, then a rewrite that deleted both. The last one worked, and why the first two didn't is the useful part.

“Saved,” said the agent
The bug

“Saved,” said the agent

Twin CV builds a structured CV from conversation. You say you worked at Acme from 2019 to 2023 as a backend developer, the assistant calls create_experiences, and a row appears in the editor beside the chat. It replies in whatever language you write in.

The transcript that started this was ordinary. The assistant asked a follow-up (which year did you start?), the user answered on the next turn, and the assistant confirmed the entry was saved. No tool was called on that turn. The run was recorded as succeeded, because from the outside it was: the model streamed text and stopped. Nothing in ordinary monitoring flags a turn whose only defect is that its last sentence is false.

Attempt 1

Tell the model not to

The first fix was a paragraph. A “capability gate” at the top of the system prompt mapped every kind of claim to the tool result that must back it: a statement about the CV's contents needed a read_cv result; a past-tense claim of a change (added, updated, removed, done) needed a successful write. It came with anti-examples (“Added that for you.”) and a self-check the model was told to run before every reply.

A rule the model has to remember is only as reliable as its attention to that rule, deep in a long conversation, under a prompt that keeps growing. By the time we deleted this one, the system prompt was 20,655 characters, and a large share of it was asking the model to check its own work.

Attempt 2

An intent gate and two agents

The second fix was architectural. If the model can't be trusted to call a tool when it should, take the decision away from it. Each turn became up to three model calls:

  1. The gate. A cheap classifier read the tail of the conversation and was forced to call exactly one tool, classify, with an intent of mutation, read or conversation.
  2. The advisor. The conversational model. On mutation it was forced to call delegate_to_worker; on read it was forced to read the CV before answering.
  3. The worker. A separate model holding the write tools, in its own transaction, also under forced tool use. It finished with final_response (commit) or report_blocker (missing_context, ambiguous_request or data_conflict: roll back everything and hand the advisor a structured reason). A missing_context blocker got one automatic retry.
_CLASSIFY_TOOL = LLMToolSchema(
    name="classify",
    description="Report the user's CURRENT intent for this turn. "
                "Call exactly once.",
    input_schema={
        "type": "object",
        "required": ["intent"],
        "properties": {
            "intent": {"type": "string",
                       "enum": ["mutation", "read", "conversation"]},
            "reason": {"type": "string", "maxLength": 300},
        },
    },
)
# called with tool_choice="any"; any provider error → "conversation"

On paper it was airtight: a confirmed change must reach a write tool, and a write either commits or rolls back. Forced tool use had a known cost we accepted: every reasoning (“thinking”) mode we tried refused a required tool_choice, so the gate and the worker always ran without it.

Why the gate made it worse
Post-mortem

Why the gate made it worse

The production failure above happened with all of this in place. The post-mortem found four causes, none of them a bad model:

  • The gate misread answers. “The user is answering the question I just asked” is neither a change request nor a question about the CV. The gate filed it as conversation (three times, in its own logged reasons), so the advisor ran unconstrained. A failed gate call also fell back to conversation: the safe-looking default was the unsafe path.
  • Every hand-off dropped the evidence. History passed between the models was rebuilt from display text. Tool calls and their results didn't survive the trip, so the advisor couldn't see that its own last write had failed, while its earlier “saved” lines sat in the transcript as precedent.
  • Unfinished work evaporated. The worker's blocker was discarded at the end of the turn. Nothing remembered that a create was half done.
  • Forced tool use had no exit. A turn that needed to ask the user a question had to burn a whole worker round-trip producing a blocker first.

Three models handing work to each other is a distributed system, and the bug lived in the seams.

The rewrite

One loop, one transcript

The rewrite deleted the gate, the advisor/worker split, delegate_to_worker, final_response and report_blocker. All of them existed only to compensate for the split. What's left is one loop:

for iteration in range(max_iterations):              # 25 by default
    reply = await stream(model, transcript, tools, tool_choice="auto")
    transcript.append(reply)                 # text AND tool_use blocks
    if not reply.tool_uses:
        verdict = await guard.check(draft, calls, state)
        if verdict is Correct and corrections_left:
            transcript.append(verdict.feedback)   # go round once more
            continue
        break                                     # the only exit
    results = await run_tools(reply.tool_uses)    # a transaction per call
    results.append(turn_report(calls))            # facts, every iteration
    transcript.append(results)

Three properties do the work. The only way out is the model returning no tool calls. The transcript is flat and replayed for real: stored turns keep their actual tool_use and tool_result blocks (and compaction folds whole turns, so a summary never lands between a call and its result), which means the next turn sees that a write failed instead of a paraphrase of the chat. And tool_choice is back to auto: the model can ask a question whenever it needs to, because safety no longer depends on forcing its hand.

The loop now lives in a small package with no imports from the app. Everything domain-specific arrives through ports, and a test enforces that boundary with an AST scan.

Defence 1

Verify every write

Every mutating CV tool has a verifier that asks the database whether the write happened, using only the ids the tool says it touched:

async def verify_cv_write(tool_name, ctx, parsed, result):
    noun, model = _entity_for(tool_name)      # "create_skills" → Skill
    ids = _ids_from(result)                   # any "*_ids" list it returned
    found = (await ctx.db.execute(
        select(func.count()).select_from(model)
        .where(model.id.in_(ids), model.user_id == ctx.user.id)
    )).scalar()
    if tool_name.startswith("delete_"):
        ok = found == 0
    else:
        ok = found == len(ids)
    return {"ok": ok, "entity": noun, "ids": ids}   # + "error" if not ok

The verdict is folded into the tool result under a reserved _verification key, so it can never overwrite a field the tool returned. If it says ok: false, the loop treats the call as failed: the model reads an error in the same turn and can fix it before it writes a word to the user. The check is deliberately narrow (existence and ownership, not field values) because existence is what failed in production, and re-reading the whole CV after each call would cost a full fetch per write and ride along in every later iteration.

Defence 2

Hand it the facts every turn

After every round of tool calls, the loop appends a report to the same message that carries the results:

<turn_report>
changes this turn: 1
  - update_profile: applied
  - create_experiences: FAILED — only 0 of 1 experiences are present in the database
This is what the database holds. Your reply must not describe anything
above as done that is not listed as applied.
</turn_report>

Two decisions matter more than the wording. The report rides on the tool-results message instead of arriving as its own, so the model reads the facts on the same pass where it decides what to say, and the transcript never has two user messages in a row, which not every provider accepts. And it appears every time, in the same shape, “changes this turn: none” included. A warning that appears only when something looks wrong is a signal, and models react to signals, often by calling a tool to make the warning go away. A constant carries no signal. It's just the state of the world.

A turn that calls no tool never gets an in-turn report, which is exactly the shape of the original bug. So each turn also opens with a report of what the previous turn changed, rendered from the stored audit trail. A bare “saved it” with nothing behind it gets confronted one turn later, for free.

The placement cost us one nasty bug. The OpenAI-compatible encoder emitted the report's text between the assistant's tool-call message and the tool messages, an ordering the API rejects with a 400. Because history replays, every later turn of that chat failed the same way. The stored rows were fine; the translation wasn't. Tool messages now go out first, and the scripted test providers run every call through the real encoder.

Defence 3

Remember unfinished work for it

The original transcript had a specific shape: the agent asked for a missing year, the user answered on the next turn, and nothing in the system knew a create was pending. The obvious fix, giving the model a tool to record what it's collecting, has the weakness of every “the model must remember to” rule.

So the draft is derived. A mutating call that fails on missing input (a validation error such as “Field required”) automatically becomes a pending entry in the conversation's state, and a later success on the same section clears it. The model never maintains it; it is shown a <pending> block at the top of every call. That state drives the one guard that runs when the model tries to finish:

async def check(self, ctx, draft):
    if draft.had_successful_mutation:
        return ACCEPT
    pending = (ctx.state or {}).get("pending_entry")
    if not pending:
        return ACCEPT                 # nothing started, nothing to misreport
    if any(c.mutating for c in draft.calls):
        return ACCEPT                 # it tried, failed, and has the error
    if pending.get("corrected_once"):
        return ACCEPT                 # one confrontation per draft
    await self._mark_corrected(ctx, pending)
    return Correct(
        StateReport.render(draft.calls)
        + "<unfinished>" + json.dumps(pending)
        + "This entry is still not in the CV. Nothing was written this "
          "turn. Either call the tool that writes it now, ask the user "
          "for what is missing, or — if the user cancelled this entry — "
          "call `discard_pending_entry`. Do not describe it as saved."
        + "</unfinished>")

Each condition was earned. The guard never fires on a turn that attempted a write: that turn already has the error and the report in front of it, and in practice answers honestly. It fires at most once per draft because, measured live, a pending draft plus a chatty user meant a correction round-trip on every turn, about 12,500 extra input tokens each time. When the user cancels, the model calls discard_pending_entry, which has a read-after-write verifier like every other mutating tool. And when the guard does fire, the text the user already saw stays on screen; the correction arrives as a visible continuation, so the stored reply equals what was streamed, character for character.

Why nothing reads the reply
Non-solutions

Why nothing reads the reply

The obvious guard is to scan the reply for “saved” and check it against reality. We had one. It doesn't survive an agent that answers in the user's language: a pattern list covers a handful of languages, and users write in whatever they write in. Worse than useless, it reads as coverage.

A second model as judge has the same blind spot from a different angle. It reads the same confident closing sentence the user reads, and false-success replies are full of confident closing sentences. What works is comparing against state the model can't argue with: rows that exist or don't, calls that returned ok or didn't.

What we'd tell another team
Takeaways

What we'd tell another team

  • If an agent can write, fear the bug that doesn't throw. Check claims against state, not against prompts.
  • Splitting one agent into specialists multiplies the seams where evidence gets lost. One loop over one replayed transcript removed a class of bugs instead of patching instances.
  • Don't make the model remember what the system already knows: derive drafts from failures, render reports from the audit trail.
  • Show the model constants, not alarms.

The rewrite was checked over a 29-turn conversation against a real model, not a mock: four context compactions, zero failed tool calls. The system prompt shrank from 20,655 to 7,787 characters, mostly by deleting the paragraphs that begged the model to check, and every new test was mutation-tested by reintroducing the bug it claims to catch and watching it go red.

You can talk to the result without an account: Quick CV runs the same loop to build a CV by chat. The other half of trusting an agent with writes is being able to take them back; that's in how we made every AI edit undoable.

What this looks like from the user's side: building a CV by chat, step by step.

FAQ

Questions about false success

A visitor asks a question at a help desk and the twin robot answers it
Is this specific to one model provider?

No. The same loop runs on Anthropic and OpenAI-compatible providers through one set of ports, and none of the defences depend on the model being good. They compare its claims with database state.

Why not keep forced tool use and fix the gate?

Because the gate was one model guessing another conversation's intent, and every guess has a failure rate. The current design doesn't guess: if a write was needed and didn't happen, state says so — a failed call, a pending draft, a report showing no changes.

Does the guard read the assistant's reply?

No. It looks only at state: whether the turn made a successful write, whether an unfinished entry is pending, and whether that entry has already been corrected once.

What stops the agent from looping forever?

An iteration ceiling per turn (25 by default), a stop when the same tool fails the same way three times in a row, at most one guard correction per turn, and a wall-clock timeout on the whole turn.

Related reads

How We Made Every AI Edit to a CV Undoable
November 8, 2026

How We Made Every AI Edit to a CV Undoable

Your twin is one conversation away.

Start free with Quick CV

Stay in the loop

Occasional product news and practical career tips — only when we have something worth your time.