Skip to content
RasaGet a free licence
Guides for AI teams

Guide · AI product engineer

How to stop an AI agent acting on an outdated confirmation

When an order or offer changes after the customer says yes, have the tool check that the terms read out still hold before acting.

by Rod Rivera

About 9 minutes

Key takeaways (3)
  • A customer's yes covers the terms they heard, not whatever the record says when the tool runs.
  • Before acting, have the tool compare the words read out with what the system would do now.
  • If they differ, change nothing, read out the new terms and ask again.

Your agent reads an order, an offer or a change back to the customer and asks “shall I go ahead?”. The customer says yes. Between the question and the action, something changes: a price is recalculated, a charge goes through or a hold runs out. The agent goes ahead anyway, on terms the customer never heard.

Here is that moment in a sample agent for Willow Shop, a fictional retailer. A member asks it to stop her Style Box subscription renewing. The sample’s test service was built to charge the October box early, just after her yes:

Chat transcriptLive run of the sample agent, GPT-5.5, 30 September 2026. The question names version r4; after the yes the record is r5. The member is scripted. Trimmed: the greeting and the closing turns are left out, and tool lines summarise the tool results.
  1. Member

    Stop my Style Box renewing, I don’t want the October box.

  2. Agent

    Please confirm this change to your Style Box (WS-SUB-3304): stop the renewal, not an immediate cancellation. It takes effect 2026-10-01 00:00 UTC. What changes for you: no charge on 2026-10-01; no October box; nothing refunded. (Change WS-SUB-3304 stop_renewal r4.) Shall I make this change? If you meant a different change, say which.

  3. Member

    Yes.

  4. Tool

    apply_subscription_change → blocked, benefit_loss_hidden, effects 0

    Annotation:

    The box was charged after the yes, so the record moved from r4 to r5. The words she agreed to were no longer true, and the tool changed nothing.

  5. Agent

    The October Style Box was charged early while this chat was open, so the effect has changed. Stopping the renewal now takes effect 2027-01-01 00:00 UTC. The October box ships 2026-10-05, there is no charge on 2027-01-01, and there is no refund. Do you still want to stop the renewal?

  6. Member

    Oh. Then I’d rather cancel it now and get the $60 back, if that stops the October box.

  7. Agent

    Please confirm this change to your Style Box (WS-SUB-3304): cancel now, so access ends today. It takes effect 2026-09-30 09:00 UTC. What changes for you: ends today 2026-09-30; October box cancelled; $60.00 refunded. (Change WS-SUB-3304 cancel_now r5.) Shall I make this change? If you meant a different change, say which.

  8. Member

    Yes, cancel it now.

  9. Tool

    apply_subscription_change → succeeded, WS-CHG-D1E144

The member and agent lines are unedited from the run’s saved conversation, except that one paragraph break in the agent’s reply after the refusal is joined.

She agreed to “no October box”. By the time her yes reached the tool, that meant a paid box. Once she heard so, she chose a different change.

The fix is to have the tool compare the terms that were read out with what the system would do now, and refuse when they differ. Then the agent reads out the new terms and asks again. Without that check, a yes can authorise a change the customer never agreed to.

What you need:

  • To run the tests in this guide: Python 3 and Git. They need no licence, model or network.
  • To run the sample agent live: a Rasa Pro licence and an OpenAI API key. The free Developer Edition licence works. In the sample’s folder, make env copies .env.example to a new .env file. Put the keys there, in RASA_LICENSE and OPENAI_API_KEY.
  • Rasa Mantle, the agent runtime in the rasa-pro package, is in beta. The sample pins a pre-release, rasa-pro 3.21.0.dev5.

Why the old yes gets through

Rasa can hold a tool until the customer confirms. You declare this in the skill, the file that describes one task the agent can do. Rasa then sends a fixed confirmation question and waits for the answer. When the customer says yes, it runs the call it was holding, with the arguments it already had.

Nothing on that path looks at the record again. Only your own system knows what the change means now, so closing the gap between hearing and acting is your tool’s job.

