@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.
Files changed (78) hide show
  1. package/README.md +38 -4
  2. package/dist/handoff/a2a.d.ts +152 -0
  3. package/dist/handoff/a2a.d.ts.map +1 -0
  4. package/dist/handoff/a2a.js +227 -0
  5. package/dist/handoff/a2a.js.map +1 -0
  6. package/dist/handoff/controller.d.ts +181 -0
  7. package/dist/handoff/controller.d.ts.map +1 -0
  8. package/dist/handoff/controller.js +543 -0
  9. package/dist/handoff/controller.js.map +1 -0
  10. package/dist/handoff/envelope.d.ts +130 -0
  11. package/dist/handoff/envelope.d.ts.map +1 -0
  12. package/dist/handoff/envelope.js +178 -0
  13. package/dist/handoff/envelope.js.map +1 -0
  14. package/dist/handoff/index.d.ts +16 -0
  15. package/dist/handoff/index.d.ts.map +1 -0
  16. package/dist/handoff/index.js +12 -0
  17. package/dist/handoff/index.js.map +1 -0
  18. package/dist/handoff/tools.d.ts +36 -0
  19. package/dist/handoff/tools.d.ts.map +1 -0
  20. package/dist/handoff/tools.js +155 -0
  21. package/dist/handoff/tools.js.map +1 -0
  22. package/dist/harness/harness.d.ts +4 -2
  23. package/dist/harness/harness.d.ts.map +1 -1
  24. package/dist/harness/harness.js +53 -12
  25. package/dist/harness/harness.js.map +1 -1
  26. package/dist/harness/renderer.d.ts +20 -0
  27. package/dist/harness/renderer.d.ts.map +1 -1
  28. package/dist/harness/router/coordinator.d.ts +13 -0
  29. package/dist/harness/router/coordinator.d.ts.map +1 -1
  30. package/dist/harness/router/coordinator.js +7 -0
  31. package/dist/harness/router/coordinator.js.map +1 -1
  32. package/dist/harness/tools/action-tools.d.ts +29 -0
  33. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  34. package/dist/harness/tools/action-tools.js +64 -4
  35. package/dist/harness/tools/action-tools.js.map +1 -1
  36. package/dist/harness/tools/discovery.d.ts +12 -1
  37. package/dist/harness/tools/discovery.d.ts.map +1 -1
  38. package/dist/harness/tools/discovery.js +19 -1
  39. package/dist/harness/tools/discovery.js.map +1 -1
  40. package/dist/harness/tools/site-actions.d.ts +27 -0
  41. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  42. package/dist/harness/tools/site-actions.js +47 -0
  43. package/dist/harness/tools/site-actions.js.map +1 -1
  44. package/dist/harness/tools/source.d.ts.map +1 -1
  45. package/dist/harness/tools/source.js +5 -2
  46. package/dist/harness/tools/source.js.map +1 -1
  47. package/dist/index.d.ts +2 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/ui/action-card.d.ts +141 -0
  52. package/dist/ui/action-card.d.ts.map +1 -0
  53. package/dist/ui/action-card.js +384 -0
  54. package/dist/ui/action-card.js.map +1 -0
  55. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  56. package/dist/ui/agent-fullscreen.js +795 -17
  57. package/dist/ui/agent-fullscreen.js.map +1 -1
  58. package/dist/ui/tailwind.generated.d.ts +1 -1
  59. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  60. package/dist/ui/tailwind.generated.js +1 -1
  61. package/dist/ui/tailwind.generated.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/handoff/a2a.ts +326 -0
  64. package/src/handoff/controller.ts +661 -0
  65. package/src/handoff/envelope.ts +216 -0
  66. package/src/handoff/index.ts +53 -0
  67. package/src/handoff/tools.ts +182 -0
  68. package/src/harness/harness.ts +55 -14
  69. package/src/harness/renderer.ts +22 -0
  70. package/src/harness/router/coordinator.ts +21 -0
  71. package/src/harness/tools/action-tools.ts +74 -4
  72. package/src/harness/tools/discovery.ts +20 -2
  73. package/src/harness/tools/site-actions.ts +58 -0
  74. package/src/harness/tools/source.ts +5 -2
  75. package/src/index.ts +4 -1
  76. package/src/ui/action-card.ts +496 -0
  77. package/src/ui/agent-fullscreen.ts +842 -16
  78. 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 action = pickAction(tool, input ?? {});
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
- const choices = (tool.choices ?? []).join(', ');
628
- return text(`Say which one this is for. Choices: ${choices}.`, true);
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.entries?.find((e) => !!e.url);
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,