Skip to content

tutorial

Chapter 6 of 6

Chapter 6 — Keep a refusal from becoming a success

by Rod Rivera Published

The last failure mode is linguistic: a tool refuses, the model reads a sympathetic message, and the caller hears a promise. Close it with a closed set of outcomes.

Everything so far guarantees that the card is not posted. There is one failure mode left, and it is not in the Python.

The tool returns {"ok": False, "message": "..."}. The model reads the message, finds it sympathetic, and tells the caller their card is on its way. Nothing crashed. The log says the tool refused. The caller heard a promise.

Outcomes as a closed set

class Result(str, Enum):
    OK                = "ok"                # a card was ordered
    STEP_UP_REQUIRED  = "step_up_required"  # nothing happened; caller can fix it
    COOLING_OFF       = "cooling_off"       # nothing happened; caller cannot
    DUPLICATE         = "duplicate"         # nothing NEW happened
    REFUSED           = "refused"           # nothing happened; ever

Five values, and the skill prose is written against categories rather than against message text somebody will reword next month.

The distinction between STEP_UP_REQUIRED and COOLING_OFF is the one that earns its keep. Both are refusals. Only one of them can be resolved on this call — and an agent that offers to “try again in a moment” after a cooling-off refusal is offering a workaround to a control that exists specifically to have no workaround.

One expression decides whether something happened

    @property
    def acted(self) -> bool:
        return self.result in (Result.OK, Result.DUPLICATE)

and at the boundary:

    payload = {"ok": outcome.acted, ...}

ok is derived, never set by hand. Every non-OK branch has at some point been mistaken for a soft success by somebody in a hurry, and the point of a single named property is that there is one thing to get right instead of five.

The constructor enforces the other half:

def refused(result, message, **detail) -> Outcome:
    if result in (Result.OK, Result.DUPLICATE):
        raise ValueError(f"{result.value!r} is not a refusal; build it with succeeded()")
    return Outcome(result=result, message=message, reference=None, detail=detail)

A refusal cannot carry a reference. A reference is a promise that something exists, and nothing does.

A refusal never carries a reference:
  ✓ refused payload has no reference key

Saying it in the skill

The Python makes the promise unsupportable. The prose stops it being made:

Never say a card has been ordered, is on its way, or will arrive unless
`reissue_card` returned ok true together with a reference.

If `reissue_card` returns cooling_off, nothing has been ordered and nothing the
caller says on this call will change that. Explain that the address is too new
to post a card to, offer the older addresses on file, and if none works offer
@skill.human_handoff. Do not offer to note the request, raise it later, send it
somewhere else as a workaround, or try again in a moment.

That last sentence is doing real work. Left to itself, a helpful model will find an adjacent thing to offer, and every one of those adjacent things is a way around the control. The forbidden alternatives have to be named, because “do not proceed” and “do not achieve the same outcome another way” are different instructions.

The escalation boundary

The agent orders replacement cards to addresses that pass policy. It does not change an address, override a cooling-off window, verify identity itself, or retry a refused reissue another way. When any of those is what the caller needs, it hands to @skill.human_handoff and stops.

Write that down, in the README and in the skill, as a behaviour rather than an apology. A refused card request that ends in a human is the system working.

Where to go next

This tutorial took the caller’s verification tier as an input and never asked how it got there. That question — what the tiers are, which factor buys which, and how a caller steps up mid-call — is the subject of the risk-tiered step-up pattern, which classifies reissue_card at its highest tier.

The two compose. That pattern decides how strong the caller’s verification is; this one decides whether, for this particular destination, that is enough.