Web developers know this as the lost update problem. HTTP’s If-Match header is most often used to prevent it: the server refuses the write if the record has changed. The check in this guide is not that guarantee on its own. It runs in your tool, just before the command. What it adds is what you compare: the customer agreed to a sentence, so the check compares that sentence.

To see what goes wrong without it, take the sample’s check and reduce it to “the question was asked and answered”. Then replay the Style Box yes offline:

The same yes, in an offline replay written for this guide

Avoid: Check reduced to asked and answered

member: Yes.
status: succeeded | effects: 1
effective: 2027-01-01 00:00 UTC
entitlements: October box ships 2026-10-05 (paid); no charge on 2027-01-01; no refund
service commands: [{'subscription': 'WS-SUB-3304', 'command': 'stop_renewal', 'request_key': 'RK-9170FA12'}]

She was promised no October box. She gets a paid one.

Prefer: The sample's full check

member: Yes.
status: blocked | effects: 0
effective: None
entitlements: None
service commands: []

No command reaches the subscription service.

This replay is a short script written for this guide, not one of the sample’s tests. It selects the Style Box change, answers “Yes.” and applies it. The left pane runs it on a copy of the sample where the disclosed = (...) expression in change_facts is replaced with disclosed = asked. The right pane runs it on the unchanged code. Both outputs are trimmed: the first line, which prints the question, is left out.

How to bind the yes to what was read out

select tool writes terms and version Rasa asks the fixed question customer says yes action tool compares read-out terms with now sends the command same blocked new terms returned different select again
  1. Only a tool writes the terms and version that go into the question.
  2. The tool that acts checks that what was read out is still true.
  3. If not, it changes nothing, and the agent selects again so Rasa asks with the new terms.
FigureWhere the check sits between the yes and the action

The sample keeps this in four files in its skill folder plus two shared modules in lib/. In your own project, the same pieces go in the skill for the task that changes something. Get the code:

git clone https://github.com/RasaHQ/rasa-community-resources.git
cd rasa-community-resources
git checkout 4aa0c4419dc193fef7a969c12d59edcf720f2606
cd examples/mantle-text-retail-loyalty-gpt

Put the terms and a version in the question

The confirmation question is a response template. Rasa fills it from memory, the values the agent keeps during the conversation. This is the sample’s skills/subscription_change/responses.yml (excerpt):

responses:
  utter_confirm_subscription_change:
    - text: >
        Please confirm this change to your {change_subscription}:
        {change_label}. It takes effect {change_effective}. What changes for
        you: {change_entitlements}. (Change {change_tag}.) Shall I make this
        change? If you meant a different change, say which.

change_tag joins the subscription, the change type and the record’s version number, for example WS-SUB-3304 stop_renewal r4. In lib/subscriptions.py it is one line:

def change_tag(sub_id: str, change_type: str, revision: int) -> str:
    return f"{sub_id} {change_type} r{revision}"

In your project, put every term the customer is agreeing to into the question. For a payment, a plan or a contract change, that usually means:

  • the amount
  • any fees
  • the date it takes effect
  • what the customer loses or gives up
  • the account, line or policy it applies to.

Let only a tool write those values

A tool called select_subscription_change asks the subscription service what the change would do. It writes the answers into memory. The fields are never llm_settable, so the model cannot set them. The sample’s skills/subscription_change/memory.yml (excerpt):

# Written only by select_subscription_change (never llm_settable), so what the
# engine reads back for confirmation is always the subscription service's.
...
schema:
  public:
    change_subscription:
      type: text
    change_tag:
      type: text

The tool writes the values through its ToolContext. Rasa passes it in as a keyword argument named context, so the parameter must have that name. From skills/subscription_change/tools.py:

def _write_memory(context: Optional[ToolContext], values: dict) -> None:
    if context is None:
        return
    for key, value in values.items():
        context.memory.set(key, value)

The skill then holds the tool that makes the change. This is the top of skills/subscription_change/skill.md (excerpt):

tool_constraints:
  - apply_subscription_change:
      requires: session.subscription_change.change_tag
      requires_confirmation:
        enabled: true
        utter_for_confirmation: utter_confirm_subscription_change

