Skip to content

Signal tool stability with waitForToolsUntil() and ToolChangeEvent - #373

Open
khushalsagar wants to merge 3 commits into
mainfrom
wait-for-tools-until
Open

khushalsagar wants to merge 3 commits into
mainfrom
wait-for-tools-until

Conversation

@khushalsagar

@khushalsagar khushalsagar commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Implement the resolution from #328 (minutes) and #328 (comment):

  • Add the IDL for waitForToolsUntil and toolsReady, and state tracking for the tools-ready state based on pending promises.
  • Wait for tools-ready (up to an implementation-defined timeout) in perform an observation.
  • Dispatch a toolchange event (ToolChangeEvent with ready) when tools become not ready and suppress dispatch of this event until tools become ready again.

Wiring the wait for tools-ready to getTools() and changing dispatch of toolchange events is deferred for a follow up PR.

Note: There's still an open question for the Document lifecycle until which tools are presumed to be not ready.

Part of #328.


Preview | Diff

@khushalsagar

khushalsagar commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator Author

A tangential thing worth taking up in a separate PR: the toolChange notification from register/unregister isn't coalesced well right now. It's triggered for every register/unregister call. Seems like it would be better to do it in a separate task (or microtask?) since in a task the state will be updated by multiple calls and we should dispatch a single event for all those mutations.

@khushalsagar
khushalsagar force-pushed the wait-for-tools-until branch 2 times, most recently from 94b382b to 9f62142 Compare November 3, 2026 20:26

@markafoltz markafoltz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall structure looks good, it this PR has several features (toolchange event, modelContext.toolsReady, blocking getTools()) not yet landed; are you thinking of adjusting this once the implementations land?

Comment thread index.bs Outdated
[=webmcp task source=] given |targetDocument|'s [=relevant global object=] to [=fire an
event=] named {{ModelContext/toolchange}} at |targetDocument|'s [=Document/associated
ModelContext|associated <code>ModelContext</code>=].
1. If |exposed origins| is "<code>*</code>", or [=tool is exposed to an origin=] given |tool

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking into multiple steps would improve readability, perhaps:

  1. If |exposed origins| is not "*" and [=tool is exposed to an origin=] ... returns false, then abort these steps.
  2. [=queue a global task=]...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not in the PR now.

Comment thread index.bs Outdated
1. If the result of running |areAllReady| is true, then return.

1. Wait until either the result of running |areAllReady| is true, or an [=implementation-defined=]
timeout has elapsed since this step began.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the timeout clear pending tool ready promises?

Also, should tools-ready be updated to true for any documents in the tree? It depends on the answer to the previous question, I suppose.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the tools-ready state depends on the state of promises, that's independent of which callers are blocked on that state. basically each time any call-site tries to read tools, it starts a timer that either waits for tools-ready to toggle or for the timeout to trigger.

Comment thread index.bs Outdated

1. If |activeDocument| is null or is not [=Document/fully active=], then return true.

Note: If |traversable| is destroyed while waiting, its [=navigable/active document=] is

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every caller of wait for tools to be ready will need to check the document active state; could these steps return a value like "ready" or "abort" so that the caller doesn't have to check again?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done.

Comment thread index.bs Outdated
Each {{Document}} object has an <dfn for=Document>associated {{ModelContext}}</dfn>, which is a
{{ModelContext}} object.

Each {{Document}} object has a <dfn for=Document>tools-ready</dfn> [=boolean=], initially true.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Move below the "Upon creation" paragraph to keep the ModelContext related bits together

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done.

Comment thread index.bs Outdated
1. [=set/Remove=] |promise| from |context|'s [=model context/pending tool readiness
promises=].

1. If |document| is [=Document/fully active=], then [=update the tools-ready state=] given

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can a |promise| be reacted to while the document is not active?
Maybe if the promise was created in a different document or if it is in bfcache?
(See comment below)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe if the promise was created in a different document

Yes, this is an example of that case:

let resolve;
const p = new Promise(r => { resolve = r; });
iframe.contentDocument.modelContext.waitForToolsUntil(p);
iframe.remove();
resolve();

Comment thread index.bs Outdated
The following delays tool discovery until asynchronous tool registration completes:

