@nxisai/agent-harness 0.5.0 → 0.7.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 +38 -4
- 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 +155 -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 +53 -12
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/renderer.d.ts +20 -0
- package/dist/harness/renderer.d.ts.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 +29 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +64 -4
- package/dist/harness/tools/action-tools.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/site-actions.d.ts +27 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +47 -0
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +5 -2
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ui/action-card.d.ts +141 -0
- package/dist/ui/action-card.d.ts.map +1 -0
- package/dist/ui/action-card.js +384 -0
- package/dist/ui/action-card.js.map +1 -0
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +795 -17
- 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 +1 -1
- 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 +182 -0
- package/src/harness/harness.ts +55 -14
- package/src/harness/renderer.ts +22 -0
- package/src/harness/router/coordinator.ts +21 -0
- package/src/harness/tools/action-tools.ts +74 -4
- package/src/harness/tools/discovery.ts +20 -2
- package/src/harness/tools/site-actions.ts +58 -0
- package/src/harness/tools/source.ts +5 -2
- package/src/index.ts +4 -1
- package/src/ui/action-card.ts +496 -0
- package/src/ui/agent-fullscreen.ts +842 -16
- package/src/ui/tailwind.generated.ts +1 -1
|
@@ -285,6 +285,11 @@ export interface BuildOptions {
|
|
|
285
285
|
navigate?: (url: string) => void;
|
|
286
286
|
/** The conversation a travelling call should report back to. */
|
|
287
287
|
conversationId?: string;
|
|
288
|
+
/**
|
|
289
|
+
* Look up a form none of a tool's own actions matched: the site's index may
|
|
290
|
+
* hold it only inside the record it belongs to. See `findEmbeddedAction`.
|
|
291
|
+
*/
|
|
292
|
+
findAction?: (query: { actionName: string; field: string; value: string }) => Promise<NxisAction | null>;
|
|
288
293
|
}
|
|
289
294
|
|
|
290
295
|
/**
|
|
@@ -356,6 +361,15 @@ export function isSiteTool(tool: ModelContextToolInfo): boolean {
|
|
|
356
361
|
return coverageOf(tool) !== null;
|
|
357
362
|
}
|
|
358
363
|
|
|
364
|
+
/**
|
|
365
|
+
* The field that says which of a site tool's forms a call means: `business`,
|
|
366
|
+
* or the added `target`. Null for a tool that covers one form, or is not a
|
|
367
|
+
* site tool.
|
|
368
|
+
*/
|
|
369
|
+
export function chooserOf(tool: ModelContextToolInfo): string | null {
|
|
370
|
+
return coverageOf(tool)?.chooser || null;
|
|
371
|
+
}
|
|
372
|
+
|
|
359
373
|
/** A tool's coverage, from what this page built or from what it carries. */
|
|
360
374
|
function coverageOf(tool: ModelContextToolInfo): Coverage | null {
|
|
361
375
|
const local = built.get(tool.name);
|
|
@@ -484,6 +498,31 @@ function pickAction(
|
|
|
484
498
|
return loose >= 0 ? tool.actions[loose] : null;
|
|
485
499
|
}
|
|
486
500
|
|
|
501
|
+
/**
|
|
502
|
+
* The options each field offers on the one form a call means.
|
|
503
|
+
*
|
|
504
|
+
* A site tool's schema accepts any option the same field offers on any of its
|
|
505
|
+
* pages, which for a per-business quote form is every service of every
|
|
506
|
+
* business. Once the call says which business, only that form's options mean
|
|
507
|
+
* anything, and a prompt should offer only those.
|
|
508
|
+
*
|
|
509
|
+
* @returns Option values by field name; empty when the tool is not a site
|
|
510
|
+
* tool built on this page, or the call does not yet say which form.
|
|
511
|
+
*/
|
|
512
|
+
export function formOptions(tool: ModelContextToolInfo, values: Record<string, unknown>): Record<string, string[]> {
|
|
513
|
+
const local = built.get(tool.name);
|
|
514
|
+
const action = local ? pickAction(local, values ?? {}) : null;
|
|
515
|
+
const out: Record<string, string[]> = {};
|
|
516
|
+
for (const spec of action?.input ?? []) {
|
|
517
|
+
const options = (spec['nxis:valueOption'] ?? []).map((o) => o.value).filter((v) => !isPlaceholder(v));
|
|
518
|
+
if (options.length) out[spec.valueName] = options;
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** An option that only asks to choose: "Select…", "Choose a service…". */
|
|
524
|
+
export const isPlaceholder = (value: string): boolean => /^\s*(select|choose|pick)\b/i.test(value) || /…\s*$|\.\.\.\s*$/.test(value);
|
|
525
|
+
|
|
487
526
|
/** Which page tool to look for: the action's id, and the name it would have. */
|
|
488
527
|
export interface PageToolRef {
|
|
489
528
|
/** The action's `@id`. */
|
|
@@ -622,10 +661,12 @@ async function runSiteAction(
|
|
|
622
661
|
input: Record<string, unknown>,
|
|
623
662
|
opts: BuildOptions,
|
|
624
663
|
): Promise<ModelContextToolResponse> {
|
|
625
|
-
const
|
|
664
|
+
const wanted = tool.discriminator ? String(input?.[tool.discriminator] ?? '').trim() : '';
|
|
665
|
+
const action = pickAction(tool, input ?? {}) ?? (await lookUp(tool, wanted, opts));
|
|
626
666
|
if (!action) {
|
|
627
|
-
|
|
628
|
-
return text(`Say which one this is for
|
|
667
|
+
// Said to the visitor as well as the model: the renderer shows a failed call's result.
|
|
668
|
+
if (!wanted) return text(`Say which one this is for: ${summarize(tool.choices ?? [])}.`, true);
|
|
669
|
+
return text(`I couldn't find a ${humanize(tool.actions[0].name)} for "${wanted}" on this site. It covers ${summarize(tool.choices ?? [])}.`, true);
|
|
629
670
|
}
|
|
630
671
|
|
|
631
672
|
const pageUrl = action.target['nxis:pageUrl'] ?? '';
|
|
@@ -633,7 +674,7 @@ async function runSiteAction(
|
|
|
633
674
|
// Filling the form means being on its page, so the confirmed call travels:
|
|
634
675
|
// held in this tab, submitted once the browser arrives.
|
|
635
676
|
const index = tool.actions.indexOf(action);
|
|
636
|
-
const label = tool.choices?.[index] || pageLabel(pageUrl);
|
|
677
|
+
const label = tool.choices?.[index] || wanted || pageLabel(pageUrl);
|
|
637
678
|
const here = opts.href ?? (typeof location === 'undefined' ? '' : location.href);
|
|
638
679
|
if (!sameOrigin(pageUrl, here)) {
|
|
639
680
|
return text(`"${action.name}" belongs to another site, so I did not open it.`, true);
|
|
@@ -684,6 +725,35 @@ async function runSiteAction(
|
|
|
684
725
|
return executeRegistered(mc, pageTool, pageTool.name, args);
|
|
685
726
|
}
|
|
686
727
|
|
|
728
|
+
/**
|
|
729
|
+
* The action a call names that is not among the tool's own: found in the
|
|
730
|
+
* site's index, inside the record it belongs to. Null when there is nothing
|
|
731
|
+
* to look up, nobody to ask, or nothing found.
|
|
732
|
+
*/
|
|
733
|
+
async function lookUp(
|
|
734
|
+
tool: Pick<SiteActionTool, 'actions' | 'discriminator'>,
|
|
735
|
+
wanted: string,
|
|
736
|
+
opts: BuildOptions,
|
|
737
|
+
): Promise<NxisAction | null> {
|
|
738
|
+
if (!wanted || !tool.discriminator || !opts.findAction) return null;
|
|
739
|
+
try {
|
|
740
|
+
return await opts.findAction({ actionName: tool.actions[0].name, field: tool.discriminator, value: wanted });
|
|
741
|
+
} catch (err) {
|
|
742
|
+
console.warn(`[nxis-mcp] could not look up "${wanted}":`, err);
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** A list of choices short enough to read: the first few, and how many more. */
|
|
748
|
+
function summarize(choices: string[]): string {
|
|
749
|
+
if (!choices.length) return 'nothing yet';
|
|
750
|
+
const shown = choices.slice(0, 5).join(', ');
|
|
751
|
+
return choices.length > 5 ? `${shown}, and ${choices.length - 5} more` : shown;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** `quote-form` → `quote form`. */
|
|
755
|
+
const humanize = (name: string): string => name.replace(/[-_]+/g, ' ').trim();
|
|
756
|
+
|
|
687
757
|
/**
|
|
688
758
|
* The arguments the form itself takes.
|
|
689
759
|
*
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
|
-
import type { CatalogFile, McpServerInfo, ServerCard } from '../../types.js';
|
|
11
|
+
import type { CatalogEntry, CatalogFile, McpServerInfo, ServerCard } from '../../types.js';
|
|
12
12
|
|
|
13
13
|
async function fetchJson<T>(url: string): Promise<T> {
|
|
14
14
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
@@ -16,13 +16,31 @@ async function fetchJson<T>(url: string): Promise<T> {
|
|
|
16
16
|
return (await res.json()) as T;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* The catalog entry for the MCP server card.
|
|
21
|
+
*
|
|
22
|
+
* A catalog can list more than the MCP server: an A2A Agent Card for a
|
|
23
|
+
* handoff target, say. An entry typed as an MCP server card wins; an entry
|
|
24
|
+
* typed as anything else is never taken for one; an untyped entry is taken
|
|
25
|
+
* only when nothing is typed, as older catalogs have no types at all.
|
|
26
|
+
*
|
|
27
|
+
* @returns The entry, or undefined when the catalog lists no MCP server.
|
|
28
|
+
*/
|
|
29
|
+
export function mcpCatalogEntry(catalog: CatalogFile): CatalogEntry | undefined {
|
|
30
|
+
const entries = (catalog.entries ?? []).filter((e) => !!e.url);
|
|
31
|
+
const typeOf = (e: CatalogEntry) => (e.type ?? e.mediaType ?? '').toLowerCase();
|
|
32
|
+
const typed = entries.find((e) => /\bmcp\b|mcp-server/.test(typeOf(e)));
|
|
33
|
+
if (typed) return typed;
|
|
34
|
+
return entries.find((e) => !typeOf(e));
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
/** Find the server-card URL by reading the well-known catalogs in order. */
|
|
20
38
|
async function resolveServerCardUrl(): Promise<string> {
|
|
21
39
|
const catalogs = ['/.well-known/ai-catalog.json', '/.well-known/mcp/catalog.json'];
|
|
22
40
|
for (const path of catalogs) {
|
|
23
41
|
try {
|
|
24
42
|
const catalog = await fetchJson<CatalogFile>(path);
|
|
25
|
-
const entry = catalog
|
|
43
|
+
const entry = mcpCatalogEntry(catalog);
|
|
26
44
|
if (entry?.url) return entry.url;
|
|
27
45
|
} catch {
|
|
28
46
|
/* try the next catalog */
|
|
@@ -256,6 +256,64 @@ export async function queryTypedRecords(client: Client, fragment: string): Promi
|
|
|
256
256
|
return records;
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/** What a form tool is looking for when none of its own actions matched. */
|
|
260
|
+
export interface EmbeddedActionQuery {
|
|
261
|
+
/** The action's name, as the tool's group shares it: `quote-form`. */
|
|
262
|
+
actionName: string;
|
|
263
|
+
/** The field that chooses between the group's actions: `business`, or the added `target`. */
|
|
264
|
+
field: string;
|
|
265
|
+
/** The value the call gave it: `Hatzel & Sons Plumbing`. */
|
|
266
|
+
value: string;
|
|
267
|
+
/** The origin the action's page must be on. Defaults to the page's own. */
|
|
268
|
+
origin?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Find a form the index holds only inside another record.
|
|
273
|
+
*
|
|
274
|
+
* The site's actions are fetched once, as standalone Action records. An index
|
|
275
|
+
* can lag behind its pages — a partial re-crawl leaves some forms only as the
|
|
276
|
+
* `potentialAction` of the business or page they belong to — and a call naming
|
|
277
|
+
* one of those would otherwise fail with "say which one". So a miss looks the
|
|
278
|
+
* value up by name, and takes an action embedded in what it finds when that
|
|
279
|
+
* action is the same form: the same name, on this origin, and meant for this
|
|
280
|
+
* value.
|
|
281
|
+
*
|
|
282
|
+
* @param client - A connected client for the site's MCP server.
|
|
283
|
+
* @param query - The form, the choosing field, and the value asked for.
|
|
284
|
+
* @returns The action, or null when nothing on the site matches.
|
|
285
|
+
*/
|
|
286
|
+
export async function findEmbeddedAction(client: Client, query: EmbeddedActionQuery): Promise<NxisAction | null> {
|
|
287
|
+
const origin = query.origin ?? location.origin;
|
|
288
|
+
const wanted = query.value.trim().toLowerCase();
|
|
289
|
+
if (!wanted) return null;
|
|
290
|
+
const result = await withRetry(() =>
|
|
291
|
+
client.callTool({
|
|
292
|
+
name: 'nxis_query',
|
|
293
|
+
arguments: {
|
|
294
|
+
filter: { schema_type: '', field_filters: [{ path: 'name', operator: 'CONTAINS', value: query.value.trim() }] },
|
|
295
|
+
limit: 20,
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
const matches: NxisAction[] = [];
|
|
300
|
+
for (const record of readResults(result).results) {
|
|
301
|
+
if (!isObj(record)) continue;
|
|
302
|
+
const owner = (str(schemaValue(record, 'name')) ?? '').trim().toLowerCase();
|
|
303
|
+
const embedded = schemaValue(record, 'potentialAction');
|
|
304
|
+
for (const raw of Array.isArray(embedded) ? embedded : [embedded]) {
|
|
305
|
+
const action = parseAction(raw);
|
|
306
|
+
if (!action || action.name !== query.actionName || !onOrigin(action, origin)) continue;
|
|
307
|
+
// Meant for this value: the form says so itself, or it belongs to the record the value names.
|
|
308
|
+
const own = action.input?.find((i) => i.valueName === query.field)?.defaultValue?.trim().toLowerCase();
|
|
309
|
+
if (own ? own === wanted : owner === wanted) matches.push(action);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Two different forms answering to one name is a question for the visitor, not a guess.
|
|
313
|
+
const pages = new Set(matches.map((a) => a.target['nxis:pageUrl']));
|
|
314
|
+
return pages.size === 1 ? matches[0] : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
259
317
|
/** The tab's session storage, or null where it is unavailable or blocked. */
|
|
260
318
|
function defaultStorage(): Storage | null {
|
|
261
319
|
try {
|
|
@@ -17,7 +17,7 @@ import { registerRemoteTools, type BridgeOptions } from './bridge.js';
|
|
|
17
17
|
import { ensureModelContext } from './local-context.js';
|
|
18
18
|
import { connectMcp } from './client.js';
|
|
19
19
|
import { discoverMcpServer } from './discovery.js';
|
|
20
|
-
import { loadSiteActions, type SiteActionsOptions } from './site-actions.js';
|
|
20
|
+
import { findEmbeddedAction, loadSiteActions, type SiteActionsOptions } from './site-actions.js';
|
|
21
21
|
import { registerSiteActionTools } from './action-tools.js';
|
|
22
22
|
import { loadSitePages, mergePages, pagesFromActions, type SitePage } from './site-pages.js';
|
|
23
23
|
import { registerOpenPageTool, OPEN_PAGE_TOOL, type OpenPageOptions } from './open-page.js';
|
|
@@ -218,7 +218,10 @@ async function connectRegistry(
|
|
|
218
218
|
let warning: string | undefined;
|
|
219
219
|
try {
|
|
220
220
|
siteActions = await loadSiteActions(client, info, siteOpts);
|
|
221
|
-
siteTools = await registerSiteActionTools(mc, siteActions
|
|
221
|
+
siteTools = await registerSiteActionTools(mc, siteActions, {
|
|
222
|
+
// A form the index holds only inside its business or page, looked up when a call names it.
|
|
223
|
+
findAction: (query) => findEmbeddedAction(client, { ...query, origin: siteOpts.origin }),
|
|
224
|
+
});
|
|
222
225
|
} catch (err) {
|
|
223
226
|
warning = `site actions unavailable — this page's own actions still work: ${errorText(err)}`;
|
|
224
227
|
console.warn(`[nxis-mcp] ${warning}`);
|
package/src/index.ts
CHANGED
|
@@ -171,10 +171,13 @@ export {
|
|
|
171
171
|
setConversationSource,
|
|
172
172
|
} from './harness/tools/pending-action.js';
|
|
173
173
|
export type { PendingAction, PendingVisit } from './harness/tools/pending-action.js';
|
|
174
|
-
export { loadSiteActions, parseAction } from './harness/tools/site-actions.js';
|
|
174
|
+
export { findEmbeddedAction, loadSiteActions, parseAction, type EmbeddedActionQuery } from './harness/tools/site-actions.js';
|
|
175
175
|
export type { ConnectResult, ConnectOptions, SiteActionsOptions } from './harness/tools/source.js';
|
|
176
176
|
export type { BridgeOptions } from './harness/tools/bridge.js';
|
|
177
177
|
|
|
178
|
+
// Handing a conversation to another agent or a person, over A2A. See docs/handoff.md.
|
|
179
|
+
export * from './handoff/index.js';
|
|
180
|
+
|
|
178
181
|
export type {
|
|
179
182
|
JsonSchema,
|
|
180
183
|
ModelContext,
|