requires keeps the tool away from the model until a change is selected. requires_confirmation makes Rasa send the question above and wait for the answer.

Compare before you act

The tool that makes the change runs the comparison first. It finds the question Rasa sent by reading the conversation’s events. This is lib/conversation.py (excerpt):

def conversation_from_events(events: Iterable[Any]) -> Conversation:
    users: list[str] = []
    question = None
    answered = False
    for event in events:
        kind = type(event).__name__
        if kind == "UserUttered":
            text = getattr(event, "text", None) or ""
            if text.startswith("/"):
                continue  # /session_start and other intents are not the member's words
            users.append(text)
            if question is not None:
                answered = True
        elif kind == "BotUttered":
            metadata = getattr(event, "metadata", None) or {}
            if metadata.get(UTTER_ACTION_KEY) == CONFIRM_UTTER:
                question = getattr(event, "text", None) or ""
                answered = False
    return Conversation(confirmation_question=question, confirmation_answered=answered, user_messages=tuple(users))

The question is the latest bot message whose utter_action metadata names the confirmation response. It counts as answered only if a customer message follows. Messages that start with /, such as /session_start, are not the customer’s words.

Then it asks the service what the change would do now, and compares. This is the comparison in change_facts, in lib/subscriptions.py (excerpt):

        disclosed = (
            tag_rev == sub["revision"]
            and effective == effective_text(option["effective_at"])
            and entitlements == option["entitlements"]
            and asked
            and effective in question
            and entitlements in question
        )

Line by line, the yes counts only if:

  1. the version in the tag is still the record’s version
  2. the date read out is still the date the service gives now
  3. the line about what the customer keeps and loses is still the service’s line
  4. the tagged question was asked and answered
  5. the date and that line both appear in the question Rasa sent

If any of these is false, the tool returns blocked before it sends a command. The execution guard chapter of the guarding tutorial explains why this check belongs inside the tool, not in the skill’s configuration.

This check narrows the gap but does not close it. Another system could still change the record between the comparison and the command. In the sample, the command to the service carries the subscription, the change type and a request key, but no expected version or terms. In your own project, let the system that makes the change refuse it too. Send an expected version to your core banking or billing API, use an If-Match header if it supports one, or check the amount inside the same database transaction as the write.

Refuse with the new terms

The sample’s refusal carries the current terms and the reason, so the agent has something to tell the customer. This is the result from the Style Box run (excerpt, trimmed to the fields used here):

{
  "status": "blocked",
  "reason": "benefit_loss_hidden",
  "effects": 0,
  "current": {
    "effective": "2027-01-01 00:00 UTC",
    "entitlements": "October box ships 2026-10-05 (paid); no charge on 2027-01-01; no refund"
  },
  "why": "The October Style Box was charged early, on 2026-09-30, while this chat was open."
}

Step 5 of the skill tells the model what to do with it: “When a result is blocked with benefit_loss_hidden, the subscription changed while you were talking. Say why, call @tool.select_subscription_change again and tell the member the new effect. Apply only after they agree.” Selecting again writes the new terms and version into memory, so Rasa’s next question carries them.

Take no version from the model

The tool that makes the change takes only the subscription and the change type from the model. This is its signature in skills/subscription_change/tools.py:

async def apply_subscription_change(subscription: str, change_type: str, context: ToolContext = None) -> ToolResult:

The version, date and terms come from memory that only the select tool writes. A model that passed the version could pass the wrong one. The sample’s test test_no_tool_takes_dates_amounts_or_facts fails if any tool gains a parameter for a date, an amount or a revision.

How to check the fix works

Two offline tests cover the comparison. The first replays the Style Box move from r4 to r5. The second keeps the version current but swaps in a false line, “benefits end today; all points kept”. Both must be refused.

From the sample’s folder, run:

Any system
python3 -m unittest -v tests.test_guard.ApplyTests.test_a_revision_change_voids_the_disclosure tests.test_guard.ApplyTests.test_an_entitlement_line_edited_in_memory_is_not_a_disclosure