A tool that cannot be cancelled performs its side effect afterwards

The round stops. The tool does not. When a user presses Stop, or a tool outruns its budget, AccessContextAIFunction stops waiting and hands the model a cancellation or a timeout. The tool body is a Task running on the bounded IoPoolNames.Ai pool, and nothing about the wrapper giving up reaches it — so it finishes its remaining steps and then does what it was called for. The draft appears in Drafts minutes after the conversation moved on; the mesh grows a record of a draft the transcript never mentions; and with Email:AgentSend=Send a mail goes out after the person cancelled it.

That is not a slow tool, and it is not a race. It is a signature: a tool method that declares no CancellationToken parameter cannot be cancelled by anything — not Stop, not the per-tool budget, not IoPool.Drain().

The one-line rule

Every agent tool method whose return type is a Task/ValueTask declares a trailing CancellationToken, and threads it through every wait it performs before its side effect.

AIFunctionFactory binds such a parameter from the invocation and keeps it OUT of the JSON schema, so the model never sees it and the tool's contract to the model is unchanged. There was never a cost to declaring one — only a consequence of not declaring it. That is why the rule has no exemption list: the obligation is read off the return type, because a tool that computes its answer synchronously has no wait to cancel, and a list of names is a thing that goes stale in silence.

The ordering rule, which is the whole design

Threading a token into a tool that SENDS something is not automatically an improvement. Done carelessly it converts one bad outcome into a worse one: instead of a stray draft you get a draft that exists and that nobody was told about, so the agent is free to write a second one. A mail already handed to Graph cannot be recalled by abandoning the wait for its acknowledgement — it can only be hidden.

So the token is threaded in three zones, and they are not the same:

Zone Rule Why
Before the effect — the credential read, grounding reads, every Graph GET, the isDraft re-read, a link lookup Observes the token. Cancelling here provably did nothing. There is no state to be honest about.
The effect itself — a POST, a PATCH, a DELETE, a mesh CreateNode, a SendText A FRESH ThrowIfCancellationRequested() immediately before it; the call itself carries NO token. The check is what stops an effect that was decided before the user cancelled. Not passing the token is what keeps both sides of that instant knowable: cancelled ⇒ nothing happened; not cancelled ⇒ the request ran to a definite answer.
After the effect — the draft's webLink, the draft's mesh record, the answer that names the mailing's link Uncancellable (CancellationToken.None), stated at the call site. The effect is now a fact, and this answer is the only thing that records it. An abandoned round must not be the reason nothing does.

The per-tool timeout still bounds what the wrapper waits for: AccessContextAIFunction uses Task.WaitAsync, which stops waiting whether or not the tool cooperates. So declining to pass the token into the irreversible call cannot pin the agent loop — that case was already handled, by design, for ill-behaved tools.

The rule applies where the EFFECT is, not at the tool boundary

The first revision of this change put ForwardToInfo's check at the top of the tool and stopped there. That is not enough, and the shape is worth naming because it will recur: InboxForward.Forward waits up to 15 s reading the mail and then creates the outbound node. A check before that whole pipeline only covers "already cancelled when the tool was called"; a cancel arriving during the read was seen by nothing, and the mail was still queued afterwards. The zones are drawn around the effect, so the token has to reach the function that performs itForward takes it, observes it across the read, re-checks immediately before CreateNode, and stops observing it there.

One trap in doing that reactively: the obvious composition for "stop the read when the token fires" is TakeUntil(cancelSignal), and it is wrong. TakeUntil completes the sequence empty, and an empty completion reaches ObserveCompletion as default(ForwardResult) — which is Forwarded, the enum's zero member. A cancellation would report a successful forward with nothing sent. The cancellation source must therefore OnError, which is the shape core already uses for this (MessageHub's buildup race, RoutingQuiescence's shutdown arm). It is the open-vocabulary zero-member trap wearing a reactive costume.

Three corollaries that are easy to get wrong

A round that gave up must escape the tool body. Every one of these tools ends in catch (Exception ex) { return Fail(...); }, and that catch swallows cancellation into an ordinary tool answer — "ListInbox failed: A task was canceled." The wrapper then never sees the cancellation it asked for, and the point-of-no-return check reports itself to the model as a Graph failure. That is the same judgement AccessContextAIFunction.IsArgumentFailure already makes for the argument family: cancellation is not a failure of the tool.

