@nxisai/agent-harness 0.2.4 → 0.4.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 +90 -2
- package/dist/a2ui/stepper.d.ts +1 -1
- package/dist/a2ui/stepper.d.ts.map +1 -1
- package/dist/a2ui/stepper.js +2 -2
- package/dist/a2ui/stepper.js.map +1 -1
- package/dist/engine.d.ts +1 -0
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +1 -0
- package/dist/engine.js.map +1 -1
- package/dist/harness/harness.d.ts +58 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +179 -4
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/renderer.d.ts +30 -0
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/steps.d.ts +101 -0
- package/dist/harness/steps.d.ts.map +1 -0
- package/dist/harness/steps.js +102 -0
- package/dist/harness/steps.js.map +1 -0
- package/dist/harness/tools/action-tools.d.ts +213 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -0
- package/dist/harness/tools/action-tools.js +724 -0
- package/dist/harness/tools/action-tools.js.map +1 -0
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +28 -2
- package/dist/harness/tools/bridge.js.map +1 -1
- package/dist/harness/tools/open-page.d.ts +42 -0
- package/dist/harness/tools/open-page.d.ts.map +1 -0
- package/dist/harness/tools/open-page.js +118 -0
- package/dist/harness/tools/open-page.js.map +1 -0
- package/dist/harness/tools/pending-action.d.ts +99 -0
- package/dist/harness/tools/pending-action.d.ts.map +1 -0
- package/dist/harness/tools/pending-action.js +175 -0
- package/dist/harness/tools/pending-action.js.map +1 -0
- package/dist/harness/tools/resume.d.ts +92 -0
- package/dist/harness/tools/resume.d.ts.map +1 -0
- package/dist/harness/tools/resume.js +84 -0
- package/dist/harness/tools/resume.js.map +1 -0
- package/dist/harness/tools/schema.js +29 -2
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts +81 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -0
- package/dist/harness/tools/site-actions.js +314 -0
- package/dist/harness/tools/site-actions.js.map +1 -0
- package/dist/harness/tools/site-pages.d.ts +95 -0
- package/dist/harness/tools/site-pages.d.ts.map +1 -0
- package/dist/harness/tools/site-pages.js +199 -0
- package/dist/harness/tools/site-pages.js.map +1 -0
- package/dist/harness/tools/source.d.ts +58 -3
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +88 -7
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +14 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/proxy.d.ts +5 -0
- package/dist/providers/proxy.d.ts.map +1 -1
- package/dist/providers/proxy.js +16 -1
- package/dist/providers/proxy.js.map +1 -1
- package/dist/providers/types.d.ts +15 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/types.d.ts +7 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +835 -36
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/journey.d.ts +33 -0
- package/dist/ui/journey.d.ts.map +1 -0
- package/dist/ui/journey.js +27 -0
- package/dist/ui/journey.js.map +1 -0
- 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/dist/webmcp-contract.d.ts +6 -2
- package/dist/webmcp-contract.d.ts.map +1 -1
- package/docs/remote-providers.md +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the site's Actions as tools the agent can see from any page.
|
|
3
|
+
*
|
|
4
|
+
* A site repeats the same form across many pages: the listings demo has one
|
|
5
|
+
* quote form per business, which are 38 actions with the same name and the
|
|
6
|
+
* same fields. Registered one by one they would be 38 indistinguishable tools,
|
|
7
|
+
* so actions that share a name and a field list become a single tool, and the
|
|
8
|
+
* field whose default value differs between them — the site's own name for the
|
|
9
|
+
* target, such as `business` — chooses which one runs.
|
|
10
|
+
*
|
|
11
|
+
* Each is named for the reach it adds: `quote_form_anywhere` beside the page's
|
|
12
|
+
* own `quote_form`. It is a courier rather than a second form — it captures an
|
|
13
|
+
* action's inputs wherever the agent happens to be, carries them to the page
|
|
14
|
+
* that can submit them, and calls the tool there, never filling a field
|
|
15
|
+
* itself. The distinct name also matters mechanically: a registry treats a
|
|
16
|
+
* repeated name as a duplicate, so the polyfill would replace the very tool
|
|
17
|
+
* this one delegates to, and a native registry would reject the registration.
|
|
18
|
+
*
|
|
19
|
+
* On the action's own page there is nothing to carry, and it calls the page's
|
|
20
|
+
* tool directly.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { samePage, sameActionPage } from './site-actions.js';
|
|
25
|
+
import { savePendingAction } from './pending-action.js';
|
|
26
|
+
import { journeySteps } from '../steps.js';
|
|
27
|
+
/** Marks a tool this module registered. */
|
|
28
|
+
export const SITE_ACTION_HINT = 'ai.nxis/siteAction';
|
|
29
|
+
/** The `@id`s a site tool covers. */
|
|
30
|
+
export const ACTION_IDS_HINT = 'ai.nxis/actionIds';
|
|
31
|
+
/** The pages those actions live on. */
|
|
32
|
+
export const PAGE_URLS_HINT = 'ai.nxis/pageUrls';
|
|
33
|
+
/** What the page's own script names the same actions. */
|
|
34
|
+
export const PAGE_TOOL_NAMES_HINT = 'ai.nxis/pageToolNames';
|
|
35
|
+
/**
|
|
36
|
+
* Marks a response that started a page change instead of submitting.
|
|
37
|
+
*
|
|
38
|
+
* Carried in `structuredContent`, where a renderer can find it without
|
|
39
|
+
* parsing prose.
|
|
40
|
+
*/
|
|
41
|
+
export const HANDOFF_RESULT = 'ai.nxis/handoff';
|
|
42
|
+
/** The field whose value says which of the covered actions a call means. */
|
|
43
|
+
export const CHOOSER_HINT = 'ai.nxis/chooser';
|
|
44
|
+
/** That field's values, one per covered action, in the same order. */
|
|
45
|
+
export const CHOICES_HINT = 'ai.nxis/choices';
|
|
46
|
+
/** The `@id` of the action a page tool came from, where the page records it. */
|
|
47
|
+
export const ACTION_ID_HINT = 'ai.nxis/actionId';
|
|
48
|
+
/** Appended to the action's own name, keeping it clear of the page's tool. */
|
|
49
|
+
const NAME_SUFFIX = '_anywhere';
|
|
50
|
+
/** WebMCP tool names are capped at this length. */
|
|
51
|
+
const MAX_NAME = 64;
|
|
52
|
+
// ── names ───────────────────────────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* An action name as a tool name.
|
|
55
|
+
*
|
|
56
|
+
* Mirrors the page script's own sanitiser, so the two agree on what a given
|
|
57
|
+
* action is called.
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizeToolName(name) {
|
|
60
|
+
return (name
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/[\s-]+/g, '_')
|
|
63
|
+
.replace(/[^a-z0-9_]/g, '_')
|
|
64
|
+
.replace(/_+/g, '_')
|
|
65
|
+
.replace(/^_|_$/g, '')
|
|
66
|
+
.slice(0, MAX_NAME) || 'unnamed_action');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The name a site-wide tool is registered under.
|
|
70
|
+
*
|
|
71
|
+
* The stem is the action's own name, so the model still reads what the form is
|
|
72
|
+
* called and matches it to what the visitor asked for; the suffix says this is
|
|
73
|
+
* the one that reaches the form from any page, and keeps it clear of the
|
|
74
|
+
* page's own tool of the same name.
|
|
75
|
+
*
|
|
76
|
+
* @param actionName - The action's name, as the record gives it.
|
|
77
|
+
* @returns The tool name, within the length a registry accepts.
|
|
78
|
+
*/
|
|
79
|
+
export function siteToolName(actionName) {
|
|
80
|
+
return sanitizeToolName(actionName).slice(0, MAX_NAME - NAME_SUFFIX.length) + NAME_SUFFIX;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A tool's name without the site-wide suffix, for showing to a person.
|
|
84
|
+
*
|
|
85
|
+
* `quote_form_anywhere` exists to be told apart from the page's `quote_form`
|
|
86
|
+
* in a registry; to the visitor both are the quote form.
|
|
87
|
+
*/
|
|
88
|
+
export function withoutSiteSuffix(toolName) {
|
|
89
|
+
return toolName.endsWith(NAME_SUFFIX) ? toolName.slice(0, -NAME_SUFFIX.length) : toolName;
|
|
90
|
+
}
|
|
91
|
+
/** A page URL's last path segment, as a label: `/listings/royal-oak.html` → `royal oak`. */
|
|
92
|
+
function pageLabel(pageUrl) {
|
|
93
|
+
if (!pageUrl)
|
|
94
|
+
return 'this page';
|
|
95
|
+
let path;
|
|
96
|
+
try {
|
|
97
|
+
path = new URL(pageUrl).pathname;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
path = pageUrl;
|
|
101
|
+
}
|
|
102
|
+
const slug = path.split('/').filter(Boolean).pop() ?? '';
|
|
103
|
+
return slug.replace(/\.\w+$/, '').replace(/[-_]+/g, ' ') || 'home';
|
|
104
|
+
}
|
|
105
|
+
// ── grouping ────────────────────────────────────────────────────────────────
|
|
106
|
+
const fieldNames = (action) => (action.input ?? []).map((i) => i.valueName);
|
|
107
|
+
/**
|
|
108
|
+
* Actions with the same name and the same fields are one tool.
|
|
109
|
+
*
|
|
110
|
+
* JSON rather than a joined string, so a separator character cannot appear in
|
|
111
|
+
* a field name and merge two groups that differ.
|
|
112
|
+
*/
|
|
113
|
+
const groupKey = (action) => JSON.stringify([sanitizeToolName(action.name), [...fieldNames(action)].sort()]);
|
|
114
|
+
/**
|
|
115
|
+
* Which field distinguishes the members of a group.
|
|
116
|
+
*
|
|
117
|
+
* The crawler records what a form already says about itself: on a per-business
|
|
118
|
+
* quote form, the `business` field carries that business's name as its default.
|
|
119
|
+
* A field every member fills with a different value is therefore the site's own
|
|
120
|
+
* way of naming the target, and is used in preference to anything invented.
|
|
121
|
+
*
|
|
122
|
+
* @param actions - The group's actions, in order.
|
|
123
|
+
* @returns The field name and each action's value, or null when no field
|
|
124
|
+
* separates them.
|
|
125
|
+
*/
|
|
126
|
+
export function findDiscriminator(actions) {
|
|
127
|
+
if (actions.length < 2)
|
|
128
|
+
return null;
|
|
129
|
+
for (const field of fieldNames(actions[0])) {
|
|
130
|
+
const values = actions.map((a) => (a.input ?? []).find((i) => i.valueName === field)?.defaultValue?.trim() ?? '');
|
|
131
|
+
if (values.some((v) => !v))
|
|
132
|
+
continue;
|
|
133
|
+
if (new Set(values).size === actions.length)
|
|
134
|
+
return { field, values };
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/** Labels for a group whose own fields do not tell its actions apart. */
|
|
139
|
+
function pageLabels(actions) {
|
|
140
|
+
const labels = actions.map((a) => pageLabel(a.target['nxis:pageUrl']));
|
|
141
|
+
if (new Set(labels).size === labels.length)
|
|
142
|
+
return labels;
|
|
143
|
+
// Two pages with the same last segment: fall back to the whole path.
|
|
144
|
+
return actions.map((a) => a.target['nxis:pageUrl'] ?? pageLabel(a.target['nxis:pageUrl']));
|
|
145
|
+
}
|
|
146
|
+
// ── schema ──────────────────────────────────────────────────────────────────
|
|
147
|
+
const JSON_TYPES = {
|
|
148
|
+
string: 'string',
|
|
149
|
+
number: 'number',
|
|
150
|
+
integer: 'integer',
|
|
151
|
+
boolean: 'boolean',
|
|
152
|
+
date: 'string',
|
|
153
|
+
file: 'string',
|
|
154
|
+
};
|
|
155
|
+
/** One field's schema, following the same rules as the page script. */
|
|
156
|
+
function propertySchema(spec) {
|
|
157
|
+
const valueType = spec['nxis:valueType'] ?? 'string';
|
|
158
|
+
const prop = { type: JSON_TYPES[valueType] ?? 'string' };
|
|
159
|
+
if (valueType === 'date')
|
|
160
|
+
prop.format = 'date';
|
|
161
|
+
if (spec.description)
|
|
162
|
+
prop.description = spec.description;
|
|
163
|
+
if (spec.defaultValue !== undefined)
|
|
164
|
+
prop.default = coerce(spec.defaultValue, valueType);
|
|
165
|
+
if (spec.valueMinLength !== undefined)
|
|
166
|
+
prop.minLength = spec.valueMinLength;
|
|
167
|
+
if (spec.valueMaxLength !== undefined)
|
|
168
|
+
prop.maxLength = spec.valueMaxLength;
|
|
169
|
+
if (spec.valuePattern) {
|
|
170
|
+
const p = spec.valuePattern;
|
|
171
|
+
prop.pattern = p.startsWith('^') && p.endsWith('$') ? p : `^${p}$`;
|
|
172
|
+
}
|
|
173
|
+
if (spec.minValue !== undefined)
|
|
174
|
+
prop.minimum = spec.minValue;
|
|
175
|
+
if (spec.maxValue !== undefined)
|
|
176
|
+
prop.maximum = spec.maxValue;
|
|
177
|
+
if (spec.stepValue !== undefined)
|
|
178
|
+
prop.multipleOf = spec.stepValue;
|
|
179
|
+
if (spec.readonlyValue)
|
|
180
|
+
prop.readOnly = true;
|
|
181
|
+
const options = spec['nxis:valueOption'] ?? [];
|
|
182
|
+
if (options.length) {
|
|
183
|
+
prop.enum = options.map((o) => o.value);
|
|
184
|
+
const labelled = options.filter((o) => o.name && o.name !== o.value);
|
|
185
|
+
if (labelled.length) {
|
|
186
|
+
// What each value means, for the model to read in the serialised schema.
|
|
187
|
+
// Deliberately not folded into `description`: every surface renders that
|
|
188
|
+
// as the field's label, and a sentence there becomes a label wide enough
|
|
189
|
+
// to crush the value beside it.
|
|
190
|
+
prop['nxis:valueLabels'] = Object.fromEntries(labelled.map((o) => [o.value, o.name]));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (spec.multipleValues) {
|
|
194
|
+
const items = { ...prop };
|
|
195
|
+
const array = { type: 'array', items };
|
|
196
|
+
if (spec.valueMinLength !== undefined) {
|
|
197
|
+
array.minItems = spec.valueMinLength;
|
|
198
|
+
delete items.minLength;
|
|
199
|
+
}
|
|
200
|
+
if (spec.valueMaxLength !== undefined) {
|
|
201
|
+
array.maxItems = spec.valueMaxLength;
|
|
202
|
+
delete items.maxLength;
|
|
203
|
+
}
|
|
204
|
+
if (items.description) {
|
|
205
|
+
array.description = items.description;
|
|
206
|
+
delete items.description;
|
|
207
|
+
}
|
|
208
|
+
return array;
|
|
209
|
+
}
|
|
210
|
+
return prop;
|
|
211
|
+
}
|
|
212
|
+
function coerce(value, valueType) {
|
|
213
|
+
if (valueType === 'number' || valueType === 'integer') {
|
|
214
|
+
const n = Number(value);
|
|
215
|
+
return Number.isNaN(n) ? value : n;
|
|
216
|
+
}
|
|
217
|
+
if (valueType === 'boolean')
|
|
218
|
+
return value === 'true' || value === '1';
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
/** Merge the group's field schemas, widening each enum to what any member accepts. */
|
|
222
|
+
function mergedSchema(actions) {
|
|
223
|
+
const properties = {};
|
|
224
|
+
const required = new Set();
|
|
225
|
+
for (const action of actions) {
|
|
226
|
+
for (const spec of action.input ?? []) {
|
|
227
|
+
const prop = propertySchema(spec);
|
|
228
|
+
const existing = properties[spec.valueName];
|
|
229
|
+
if (!existing) {
|
|
230
|
+
properties[spec.valueName] = prop;
|
|
231
|
+
}
|
|
232
|
+
else if (Array.isArray(existing.enum) && Array.isArray(prop.enum)) {
|
|
233
|
+
// The same field offers different options per page: accept any of them,
|
|
234
|
+
// since the form on the page decides what is really valid.
|
|
235
|
+
existing.enum = [...new Set([...existing.enum, ...prop.enum])];
|
|
236
|
+
}
|
|
237
|
+
if (spec.valueRequired)
|
|
238
|
+
required.add(spec.valueName);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const schema = { type: 'object', properties };
|
|
242
|
+
if (required.size)
|
|
243
|
+
schema.required = [...required];
|
|
244
|
+
return schema;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Group the site's actions and build one tool definition for each group.
|
|
248
|
+
*
|
|
249
|
+
* Names are derived from the whole set rather than the order actions arrive
|
|
250
|
+
* in, so a tool is called the same thing on every page of the site.
|
|
251
|
+
*
|
|
252
|
+
* @param actions - The site's actions.
|
|
253
|
+
* @param mc - The registry the tools will run against, used to find the page's
|
|
254
|
+
* own tool when the agent is already on the right page.
|
|
255
|
+
* @param opts - The current page.
|
|
256
|
+
* @returns One entry per group, ordered by tool name.
|
|
257
|
+
*/
|
|
258
|
+
export function buildSiteActionTools(actions, mc, opts = {}) {
|
|
259
|
+
const groups = new Map();
|
|
260
|
+
for (const action of actions) {
|
|
261
|
+
const key = groupKey(action);
|
|
262
|
+
const group = groups.get(key);
|
|
263
|
+
if (group)
|
|
264
|
+
group.push(action);
|
|
265
|
+
else
|
|
266
|
+
groups.set(key, [action]);
|
|
267
|
+
}
|
|
268
|
+
const taken = new Set();
|
|
269
|
+
const tools = [];
|
|
270
|
+
// Sorted so that two groups whose names collide are numbered the same way on
|
|
271
|
+
// every page, whatever order the server returned them in.
|
|
272
|
+
for (const key of [...groups.keys()].sort()) {
|
|
273
|
+
const group = groups.get(key);
|
|
274
|
+
const tool = buildTool(group, mc, taken, opts);
|
|
275
|
+
built.set(tool.name, tool);
|
|
276
|
+
tools.push(tool);
|
|
277
|
+
}
|
|
278
|
+
return tools;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Every site-wide tool built in this page, by registered name.
|
|
282
|
+
*
|
|
283
|
+
* Annotations are the obvious place to record what a tool covers, and a tool
|
|
284
|
+
* read back from `getTools()` carries them only if the registry kept them: a
|
|
285
|
+
* native one keeps the hints it recognises and drops the rest, which leaves
|
|
286
|
+
* one of these indistinguishable from any other write. The builder and the
|
|
287
|
+
* harness run in the same page, so what was built is remembered here, and
|
|
288
|
+
* annotations are the fallback for anything registered elsewhere.
|
|
289
|
+
*/
|
|
290
|
+
const built = new Map();
|
|
291
|
+
/**
|
|
292
|
+
* Whether a tool is a site-wide one, reaching a form from any page.
|
|
293
|
+
*
|
|
294
|
+
* Checked against what this page built, then the tool's annotations, so it
|
|
295
|
+
* holds even where a native registry has stripped them.
|
|
296
|
+
*/
|
|
297
|
+
export function isSiteTool(tool) {
|
|
298
|
+
return coverageOf(tool) !== null;
|
|
299
|
+
}
|
|
300
|
+
/** A tool's coverage, from what this page built or from what it carries. */
|
|
301
|
+
function coverageOf(tool) {
|
|
302
|
+
const local = built.get(tool.name);
|
|
303
|
+
if (local) {
|
|
304
|
+
return {
|
|
305
|
+
ids: local.actions.map((a) => a['@id']),
|
|
306
|
+
pages: local.actions.map((a) => a.target['nxis:pageUrl'] ?? ''),
|
|
307
|
+
pageToolNames: [...new Set(local.actions.map((a) => sanitizeToolName(a.name)))],
|
|
308
|
+
chooser: local.discriminator ?? '',
|
|
309
|
+
choices: local.choices ?? [],
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const notes = tool.annotations;
|
|
313
|
+
if (!notes?.[SITE_ACTION_HINT])
|
|
314
|
+
return null;
|
|
315
|
+
return {
|
|
316
|
+
ids: notes[ACTION_IDS_HINT] ?? [],
|
|
317
|
+
pages: notes[PAGE_URLS_HINT] ?? [],
|
|
318
|
+
pageToolNames: notes[PAGE_TOOL_NAMES_HINT] ?? [],
|
|
319
|
+
chooser: notes[CHOOSER_HINT] ?? '',
|
|
320
|
+
choices: notes[CHOICES_HINT] ?? [],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function buildTool(actions, mc, taken, opts) {
|
|
324
|
+
const first = actions[0];
|
|
325
|
+
const name = uniqueName(siteToolName(first.name), taken);
|
|
326
|
+
taken.add(name);
|
|
327
|
+
const schema = mergedSchema(actions);
|
|
328
|
+
const found = findDiscriminator(actions);
|
|
329
|
+
const discriminator = actions.length > 1 ? (found?.field ?? 'target') : undefined;
|
|
330
|
+
const choices = actions.length > 1 ? (found?.values ?? pageLabels(actions)) : undefined;
|
|
331
|
+
if (discriminator && choices) {
|
|
332
|
+
const here = actions.findIndex((a) => sameActionPage(a, opts.href));
|
|
333
|
+
schema.properties ?? (schema.properties = {});
|
|
334
|
+
const existing = { ...(schema.properties[discriminator] ?? { type: 'string' }) };
|
|
335
|
+
// The merged schema carries the first action's own default — the first
|
|
336
|
+
// business's name. Left there, a call that named no business would submit
|
|
337
|
+
// to that one, so the choice starts empty unless this page settles it.
|
|
338
|
+
delete existing.default;
|
|
339
|
+
// A label, not an instruction: what this field is for belongs in the
|
|
340
|
+
// tool's description, which the model reads and no surface renders as a
|
|
341
|
+
// field name. A real field keeps whatever the form called it, and an
|
|
342
|
+
// added one is named after what it selects.
|
|
343
|
+
const label = found ? existing.description : 'Page';
|
|
344
|
+
schema.properties[discriminator] = {
|
|
345
|
+
...existing,
|
|
346
|
+
type: 'string',
|
|
347
|
+
...(label ? { description: label } : {}),
|
|
348
|
+
enum: choices,
|
|
349
|
+
// On a page this tool covers, that page's action is the obvious one.
|
|
350
|
+
...(here >= 0 ? { default: choices[here] } : {}),
|
|
351
|
+
};
|
|
352
|
+
schema.required = [...new Set([...(schema.required ?? []), discriminator])];
|
|
353
|
+
}
|
|
354
|
+
const definition = {
|
|
355
|
+
name,
|
|
356
|
+
description: describe(actions, discriminator, choices),
|
|
357
|
+
inputSchema: schema,
|
|
358
|
+
annotations: {
|
|
359
|
+
// Actions submit a form. Never run one without a human pressing Submit.
|
|
360
|
+
readOnlyHint: false,
|
|
361
|
+
[SITE_ACTION_HINT]: true,
|
|
362
|
+
[ACTION_IDS_HINT]: actions.map((a) => a['@id']),
|
|
363
|
+
[PAGE_URLS_HINT]: actions.map((a) => a.target['nxis:pageUrl'] ?? ''),
|
|
364
|
+
[PAGE_TOOL_NAMES_HINT]: [...new Set(actions.map((a) => sanitizeToolName(a.name)))],
|
|
365
|
+
// Enough for a caller that holds only the registered tool — the harness,
|
|
366
|
+
// drawing a confirmation — to work out where a call is bound for.
|
|
367
|
+
[CHOOSER_HINT]: discriminator ?? '',
|
|
368
|
+
[CHOICES_HINT]: choices ?? [],
|
|
369
|
+
},
|
|
370
|
+
execute: (input) => runSiteAction({ actions, discriminator, choices }, mc, input, opts),
|
|
371
|
+
};
|
|
372
|
+
return { name, actions, discriminator, choices, definition };
|
|
373
|
+
}
|
|
374
|
+
function uniqueName(base, taken) {
|
|
375
|
+
if (!taken.has(base))
|
|
376
|
+
return base;
|
|
377
|
+
for (let i = 2;; i++) {
|
|
378
|
+
const suffix = `_${i}`;
|
|
379
|
+
const candidate = base.slice(0, MAX_NAME - suffix.length) + suffix;
|
|
380
|
+
if (!taken.has(candidate))
|
|
381
|
+
return candidate;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function describe(actions, discriminator, choices) {
|
|
385
|
+
const base = actions[0].description ?? `Submit the "${actions[0].name}" form.`;
|
|
386
|
+
if (!discriminator || !choices)
|
|
387
|
+
return base;
|
|
388
|
+
const shown = choices.slice(0, 8).join(', ');
|
|
389
|
+
const rest = choices.length > 8 ? `, and ${choices.length - 8} more` : '';
|
|
390
|
+
return `${base} Set ${discriminator} to choose which: ${shown}${rest}.`;
|
|
391
|
+
}
|
|
392
|
+
// ── running ─────────────────────────────────────────────────────────────────
|
|
393
|
+
const text = (body, isError = false) => ({
|
|
394
|
+
content: [{ type: 'text', text: body }],
|
|
395
|
+
isError,
|
|
396
|
+
});
|
|
397
|
+
/** The action a call refers to, by the value of the deciding field. */
|
|
398
|
+
function pickAction(tool, input) {
|
|
399
|
+
if (tool.actions.length === 1)
|
|
400
|
+
return tool.actions[0];
|
|
401
|
+
if (!tool.discriminator || !tool.choices)
|
|
402
|
+
return null;
|
|
403
|
+
const wanted = String(input[tool.discriminator] ?? '').trim();
|
|
404
|
+
if (!wanted)
|
|
405
|
+
return null;
|
|
406
|
+
const exact = tool.choices.indexOf(wanted);
|
|
407
|
+
if (exact >= 0)
|
|
408
|
+
return tool.actions[exact];
|
|
409
|
+
const loose = tool.choices.findIndex((c) => c.toLowerCase() === wanted.toLowerCase());
|
|
410
|
+
return loose >= 0 ? tool.actions[loose] : null;
|
|
411
|
+
}
|
|
412
|
+
/** The reference for an action. */
|
|
413
|
+
export const pageToolRef = (action) => ({
|
|
414
|
+
id: action['@id'],
|
|
415
|
+
name: sanitizeToolName(action.name),
|
|
416
|
+
});
|
|
417
|
+
/** The page's own tool for an action: by recorded `@id`, else by name. */
|
|
418
|
+
export async function findPageTool(mc, ref) {
|
|
419
|
+
let tools;
|
|
420
|
+
try {
|
|
421
|
+
tools = await mc.getTools();
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
const byId = tools.find((t) => t.annotations?.[ACTION_ID_HINT] === ref.id);
|
|
427
|
+
if (byId)
|
|
428
|
+
return byId;
|
|
429
|
+
// Only on the action's own page: elsewhere a tool of the same name belongs
|
|
430
|
+
// to a different form.
|
|
431
|
+
return tools.find((t) => t.name === ref.name && !t.annotations?.[SITE_ACTION_HINT]) ?? null;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Wait for the page's own tool to register.
|
|
435
|
+
*
|
|
436
|
+
* After a page change the agent is running before the page's script has read
|
|
437
|
+
* its JSON-LD, so the tool a resumed call needs does not exist yet.
|
|
438
|
+
*
|
|
439
|
+
* @param mc - The page's registry.
|
|
440
|
+
* @param ref - The tool to wait for.
|
|
441
|
+
* @param timeoutMs - How long to wait before giving up.
|
|
442
|
+
* @returns The tool, or null if it never appears.
|
|
443
|
+
*/
|
|
444
|
+
export async function awaitPageTool(mc, ref, timeoutMs = 8000) {
|
|
445
|
+
const found = await findPageTool(mc, ref);
|
|
446
|
+
if (found)
|
|
447
|
+
return found;
|
|
448
|
+
return new Promise((resolve) => {
|
|
449
|
+
let done = false;
|
|
450
|
+
const finish = (tool) => {
|
|
451
|
+
if (done)
|
|
452
|
+
return;
|
|
453
|
+
done = true;
|
|
454
|
+
clearInterval(poll);
|
|
455
|
+
clearTimeout(timer);
|
|
456
|
+
mc.removeEventListener('toolchange', onChange);
|
|
457
|
+
resolve(tool);
|
|
458
|
+
};
|
|
459
|
+
const check = async () => {
|
|
460
|
+
const tool = await findPageTool(mc, ref);
|
|
461
|
+
if (tool)
|
|
462
|
+
finish(tool);
|
|
463
|
+
};
|
|
464
|
+
const onChange = () => void check();
|
|
465
|
+
mc.addEventListener('toolchange', onChange);
|
|
466
|
+
// Polled as well as event-driven: a native registry need not announce a
|
|
467
|
+
// registration that happened before this listener was attached.
|
|
468
|
+
const poll = setInterval(() => void check(), 300);
|
|
469
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Where a call to a site tool will have to go, if not here.
|
|
474
|
+
*
|
|
475
|
+
* Needs nothing but the tool, so a caller holding only what the registry
|
|
476
|
+
* reported — the harness, labelling a confirmation — can tell that pressing
|
|
477
|
+
* submit also means a page change.
|
|
478
|
+
*
|
|
479
|
+
* @param tool - The tool about to be called.
|
|
480
|
+
* @param values - The arguments resolved so far.
|
|
481
|
+
* @param href - The current page. Defaults to the browser's.
|
|
482
|
+
* @returns The destination, or null when the call runs here, when the tool is
|
|
483
|
+
* not one of these, or when the values do not yet say which action is meant.
|
|
484
|
+
*/
|
|
485
|
+
export function handoffFor(tool, values, href) {
|
|
486
|
+
const cover = coverageOf(tool);
|
|
487
|
+
if (!cover)
|
|
488
|
+
return null;
|
|
489
|
+
let index = 0;
|
|
490
|
+
if (cover.ids.length > 1) {
|
|
491
|
+
if (!cover.chooser)
|
|
492
|
+
return null;
|
|
493
|
+
const wanted = String(values?.[cover.chooser] ?? '').trim();
|
|
494
|
+
if (!wanted)
|
|
495
|
+
return null;
|
|
496
|
+
index = cover.choices.indexOf(wanted);
|
|
497
|
+
if (index < 0) {
|
|
498
|
+
index = cover.choices.findIndex((c) => c.toLowerCase() === wanted.toLowerCase());
|
|
499
|
+
}
|
|
500
|
+
if (index < 0)
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
const pageUrl = cover.pages[index];
|
|
504
|
+
if (!pageUrl || samePage(pageUrl, href ?? (typeof location === 'undefined' ? '' : location.href))) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
return {
|
|
508
|
+
actionId: cover.ids[index],
|
|
509
|
+
pageUrl,
|
|
510
|
+
label: cover.choices[index] || pageLabel(pageUrl),
|
|
511
|
+
// One name covers the group: these actions share the name they came from.
|
|
512
|
+
pageToolName: cover.pageToolNames[0] ?? '',
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
async function runSiteAction(tool, mc, input, opts) {
|
|
516
|
+
const action = pickAction(tool, input ?? {});
|
|
517
|
+
if (!action) {
|
|
518
|
+
const choices = (tool.choices ?? []).join(', ');
|
|
519
|
+
return text(`Say which one this is for. Choices: ${choices}.`, true);
|
|
520
|
+
}
|
|
521
|
+
const pageUrl = action.target['nxis:pageUrl'] ?? '';
|
|
522
|
+
if (!sameActionPage(action, opts.href)) {
|
|
523
|
+
// Filling the form means being on its page, so the confirmed call travels:
|
|
524
|
+
// held in this tab, submitted once the browser arrives.
|
|
525
|
+
const index = tool.actions.indexOf(action);
|
|
526
|
+
const label = tool.choices?.[index] || pageLabel(pageUrl);
|
|
527
|
+
const here = opts.href ?? (typeof location === 'undefined' ? '' : location.href);
|
|
528
|
+
if (!sameOrigin(pageUrl, here)) {
|
|
529
|
+
return text(`"${action.name}" belongs to another site, so I did not open it.`, true);
|
|
530
|
+
}
|
|
531
|
+
const held = savePendingAction({
|
|
532
|
+
actionId: action['@id'],
|
|
533
|
+
pageToolName: sanitizeToolName(action.name),
|
|
534
|
+
toolName: siteToolName(action.name),
|
|
535
|
+
values: withoutChooser(tool, input ?? {}),
|
|
536
|
+
pageUrl,
|
|
537
|
+
conversationId: opts.conversationId,
|
|
538
|
+
// The steps that led here happened on a page that is about to stop
|
|
539
|
+
// existing. Carried, so the surface on the other side can show why
|
|
540
|
+
// it is there rather than starting mid-journey.
|
|
541
|
+
steps: journeySteps(),
|
|
542
|
+
}, { storage: opts.storage });
|
|
543
|
+
if (!held) {
|
|
544
|
+
return text(`This form is on ${pageUrl}. I could not carry the details there, ` +
|
|
545
|
+
'so open that page and I will fill it in.', true);
|
|
546
|
+
}
|
|
547
|
+
(opts.navigate ?? ((url) => location.assign(url)))(pageUrl);
|
|
548
|
+
// Not a submission: the form has not been filled, and the page is about to
|
|
549
|
+
// change under us. Marked so a renderer reports a journey rather than
|
|
550
|
+
// drawing the "submitted" card that every other write earns.
|
|
551
|
+
return {
|
|
552
|
+
content: [{ type: 'text', text: `Opening ${label} to submit this…` }],
|
|
553
|
+
structuredContent: { [HANDOFF_RESULT]: { pageUrl, label } },
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const pageTool = await findPageTool(mc, pageToolRef(action));
|
|
557
|
+
if (!pageTool) {
|
|
558
|
+
return text(`The form for "${action.name}" is not registered on this page yet. Reload and try again.`, true);
|
|
559
|
+
}
|
|
560
|
+
const args = withoutChooser(tool, input ?? {});
|
|
561
|
+
return mc.executeTool(pageTool, JSON.stringify(args)).catch(() => mc.executeTool(pageTool.name, args));
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* The arguments the form itself takes.
|
|
565
|
+
*
|
|
566
|
+
* A `target` field is the group's own invention, for choosing between pages;
|
|
567
|
+
* a real field such as `business` belongs to the form and stays.
|
|
568
|
+
*/
|
|
569
|
+
function withoutChooser(tool, input) {
|
|
570
|
+
const args = { ...input };
|
|
571
|
+
if (tool.discriminator === 'target')
|
|
572
|
+
delete args.target;
|
|
573
|
+
return args;
|
|
574
|
+
}
|
|
575
|
+
/** Same origin, so a stale or tampered record cannot send a visitor away. */
|
|
576
|
+
function sameOrigin(url, here) {
|
|
577
|
+
try {
|
|
578
|
+
return new URL(url, here).origin === new URL(here).origin;
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// ── registering ─────────────────────────────────────────────────────────────
|
|
585
|
+
/**
|
|
586
|
+
* Register a tool for each of the site's actions.
|
|
587
|
+
*
|
|
588
|
+
* @param mc - The registry to register into.
|
|
589
|
+
* @param actions - The site's actions.
|
|
590
|
+
* @param opts - The current page.
|
|
591
|
+
* @returns The names registered. A name the registry already holds is left
|
|
592
|
+
* alone, and a registration it refuses is warned about rather than thrown.
|
|
593
|
+
*/
|
|
594
|
+
export async function registerSiteActionTools(mc, actions, opts = {}) {
|
|
595
|
+
if (!actions.length)
|
|
596
|
+
return [];
|
|
597
|
+
const tools = buildSiteActionTools(actions, mc, opts);
|
|
598
|
+
let present;
|
|
599
|
+
try {
|
|
600
|
+
present = new Set((await mc.getTools()).map((t) => t.name));
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
present = new Set();
|
|
604
|
+
}
|
|
605
|
+
const registered = [];
|
|
606
|
+
for (const tool of tools) {
|
|
607
|
+
if (present.has(tool.name))
|
|
608
|
+
continue;
|
|
609
|
+
try {
|
|
610
|
+
await mc.registerTool(tool.definition);
|
|
611
|
+
}
|
|
612
|
+
catch (err) {
|
|
613
|
+
console.warn(`[nxis-mcp] could not register ${tool.name}:`, err);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
registered.push(tool.name);
|
|
617
|
+
}
|
|
618
|
+
return registered;
|
|
619
|
+
}
|
|
620
|
+
// ── what reaches the prompt ─────────────────────────────────────────────────
|
|
621
|
+
/** How many site-wide tools a turn may carry, before relevance decides. */
|
|
622
|
+
export const DEFAULT_SITE_TOOL_LIMIT = 8;
|
|
623
|
+
/** Words too common to say anything about which form is meant. */
|
|
624
|
+
const STOP = new Set([
|
|
625
|
+
'the', 'and', 'for', 'from', 'with', 'this', 'that', 'need', 'want', 'would',
|
|
626
|
+
'like', 'please', 'can', 'you', 'get', 'got', 'have', 'has', 'about', 'out',
|
|
627
|
+
'she', 'her', 'his', 'him', 'they', 'them', 'their', 'some', 'any', 'here',
|
|
628
|
+
]);
|
|
629
|
+
const words = (text) => new Set((text.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => w.length > 2 && !STOP.has(w)));
|
|
630
|
+
/** How well a tool answers what was asked. */
|
|
631
|
+
function relevance(tool, cover, goal, asked) {
|
|
632
|
+
let score = 0;
|
|
633
|
+
for (const word of words(`${tool.name} ${tool.description ?? ''}`)) {
|
|
634
|
+
if (asked.has(word))
|
|
635
|
+
score += 1;
|
|
636
|
+
}
|
|
637
|
+
// A choice named in the request is the strongest signal there is: these are
|
|
638
|
+
// the site's own names for what each action acts on.
|
|
639
|
+
const lower = goal.toLowerCase();
|
|
640
|
+
if (cover.choices.some((c) => c && lower.includes(c.toLowerCase())))
|
|
641
|
+
score += 3;
|
|
642
|
+
return score;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Narrow the site-wide tools to those this turn could plausibly need.
|
|
646
|
+
*
|
|
647
|
+
* Every registered tool's schema is written into every prompt, and a site
|
|
648
|
+
* with many distinct forms would fill the context with forms nobody asked
|
|
649
|
+
* about. Tools belonging to the page the agent is on are always kept — they
|
|
650
|
+
* are what "this form" means — and the rest compete on how well they answer
|
|
651
|
+
* what was asked.
|
|
652
|
+
*
|
|
653
|
+
* Only site-wide tools are ever dropped. The page's own tools and the
|
|
654
|
+
* retrieval tools are few, and losing one of those loses a capability rather
|
|
655
|
+
* than trimming a list.
|
|
656
|
+
*
|
|
657
|
+
* @param tools - The tools on offer this turn.
|
|
658
|
+
* @param goal - What the user asked for.
|
|
659
|
+
* @param opts - The budget and the current page.
|
|
660
|
+
* @returns The tools to show, in the order they arrived.
|
|
661
|
+
*/
|
|
662
|
+
export function selectSiteTools(tools, goal, opts = {}) {
|
|
663
|
+
const limit = opts.limit ?? DEFAULT_SITE_TOOL_LIMIT;
|
|
664
|
+
const here = opts.href ?? (typeof location === 'undefined' ? '' : location.href);
|
|
665
|
+
const asked = words(goal ?? '');
|
|
666
|
+
const ranked = [];
|
|
667
|
+
for (const tool of tools) {
|
|
668
|
+
const cover = coverageOf(tool);
|
|
669
|
+
if (!cover)
|
|
670
|
+
continue;
|
|
671
|
+
ranked.push({
|
|
672
|
+
name: tool.name,
|
|
673
|
+
onPage: cover.pages.some((p) => samePage(p, here)),
|
|
674
|
+
score: relevance(tool, cover, goal ?? '', asked),
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
if (ranked.length <= limit)
|
|
678
|
+
return tools;
|
|
679
|
+
ranked.sort((a, b) => Number(b.onPage) - Number(a.onPage) || b.score - a.score);
|
|
680
|
+
const keep = new Set(ranked.slice(0, limit).map((r) => r.name));
|
|
681
|
+
return tools.filter((tool) => !coverageOf(tool) || keep.has(tool.name));
|
|
682
|
+
}
|
|
683
|
+
// ── one route per action ────────────────────────────────────────────────────
|
|
684
|
+
/**
|
|
685
|
+
* Drop page tools that a site tool already covers.
|
|
686
|
+
*
|
|
687
|
+
* On the page an action belongs to, the page's script registers its own tool
|
|
688
|
+
* and the site tool delegates to it. Both in the prompt would offer the model
|
|
689
|
+
* two ways to do one thing, and the second is chosen half the time.
|
|
690
|
+
*
|
|
691
|
+
* @param tools - Every tool the registry reported.
|
|
692
|
+
* @param href - The current page. Defaults to the browser's.
|
|
693
|
+
* @returns The tools to show the model.
|
|
694
|
+
*/
|
|
695
|
+
export function withoutShadowedTools(tools, href) {
|
|
696
|
+
const coveredIds = new Set();
|
|
697
|
+
const coveredNames = new Set();
|
|
698
|
+
const here = href ?? (typeof location === 'undefined' ? '' : location.href);
|
|
699
|
+
for (const tool of tools) {
|
|
700
|
+
const cover = coverageOf(tool);
|
|
701
|
+
if (!cover)
|
|
702
|
+
continue;
|
|
703
|
+
for (const id of cover.ids)
|
|
704
|
+
coveredIds.add(id);
|
|
705
|
+
// Names are only ambiguous evidence, so they count only where this tool
|
|
706
|
+
// actually covers the page the agent is on.
|
|
707
|
+
if (cover.pages.some((p) => samePage(p, here))) {
|
|
708
|
+
for (const name of cover.pageToolNames)
|
|
709
|
+
coveredNames.add(name);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (!coveredIds.size && !coveredNames.size)
|
|
713
|
+
return tools;
|
|
714
|
+
return tools.filter((tool) => {
|
|
715
|
+
// The site-wide tool is never hidden: it is the route, not the duplicate.
|
|
716
|
+
if (coverageOf(tool))
|
|
717
|
+
return true;
|
|
718
|
+
const id = tool.annotations?.[ACTION_ID_HINT];
|
|
719
|
+
if (typeof id === 'string' && coveredIds.has(id))
|
|
720
|
+
return false;
|
|
721
|
+
return !coveredNames.has(tool.name);
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
//# sourceMappingURL=action-tools.js.map
|