@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,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tool for taking the visitor to a page on the site.
|
|
3
|
+
*
|
|
4
|
+
* "Take me to that page" is a request the agent should be able to carry out.
|
|
5
|
+
* Without a tool for it a model reaches for whatever it has — a lookup — and
|
|
6
|
+
* nothing moves. This one opens a page by name, from the pages the site
|
|
7
|
+
* reported — or home, or back to the page before — and carries the conversation
|
|
8
|
+
* across so it continues there.
|
|
9
|
+
*
|
|
10
|
+
* It reads nothing and submits nothing, so it runs without a confirmation, and
|
|
11
|
+
* it never leaves the origin.
|
|
12
|
+
*
|
|
13
|
+
* @module
|
|
14
|
+
*/
|
|
15
|
+
import { samePage } from './site-actions.js';
|
|
16
|
+
import { findPage, type SitePage } from './site-pages.js';
|
|
17
|
+
import { nameOf, navFor, parentOf, type NavLink, type SiteNav } from './site-nav.js';
|
|
18
|
+
import { savePendingVisit } from './pending-action.js';
|
|
19
|
+
import { HANDOFF_RESULT } from './action-tools.js';
|
|
20
|
+
import { journeySteps } from '../steps.js';
|
|
21
|
+
import type { JsonSchema, ModelContext, ModelContextTool, ModelContextToolInfo, ModelContextToolResponse } from '../../types.js';
|
|
22
|
+
|
|
23
|
+
/** The tool's registered name. */
|
|
24
|
+
export const OPEN_PAGE_TOOL = 'open_page';
|
|
25
|
+
|
|
26
|
+
/** Past this many pages the names are described rather than listed. */
|
|
27
|
+
const MAX_LISTED = 40;
|
|
28
|
+
|
|
29
|
+
/** Where the tool takes the visitor, and how to go. */
|
|
30
|
+
export interface OpenPageOptions {
|
|
31
|
+
/** The site's origin. Defaults to the page's own. */
|
|
32
|
+
origin?: string;
|
|
33
|
+
/** The current page URL. Defaults to the browser's. */
|
|
34
|
+
href?: string;
|
|
35
|
+
/** Where the visit waits while the browser moves. Defaults to `sessionStorage`. */
|
|
36
|
+
storage?: Storage | null;
|
|
37
|
+
/** How to go to another page. Defaults to assigning `location`. */
|
|
38
|
+
navigate?: (url: string) => void;
|
|
39
|
+
/** How to return to the previous page. Defaults to `history.back()`. */
|
|
40
|
+
back?: () => void;
|
|
41
|
+
/** The page the visitor came from. Defaults to `document.referrer`. Read when the tool runs. */
|
|
42
|
+
referrer?: () => string;
|
|
43
|
+
/** The conversation to continue on arrival. Read when the tool runs. */
|
|
44
|
+
conversationId?: () => string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* The site's graph, from its breadcrumb trails and page links.
|
|
47
|
+
*
|
|
48
|
+
* What makes "up a level" and "the section above" mean something, and what
|
|
49
|
+
* keeps the tool's list of destinations to the pages near this one rather
|
|
50
|
+
* than every page on the site.
|
|
51
|
+
*/
|
|
52
|
+
nav?: SiteNav;
|
|
53
|
+
/**
|
|
54
|
+
* Fetch the trails the graph is missing, if they have not been fetched yet.
|
|
55
|
+
*
|
|
56
|
+
* Awaited when the tool runs, not at connect: the fetch is a read, and a
|
|
57
|
+
* site's rate limit counts reads. Going somewhere is what makes it worth one.
|
|
58
|
+
*/
|
|
59
|
+
loadNav?: () => Promise<SiteNav>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the visitor says for the site's home page: "home", "take me to the home page". */
|
|
63
|
+
const HOME =
|
|
64
|
+
/^(?:(?:go|take\s+me|return)\s+(?:to\s+)?)?(?:the\s+)?(?:home|homepage|home\s*page|start(?:\s*page)?|main\s*page|front\s*page|landing\s*page|index)$/i;
|
|
65
|
+
/** What the visitor says for the page they came from: "back", "go back to the previous page". */
|
|
66
|
+
const BACK =
|
|
67
|
+
/^(?:(?:go|take\s+me|return|head)\s+)?back(?:\s+to)?(?:\s+(?:the\s+)?(?:previous|last)\s+page)?$|^(?:the\s+)?(?:previous|last)\s+page$|^where\s+i\s+was$/i;
|
|
68
|
+
|
|
69
|
+
/** What the visitor says for the page above this one: "up", "the section above". */
|
|
70
|
+
const UP =
|
|
71
|
+
/^(?:(?:go|take\s+me|move)\s+)?(?:up|upward)(?:\s+(?:a|one)\s+level)?$|^(?:the\s+)?(?:parent|section|category)(?:\s+(?:page|above))?$|^(?:go\s+)?up\s+to\s+(?:the\s+)?(?:parent|section|category)$/i;
|
|
72
|
+
|
|
73
|
+
/** A page's name compared the way a person would. */
|
|
74
|
+
const normalName = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
|
|
75
|
+
|
|
76
|
+
/** Where a request to open a page resolves: a page, and whether it is reached by going back. */
|
|
77
|
+
export type Destination =
|
|
78
|
+
| { kind: 'page' | 'home'; url: string; label: string }
|
|
79
|
+
| { kind: 'back'; url: string; label: string }
|
|
80
|
+
| { kind: 'none'; reason: string };
|
|
81
|
+
|
|
82
|
+
/** The pages and options the tool was built with, so the harness can name a destination. */
|
|
83
|
+
let known: { pages: SitePage[]; origin: string; opts: OpenPageOptions } = { pages: [], origin: '', opts: {} };
|
|
84
|
+
|
|
85
|
+
const here = (href?: string): string => href ?? (typeof location === 'undefined' ? '' : location.href);
|
|
86
|
+
const originOf = (origin?: string): string => origin ?? (typeof location === 'undefined' ? '' : location.origin);
|
|
87
|
+
const referrerOf = (opts: OpenPageOptions): string =>
|
|
88
|
+
opts.referrer?.() ?? (typeof document === 'undefined' ? '' : document.referrer);
|
|
89
|
+
|
|
90
|
+
const text = (t: string, isError = false): ModelContextToolResponse => ({
|
|
91
|
+
content: [{ type: 'text', text: t }],
|
|
92
|
+
...(isError ? { isError: true } : {}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where a request to open a page goes.
|
|
97
|
+
*
|
|
98
|
+
* A page by name or address, and two that no site lists by name: "home", the
|
|
99
|
+
* site's root, and "back", the page the visitor came from — only when that was
|
|
100
|
+
* a page of this site, since going back must not leave it.
|
|
101
|
+
*
|
|
102
|
+
* @param wanted - What the visitor or model asked for.
|
|
103
|
+
* @param pages - The site's pages.
|
|
104
|
+
* @param origin - The site's origin.
|
|
105
|
+
* @param opts - The referrer seam.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveDestination(
|
|
108
|
+
wanted: string,
|
|
109
|
+
pages: SitePage[],
|
|
110
|
+
origin: string,
|
|
111
|
+
opts: OpenPageOptions = {},
|
|
112
|
+
): Destination {
|
|
113
|
+
const w = wanted.trim();
|
|
114
|
+
const named = (url: string): string | undefined => pages.find((p) => samePage(p.url, url))?.name;
|
|
115
|
+
|
|
116
|
+
// A page actually called that wins: "Back Office Plumbing" is a page, not a direction.
|
|
117
|
+
const exact = pages.filter((p) => normalName(p.name) === normalName(w));
|
|
118
|
+
if (exact.length === 1) return { kind: 'page', url: exact[0].url, label: exact[0].name };
|
|
119
|
+
|
|
120
|
+
if (UP.test(w)) {
|
|
121
|
+
const above = opts.nav && parentOf(here(opts.href), opts.nav);
|
|
122
|
+
return above
|
|
123
|
+
? { kind: 'page', url: above, label: nameOf(above, opts.nav!) }
|
|
124
|
+
: { kind: 'none', reason: 'This page is not under a section I know of.' };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Somewhere this page leads: a section above it, a page beside it, or a page it links to. Named
|
|
128
|
+
// by the site's own name for it, which is how the visitor heard of it.
|
|
129
|
+
const near = opts.nav ? nearby(opts.nav, here(opts.href)) : [];
|
|
130
|
+
const calledThat = near.filter((l) => normalName(l.name) === normalName(w));
|
|
131
|
+
if (calledThat.length === 1) return { kind: 'page', url: calledThat[0].url, label: calledThat[0].name };
|
|
132
|
+
|
|
133
|
+
if (BACK.test(w)) {
|
|
134
|
+
const from = referrerOf(opts);
|
|
135
|
+
let sameSite = false;
|
|
136
|
+
try {
|
|
137
|
+
sameSite = !!from && new URL(from).origin === new URL(origin).origin;
|
|
138
|
+
} catch {
|
|
139
|
+
sameSite = false;
|
|
140
|
+
}
|
|
141
|
+
return sameSite
|
|
142
|
+
? { kind: 'back', url: from, label: named(from) ?? 'the previous page' }
|
|
143
|
+
: { kind: 'none', reason: 'There is no previous page on this site to go back to.' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (HOME.test(w)) {
|
|
147
|
+
const root = new URL('/', origin).href;
|
|
148
|
+
const home = pages.find((p) => ['/', '/index.html', '/index.htm'].includes(new URL(p.url).pathname));
|
|
149
|
+
return { kind: 'home', url: home?.url ?? root, label: home ? `the home page (${home.name})` : 'the home page' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const found = findPage(w, pages, origin);
|
|
153
|
+
if (found) return { kind: 'page', url: found.url, label: found.name };
|
|
154
|
+
const loose = near.filter((l) => normalName(l.name).includes(normalName(w)));
|
|
155
|
+
if (loose.length === 1) return { kind: 'page', url: loose[0].url, label: loose[0].name };
|
|
156
|
+
const some = [...new Set([...near.map((l) => l.name), ...pages.map((p) => p.name)])].slice(0, 8).join(', ');
|
|
157
|
+
return {
|
|
158
|
+
kind: 'none',
|
|
159
|
+
reason: `There is no page called "${w}" on this site${some ? `. Pages include: ${some}` : ''}.`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build the page-opening tool for a site's pages.
|
|
165
|
+
*
|
|
166
|
+
* @param pages - Every page the agent may open.
|
|
167
|
+
* @param opts - Origin, current page, and the navigation seams.
|
|
168
|
+
* @returns The definition to register.
|
|
169
|
+
*/
|
|
170
|
+
export function buildOpenPageTool(pages: SitePage[], opts: OpenPageOptions = {}): ModelContextTool {
|
|
171
|
+
const origin = originOf(opts.origin);
|
|
172
|
+
known = { pages, origin, opts };
|
|
173
|
+
// Near first: a page's own surroundings are what a visitor asks for, and every name here reaches
|
|
174
|
+
// the prompt. The rest of the site stays reachable by name, and the description says so.
|
|
175
|
+
const near = opts.nav ? nearby(opts.nav, here(opts.href)).map((l) => l.name) : [];
|
|
176
|
+
const names = [...new Set([...near, ...pages.map((p) => p.name)])];
|
|
177
|
+
const listed = names.length <= MAX_LISTED ? names : [...new Set(near)].slice(0, MAX_LISTED);
|
|
178
|
+
const page: JsonSchema = {
|
|
179
|
+
type: 'string',
|
|
180
|
+
description:
|
|
181
|
+
(names.length <= MAX_LISTED
|
|
182
|
+
? 'The page to open, by name.'
|
|
183
|
+
: 'The page to open: the name of the business, listing or record it is about. Any page of the site by name, not only those listed.') +
|
|
184
|
+
' Use "home" for the home page, "back" for the page the visitor was on before, and "up" for the section above this page.',
|
|
185
|
+
};
|
|
186
|
+
if (listed.length) page.enum = ['home', 'back', ...(opts.nav || opts.loadNav ? ['up'] : []), ...listed];
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
name: OPEN_PAGE_TOOL,
|
|
190
|
+
description:
|
|
191
|
+
'Take the visitor to a page on this site. Use when they ask to go to, open, visit or see a page — ' +
|
|
192
|
+
'for example the page of a result you found, the home page, or back to the previous page.',
|
|
193
|
+
inputSchema: { type: 'object', properties: { page }, required: ['page'] },
|
|
194
|
+
annotations: { readOnlyHint: true },
|
|
195
|
+
execute: async (input: Record<string, unknown>) => {
|
|
196
|
+
const filled = await opts.loadNav?.().catch(() => opts.nav);
|
|
197
|
+
const to = resolveDestination(String(input?.page ?? ''), pages, origin, {
|
|
198
|
+
...opts,
|
|
199
|
+
nav: filled ?? opts.nav,
|
|
200
|
+
});
|
|
201
|
+
if (to.kind === 'none') return text(to.reason, true);
|
|
202
|
+
if (samePage(to.url, here(opts.href))) return text(`You are already on ${to.label}.`);
|
|
203
|
+
|
|
204
|
+
savePendingVisit(
|
|
205
|
+
{ pageUrl: to.url, label: to.label, conversationId: opts.conversationId?.(), steps: journeySteps() },
|
|
206
|
+
{ storage: opts.storage },
|
|
207
|
+
);
|
|
208
|
+
if (to.kind === 'back') (opts.back ?? (() => history.back()))();
|
|
209
|
+
else (opts.navigate ?? ((url: string) => location.assign(url)))(to.url);
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: 'text', text: `Opening ${to.label}…` }],
|
|
212
|
+
structuredContent: { [HANDOFF_RESULT]: { pageUrl: to.url, label: to.label } },
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
} as ModelContextTool;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Everywhere this page leads: the sections above it, the pages beside and under
|
|
220
|
+
* it, and the pages it points at. Ancestors first, because "up" and "the
|
|
221
|
+
* section" are what a visitor asks for most.
|
|
222
|
+
*/
|
|
223
|
+
function nearby(nav: SiteNav, href: string): NavLink[] {
|
|
224
|
+
const { ancestors, siblings, children, links } = navFor(nav, href);
|
|
225
|
+
const seen = new Set<string>();
|
|
226
|
+
return [...ancestors, ...children, ...links, ...siblings].filter((l) =>
|
|
227
|
+
seen.has(l.url) ? false : (seen.add(l.url), true),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Where a call to the page-opening tool goes, before it runs.
|
|
233
|
+
*
|
|
234
|
+
* @returns The destination, or null when the tool is another one, the page is
|
|
235
|
+
* unknown, or it is the page already open.
|
|
236
|
+
*/
|
|
237
|
+
export function visitFor(
|
|
238
|
+
tool: Pick<ModelContextToolInfo, 'name'>,
|
|
239
|
+
values: Record<string, unknown>,
|
|
240
|
+
href?: string,
|
|
241
|
+
): { pageUrl: string; label: string } | null {
|
|
242
|
+
if (tool.name !== OPEN_PAGE_TOOL) return null;
|
|
243
|
+
const to = resolveDestination(String(values?.page ?? ''), known.pages, known.origin || originOf(), known.opts);
|
|
244
|
+
if (to.kind === 'none' || samePage(to.url, here(href ?? known.opts.href))) return null;
|
|
245
|
+
return { pageUrl: to.url, label: to.label };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Register the page-opening tool, unless the registry already holds one.
|
|
250
|
+
*
|
|
251
|
+
* @returns Whether it was registered.
|
|
252
|
+
*/
|
|
253
|
+
export async function registerOpenPageTool(
|
|
254
|
+
mc: ModelContext,
|
|
255
|
+
pages: SitePage[],
|
|
256
|
+
opts: OpenPageOptions = {},
|
|
257
|
+
): Promise<boolean> {
|
|
258
|
+
// Registered with no pages too: "home" and "back" need no list, and a site whose page records
|
|
259
|
+
// could not be read is exactly where a visitor still wants to get home.
|
|
260
|
+
const tool = buildOpenPageTool(pages, opts);
|
|
261
|
+
try {
|
|
262
|
+
if ((await mc.getTools()).some((t) => t.name === OPEN_PAGE_TOOL)) return false;
|
|
263
|
+
} catch {
|
|
264
|
+
// A registry that cannot list may still register.
|
|
265
|
+
}
|
|
266
|
+
try {
|
|
267
|
+
await mc.registerTool(tool);
|
|
268
|
+
return true;
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.warn(`[nxis-mcp] could not register ${OPEN_PAGE_TOOL}:`, err);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carries one confirmed action across a page change.
|
|
3
|
+
*
|
|
4
|
+
* An action is run by filling the form on its own page, so a call made from
|
|
5
|
+
* anywhere else has to travel: the values are collected and confirmed where
|
|
6
|
+
* the visitor is, held here, and submitted once the browser arrives.
|
|
7
|
+
*
|
|
8
|
+
* What is held is what someone typed — a name, an email, a phone number — so
|
|
9
|
+
* it is kept in the tab's own session storage, expires in two minutes, and is
|
|
10
|
+
* read exactly once.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { samePage } from './site-actions.js';
|
|
15
|
+
import type { AgentStep } from '../steps.js';
|
|
16
|
+
|
|
17
|
+
/** A confirmed call waiting for its page to load. */
|
|
18
|
+
export interface PendingAction {
|
|
19
|
+
/** The `@id` of the action to run on arrival. */
|
|
20
|
+
actionId: string;
|
|
21
|
+
/** What the page's own tool is expected to be called, if the id is not recorded. */
|
|
22
|
+
pageToolName: string;
|
|
23
|
+
/** The site tool the call was made through, for the card it produces. */
|
|
24
|
+
toolName: string;
|
|
25
|
+
/** The values to submit. */
|
|
26
|
+
values: Record<string, unknown>;
|
|
27
|
+
/** The page to submit on. */
|
|
28
|
+
pageUrl: string;
|
|
29
|
+
/** The conversation to return the result to. */
|
|
30
|
+
conversationId?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The journey so far, so a surface can show it after the page has changed.
|
|
33
|
+
*
|
|
34
|
+
* The steps that led here happened on a page that no longer exists.
|
|
35
|
+
*/
|
|
36
|
+
steps?: AgentStep[];
|
|
37
|
+
/** When it was stored, in epoch milliseconds. */
|
|
38
|
+
savedAt: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const KEY = 'nxis:pending-action:v1';
|
|
42
|
+
|
|
43
|
+
/** How long a confirmed call stays good. Long enough for a page load, no longer. */
|
|
44
|
+
export const PENDING_TTL_MS = 2 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
/** Overrides for tests and for callers that manage their own storage. */
|
|
47
|
+
export interface PendingOptions {
|
|
48
|
+
/** Where the call is held. Defaults to `sessionStorage`; null disables. */
|
|
49
|
+
storage?: Storage | null;
|
|
50
|
+
/** The clock. */
|
|
51
|
+
now?: () => number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The tab's session storage, or null where it is unavailable or blocked. */
|
|
55
|
+
function defaultStorage(): Storage | null {
|
|
56
|
+
try {
|
|
57
|
+
return typeof sessionStorage === 'undefined' ? null : sessionStorage;
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Where the id of the conversation in progress is read from. */
|
|
64
|
+
let conversationSource: (() => string | undefined) | null = null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Say which conversation a call or visit that leaves this page belongs to.
|
|
68
|
+
*
|
|
69
|
+
* Set by whatever owns the conversation — the element, or a host's own UI —
|
|
70
|
+
* rather than passed where tools are built, because the tools are built by
|
|
71
|
+
* whichever script connects first, and on most sites that is not the UI.
|
|
72
|
+
*
|
|
73
|
+
* @param source - Returns the id of the conversation in progress; null to stop.
|
|
74
|
+
*/
|
|
75
|
+
export function setConversationSource(source: (() => string | undefined) | null): void {
|
|
76
|
+
conversationSource = source;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The conversation in progress, as its owner reports it. */
|
|
80
|
+
function currentConversation(): string | undefined {
|
|
81
|
+
try {
|
|
82
|
+
return conversationSource?.() ?? undefined;
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const store = (opts: PendingOptions): Storage | null =>
|
|
89
|
+
opts.storage === undefined ? defaultStorage() : opts.storage;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Hold a confirmed call until its page loads.
|
|
93
|
+
*
|
|
94
|
+
* @returns Whether it could be stored. A page that cannot store it must not
|
|
95
|
+
* navigate, or the values would be lost in transit.
|
|
96
|
+
*/
|
|
97
|
+
export function savePendingAction(
|
|
98
|
+
pending: Omit<PendingAction, 'savedAt'>,
|
|
99
|
+
opts: PendingOptions = {},
|
|
100
|
+
): boolean {
|
|
101
|
+
const storage = store(opts);
|
|
102
|
+
if (!storage) return false;
|
|
103
|
+
const now = (opts.now ?? Date.now)();
|
|
104
|
+
try {
|
|
105
|
+
const conversationId = pending.conversationId ?? currentConversation();
|
|
106
|
+
storage.setItem(KEY, JSON.stringify({ ...pending, conversationId, savedAt: now }));
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Take the call waiting for this page, if there is one.
|
|
115
|
+
*
|
|
116
|
+
* Reads once: a call this returns is removed, so a reload cannot submit the
|
|
117
|
+
* same form twice. An expired call is discarded wherever it is found. A call
|
|
118
|
+
* for another page is left alone, since the browser may still be on its way
|
|
119
|
+
* there.
|
|
120
|
+
*
|
|
121
|
+
* @param href - The page now open. Defaults to the browser's.
|
|
122
|
+
* @returns The call to submit here, or null.
|
|
123
|
+
*/
|
|
124
|
+
export function takePendingAction(href?: string, opts: PendingOptions = {}): PendingAction | null {
|
|
125
|
+
const storage = store(opts);
|
|
126
|
+
if (!storage) return null;
|
|
127
|
+
let pending: PendingAction | null;
|
|
128
|
+
try {
|
|
129
|
+
pending = JSON.parse(storage.getItem(KEY) || 'null') as PendingAction | null;
|
|
130
|
+
} catch {
|
|
131
|
+
pending = null;
|
|
132
|
+
}
|
|
133
|
+
if (!pending || typeof pending.savedAt !== 'number' || !pending.actionId) {
|
|
134
|
+
clearPendingAction(opts);
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const now = (opts.now ?? Date.now)();
|
|
139
|
+
if (now - pending.savedAt > PENDING_TTL_MS || pending.savedAt > now) {
|
|
140
|
+
clearPendingAction(opts);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const here = href ?? (typeof location === 'undefined' ? undefined : location.href);
|
|
145
|
+
if (!samePage(pending.pageUrl, here)) return null;
|
|
146
|
+
|
|
147
|
+
clearPendingAction(opts);
|
|
148
|
+
return pending;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Forget any waiting call. */
|
|
152
|
+
export function clearPendingAction(opts: PendingOptions = {}): void {
|
|
153
|
+
try {
|
|
154
|
+
store(opts)?.removeItem(KEY);
|
|
155
|
+
} catch {
|
|
156
|
+
/* nothing held, or storage gone */
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A page the agent opened, waiting to pick the conversation up on arrival.
|
|
162
|
+
*
|
|
163
|
+
* Carries no values, only where the visitor was taken and the conversation
|
|
164
|
+
* that took them, so the thread they were in is the one they find there.
|
|
165
|
+
*/
|
|
166
|
+
export interface PendingVisit {
|
|
167
|
+
/** The page being opened. */
|
|
168
|
+
pageUrl: string;
|
|
169
|
+
/** What to call it. */
|
|
170
|
+
label: string;
|
|
171
|
+
/** The conversation to continue. */
|
|
172
|
+
conversationId?: string;
|
|
173
|
+
/** The journey so far. */
|
|
174
|
+
steps?: AgentStep[];
|
|
175
|
+
/** When it was stored, in epoch milliseconds. */
|
|
176
|
+
savedAt: number;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const VISIT_KEY = 'nxis:pending-visit:v1';
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Remember a page being opened, so the conversation follows the visitor.
|
|
183
|
+
*
|
|
184
|
+
* @returns Whether it could be stored. Unlike a pending action, nothing is lost
|
|
185
|
+
* when it cannot: the page still opens, in a fresh thread.
|
|
186
|
+
*/
|
|
187
|
+
export function savePendingVisit(visit: Omit<PendingVisit, 'savedAt'>, opts: PendingOptions = {}): boolean {
|
|
188
|
+
const storage = store(opts);
|
|
189
|
+
if (!storage) return false;
|
|
190
|
+
try {
|
|
191
|
+
const conversationId = visit.conversationId ?? currentConversation();
|
|
192
|
+
storage.setItem(VISIT_KEY, JSON.stringify({ ...visit, conversationId, savedAt: (opts.now ?? Date.now)() }));
|
|
193
|
+
return true;
|
|
194
|
+
} catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Take the visit that brought the browser to this page, if there is one.
|
|
201
|
+
*
|
|
202
|
+
* Read once and discarded when stale, as a pending action is; a visit for
|
|
203
|
+
* another page is left, since the browser may still be on its way.
|
|
204
|
+
*
|
|
205
|
+
* @param href - The page now open. Defaults to the browser's.
|
|
206
|
+
*/
|
|
207
|
+
export function takePendingVisit(href?: string, opts: PendingOptions = {}): PendingVisit | null {
|
|
208
|
+
const storage = store(opts);
|
|
209
|
+
if (!storage) return null;
|
|
210
|
+
let visit: PendingVisit | null;
|
|
211
|
+
try {
|
|
212
|
+
visit = JSON.parse(storage.getItem(VISIT_KEY) || 'null') as PendingVisit | null;
|
|
213
|
+
} catch {
|
|
214
|
+
visit = null;
|
|
215
|
+
}
|
|
216
|
+
const drop = () => {
|
|
217
|
+
try {
|
|
218
|
+
storage.removeItem(VISIT_KEY);
|
|
219
|
+
} catch {
|
|
220
|
+
/* storage gone */
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const now = (opts.now ?? Date.now)();
|
|
224
|
+
if (!visit || typeof visit.savedAt !== 'number' || !visit.pageUrl || now - visit.savedAt > PENDING_TTL_MS || visit.savedAt > now) {
|
|
225
|
+
drop();
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const here = href ?? (typeof location === 'undefined' ? undefined : location.href);
|
|
229
|
+
if (!samePage(visit.pageUrl, here)) return null;
|
|
230
|
+
drop();
|
|
231
|
+
return visit;
|
|
232
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finishing, on arrival, a call that was confirmed on another page.
|
|
3
|
+
*
|
|
4
|
+
* The decisions here are the awkward ones — is there a call waiting, did the
|
|
5
|
+
* page's script ever register the form, does the live form want more than the
|
|
6
|
+
* record said — and they are worth testing without a browser. So the work is
|
|
7
|
+
* a function that returns what happened, and the caller draws it.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { takePendingAction, type PendingAction, type PendingOptions } from './pending-action.js';
|
|
12
|
+
import { awaitPageTool } from './action-tools.js';
|
|
13
|
+
import { extractResult, missingRequired } from './schema.js';
|
|
14
|
+
import { recordStep, restoreJourney } from '../steps.js';
|
|
15
|
+
import { waitForModelContext } from './source.js';
|
|
16
|
+
import { WebMcpTools } from './webmcp.js';
|
|
17
|
+
import type { ModelContext, ModelContextToolInfo, ModelContextToolResponse } from '../../types.js';
|
|
18
|
+
|
|
19
|
+
/** How long to wait for the registry, and then for the form's own tool. */
|
|
20
|
+
const WAIT_MS = 8000;
|
|
21
|
+
|
|
22
|
+
/** What became of the call that was waiting for this page. */
|
|
23
|
+
export type ResumeOutcome =
|
|
24
|
+
/** Nothing was waiting for this page. The ordinary case. */
|
|
25
|
+
| { status: 'none' }
|
|
26
|
+
/** WebMCP never appeared, so the form's tool could not be reached. */
|
|
27
|
+
| { status: 'no-registry'; pending: PendingAction }
|
|
28
|
+
/** The page never registered a tool for this action. */
|
|
29
|
+
| { status: 'no-tool'; pending: PendingAction }
|
|
30
|
+
/** The live form requires values the record did not carry. */
|
|
31
|
+
| { status: 'incomplete'; pending: PendingAction; tool: ModelContextToolInfo; missing: string[] }
|
|
32
|
+
/** Submitted. */
|
|
33
|
+
| { status: 'submitted'; pending: PendingAction; tool: ModelContextToolInfo; text: string }
|
|
34
|
+
/** The tool ran and reported a failure. */
|
|
35
|
+
| { status: 'rejected'; pending: PendingAction; tool: ModelContextToolInfo; text: string }
|
|
36
|
+
/** The call threw. */
|
|
37
|
+
| { status: 'failed'; pending: PendingAction; tool: ModelContextToolInfo; error: string };
|
|
38
|
+
|
|
39
|
+
/** Overrides, so this can be exercised without a page. */
|
|
40
|
+
export interface ResumeOptions extends PendingOptions {
|
|
41
|
+
/** The page now open. Defaults to the browser's. */
|
|
42
|
+
href?: string;
|
|
43
|
+
/** How long to wait for the registry and the tool. */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
/** Wait for a WebMCP registry. */
|
|
46
|
+
registry?(timeoutMs: number): Promise<ModelContext | null>;
|
|
47
|
+
/** Wait for the page's own tool for this action. */
|
|
48
|
+
pageTool?(
|
|
49
|
+
mc: ModelContext,
|
|
50
|
+
ref: { id: string; name: string },
|
|
51
|
+
timeoutMs: number,
|
|
52
|
+
): Promise<ModelContextToolInfo | null>;
|
|
53
|
+
/** Run the tool. */
|
|
54
|
+
exec?(
|
|
55
|
+
tool: ModelContextToolInfo,
|
|
56
|
+
values: Record<string, unknown>,
|
|
57
|
+
): Promise<ModelContextToolResponse>;
|
|
58
|
+
/**
|
|
59
|
+
* Called once a waiting call is found, before the waits begin.
|
|
60
|
+
*
|
|
61
|
+
* The page has just loaded and the registry may take seconds to appear, so
|
|
62
|
+
* this is where a UI opens the conversation the call came from rather than
|
|
63
|
+
* leaving the visitor looking at an unrelated thread.
|
|
64
|
+
*/
|
|
65
|
+
onStart?(pending: PendingAction): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Submit the call waiting for this page, if there is one.
|
|
70
|
+
*
|
|
71
|
+
* Runs while the page is still loading, so neither the registry nor the
|
|
72
|
+
* page's tools exist yet — the script that installs them is usually still
|
|
73
|
+
* being fetched. Both waits are needed, and in this order.
|
|
74
|
+
*
|
|
75
|
+
* @param opts - Page, timeouts, and the seams tests replace.
|
|
76
|
+
* @returns What happened, for the caller to render.
|
|
77
|
+
*/
|
|
78
|
+
export async function resumePendingAction(opts: ResumeOptions = {}): Promise<ResumeOutcome> {
|
|
79
|
+
const pending = takePendingAction(opts.href, opts);
|
|
80
|
+
if (!pending) return { status: 'none' };
|
|
81
|
+
// The journey continues from where the other page left it.
|
|
82
|
+
restoreJourney(pending.steps);
|
|
83
|
+
recordStep({ id: 'navigate', label: 'Opened the page', status: 'done' });
|
|
84
|
+
recordStep({ id: 'arrive', label: 'Found the form', status: 'active' });
|
|
85
|
+
opts.onStart?.(pending);
|
|
86
|
+
|
|
87
|
+
const timeoutMs = opts.timeoutMs ?? WAIT_MS;
|
|
88
|
+
const waitForRegistry = opts.registry ?? ((ms: number) => waitForModelContext(ms));
|
|
89
|
+
const mc = await waitForRegistry(timeoutMs);
|
|
90
|
+
if (!mc) {
|
|
91
|
+
recordStep({ id: 'arrive', label: 'This page has no agent tools', status: 'failed' });
|
|
92
|
+
return { status: 'no-registry', pending };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const findTool = opts.pageTool ?? ((c, ref, ms) => awaitPageTool(c, ref, ms));
|
|
96
|
+
const tool = await findTool(mc, { id: pending.actionId, name: pending.pageToolName }, timeoutMs);
|
|
97
|
+
if (!tool) {
|
|
98
|
+
recordStep({ id: 'arrive', label: 'Could not find the form here', status: 'failed' });
|
|
99
|
+
return { status: 'no-tool', pending };
|
|
100
|
+
}
|
|
101
|
+
recordStep({ id: 'arrive', label: 'Found the form', status: 'done' });
|
|
102
|
+
|
|
103
|
+
// The record says only what the crawler recorded; the form in front of us
|
|
104
|
+
// says what it actually insists on. Now that its page is open, ask rather
|
|
105
|
+
// than submit a half-filled form on someone's behalf.
|
|
106
|
+
const missing = missingRequired(tool, pending.values);
|
|
107
|
+
if (missing.length) {
|
|
108
|
+
recordStep({
|
|
109
|
+
id: 'collect',
|
|
110
|
+
label: 'This form needs more',
|
|
111
|
+
status: 'active',
|
|
112
|
+
detail: missing.join(', '),
|
|
113
|
+
});
|
|
114
|
+
return { status: 'incomplete', pending, tool, missing };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
recordStep({ id: 'fill', label: 'Filling the form', status: 'active' });
|
|
118
|
+
const run = opts.exec ?? ((t, values) => new WebMcpTools().execTool(t, t.name, values));
|
|
119
|
+
try {
|
|
120
|
+
const res = await run(tool, pending.values);
|
|
121
|
+
const text = extractResult(res);
|
|
122
|
+
if (res?.isError) {
|
|
123
|
+
recordStep({ id: 'submit', label: 'It did not go through', status: 'failed', detail: text });
|
|
124
|
+
return { status: 'rejected', pending, tool, text };
|
|
125
|
+
}
|
|
126
|
+
recordStep({ id: 'fill', label: 'Filled the form', status: 'done' });
|
|
127
|
+
recordStep({ id: 'submit', label: 'Submitted', status: 'done' });
|
|
128
|
+
return { status: 'submitted', pending, tool, text };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
const error = String((err as Error)?.message || err);
|
|
131
|
+
recordStep({ id: 'submit', label: 'It did not go through', status: 'failed', detail: error });
|
|
132
|
+
return { status: 'failed', pending, tool, error };
|
|
133
|
+
}
|
|
134
|
+
}
|