🚨 …but the predicate is the TOKEN, never the exception's TYPE. catch (Exception ex) when (ex is not OperationCanceledException) reads as that rule and is not it. Kiota surfaces an HttpClient TIMEOUT as TaskCanceledException, which is an OperationCanceledException — so a type filter lets a genuine transport failure out of the tool unreported, and AccessContextAIFunction excludes cancellation from the failures it hands back, leaving a round that is still listening told nothing at all about a request that really did fail. The question that was actually meant is did THIS round give up?, so the sink reads cancellationToken.IsCancellationRequested:

private static string Fail(string op, Exception ex, CancellationToken cancellationToken)
{
    if (ex is OperationCanceledException && cancellationToken.IsCancellationRequested)
        ExceptionDispatchInfo.Capture(ex).Throw();
    return $"{op} failed: {Describe(ex)}";
}

Routing every tool's exceptions through one sink — and making the token a required parameter of it — is what enlists the compiler: a new tool cannot reach the failure sink without saying which token it was invoked with, instead of two dozen remembered when clauses, one of which would eventually be forgotten. Pinned by ExecutiveAssistantCancellationTests.DraftMail_WhenTheTransportTimesOutOnALiveRound_ReportsTheFailure, which stages the timeout at the transport with the round deliberately alive and asserts the failure comes back as a sentence.

cancelSource is a resource decision, not a style. ReactiveCompletion.ObserveCompletion cancels the WAIT by default and leaves the subscription attached, so a late fault still reaches the reporter; cancelSource: true disposes the subscription, which propagates cancellation INTO the source and trades that diagnostic away. Pass it only where the wait owns a bounded resource:

What the ratchet was covering, and what it was not

MeshToolCancellationTest.EveryToolBindsTheRoundsCancellationToken already asserted this property. It passed. It was also nearly silent, because a reflection ratchet can only assert what it can construct: the five plugins reachable from (hub, chat)MeshPlugin, LspPlugin, VersionPlugin, CollaborationPlugin, AgentFilesPlugin.

