Skip to content
Chapter 5 of 7 · ElicitationAccept, decline and cancel

13 min read

Accept, decline and cancel

On this page

The response to an elicitation is a result rather than an error even when the user says no, which is correct — being asked a question and answering it is the protocol working — and it means the outcome is carried in a field rather than in the shape of the frame. That field is action, it has three values, and content appears only under the first.

json
{ "jsonrpc": "2.0", "id": 7, "result": {
    "action": "accept", "content": { "branch": "release/4.2" } } }

{ "jsonrpc": "2.0", "id": 7, "result": { "action": "decline" } }

{ "jsonrpc": "2.0", "id": 7, "result": { "action": "cancel" } }

Two branches are enough to compile and not enough to be correct. A handler that tests accepted and puts everything else in an else has collapsed a decision and a non-decision into one path, and the behaviour it attaches to that path will be right for one of them at most.

That shape gets written so consistently because the predicate in it is correct. accepted is true exactly when the user accepted, so the first branch is right and no type checker has anything to say about it. The error is on the other side of the negation, where one expression is covering two states and a boolean has no room to record which one arrived. The collapse therefore happens in the type rather than in the logic, which is why reading the line does not reveal it, and why the consequence surfaces only when the two states need different behaviour — on a deploy, the first time somebody presses Escape.

The outcome that gets folded away

The difference between the last two is whether a decision was made. decline means the user considered the question and answered no — they clicked the reject button, they do not want to supply a branch, and the operation should stop with that as its reason. cancel means the user made no decision at all: they closed the dialog, switched away, hit Escape, or the client tore the window down. Nothing about the user's intent is recoverable from a cancel, which is exactly what makes it the more dangerous of the two to guess at. A decline at least tells you the user engaged and said no, so a handler that treats decline as "abort and report the refusal" is doing something the user would recognise. A cancel tells you only that the question is unanswered, and any behaviour you attach to it beyond stopping is a behaviour you invented.

That is the reasoning behind the rule that neither non-accept branch may fall through to a default. Where a default exists and is safe, it belongs in the argument, applied when the field is absent, and the question should not have been asked at all — the previous chapter made that case for optional fields. Where the question was worth asking, there is no safe default by construction, and the correct response to both decline and cancel is to end the operation and say which one happened.

python
result = await ctx.elicit(message="Deploy to which branch?", schema=Branch)

match result.action:
    case "accept":
        branch = result.data.branch
    case "decline":
        return ToolResult(
            isError=True,
            text=(f"Deploy cancelled: the user declined to choose a target "
                  f"branch. Artefact {artifact.sha[:12]} is built and cached; "
                  f"call deploy(branch=...) to use it within 24h."),
        )
    case "cancel":
        return ToolResult(
            isError=True,
            text=(f"Deploy did not proceed: the branch prompt was dismissed "
                  f"without an answer. Nothing was deployed. Artefact "
                  f"{artifact.sha[:12]} is cached; ask the user which branch "
                  f"they want and call deploy(branch=...)."),
        )

The two error strings differ in a way that matters to the model, which is the audience for both. After a decline, re-asking is an intrusion — the user has said no once, and a model that immediately raises the same question in chat is being obtuse. After a cancel, re-asking in the conversation is the natural repair, because the user probably intended to answer and got distracted, and a question in the transcript survives a dismissed window. Distinguishing them in your own logs matters for a different reason: a cancel rate climbing past a few percent is a signal about your prompt, and it is invisible if cancels are being counted as declines.

Neither outcome is a malfunction, which affects how you report them. A decline is the protocol working: the user was offered a choice and exercised it, and an alert that fires on declines will page somebody at three in the morning because a colleague changed their mind about a deploy. The same applies to the result you hand back — a decline returned as isError: true reads to the model as something that went wrong and may be worth retrying, whereas a plain result stating that the user declined reads as a fact about the world. Both forms exist in the wild and the choice between them is about what you want the model to do next. Marking it an error is defensible when the operation genuinely failed to complete and the model should say so; a plain result is better when the refusal is a normal end state, such as a user who was asked whether to overwrite a file and said no.