```js
const toolsReady = (async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shadows modelContext.toolsReady...

const toolsRegistered = (async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done.

Comment thread index.bs Outdated
Comment on lines +1048 to +1053
1. Let |toolsReady| be |tool owner|'s [=Document/tools-ready=].

1. Run the following steps [=in parallel=]:

1. [=Notify documents of a tool change=] given |tool owner| and |exposed origins|.
1. If |toolsReady| is true, then [=notify documents of a tool change=] given |tool owner|,
|exposed origins|, and |toolsReady|.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: here the |toolsReady| check sits inside the in-parallel steps, while unregister a tool returns early before going in parallel. Could we use one style? The early return reads more clearly to me.

Nit: these in-parallel substeps are indented 2 spaces (that was already the case); Bikeshed style is 3, lined up with the parent step's text. Since you're editing these lines, maybe fix it here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the existing indentation. I want to take the toolChange dispatch in a separate PR.

Comment thread index.bs
Comment on lines +1077 to +1079
1. [=set/Append=] |promise| to |context|'s [=model context/pending tool readiness promises=].

1. [=Update the tools-ready state=] given |document|.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Web IDL wraps every Promise<any> argument in a new promise, so even waitForToolsUntil(Promise.resolve()) or waitForToolsUntil(42) synchronously sets tools-ready to false and fires toolchange{ready:false} at every document. A microtask later it fires toolchange{ready:true}.

Chromium returns early when the promise has already settled, so it fires nothing. The waitForToolsUntil(42) case in the WPT would behave differently from the spec once ToolChangeEvent ships.

Suggest coalescing: have update the tools-ready state queue a task that compares the current state with the last state we notified about, and notify only when they differ. This fits with your PR comment about coalescing the register/unregister notifications.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the spec to match the WPTs and ignore already settled promises, nice catch!

Deferred the ToolChangeEvent dispatch changes to a follow-up PR, will take care of toolchange event batching there. Also, my comment was about an existing issue with no batching for toolchange in the register/unregister case tracked in #374.

Comment thread index.bs Outdated
Comment on lines +1081 to +1086
1. [=promise/React=] to |promise|:

- If |promise| was fulfilled or rejected, then:

1. [=set/Remove=] |promise| from |context|'s [=model context/pending tool readiness
promises=].

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if |promise| comes from another realm that is then destroyed? For example, a same-origin iframe passes one of its own promises to parent.document.modelContext.waitForToolsUntil() and the iframe is removed. HTML doesn't run promise jobs for documents that aren't fully active, so neither the Web IDL wrapper nor this reaction ever settles. tools-ready stays false for good, and every observation hits the timeout.

Chromium handles this. PendingWaitForTools watches the context the promise was created in and counts the promise as settled when that context is destroyed; see the tests wait-for-tools-until-detach-crash.https.html and detached-frame-waitForToolsUntil.https.html. Could we specify that, or at least add an Issue()?

Nit: Web IDL's react uses separate fulfilled and rejected branches. Both branches could call a shared "settle" algorithm, which the destroyed-realm case could reuse.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed on crrev/c/8778494, we should ignore the case of the promise coming from another realm which queues it in a task that doesn't run when the realm is destroyed.

Fixed the nit on [=promise/React=] to use separate fulfilled and rejected branches running |settleSteps|.

Comment thread index.bs Outdated
Comment on lines +1088 to +1089
1. If |document| is [=Document/fully active=], then [=update the tools-ready state=] given
|document|.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
1. If |document| is [=Document/fully active=], then [=update the tools-ready state=] given
|document|.
1. [=Update the tools-ready state=] given |document|.

If |document| isn't fully active when the promise settles (e.g. it's in bfcache), we remove the promise but don't recalculate tools-ready. After restore, the set is empty but tools-ready is still false. Update the tools-ready state already skips notification for documents that aren't fully active, so I think this check can just go.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done.

…omments

- Defer ToolChangeEvent/toolchange readiness dispatch and the getTools() wait
  to a follow-up PR.
- wait for tools to be ready: capture the top-level active document up front,
  drop the null check, and return "ready" or "navigated".
- perform an observation: return early on "navigated".
- waitForToolsUntil(): ignore already-settled promises, split React into
  fulfilled/rejected branches, and always update tools-ready on settle.
- Move tools-ready below the ModelContext creation paragraph, rename the
  example variable, and fix in-parallel indentation in registerTool().
The navigable can also be destroyed while waiting, so "navigated" was too
narrow. Update the note in perform an observation to cover both cases.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants