@nxisai/agent-harness 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/dist/handoff/a2a.d.ts +152 -0
- package/dist/handoff/a2a.d.ts.map +1 -0
- package/dist/handoff/a2a.js +227 -0
- package/dist/handoff/a2a.js.map +1 -0
- package/dist/handoff/controller.d.ts +181 -0
- package/dist/handoff/controller.d.ts.map +1 -0
- package/dist/handoff/controller.js +543 -0
- package/dist/handoff/controller.js.map +1 -0
- package/dist/handoff/envelope.d.ts +130 -0
- package/dist/handoff/envelope.d.ts.map +1 -0
- package/dist/handoff/envelope.js +178 -0
- package/dist/handoff/envelope.js.map +1 -0
- package/dist/handoff/index.d.ts +16 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/tools.d.ts +36 -0
- package/dist/handoff/tools.d.ts.map +1 -0
- package/dist/handoff/tools.js +148 -0
- package/dist/handoff/tools.js.map +1 -0
- package/dist/harness/harness.d.ts +4 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +61 -16
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/router/coordinator.d.ts +13 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +7 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +7 -5
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +33 -12
- package/dist/harness/tools/bridge.js.map +1 -1
- package/dist/harness/tools/discovery.d.ts +12 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +19 -1
- package/dist/harness/tools/discovery.js.map +1 -1
- package/dist/harness/tools/local-context.d.ts +49 -9
- package/dist/harness/tools/local-context.d.ts.map +1 -1
- package/dist/harness/tools/local-context.js +377 -22
- package/dist/harness/tools/local-context.js.map +1 -1
- package/dist/harness/tools/open-page.d.ts +16 -0
- package/dist/harness/tools/open-page.d.ts.map +1 -1
- package/dist/harness/tools/open-page.js +45 -9
- package/dist/harness/tools/open-page.js.map +1 -1
- package/dist/harness/tools/retry.d.ts +53 -0
- package/dist/harness/tools/retry.d.ts.map +1 -0
- package/dist/harness/tools/retry.js +96 -0
- package/dist/harness/tools/retry.js.map +1 -0
- package/dist/harness/tools/schema.d.ts +18 -0
- package/dist/harness/tools/schema.d.ts.map +1 -1
- package/dist/harness/tools/schema.js +38 -0
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +3 -2
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/site-nav.d.ts +168 -0
- package/dist/harness/tools/site-nav.d.ts.map +1 -0
- package/dist/harness/tools/site-nav.js +316 -0
- package/dist/harness/tools/site-nav.js.map +1 -0
- package/dist/harness/tools/site-pages.d.ts +4 -0
- package/dist/harness/tools/site-pages.d.ts.map +1 -1
- package/dist/harness/tools/site-pages.js +19 -3
- package/dist/harness/tools/site-pages.js.map +1 -1
- package/dist/harness/tools/source.d.ts +9 -0
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +54 -14
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/harness/tools/webmcp.d.ts +39 -3
- package/dist/harness/tools/webmcp.d.ts.map +1 -1
- package/dist/harness/tools/webmcp.js +93 -14
- package/dist/harness/tools/webmcp.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +276 -2
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +2 -1
- package/src/a2ui/cards.ts +177 -0
- package/src/a2ui/emit.ts +147 -0
- package/src/a2ui/form.ts +117 -0
- package/src/a2ui/host.ts +73 -0
- package/src/a2ui/index.ts +22 -0
- package/src/a2ui/stepper.ts +211 -0
- package/src/a2ui/theme.ts +134 -0
- package/src/a2ui/to-v09.ts +118 -0
- package/src/engine.ts +701 -0
- package/src/handoff/a2a.ts +326 -0
- package/src/handoff/controller.ts +661 -0
- package/src/handoff/envelope.ts +216 -0
- package/src/handoff/index.ts +53 -0
- package/src/handoff/tools.ts +178 -0
- package/src/harness/harness.ts +981 -0
- package/src/harness/plan.ts +91 -0
- package/src/harness/policy.ts +59 -0
- package/src/harness/renderer.ts +208 -0
- package/src/harness/router/coordinator.ts +241 -0
- package/src/harness/router/rules.ts +65 -0
- package/src/harness/slots.ts +117 -0
- package/src/harness/steps.ts +156 -0
- package/src/harness/tools/action-tools.ts +870 -0
- package/src/harness/tools/bridge.ts +393 -0
- package/src/harness/tools/client.ts +26 -0
- package/src/harness/tools/discovery.ts +73 -0
- package/src/harness/tools/local-context.ts +480 -0
- package/src/harness/tools/open-page.ts +273 -0
- package/src/harness/tools/pending-action.ts +232 -0
- package/src/harness/tools/resume.ts +134 -0
- package/src/harness/tools/retry.ts +117 -0
- package/src/harness/tools/schema.ts +245 -0
- package/src/harness/tools/site-actions.ts +356 -0
- package/src/harness/tools/site-nav.ts +432 -0
- package/src/harness/tools/site-pages.ts +288 -0
- package/src/harness/tools/source.ts +353 -0
- package/src/harness/tools/webmcp.ts +200 -0
- package/src/index.ts +196 -0
- package/src/providers/capability.ts +167 -0
- package/src/providers/proxy.ts +191 -0
- package/src/providers/types.ts +150 -0
- package/src/types.ts +258 -0
- package/src/ui/agent-fullscreen.ts +4262 -0
- package/src/ui/agent.tw.css +49 -0
- package/src/ui/config.ts +138 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/journey.ts +57 -0
- package/src/ui/tailwind.generated.ts +2 -0
- package/src/webmcp-contract.ts +65 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WebMCP registry for pages that have none of their own.
|
|
3
|
+
*
|
|
4
|
+
* `document.modelContext` is normally installed by the browser, a page script
|
|
5
|
+
* or an extension. Where none is present, discovered remote tools would have
|
|
6
|
+
* nowhere to be registered, and a site with a working MCP server would still
|
|
7
|
+
* offer the agent no tools.
|
|
8
|
+
*
|
|
9
|
+
* '''It is built to the WebMCP spec''' (https://webmachinelearning.github.io/webmcp/),
|
|
10
|
+
* step for step, as `@nxisai/webmcp` is, and `npm run conformance` there checks
|
|
11
|
+
* both against it. A registry kinder than the spec lets code pass every test
|
|
12
|
+
* here and fail in a browser that follows it — which is how an answer in words
|
|
13
|
+
* once reached visitors as an empty "No results found." card. In short:
|
|
14
|
+
*
|
|
15
|
+
* - `registerTool` never throws; every failure rejects. A missing `name`,
|
|
16
|
+
* `description` or `execute`, or an `inputSchema` that is not an object, is a
|
|
17
|
+
* `TypeError`; a duplicate name, one outside 1–128 of `[A-Za-z0-9_.-]`, or an
|
|
18
|
+
* empty description is an `InvalidStateError`. It resolves after `toolchange`.
|
|
19
|
+
* - `getTools` lists tools sorted by name, with `title` (`""` when absent),
|
|
20
|
+
* `origin` and `window`; `inputSchema` and `annotations` only when given, the
|
|
21
|
+
* annotations as the spec's four hints.
|
|
22
|
+
* - `executeTool` takes a tool as `getTools` reports it and an input object —
|
|
23
|
+
* required. The tool gets a copy of the input through JSON, and `{ signal }`.
|
|
24
|
+
* The result comes back serialized as JSON, so a string arrives quoted; a
|
|
25
|
+
* tool that throws, or returns what JSON cannot hold (`undefined`), rejects
|
|
26
|
+
* with `UnknownError`. Input is not checked against the schema.
|
|
27
|
+
* - `toolactivated` fires as a tool starts, `toolcancel` when a caller aborts it.
|
|
28
|
+
*
|
|
29
|
+
* Browsers are not there yet: Chrome 155 returns strings unquoted, `"undefined"`
|
|
30
|
+
* for nothing, accepts an omitted input and fires neither new event; Chrome 153
|
|
31
|
+
* wants the input as a JSON string. {@link executeRegistered} and
|
|
32
|
+
* {@link asResponse} take all of them.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
import type {
|
|
37
|
+
ModelContext,
|
|
38
|
+
ModelContextTool,
|
|
39
|
+
ModelContextToolInfo,
|
|
40
|
+
ModelContextToolResponse,
|
|
41
|
+
} from '../../types.js';
|
|
42
|
+
|
|
43
|
+
/** Set on the instance so a later, real provider can be told apart from ours. */
|
|
44
|
+
export const LOCAL_CONTEXT_FLAG = '__nxisLocalModelContext';
|
|
45
|
+
|
|
46
|
+
/** The spec's four hints, as `getTools` reports them. */
|
|
47
|
+
type Hints = { consequentialHint: boolean; debugging: boolean; readOnlyHint: boolean; untrustedContentHint: boolean };
|
|
48
|
+
|
|
49
|
+
/** A registered tool, as the spec's "tool definition" holds it. */
|
|
50
|
+
interface ToolDefinition {
|
|
51
|
+
name: string;
|
|
52
|
+
title: string | null;
|
|
53
|
+
description: string;
|
|
54
|
+
/** The schema as JSON; `""` when there was none. */
|
|
55
|
+
inputSchema: string;
|
|
56
|
+
annotations: Hints | null;
|
|
57
|
+
execute: ModelContextTool['execute'];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** An execution in flight. `started` stands in for the spec's local pending execution. */
|
|
61
|
+
interface PendingExecution {
|
|
62
|
+
toolName: string;
|
|
63
|
+
controller: AbortController;
|
|
64
|
+
started: boolean;
|
|
65
|
+
complete: (result: string | null, success: boolean) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
69
|
+
|
|
70
|
+
function domException(message: string, name: string): Error {
|
|
71
|
+
if (typeof DOMException === 'function') return new DOMException(message, name);
|
|
72
|
+
return Object.assign(new Error(message), { name });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** "Queue a global task": after the current task and its microtasks. */
|
|
76
|
+
const queueTask = (steps: () => void): void => void setTimeout(steps, 0);
|
|
77
|
+
|
|
78
|
+
const isObject = (value: unknown): value is object =>
|
|
79
|
+
(typeof value === 'object' && value !== null) || typeof value === 'function';
|
|
80
|
+
|
|
81
|
+
/** `JSON.stringify`, with a `TypeError` where it yields nothing, as Infra's serializer has it. */
|
|
82
|
+
function serializeJSON(value: unknown): string {
|
|
83
|
+
const json = JSON.stringify(value);
|
|
84
|
+
if (json === undefined) throw new TypeError('The value cannot be serialized as JSON.');
|
|
85
|
+
return json;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function dictionary(value: unknown, type: string): Record<string, unknown> {
|
|
89
|
+
if (value === undefined || value === null) return {};
|
|
90
|
+
if (!isObject(value)) throw new TypeError(`The provided value is not of type '${type}'.`);
|
|
91
|
+
return value as Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function required(dict: Record<string, unknown>, member: string, type: string): unknown {
|
|
95
|
+
if (dict[member] === undefined) {
|
|
96
|
+
throw new TypeError(`Failed to read the '${member}' property from '${type}': Required member is undefined.`);
|
|
97
|
+
}
|
|
98
|
+
return dict[member];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toHints(value: unknown): Hints {
|
|
102
|
+
const dict = dictionary(value, 'ToolAnnotations');
|
|
103
|
+
const hint = (key: keyof Hints) => (dict[key] === undefined ? false : Boolean(dict[key]));
|
|
104
|
+
return {
|
|
105
|
+
consequentialHint: hint('consequentialHint'),
|
|
106
|
+
debugging: hint('debugging'),
|
|
107
|
+
readOnlyHint: hint('readOnlyHint'),
|
|
108
|
+
untrustedContentHint: hint('untrustedContentHint'),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toSignal(value: unknown): AbortSignal | undefined {
|
|
113
|
+
if (value === undefined) return undefined;
|
|
114
|
+
if (typeof AbortSignal === 'function' && !(value instanceof AbortSignal)) {
|
|
115
|
+
throw new TypeError("Failed to read the 'signal' property: The provided value is not of type 'AbortSignal'.");
|
|
116
|
+
}
|
|
117
|
+
return value as AbortSignal;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const ownWindow = (): object => (typeof window !== 'undefined' ? window : globalThis);
|
|
121
|
+
const isWindow = (value: unknown): boolean =>
|
|
122
|
+
typeof window === 'undefined' ? value === globalThis : isObject(value) && (value as { window?: unknown }).window === value;
|
|
123
|
+
/** Outside a browser `http://localhost` stands in: an opaque origin would refuse every call. */
|
|
124
|
+
const ownOrigin = (): string => (typeof location !== 'undefined' ? location.origin : 'http://localhost');
|
|
125
|
+
|
|
126
|
+
function isTrustworthy(url: URL): boolean {
|
|
127
|
+
if (url.origin === 'null') return false;
|
|
128
|
+
if (url.protocol === 'https:' || url.protocol === 'wss:') return true;
|
|
129
|
+
const host = url.hostname;
|
|
130
|
+
return host === 'localhost' || host.endsWith('.localhost') || /^127(?:\.\d{1,3}){3}$/.test(host) || host === '[::1]';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `SecurityError` for an origin that doesn't parse or isn't potentially trustworthy. */
|
|
134
|
+
function checkOrigins(origins: unknown, member: string): void {
|
|
135
|
+
if (origins === undefined) return;
|
|
136
|
+
if (!isObject(origins) || typeof (origins as Partial<Iterable<unknown>>)[Symbol.iterator] !== 'function') {
|
|
137
|
+
throw new TypeError(`Failed to read the '${member}' property: The provided value cannot be converted to a sequence.`);
|
|
138
|
+
}
|
|
139
|
+
for (const origin of origins as Iterable<unknown>) {
|
|
140
|
+
let url: URL;
|
|
141
|
+
try {
|
|
142
|
+
url = new URL(`${origin as string}`);
|
|
143
|
+
} catch {
|
|
144
|
+
throw domException(`"${origin}" is not a valid origin.`, 'SecurityError');
|
|
145
|
+
}
|
|
146
|
+
if (!isTrustworthy(url)) throw domException(`"${origin}" is not a potentially trustworthy origin.`, 'SecurityError');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The spec's opening checks: an origin-keyed agent cluster, unless the page is a `file:` one. */
|
|
151
|
+
function accessError(): Error | null {
|
|
152
|
+
const keyed = (globalThis as { originAgentCluster?: unknown }).originAgentCluster;
|
|
153
|
+
const scheme = typeof location !== 'undefined' ? location.protocol : '';
|
|
154
|
+
return keyed === false && scheme !== 'file:'
|
|
155
|
+
? domException("This page's agent cluster is not origin-keyed.", 'SecurityError')
|
|
156
|
+
: null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type Handler = ((event: Event) => unknown) | null;
|
|
160
|
+
|
|
161
|
+
/** An event carrying the name of the tool it concerns: `toolactivated`, `toolcancel`. */
|
|
162
|
+
class ToolEvent extends Event {
|
|
163
|
+
constructor(
|
|
164
|
+
type: string,
|
|
165
|
+
readonly toolName: string,
|
|
166
|
+
) {
|
|
167
|
+
super(type);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* An in-memory tool registry built to the WebMCP spec.
|
|
173
|
+
*
|
|
174
|
+
* Installed by {@link ensureModelContext} on pages that have no registry of
|
|
175
|
+
* their own, so that discovered remote tools have somewhere to live.
|
|
176
|
+
*/
|
|
177
|
+
export class LocalModelContext extends EventTarget implements ModelContext {
|
|
178
|
+
readonly [LOCAL_CONTEXT_FLAG] = true;
|
|
179
|
+
private readonly _tools = new Map<string, ToolDefinition>();
|
|
180
|
+
private readonly _pending = new Map<symbol, PendingExecution>();
|
|
181
|
+
private readonly _handlers: Record<string, Handler> = {};
|
|
182
|
+
private readonly _listeners: Record<string, EventListener> = {};
|
|
183
|
+
|
|
184
|
+
get ontoolchange(): Handler {
|
|
185
|
+
return this._handlers.toolchange ?? null;
|
|
186
|
+
}
|
|
187
|
+
set ontoolchange(handler: Handler) {
|
|
188
|
+
this._setHandler('toolchange', handler);
|
|
189
|
+
}
|
|
190
|
+
get ontoolactivated(): Handler {
|
|
191
|
+
return this._handlers.toolactivated ?? null;
|
|
192
|
+
}
|
|
193
|
+
set ontoolactivated(handler: Handler) {
|
|
194
|
+
this._setHandler('toolactivated', handler);
|
|
195
|
+
}
|
|
196
|
+
get ontoolcancel(): Handler {
|
|
197
|
+
return this._handlers.toolcancel ?? null;
|
|
198
|
+
}
|
|
199
|
+
set ontoolcancel(handler: Handler) {
|
|
200
|
+
this._setHandler('toolcancel', handler);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
registerTool(tool: ModelContextTool, options: unknown = {}): Promise<void> {
|
|
204
|
+
const type = 'ModelContextTool';
|
|
205
|
+
let definition: ToolDefinition;
|
|
206
|
+
let signal: AbortSignal | undefined;
|
|
207
|
+
let schema: unknown;
|
|
208
|
+
let exposedTo: unknown;
|
|
209
|
+
try {
|
|
210
|
+
// Members in lexicographic order, as WebIDL converts a dictionary.
|
|
211
|
+
const dict = dictionary(tool, type);
|
|
212
|
+
const annotations = dict.annotations === undefined ? null : toHints(dict.annotations);
|
|
213
|
+
const description = `${required(dict, 'description', type) as string}`;
|
|
214
|
+
const execute = required(dict, 'execute', type);
|
|
215
|
+
if (typeof execute !== 'function') {
|
|
216
|
+
throw new TypeError(`Failed to read the 'execute' property from '${type}': The provided value is not a function.`);
|
|
217
|
+
}
|
|
218
|
+
schema = dict.inputSchema;
|
|
219
|
+
if (schema !== undefined && !isObject(schema)) {
|
|
220
|
+
throw new TypeError(`Failed to read the 'inputSchema' property from '${type}': The provided value is not of type 'object'.`);
|
|
221
|
+
}
|
|
222
|
+
const name = `${required(dict, 'name', type) as string}`;
|
|
223
|
+
const title = dict.title === undefined ? null : `${dict.title as string}`;
|
|
224
|
+
definition = { name, title, description, inputSchema: '', annotations, execute: execute as ToolDefinition['execute'] };
|
|
225
|
+
const opts = dictionary(options, 'ModelContextRegisterToolOptions');
|
|
226
|
+
exposedTo = opts.exposedTo;
|
|
227
|
+
signal = toSignal(opts.signal);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return Promise.reject(error);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const denied = accessError();
|
|
233
|
+
if (denied) return Promise.reject(denied);
|
|
234
|
+
const { name } = definition;
|
|
235
|
+
if (this._tools.has(name)) {
|
|
236
|
+
return Promise.reject(domException(`A tool named "${name}" is already registered.`, 'InvalidStateError'));
|
|
237
|
+
}
|
|
238
|
+
if (!TOOL_NAME.test(name)) {
|
|
239
|
+
return Promise.reject(
|
|
240
|
+
domException(`"${name}" is not a valid tool name: 1 to 128 ASCII letters, digits, "_", "-" or ".".`, 'InvalidStateError'),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (definition.description === '') {
|
|
244
|
+
return Promise.reject(domException("A tool's description must not be empty.", 'InvalidStateError'));
|
|
245
|
+
}
|
|
246
|
+
if (schema !== undefined) {
|
|
247
|
+
try {
|
|
248
|
+
definition.inputSchema = serializeJSON(schema);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
return Promise.reject(error);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (signal?.aborted) return Promise.reject(signal.reason);
|
|
254
|
+
try {
|
|
255
|
+
checkOrigins(exposedTo, 'exposedTo');
|
|
256
|
+
} catch (error) {
|
|
257
|
+
return Promise.reject(error);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let settle!: { resolve: () => void; reject: (reason: unknown) => void };
|
|
261
|
+
const promise = new Promise<void>((resolve, reject) => (settle = { resolve, reject }));
|
|
262
|
+
if (signal) {
|
|
263
|
+
const s = signal;
|
|
264
|
+
s.addEventListener(
|
|
265
|
+
'abort',
|
|
266
|
+
() => {
|
|
267
|
+
if (this._tools.delete(name)) this._notifyToolChange();
|
|
268
|
+
settle.reject(s.reason);
|
|
269
|
+
},
|
|
270
|
+
{ once: true },
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
this._tools.set(name, definition);
|
|
274
|
+
this._notifyToolChange();
|
|
275
|
+
queueTask(() => settle.resolve());
|
|
276
|
+
return promise;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
getTools(options: unknown = {}): Promise<ModelContextToolInfo[]> {
|
|
280
|
+
try {
|
|
281
|
+
checkOrigins(dictionary(options, 'ModelContextGetToolOptions').fromOrigins, 'fromOrigins');
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return Promise.reject(error);
|
|
284
|
+
}
|
|
285
|
+
const denied = accessError();
|
|
286
|
+
if (denied) return Promise.reject(denied);
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const origin = ownOrigin();
|
|
289
|
+
const win = ownWindow();
|
|
290
|
+
const tools = [...this._tools.values()].map((t) => {
|
|
291
|
+
// Members in lexicographic order, as WebIDL creates a dictionary; absent ones are left out.
|
|
292
|
+
const info: Record<string, unknown> = {};
|
|
293
|
+
if (t.annotations) info.annotations = { ...t.annotations };
|
|
294
|
+
info.description = t.description;
|
|
295
|
+
if (t.inputSchema !== '') info.inputSchema = JSON.parse(t.inputSchema);
|
|
296
|
+
info.name = t.name;
|
|
297
|
+
info.origin = origin;
|
|
298
|
+
info.title = t.title ?? '';
|
|
299
|
+
info.window = win;
|
|
300
|
+
return info as unknown as ModelContextToolInfo;
|
|
301
|
+
});
|
|
302
|
+
tools.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
303
|
+
queueTask(() => resolve(tools));
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
executeTool(tool: string | object, inputObject?: unknown, options: unknown = {}): Promise<ModelContextToolResponse> {
|
|
308
|
+
const type = 'RegisteredTool';
|
|
309
|
+
let name: string;
|
|
310
|
+
let origin: string;
|
|
311
|
+
let win: unknown;
|
|
312
|
+
let signal: AbortSignal | undefined;
|
|
313
|
+
try {
|
|
314
|
+
const dict = dictionary(tool, type);
|
|
315
|
+
if (dict.annotations !== undefined) toHints(dict.annotations);
|
|
316
|
+
required(dict, 'description', type);
|
|
317
|
+
if (dict.inputSchema !== undefined && !isObject(dict.inputSchema)) {
|
|
318
|
+
throw new TypeError(`Failed to read the 'inputSchema' property from '${type}': The provided value is not of type 'object'.`);
|
|
319
|
+
}
|
|
320
|
+
name = `${required(dict, 'name', type) as string}`;
|
|
321
|
+
origin = `${required(dict, 'origin', type) as string}`;
|
|
322
|
+
win = required(dict, 'window', type);
|
|
323
|
+
if (!isWindow(win)) {
|
|
324
|
+
throw new TypeError(`Failed to read the 'window' property from '${type}': The provided value is not of type 'Window'.`);
|
|
325
|
+
}
|
|
326
|
+
signal = toSignal(dictionary(options, 'ModelContextExecuteToolOptions').signal);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return Promise.reject(error);
|
|
329
|
+
}
|
|
330
|
+
const denied = accessError();
|
|
331
|
+
if (denied) return Promise.reject(denied);
|
|
332
|
+
|
|
333
|
+
let expectedOrigin: string;
|
|
334
|
+
try {
|
|
335
|
+
expectedOrigin = new URL(origin).origin;
|
|
336
|
+
} catch {
|
|
337
|
+
return Promise.reject(domException(`"${origin}" is not a valid origin.`, 'NotSupportedError'));
|
|
338
|
+
}
|
|
339
|
+
if (expectedOrigin === 'null') return Promise.reject(domException("A tool's origin must not be opaque.", 'NotSupportedError'));
|
|
340
|
+
if (!isObject(inputObject)) {
|
|
341
|
+
return Promise.reject(new TypeError("Failed to execute 'executeTool' on 'ModelContext': The input is not an object."));
|
|
342
|
+
}
|
|
343
|
+
let inputArguments: string;
|
|
344
|
+
try {
|
|
345
|
+
inputArguments = serializeJSON(inputObject);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
return Promise.reject(error);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let settle!: { resolve: (value: string) => void; reject: (reason: unknown) => void };
|
|
351
|
+
const promise = new Promise<string>((resolve, reject) => (settle = { resolve, reject }));
|
|
352
|
+
const uuid = Symbol('tool execution');
|
|
353
|
+
if (signal) {
|
|
354
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
355
|
+
const s = signal;
|
|
356
|
+
s.addEventListener(
|
|
357
|
+
'abort',
|
|
358
|
+
() => {
|
|
359
|
+
settle.reject(s.reason);
|
|
360
|
+
queueTask(() => this._cancel(uuid));
|
|
361
|
+
},
|
|
362
|
+
{ once: true },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const fail = () => queueTask(() => settle.reject(domException('The tool could not be executed.', 'UnknownError')));
|
|
367
|
+
queueTask(() => {
|
|
368
|
+
if (win !== ownWindow() || ownOrigin() !== expectedOrigin || !this._tools.has(name)) return fail();
|
|
369
|
+
const execution: PendingExecution = {
|
|
370
|
+
toolName: name,
|
|
371
|
+
controller: new AbortController(),
|
|
372
|
+
started: false,
|
|
373
|
+
complete: (result, success) => {
|
|
374
|
+
if (!this._pending.delete(uuid)) return;
|
|
375
|
+
queueTask(() =>
|
|
376
|
+
success
|
|
377
|
+
? settle.resolve(result as string)
|
|
378
|
+
: settle.reject(domException("The tool's execution failed.", 'UnknownError')),
|
|
379
|
+
);
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
this._pending.set(uuid, execution);
|
|
383
|
+
queueTask(() => this._runTool(name, inputArguments, execution));
|
|
384
|
+
});
|
|
385
|
+
// A string, as the spec resolves it. Typed as a response for the callers that read it through
|
|
386
|
+
// `asResponse` in webmcp.ts.
|
|
387
|
+
return promise as unknown as Promise<ModelContextToolResponse>;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** The spec's tool execute steps, then its imperative execute steps. */
|
|
391
|
+
private _runTool(name: string, inputArguments: string, execution: PendingExecution): void {
|
|
392
|
+
const definition = this._tools.get(name);
|
|
393
|
+
if (!definition) return execution.complete(null, false);
|
|
394
|
+
let input: unknown;
|
|
395
|
+
try {
|
|
396
|
+
input = JSON.parse(inputArguments);
|
|
397
|
+
} catch {
|
|
398
|
+
return execution.complete(null, false);
|
|
399
|
+
}
|
|
400
|
+
if (!isObject(input)) return execution.complete(null, false);
|
|
401
|
+
|
|
402
|
+
this.dispatchEvent(new ToolEvent('toolactivated', name));
|
|
403
|
+
execution.started = true;
|
|
404
|
+
let toolPromise: Promise<unknown>;
|
|
405
|
+
try {
|
|
406
|
+
// A WebIDL callback: `this` undefined, and a synchronous throw is a rejection.
|
|
407
|
+
toolPromise = Promise.resolve(
|
|
408
|
+
definition.execute.call(undefined, input as Record<string, unknown>, { signal: execution.controller.signal }),
|
|
409
|
+
);
|
|
410
|
+
} catch (error) {
|
|
411
|
+
toolPromise = Promise.reject(error);
|
|
412
|
+
}
|
|
413
|
+
toolPromise.then(
|
|
414
|
+
(value) => {
|
|
415
|
+
if (!execution.started) return; // cancelled before the tool settled
|
|
416
|
+
execution.started = false;
|
|
417
|
+
let serialized: string;
|
|
418
|
+
try {
|
|
419
|
+
serialized = serializeJSON(value);
|
|
420
|
+
} catch {
|
|
421
|
+
return execution.complete(null, false);
|
|
422
|
+
}
|
|
423
|
+
execution.complete(serialized, true);
|
|
424
|
+
},
|
|
425
|
+
() => {
|
|
426
|
+
if (!execution.started) return;
|
|
427
|
+
execution.started = false;
|
|
428
|
+
execution.complete(null, false);
|
|
429
|
+
},
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The spec's "cancel a pending tool execution". */
|
|
434
|
+
private _cancel(uuid: symbol): void {
|
|
435
|
+
const execution = this._pending.get(uuid);
|
|
436
|
+
if (!execution) return;
|
|
437
|
+
this._pending.delete(uuid);
|
|
438
|
+
queueTask(() => {
|
|
439
|
+
if (!execution.started) return;
|
|
440
|
+
execution.started = false;
|
|
441
|
+
execution.controller.abort();
|
|
442
|
+
this.dispatchEvent(new ToolEvent('toolcancel', execution.toolName));
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private _notifyToolChange(): void {
|
|
447
|
+
queueTask(() => this.dispatchEvent(new Event('toolchange')));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** An event handler attribute: one listener, kept in its place however often the handler changes. */
|
|
451
|
+
private _setHandler(type: string, handler: Handler): void {
|
|
452
|
+
const value = typeof handler === 'function' ? handler : null;
|
|
453
|
+
this._handlers[type] = value;
|
|
454
|
+
if (value && !this._listeners[type]) {
|
|
455
|
+
const listener: EventListener = (event) => {
|
|
456
|
+
if (this._handlers[type]?.call(this, event) === false) event.preventDefault();
|
|
457
|
+
};
|
|
458
|
+
this._listeners[type] = listener;
|
|
459
|
+
this.addEventListener(type, listener);
|
|
460
|
+
} else if (!value && this._listeners[type]) {
|
|
461
|
+
this.removeEventListener(type, this._listeners[type]);
|
|
462
|
+
delete this._listeners[type];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Install a {@link LocalModelContext} on `document` if the page has no registry.
|
|
469
|
+
*
|
|
470
|
+
* Never replaces an existing provider, which owns the page's own action tools.
|
|
471
|
+
*
|
|
472
|
+
* @returns The page's registry, whether pre-existing or newly installed.
|
|
473
|
+
*/
|
|
474
|
+
export function ensureModelContext(): ModelContext {
|
|
475
|
+
const existing = document.modelContext ?? navigator.modelContext ?? null;
|
|
476
|
+
if (existing) return existing;
|
|
477
|
+
const local = new LocalModelContext();
|
|
478
|
+
document.modelContext = local;
|
|
479
|
+
return local;
|
|
480
|
+
}
|