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 it — Forward 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:
- Yes —
WebSearchPlugin.SearchWebandFetchWebPage, whose bodies run on the boundedIoPoolNames.Httppool. That pool's token is Rx'ssubscriberCt, so disposing the subscription is the only thing that cancels the in-flight HTTP call and releases the permit; without it a round the user stopped keeps a permit for the whole duration of a search nobody will read. Both are read-only, so the late-fault arm being traded away costs a log line and never an effect. - Yes —
AppleMessagesPlugin's bridge probe, for the same reason (the module owns its ownIIoPool). - No — the Executive Assistant's credential read. Its late-fault arm is the only diagnostic a credential read has, which is the whole point of the seam it goes through, and the read holds no pooled permit in exchange.
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:
- 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.
- The match is whitespace-tolerant, and the scanner has its own controls. The first revision
looked for one exact spelling of
AIFunctionFactory.Create(, soCreate (or a line break before.Createwould 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. - The counts are asserted against floors — a backstop for a pattern that stops matching altogether, not the coverage check.
- There is no lambda exemption. The first revision admitted a parameterless non-async lambda,
which is unsound:
() => SomeAsyncTool()omitsasync, returns aTask, 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:
- 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 its0 Error(s)line; never infer the build from the run. - 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.