Measured over src/ (2026-09-22, AIFunctionFactory.Create( sites excluding test projects):

count
registrations 79
files registering tools 17
distinct awaiting tool methods 69
synchronous tool methods (no wait to cancel) 4
lambda-registered tools 1
covered by the reflection ratchet 28 (41%)
awaiting tool methods with NO token 35
…of those, in the ratchet's blind spot 35 (all of them)

Every tool that had no token sat in the 41 the ratchet could not see — and 14 of the 35 have an effect that cannot be undone, six of which put a message in front of somebody other than the caller (SendMail, ReplyToMail, ForwardToInfo, PostChannelMessage, ReplyToChannelMessage, SendChatMessage).

That table is the pre-fix census, and one row moved afterwards: check_inbox was the single lambda-registered tool, and it is now a named local function (see below), so the counts are 0 lambdas and 5 synchronous tools. The registration and awaiting counts are unchanged. Extending the reflection arm cannot close that: those plugins live in Store MODULE assemblies, and a test executable must not take a project reference on a module (a second copy of one assembly in a host closure is the blank-page defect core #3175 describes).

So the second arm — EveryRegisteredAgentToolBindsTheRoundsCancellationToken — derives its subject from the registration code itself: every AIFunctionFactory.Create( under src/, resolved to the method it names, judged on that method's return type and parameter list. It needs no project reference, and it cannot stop covering a plugin by having its reference dropped.

Four things keep it from passing on no evidence, and two of them are there because review found the first revision short:

  1. An unresolvable registration is a FAILURE, not a skip. A scanner that quietly stopped understanding the code would otherwise read exactly like a clean tree.
  2. The match is whitespace-tolerant, and the scanner has its own controls. The first revision looked for one exact spelling of AIFunctionFactory.Create(, so Create ( or a line break before .Create would have been skipped in silence. 🚨 The floors do not catch that — missing a handful of registrations still clears them, which is exactly why a coverage claim needs a control that FEEDS the scanner each spelling and expects it to be seen, not just a denominator that looks plausible. Negative controls too: MyAIFunctionFactory.Create( and a commented-out registration must not be counted, or the guard inflates its own denominator.
  3. The counts are asserted against floors — a backstop for a pattern that stops matching altogether, not the coverage check.
  4. There is no lambda exemption. The first revision admitted a parameterless non-async lambda, which is unsound: () => SomeAsyncTool() omits async, returns a Task, waits, and accepts no token — and the ratchet called it clean. The one lambda-registered tool (check_inbox) was promoted to a named local function instead, so every registration goes through the declaration check and the exemption is gone rather than narrowed. An exemption nobody can state precisely is how the previous ratchet got to 41%.

Both arms stay. The reflection arm proves the property on the real AIFunction objects the agent loop uses; the source arm proves it over the whole set.

The tools that changed

Plugin Tools Effect
ExecutiveAssistantPlugin 24 11 read-only; DraftMail/DraftReply/UpdateDraft/PrepareMailing write; SendMail/ReplyToMail/DiscardDraft/CreateEvent/UpdateEvent/CancelEvent/PostChannelMessage/ReplyToChannelMessage/SendChatMessage are irreversible
MemexInboxPlugin 1 ForwardToInfo sends mail (idempotent per email — it forwards once). The token goes into InboxForward.Forward, not around it — see the zones above
AppleMessagesPlugin 4 SendIMessage irreversible, UnlinkIMessage destructive
WhatsAppPlugin 4 SendWhatsAppMessage irreversible, UnlinkWhatsApp destructive
WebSearchPlugin 2 read-only; both hold a bounded HTTP permit

🚨 Building a control that can actually fail

Every claim on this page rests on a control — the guard run against the unfixed sources, the forward test run against a build with the token made inert. A control is worth exactly as much as its construction, and the construction failed twice here in one afternoon, in two independent sessions, the same way:

Do not build a control by making code UNREACHABLE. if (false) raises CS0162, and TreatWarningsAsErrors is set unconditionally in src/Directory.Build.props — so dropping -warnaserror does not rescue it. The build fails, the test host then runs against the STALE assembly from the previous build, and the suite reports a full green that describes code nobody is running. Total: 7, Failed: 0 over a build that never happened.

Two rules, both cheap:

  1. Assert Build succeeded — or a fresh assembly timestamp — BEFORE believing any test count. A test count is a statement about a binary, not about a source tree, and the two part company silently. Read the build's own exit and its 0 Error(s) line; never infer the build from the run.
  2. Make a VALUE inert rather than making code unreachable. Keep the signature, keep every statement reachable, and remove only the effect — here, _ = cancellationToken; in place of the checks. It compiles warning-free, so the control's build is as clean as the real one and the only difference between them is the behaviour under test.

The second rule is what makes the first one rarely needed, which is the better place to spend the care.

What this does NOT cover

The MCP tool surface. McpMeshPlugin carries 35 [McpServerTool] methods, and measured on the same tree almost none of them declares a CancellationToken either (the file mentions CancellationToken twice in all). That is a different invocation path — the MCP server's request context, not an agent round's executionCts — and a different owner, so it is neither fixed nor guarded here. It is named because "we swept the tool surface" would otherwise read as covering it.

What a local green here cannot mean. The builds behind the numbers on this page resolve the platform from a sibling core CHECKOUT, not from the pinned CD image a pull request compiles against. Those are different reference sets, and a symbol added to core after the pin exists in one and not the other — so a clean local build does not exclude a CI-only compile failure in a module bundle. When one appears, the discipline is to confirm it is the SAME symbol and the SAME pin before dismissing it as the known ceiling, rather than reasoning from "it built for me".

The window between dispatch and acknowledgement. Cancelling during an in-flight irreversible request is not made impossible by any of this; it is made knowable. If the wrapper stops waiting while a POST is on the wire, the tool still completes it and still records it — the model is told it timed out, and the record in the mesh is what reconciles the two. Closing that window for real needs a per-send approval gate (a tool call suspended and surfaced for a human), which is #2612 and is deliberately not this.