There is also a third meaning of the word cancel in play, and conflating the two produces a subtle bug. The cancel action describes the user dismissing your dialog while the tool call continues to exist; a notifications/cancelled for the enclosing tools/call describes the client abandoning the whole operation, usually because the user stopped the agent or the model's turn was interrupted. These arrive through different channels, mean different things, and require different cleanup — the first leaves you with a live call that owes the model a result, the second leaves you with nothing to reply to at all.

Which call does this answer belong to

Two create_ticket calls arrive in the same session a second apart, one for a broken checkout and one for a typo on the pricing page, and each stops to elicit a priority. The user now has two dialogs. They answer the second one first, because the typo is obviously P4, then go back and mark the checkout bug P1.

Sequence: Client → Server (tools/call create_ticket (checkout)); Client → Server (tools/call create_ticket (typo)); Server → Client (elicitation/create id=11 "priority?); Server → Client (elicitation/create id=12 "priority?); User → Client (answers the typo dialog: P4); Client → Server (result id=12 {priority: "P4"}); User → Client (answers the checkout dialog: P1); Client → Server (result id=11 {priority: "P1"}).

Concurrency of this kind is not exotic. Models emit parallel tool calls routinely when a user says "file tickets for both of these", and a client that supports parallel calls will dispatch them without waiting for the first to return, so any tool that elicits will eventually elicit twice at once in a single session. It is also not something you can test by hand reliably, because the interleaving that breaks a session-keyed store requires the two prompts to overlap and a human clicking through a demo will usually answer them in order.

Every response carries the id of the request it answers, so the raw material for getting this right is always present. What goes wrong is a server that discards it. The failure mode is a pending-state store keyed by something coarser than the request — self._awaiting[session_id] = future is the version that appears most often, usually written when someone builds a dispatcher because they want to log elicitations centrally. The second elicitation/create overwrites the first entry, the first operation's future is never resolved and hangs until it times out, and the P4 the user chose for the typo is applied to the checkout ticket. Both tickets are wrong and only one of them looks wrong.

The straightforward way to avoid this is to not build the store. When the elicit call is awaited inline in the tool handler, the handler is a coroutine, a function the runtime suspends at the await and resumes when the answer arrives, so the pending state is its own local variables — artifact, ticket_draft, the open transaction — and correlation is the SDK's job, handled by the table it keeps of outbound request ids and the callers waiting on them. Two concurrent create_ticket calls are two coroutines with two independent sets of locals, and nothing is shared for the second to clobber. That is the structural reason to prefer await ctx.elicit(...) in the middle of the operation over any design that returns from the handler, waits for an answer elsewhere, and tries to resume.

Where a shared map is genuinely unavoidable — a server that must survive a process restart mid-prompt, or one that fans an operation across workers — the key is the elicitation request id and the value carries everything needed to resume, including which tool call it belongs to:

python
@dataclass
class Pending:
    tool_call_id: str
    session_id: str
    started_at: float
    resume: Callable[[dict], Awaitable[None]]

pending: dict[int, Pending] = {}   # keyed by elicitation request id

Keying by session is the bug. Keying by tool call id is nearly right and still wrong, because one operation may elicit more than once and the second question would collide with the first. The elicitation request id is the only identifier that is unique per question, which is what the pending entry represents.

Why that id is unique per question is worth stating rather than assuming, because the whole scheme rests on it. Every outbound JSON-RPC request needs an id the sender has not already used on this connection, so the SDK allocates a fresh one on each send. A handler that elicits twice produces two ids, and the client echoes each one back on the response it belongs to, which is exactly the correlation you need. A tool call id is allocated once for the call and reused by everything inside it, so it cannot separate two questions asked during one operation. It is a single identifier for something that happened more than once, and no amount of care in the store makes it into two.

Every path that ends the wait needs its own name

Accept, decline and cancel are the paths where the client answers. The rest of the terminal states, meaning the outcomes after which the tool call is over and the operation owes nothing further, are the ones where it does not, and chapter 3, on capability checks, established why none of them resolve themselves: the request has no deadline of its own, so whatever bound exists is one you impose.

PathWhat triggers itServer responseDistinguishing log field
acceptuser submits the formvalidate, then continue the operationoutcome=accept plus the validated fields
declineuser rejects the promptabort, report the refusaloutcome=decline
canceldialog dismissed without a choiceabort, report the non-answeroutcome=cancel
timeoutyour own deadline expiresabort, cancel the outbound requestoutcome=timeout, waited_ms
call cancelledclient sends notifications/cancelled for the tools/callabandon the elicitation, unwind, emit nothingoutcome=upstream_cancelled
disconnecttransport closes while waitingunwind, no result to sendoutcome=disconnected
malformedaccept whose content fails validationabort or re-prompt onceoutcome=invalid_content, plus the failing field

Collapsing any two of these rows costs you a diagnosis later. A timeout logged as a cancel makes a slow user indistinguishable from a client that silently dropped the request, which is the exact confusion the partner ticket in chapter 3 started from. A disconnect logged as a timeout hides a transport problem behind a number you will spend a week tuning.

The branch prompt's own figures show how thin the evidence under a rate like that can be. Three events reported as four tenths of a percent implies about seven hundred and fifty prompts across the eleven weeks, which is roughly ten a day, and a denominator that small means one further event would have moved the figure by a third. A rate computed over a few hundred trials is a label rather than a measurement, and what it is worth depends entirely on which bucket the events were put in. Three cancels and no declines, and three declines and no cancels, produce the identical four tenths of a percent. They are opposite facts about the tool: one says nobody objects to the question, the other says nobody can answer it from where they are standing.

The implementation is a deadline around the elicit call and a finally that runs on every path:

python
async def ask_priority(ctx, ticket_draft, lock):
    started = time.monotonic()
    try:
        result = await asyncio.wait_for(
            ctx.elicit(message="Priority for this ticket?", schema=Priority),
            timeout=120.0,
        )
    except asyncio.TimeoutError:
        log.info("elicit", outcome="timeout",
                 waited_ms=int((time.monotonic() - started) * 1000))
        raise ToolError(
            "No priority was chosen within 2 minutes; the ticket was not "
            "created. Call create_ticket with priority set to P1-P4."
        )
    except (ConnectionError, anyio.ClosedResourceError):
        log.info("elicit", outcome="disconnected")
        raise
    finally:
        await lock.release()
    ...

Two details in that block are load-bearing. The timeout is a number chosen against how long a human takes to answer this specific question, and 120 seconds is generous for a four-option enum and much too short for a question that requires the user to go and look something up — a branch prompt that sends people to another window deserves ten minutes or a design that does not need the lookup. Setting it to a value borrowed from an HTTP client is how you get timeouts firing on users who were about to answer. The finally releases the resource the operation was holding before the prompt went out, which is the part of chapter 3's incident that made stuck calls expensive rather than merely slow. Any lock, transaction, reservation or temporary allocation held across the pause must be released on every one of the seven paths in the table, and the way to be sure of that is to hold it in a scope that unwinds rather than in a variable you remember to clean up.

Testing the paths that do not produce a response takes deliberate effort, since none of them occur in a demo. A client stub that can be told to respond with each of the three actions, to respond after a configurable delay, to close the transport mid-wait, and to return an accept whose content violates the schema will exercise six of the seven rows, and the seventh comes from sending a cancellation notification for the outer call. Each test asserts two things: that the tool call reaches a terminal state within a bounded time, and that whatever the operation was holding is no longer held. The second assertion is the one that catches regressions, because a refactor that moves a lock acquisition earlier will pass every functional test and leak on five of the seven paths.

Cancelling the outbound request when your deadline fires is the piece people leave out. A notifications/cancelled for the elicitation id tells the client to take the dialog down, so a user who answers thirty seconds late is not left with a form whose submission goes nowhere, and your process is not holding a response handler for a request nobody is waiting on.

What ties the two halves together is that both are about identity: which decision this answer represents, and which operation it belongs to. Folding cancel into decline loses the first, and keying the pending state by session loses the second, and both failures produce an operation that completes with a value nobody supplied. The seven-row table is the same idea extended to the paths where no answer arrives at all — each one is a different fact about the world, each deserves its own diagnostic, and every one of them has to release what the operation was holding.

The accept branch is now the only one left that continues the operation, and it is carrying a value that arrived from outside your process, through a client whose rendering of your schema you have never seen, typed by a person. Everything so far has treated that value as the thing you asked for. The next chapter treats it as the thing you received.

Elicitation — read the current version at https://daviddominguez.dev/learn/en/elicitation/accept-decline-and-cancel

Back to top