diff --git a/README.md b/README.md index c26257d..14c9f95 100644 --- a/README.md +++ b/README.md @@ -325,7 +325,7 @@ By default, WebMCP is enabled in top-level `Window`s and its same-origin iframes Calls to `document.modelContext.registerTool()` will return a promise rejected with `NotAllowedError` DOMException when the permission is disabled, whether by the `allow` attribute or the `Permissions-Policy: tools=()` header. Handling of declarative tool registration errors, including when the permission is disabled is TBD; see [Issue #182](https://github.com/webmachinelearning/webmcp/issues/182). -#### Cross-origin iframe exposure: `exposedTo` +#### Cross-origin iframe exposure: `registerTool() and `exposedTo` By default, tools registered by a document are only exposed to itself, same-origin documents in the same tree, and built-in browser agents (see this discussion). To support author-provided agents running in frames, developers can selectively share tools with secure origins of their choice, `exposedTo` option during registration: @@ -342,9 +342,76 @@ Any document in the tree matching these origins (and allowed to use `tools` perm - Receive the `toolchange` event on its `document.modelContext` when the tool is registered or unregistered. - Be able to discover and run the tools -#### Discovering and running tools +#### Discovering and running tools: `getTools()` and `executeTool()` -TODO: Spec and describe the `modelContext.getTools()` and `modelContext.executeTool()` APIs. +Once tools are registered, in-page agents can discover and invoke them using `getTools()` and `executeTool()`. + +Calling `document.modelContext.getTools()` returns a promise that resolves with an array of `RegisteredTool` dictionary objects. Each object contains the tool's `name`, `description`, `inputSchema`, `origin`, and owner `window`: + +```js +// Discover tools exposed by same-origin frames in the tree (default) +const tools = await document.modelContext.getTools(); + +for (const tool of tools) { + console.log(`Tool: ${tool.name} (from ${tool.origin})`); + console.log(`Description: ${tool.description}`); + console.log(`Parameters schema:`, tool.inputSchema); +} + +// Or discover tools exposed by specific cross-origin partner frames: +const crossOriginTools = await document.modelContext.getTools({ + fromOrigins: ["https://trusted-partner.example"] +}); +``` + +An agent executes a discovered `RegisteredTool` by passing the tool dictionary and input arguments along to `document.modelContext.executeTool()`. The browser securely mediates the execution, ensuring the [`exposedTo`](https://webmachinelearning.github.io/webmcp/#dom-modelcontextregistertooloptions-exposedto) and [`fromOrigins`](https://webmachinelearning.github.io/webmcp/#dom-modelcontextgettooloptions-fromorigins) agree, and the tool runs in the tool owner's execution context: + +```js +const tools = await document.modelContext.getTools(); +const addTodoTool = tools.find(t => t.name === "add-todo"); + +if (addTodoTool) { + try { + const result = await document.modelContext.executeTool( + addTodoTool, + { text: "Buy groceries" } + ); + console.log("Tool result:", result); + } catch (error) { + console.error("Tool execution failed:", error); + } +} +``` + +##### Cancelling execution with `AbortSignal` + +Tool invocations can be cancelled mid-execution (e.g., if the user aborts an ongoing request, as they might with the "stop button" that's present in most agent UIs) by passing an `AbortSignal`: + +```js +const controller = new AbortController(); + +const executionPromise = document.modelContext.executeTool( + addTodoTool, + { text: "Buy groceries" }, + { signal: controller.signal } +); + +// If the user cancels the interaction: +stopButton.addEventListener('click', e => controller.abort()); +``` + +The tool's [execution callback](https://webmachinelearning.github.io/webmcp/#callbackdef-toolexecutecallback) receives this signal via its [`options.signal`](https://webmachinelearning.github.io/webmcp/#dom-modelcontextexecutetooloptions-signal) parameter, allowing it to abort underlying network requests or asynchronous tasks cleanly. + +##### Responding to dynamic tool updates: the `toolchange` event + +When tools are added, removed, or updated dynamically (such as when user interactions result in new tools being registered), `document.modelContext` fires a `toolchange` event: + +```js +document.modelContext.addEventListener("toolchange", async () => { + const currentTools = await document.modelContext.getTools(); + updateAgentToolRegistry(currentTools); +}); +``` ## Alternatives Considered diff --git a/index.bs b/index.bs index 48c44a1..07c7e05 100644 --- a/index.bs +++ b/index.bs @@ -94,8 +94,13 @@ spec:html; type:dfn; text:browsing context group set text:unique internal value text:is origin-keyed +spec: webidl; type: dfn; + text: an exception was thrown
+spec: html; urlPrefix: https://html.spec.whatwg.org/C
+ type: dfn
+ text: browsing context group
spec: SECURE-CONTEXTS; urlPrefix: https://w3c.github.io/webappsec-secure-contexts/
type: abstract-op
text: is origin potentially trustworthy?; url: is-origin-trustworthy
@@ -164,10 +169,11 @@ A tool definition is a [=struct=] with the following [=struct/items=]
[[!JSON-SCHEMA]]
: execute steps
- :: a set of steps to invoke the tool.
+ :: an algorithm that takes both a [=string=] and an algorithm completionSteps
+ that itself takes a [=string=]-or-null and a [=boolean=].
- Note: For tools registered imperatively, these steps will simply invoke the supplied
- {{ToolExecuteCallback}} callback. For tools registered
+ Note: For tools registered imperatively, these steps will simply invoke the [=imperative
+ execute steps=]. For tools registered
[declaratively](https://github.com/webmachinelearning/webmcp/pull/76), this will be a set of
"internal" steps that have not been defined yet, that describe how to fill out a <{form}> and
its [=form-associated elements=].
@@ -189,6 +195,76 @@ An annotations is a [=struct=] with the following [=struct/items=]:
:: a [=boolean=], initially false.
+Pending tool executions
+
+A pending tool execution is a [=struct=] with the following [=struct/items=]:
+
+If |document| is |execution|'s [=pending tool + execution/target document=], then run |execution|'s [=pending tool execution/completion + steps=] given null and false.
+ + Note: This removes |execution| from the [=traversable navigable/pending tool executions + map=]. + + 1. If |document| is |execution|'s [=pending tool execution/caller document=], then: + + 1. [=map/Remove=] |traversable|'s [=traversable navigable/pending tool executions + map=][|uuid|]. + + 1. Issue(#48): Abort the `AbortSignal` in |execution|'s [=pending tool execution/target + document=], so the tool can abort early now that the caller is dead. + + 1. [=Assert=]: |traversable|'s [=traversable navigable/pending tool executions map=][|uuid|] + does not [=map/exist=]. + +ModelContext=]'s [=ModelContext/internal context=]'s [=model context/tool map=].
+
+1. If |toolMap|[|toolName|] does not [=map/exist=], then run |completionSteps| given null and false,
+ and abort these steps.
+
+ Issue: Support the plumbing of more granular errors back to the invoker; this should result in a
+ "{{NotFoundError}}" in the calling document.
+
+ This protects us against a race between tool unregistration and execution. While tool + existence is protected from this race, tool unregistration followed by a quick + re-registration of a tool with the same |toolName| but input schema is not protected + against.
+ +This might result in |inputArguments| for an old tool being applied to the [=tool + definition/input schema=] of a newer tool, and causing whatever error that might cause, when issue #92 is resolved.
+ +toolactivated" event.
+
+1. Let |toolPromise| be the result of [=invoke|invoking=] |tool|'s {{ModelContextTool/execute}} with
+ |inputObject|.
+
+1. [=promise/React=] to |toolPromise|:
+
+ - If |toolPromise| was fulfilled with value |v|:
+
+ 1. Let |serializedResult| be the result of [=serializing a JavaScript value to a JSON
+ string=] given |v|. If this throws an exception, run |completionSteps| given null and
+ false, and abort these steps.
+
+ 1. Run |completionSteps| given |serializedResult| and true.
+
+ - If |toolPromise| was rejected with reason |r|, then:
+
+ 1. Optionally [=report a warning to the console=] describing |r|.
+
+ 1. Run |completionSteps| given null and false.
+
+Document=].
+
+1. Let |traversable| be |targetDocument|'s [=node navigable=]'s [=navigable/traversable navigable=].
+
1. Run the following steps [=in parallel=]:
- 1. [=Notify documents of a tool change=] given |modelContext|'s [=relevant global object=]'s
- [=associated Document|associated Document=] and |exposed origins|.
+ 1. Let |canceledExecutions| be an empty [=list=].
+
+ 1. [=map/For each=] |uuid| → |execution| of |traversable|'s [=traversable navigable/pending
+ tool executions map=]:
+
+ 1. If |execution|'s [=pending tool execution/target document=] is |targetDocument| and
+ |execution|'s [=pending tool execution/tool name=] is |tool name|, then [=list/append=]
+ |uuid| to |canceledExecutions|.
+
+ 1. [=list/For each=] |uuid| of |canceledExecutions|:
+
+ 1. Let |execution| be |traversable|'s [=traversable navigable/pending tool executions
+ map=][|uuid|].
+
+ 1. Run |execution|'s [=pending tool execution/completion steps=] given null and false.
+
+ Note: This removes |execution| from the [=traversable navigable/pending tool executions
+ map=].
+
+ 1. [=Notify documents of a tool change=] given |targetDocument| and |exposed origins|.
document.{{Document/modelContext}}.{{ModelContext/executeTool(tool, inputArguments, options)}}Executes a tool on the document it was registered on. Returns a promise that resolves to the + stringified result of the tool's execution.
+Document=].
+
+1. If |callerDocument| is not [=Document/fully active=], then return [=a promise rejected with=]
+ an "{{InvalidStateError}}" {{DOMException}}.
+
+1. If [=this=]'s [=surrounding agent=]'s [=agent cluster=]'s [=is origin-keyed=] is false and
+ [=this=]'s [=relevant settings object=]'s [=environment settings object/origin=]'s
+ [=origin/scheme=] is not "file", then return [=a promise rejected with=] a
+ "{{SecurityError}}" {{DOMException}}.
+
+1. If |callerDocument| is not [=allowed to use=] the "{{tools}}" feature, then return [=a promise
+ rejected with=] a "{{NotAllowedError}}" {{DOMException}}.
+
+1. Let |expectedTargetOriginURL| be the result of [=URL parser|parsing=] |tool|'s
+ {{RegisteredTool/origin}}.
+
+1. If |expectedTargetOriginURL| is failure, or |expectedTargetOriginURL|'s [=url/origin=] is an
+ [=opaque origin=], then return [=a promise rejected with=] a "{{NotSupportedError}}"
+ {{DOMException}}.
+
+1. Let |expectedTargetOrigin| be |expectedTargetOriginURL|'s [=url/origin=].
+
+1. [=Assert=]: |expectedTargetOrigin| is not an [=opaque origin=].
+
+1. Let |promise| be [=a new promise=] created in [=this=]'s [=relevant realm=].
+
+1. If |options|'s {{ModelContextExecuteToolOptions/signal}} [=map/exists=], then:
+
+ 1. Let |signal| be |options|'s {{ModelContextExecuteToolOptions/signal}}.
+
+ 1. If |signal| is [=AbortSignal/aborted=], then return [=a promise rejected with=] |signal|'s
+ [=AbortSignal/abort reason=].
+
+ 1. [=AbortSignal/add|Add the following abort steps=] to |signal|:
+
+ 1. [=Reject=] |promise| with |signal|'s [=AbortSignal/abort reason=].
+
+ Issue(#48): Wire up |signal| to the tool execution callback in the tool host's document, so
+ that tool abort is communicated all the way through. This should also fire the
+ "toolcanceled" event in the target document. See also pull request #146.
+
+1. Let |targetWindow| be |tool|'s {{RegisteredTool/window}}.
+
+1. Let |targetDocument| be |targetWindow|'s [=associated Document|associated
+ Document=].
+
+1. Run the following steps [=in parallel=]:
+
+ 1. If |targetDocument|'s [=node navigable=]'s [=navigable/traversable navigable=] is not
+ |callerDocument|'s [=node navigable=]'s [=navigable/traversable navigable=], then [=queue a
+ global task=] on the [=webmcp task source=] given |callerDocument|'s [=relevant global
+ object=] to [=reject=] |promise| with an "{{UnknownError}}" {{DOMException}}, and abort these
+ steps.
+
+ Issue(#227): Consider supporting tool execution across top-level documents in the same
+ [=browsing context group=].
+
+ Issue: Support more granular errors than "{{UnknownError}}", based on each failure case.
+
+ 1. Let |targetOrigin| be |targetDocument|'s [=Document/origin=].
+
+ 1. Let |callerOrigin| be |callerDocument|'s [=Document/origin=].
+
+ 1. If |targetOrigin| is not [=same origin=] with |expectedTargetOrigin|, then [=queue a global
+ task=] on the [=webmcp task source=] given |callerDocument|'s [=relevant global object=] to
+ [=reject=] |promise| with an "{{UnknownError}}" {{DOMException}}, and abort these steps.
+
+ Issue: Support more granular errors than "{{UnknownError}}", based on each failure case.
+
+ 1. Let |targetToolMap| be |targetDocument|'s [=Document/associated ModelContext|associated
+ ModelContext=]'s [=ModelContext/internal context=]'s [=model context/tool map=].
+
+ 1. Let |toolName| be |tool|'s {{RegisteredTool/name}}.
+
+ 1. If |targetToolMap|[|toolName|] does not [=map/exist=], then [=queue a global task=] on the
+ [=webmcp task source=] given |callerDocument|'s [=relevant global object=] to [=reject=]
+ |promise| with an "{{UnknownError}}" {{DOMException}}, and abort these steps.
+
+ Issue: Support more granular errors than "{{UnknownError}}", based on each failure case.
+
+ 1. Let |tool definition| be |targetToolMap|[|toolName|].
+
+ 1. If [=tool is exposed to an origin=] given |targetOrigin|, |tool definition|'s [=tool
+ definition/exposed origins=], and |callerOrigin| returns false, then [=queue a global task=]
+ on the [=webmcp task source=] given |callerDocument|'s [=relevant global object=] to
+ [=reject=] |promise| with an "{{UnknownError}}" {{DOMException}}, and abort these steps.
+
+ Issue: Support more granular errors than "{{UnknownError}}", based on each failure case.
+
+ 1. Let |uuid| be a new [=unique internal value=].
+
+ 1. Let |completionSteps| be an algorithm that takes a [=string=]-or-null |result| and a
+ [=boolean=] |success|, and runs the following steps:
+
+ 1. [=Assert=]: these steps are running [=in parallel=].
+
+ 1. If |targetDocument|'s [=node navigable=]'s [=navigable/traversable navigable=]'s
+ [=traversable navigable/pending tool executions map=][|uuid|] does not [=map/exist=], then
+ return.
+
+ It is possible that a pending execution identified by |uuid| no longer exists. This can + happen due to a race between (a) tool cancellation when the caller document gets destroyed; and (b) tool promise resolution. Both + of these race to invoke |completionSteps|, and the first invocation will remove the + pending execution by its key |uuid|, this check protects subsequent racing + invocations.
+ +This can also happen due to a limitation with tool unregistration. Consider this + example:
+ +In this case, tool unregistration happens after a tool Promise is resolved, but before + its fulfillment handler runs. Since both run |completionSteps|, this check protects the + second invocation from trying to remove the same pending execution twice.
+tool["{{ModelContextTool/name}}"]A unique identifier for the tool. This is used by [=agents=] to reference the tool when making tool calls. +
A unique identifier for the tool. This is used by [=agents=] to reference the tool when + making tool calls.
tool["{{ModelContextTool/title}}"]A label for the tool. This is used by the user agent to reference the tool in the user interface. -
It is recommended that this string be localized to the user's -{{NavigatorLanguage/language}}. +
A label for the tool. This is used by the user agent to reference the tool in the user + interface.
It is recommended that this string be localized to the user's + {{NavigatorLanguage/language}}.
tool["{{ModelContextTool/description}}"]A natural language description of the tool's functionality. This helps [=agents=] understand when and how to use the tool. +
A natural language description of the tool's functionality. This helps [=agents=] understand + when and how to use the tool.
tool["{{ModelContextTool/inputSchema}}"]options["{{ModelContextExecuteToolOptions/signal}}"]
+ :: An {{AbortSignal}} that can be used to cancel the execution of the tool.
+