Repository navigation
Let a tool deliver a final result on caller abort #299
Description
Activity
Measurements from a second implementation, Chrome 152, which I think make the case stronger than "partial progress is unreportable" — and one question about your workaround.
executehas no signal to observeThe proposal's first half is "allow
executeto observesignal.aborted". In Chrome 152 there is nothing to observe, becauseexecuteis invoked with one argument:argc: 1 inputType: "object" optionsType: "undefined" hasSignal: falseRegistered via a bare
registerToolfrom a probe page, not through any framework.executeTool(tool, args, { signal })is accepted on the caller side and does cancel the caller — so the gap is entirely on theexecuteside. A tool cannot observe the abort, promptly or otherwise.That is worth separating from the resolution-delivery question, because shape (a) and shape (b) both presume the tool can notice. Neither is implementable today regardless of what the spec says about the settled value.
The tool does not stop — it finishes, and the writes land
This is the part I did not expect. A tool that applies items in a loop, with the caller aborting partway:
caller: AbortError "stopped by the probe" (hasCause: false) tool: ran all 5 iterations to completion page: <body data-applied="5">The side effect is written to the DOM inside the loop, so
data-applied="5"is a fact about the page rather than a variable inside the closure. Every item landed. The caller was told the operation was aborted.So for a write tool the current behaviour is not "the caller cannot learn what was applied before the abort". It is the caller is told nothing was completed while everything was. Same shape as #300 — an action reported as not-having-happened after it happened — reached by a different route. An agent that aborts a batch edit and then retries it has now applied it twice.
No
causeto hang a result onShape (a) proposes rejecting with an
AbortErrorwhosecausecarries the tool's result. For reference, today's rejection hascause: undefined, so that shape needs a vehicle added rather than populated.Shape (b) — resolve if the tool settles within the same task as the abort — seems the more robust of the two, since it does not depend on callers reading a non-standard place for a payload. Though it only helps a tool that can stop promptly, which loops back to the missing signal.
A question about your workaround
You describe combining a page-owned
AbortControllerwith the caller's viaAbortSignal.any(). Given the measurement above I cannot see how a page reaches the caller's signal — isexecutereceiving an options argument in your build, or are you obtaining it another way? If there is a route to it, that is worth having in the issue, because the page-side half of your mitigation is otherwise unavailable to anyone on 152 and the "site that does not build the second path" is then every site.Probe and notes: https://github.com/mysticalseeker24/parity-webmcp
Hi @mysticalseeker24 — you are right, and the honest answer to your question is that we do not reach the caller's signal either. Thanks for probing it rather than taking the issue text at its word.
The line is a conditional:
const signal = opts?.signal ? AbortSignal.any([opts.signal, page]) : page;
On Chrome 152
optsis undefined, so it falls through to the page signal alone and theAbortSignal.any()branch never runs. The issue text describes the intended shape rather than what the tested browser exercises, which is the same defect you found in my #300 workaround paragraph. I will correct it.Two consequences I had not stated, which your measurement makes visible:
-
stoppedBy: 'agent'is unreachable on 152. The payload distinguishes a person's stop from a caller's abort, but only the person's branch can be produced there. The distinction is real in the code and dead in the browser. -
The sweep runs to completion on caller abort in our build too. Nothing in the loop observes the caller, so the writes land exactly as your probe shows while the caller receives
AbortError. I had described the loss as the report not arriving. Your framing is the accurate one: the caller is told nothing completed while everything did, and a batch edit retried after an abort applies twice.
So your separation is right, and the missing
executesignal should come first. Shapes (a) and (b) both presume a tool that can notice the abort, and today none can, so neither is implementable regardless of what the spec says about the settled value. That deserves to be its own ask rather than an assumption inside this one.On (b) being the more robust shape: agreed, with the caveat you name. It only helps a tool that can stop promptly, and "promptly" is not expressible without the signal.
I will edit the issue to separate the two asks and to correct the workaround description.
-
A correction from me, and thanks again to @mysticalseeker24 for the probe and to @mlmrx for catching what I made of it on #308. The "a tool must be able to observe the abort" ask I added above is not a spec gap. The draft passes
ToolExecuteCallbackOptions.signaltoexecute, and Chromium threads it in CL 8025300, first shipped in 153. Chrome 152 branched before that landed, which is why the probe sees one argument. I have verified on Chrome 153:executereceives two arguments, the signal fires, and a loop that checks it stops partway (3 of 5 items in my run). What the caller receives is still a bareAbortErrorwith nocause, so the settlement question is unchanged. I have taken the extra ask out of the body.What remains is the original question, and I think it is the interesting one: when the caller's abort and a later fulfilment race, which settlement wins.
Taking the correction — @mlmrx is right and my framing was wrong. I described a browser and called it a precondition on the spec, which is exactly the mistake I had just flagged in someone else's paragraph. Thanks for catching it rather than building on it.
I can add an independent confirmation and one observation that survives the fix.
Confirmed on Chromium 153, from a different browser
Edge 153.0.4234.32, same probe as before:
execute: argc 2, optionsKeys ["signal"], a real AbortSignal tool: observed the abort at item 2 and stopped page: <body data-applied="3"> (3 of 5, not 5 of 5) caller: AbortError "stopped by the probe", cause: undefinedSame 3-of-5 you saw. The write is genuinely cancelled, so the "runs to completion" behaviour is 152-only and I have scoped it that way in our notes.
The caller's abort reason does not reach the tool
This one I had not looked for, and it seems relevant to the settlement question you say is what remains.
Aborting with an explicit reason:
callerAc.abort(new DOMException("stopped by the probe", "AbortError"));
gives, on 153:
caller's rejection .message"stopped by the probe"tool's signal.reason.message"signal is aborted without reason"tool's signal.reason === the caller's objectfalseSo the tool is told that it was cancelled but never why. The caller's reason reaches the caller and stops there.
That bites the same use case this issue is about. Your
CANCELLEDpayload carriesstoppedBy, and with the signal arriving you can now distinguish a person's stop from a caller's abort by which controller fired — but a tool still cannot record what the caller said when it aborted, so it cannot annotate the final value with the cause even once it is able to produce one. If the resolution here ends up being shape (a), a tool that could attach{ applied, remaining, abortedBecause }seems strictly more useful than one that can only attach{ applied, remaining }.Worth noting it may well be deliberate — the tool's signal is a distinct signal, not the caller's — in which case the question is whether the reason should be propagated onto it, rather than whether this is a bug.
Probe: https://github.com/mysticalseeker24/parity-webmcp
Thanks for filing this! Now that we've clarified that the tool does receive the caller's
AbortSignal, I don't think we need any additional API here.Once the caller aborts an invocation, I think the cleanest semantic is that its promise rejects and any eventual result from the tool is discarded. This matches all othe rusages of AbortSignal on the platform too, like
fetch("...", {signal}). If it needs to expose the resulting state after cancellation, that seems better handled by the tool/application itself rather than by returning a result through an invocation the caller has already aborted.Given this, I really don't need we should deviate from existing AbortSignal semantics, so I'll close this for now.
Per §3.1 cancel a pending tool execution, aborting the
signalpassed toexecuteTool()removes the pending execution and rejects the caller's promise with the abort reason; the tool's own resolution is discarded.Verified in Chrome 152 and again in Chrome 153: the caller gets
AbortErrorwith nocause, and the tool's own resolution is discarded.Why it matters
For a long-running tool that applies changes incrementally (a batch edit landing item by item), the caller cannot learn what was applied before the abort. Partial progress is structurally unreportable on the caller's abort path, even though the tool knows exactly what it did.
This is not hypothetical for us. A sweep across a document is stoppable mid-run by design, and the agent has to be able to say what landed, or the person is left to diff the document by eye.
What we had to do instead
A page-owned
AbortController, combined with the caller's viaAbortSignal.any()whenexecutereceives one, so the tool resolves normally with aCANCELLEDpayload carryingapplied/remaining/stoppedBy. This works around the reporting gap; it is not a substitute for cancellation itself.Correction (2026-09-12). An earlier revision of this section claimed that a tool cannot observe the caller's abort at all, and split this issue around that. That described Chrome 152, not the spec: the draft passes
ToolExecuteCallbackOptions.signaltoexecute, Chromium threads it in CL 8025300, and on Chrome 153 the signal arrives and a loop that checks it stops partway. Thanks to @mysticalseeker24 for measuring the 152 behaviour and to @mlmrx for pointing out on #308 that it was implementation lag rather than a spec gap. On a build without the signal the loop runs to completion while the caller receivesAbortError, which is worth knowing while 152 is still in use, but it is not what this issue asks the spec to change.Proposal
Allow
executeto observesignal.aborted, finish promptly, and have that settled value delivered to the caller. Two shapes that would both work:AbortErrorwhosecausecarries the tool's result, orEither keeps cancellation semantics, since the caller still learns it was aborted, while making partial progress reportable.
Notes from the build: https://github.com/minjikim89/redline/blob/main/docs/findings.md · live: https://minjikim89.github.io/redline/