@nxisai/agent-harness 0.6.0 → 0.8.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 (65) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +32 -7
  3. package/dist/handoff/tools.d.ts.map +1 -1
  4. package/dist/handoff/tools.js +11 -4
  5. package/dist/handoff/tools.js.map +1 -1
  6. package/dist/harness/harness.d.ts +16 -0
  7. package/dist/harness/harness.d.ts.map +1 -1
  8. package/dist/harness/harness.js +70 -10
  9. package/dist/harness/harness.js.map +1 -1
  10. package/dist/harness/recommend.d.ts +46 -0
  11. package/dist/harness/recommend.d.ts.map +1 -0
  12. package/dist/harness/recommend.js +144 -0
  13. package/dist/harness/recommend.js.map +1 -0
  14. package/dist/harness/records.d.ts +70 -0
  15. package/dist/harness/records.d.ts.map +1 -0
  16. package/dist/harness/records.js +209 -0
  17. package/dist/harness/records.js.map +1 -0
  18. package/dist/harness/renderer.d.ts +31 -1
  19. package/dist/harness/renderer.d.ts.map +1 -1
  20. package/dist/harness/tools/action-tools.d.ts +39 -0
  21. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  22. package/dist/harness/tools/action-tools.js +82 -4
  23. package/dist/harness/tools/action-tools.js.map +1 -1
  24. package/dist/harness/tools/site-actions.d.ts +27 -0
  25. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  26. package/dist/harness/tools/site-actions.js +47 -0
  27. package/dist/harness/tools/site-actions.js.map +1 -1
  28. package/dist/harness/tools/site-pages.js +1 -1
  29. package/dist/harness/tools/source.d.ts.map +1 -1
  30. package/dist/harness/tools/source.js +5 -2
  31. package/dist/harness/tools/source.js.map +1 -1
  32. package/dist/index.d.ts +4 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +3 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/ui/action-card.d.ts +141 -0
  37. package/dist/ui/action-card.d.ts.map +1 -0
  38. package/dist/ui/action-card.js +384 -0
  39. package/dist/ui/action-card.js.map +1 -0
  40. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  41. package/dist/ui/agent-fullscreen.js +615 -88
  42. package/dist/ui/agent-fullscreen.js.map +1 -1
  43. package/dist/ui/result-pick.d.ts +52 -0
  44. package/dist/ui/result-pick.d.ts.map +1 -0
  45. package/dist/ui/result-pick.js +196 -0
  46. package/dist/ui/result-pick.js.map +1 -0
  47. package/dist/ui/tailwind.generated.d.ts +1 -1
  48. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  49. package/dist/ui/tailwind.generated.js +1 -1
  50. package/dist/ui/tailwind.generated.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/handoff/tools.ts +11 -7
  53. package/src/harness/harness.ts +82 -17
  54. package/src/harness/recommend.ts +194 -0
  55. package/src/harness/records.ts +237 -0
  56. package/src/harness/renderer.ts +34 -1
  57. package/src/harness/tools/action-tools.ts +92 -4
  58. package/src/harness/tools/site-actions.ts +58 -0
  59. package/src/harness/tools/site-pages.ts +1 -1
  60. package/src/harness/tools/source.ts +5 -2
  61. package/src/index.ts +4 -1
  62. package/src/ui/action-card.ts +496 -0
  63. package/src/ui/agent-fullscreen.ts +655 -78
  64. package/src/ui/result-pick.ts +233 -0
  65. 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,49 @@ 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
+ /**
524
+ * The site forms that can be sent to a business, by its name: the site tools
525
+ * on this page whose choices include it.
526
+ *
527
+ * @returns Each form's tool name and what it is called ("quote form").
528
+ */
529
+ export function formsFor(name: string): { tool: string; noun: string }[] {
530
+ const wanted = name.trim().toLowerCase();
531
+ if (!wanted) return [];
532
+ const out: { tool: string; noun: string }[] = [];
533
+ for (const [toolName, t] of built) {
534
+ if (t.choices?.some((c) => c.toLowerCase() === wanted)) {
535
+ out.push({ tool: toolName, noun: withoutSiteSuffix(toolName).replace(/[_-]+/g, ' ').trim().toLowerCase() });
536
+ }
537
+ }
538
+ return out;
539
+ }
540
+
541
+ /** An option that only asks to choose: "Select…", "Choose a service…". */
542
+ export const isPlaceholder = (value: string): boolean => /^\s*(select|choose|pick)\b/i.test(value) || /…\s*$|\.\.\.\s*$/.test(value);
543
+
487
544
  /** Which page tool to look for: the action's id, and the name it would have. */
488
545
  export interface PageToolRef {
489
546
  /** The action's `@id`. */
@@ -622,10 +679,12 @@ async function runSiteAction(
622
679
  input: Record<string, unknown>,
623
680
  opts: BuildOptions,
624
681
  ): Promise<ModelContextToolResponse> {
625
- const action = pickAction(tool, input ?? {});
682
+ const wanted = tool.discriminator ? String(input?.[tool.discriminator] ?? '').trim() : '';
683
+ const action = pickAction(tool, input ?? {}) ?? (await lookUp(tool, wanted, opts));
626
684
  if (!action) {
627
- const choices = (tool.choices ?? []).join(', ');
628
- return text(`Say which one this is for. Choices: ${choices}.`, true);
685
+ // Said to the visitor as well as the model: the renderer shows a failed call's result.
686
+ if (!wanted) return text(`Say which one this is for: ${summarize(tool.choices ?? [])}.`, true);
687
+ return text(`I couldn't find a ${humanize(tool.actions[0].name)} for "${wanted}" on this site. It covers ${summarize(tool.choices ?? [])}.`, true);
629
688
  }
630
689
 
631
690
  const pageUrl = action.target['nxis:pageUrl'] ?? '';
@@ -633,7 +692,7 @@ async function runSiteAction(
633
692
  // Filling the form means being on its page, so the confirmed call travels:
634
693
  // held in this tab, submitted once the browser arrives.
635
694
  const index = tool.actions.indexOf(action);
636
- const label = tool.choices?.[index] || pageLabel(pageUrl);
695
+ const label = tool.choices?.[index] || wanted || pageLabel(pageUrl);
637
696
  const here = opts.href ?? (typeof location === 'undefined' ? '' : location.href);
638
697
  if (!sameOrigin(pageUrl, here)) {
639
698
  return text(`"${action.name}" belongs to another site, so I did not open it.`, true);
@@ -684,6 +743,35 @@ async function runSiteAction(
684
743
  return executeRegistered(mc, pageTool, pageTool.name, args);
685
744
  }
686
745
 
746
+ /**
747
+ * The action a call names that is not among the tool's own: found in the
748
+ * site's index, inside the record it belongs to. Null when there is nothing
749
+ * to look up, nobody to ask, or nothing found.
750
+ */
751
+ async function lookUp(
752
+ tool: Pick<SiteActionTool, 'actions' | 'discriminator'>,
753
+ wanted: string,
754
+ opts: BuildOptions,
755
+ ): Promise<NxisAction | null> {
756
+ if (!wanted || !tool.discriminator || !opts.findAction) return null;
757
+ try {
758
+ return await opts.findAction({ actionName: tool.actions[0].name, field: tool.discriminator, value: wanted });
759
+ } catch (err) {
760
+ console.warn(`[nxis-mcp] could not look up "${wanted}":`, err);
761
+ return null;
762
+ }
763
+ }
764
+
765
+ /** A list of choices short enough to read: the first few, and how many more. */
766
+ function summarize(choices: string[]): string {
767
+ if (!choices.length) return 'nothing yet';
768
+ const shown = choices.slice(0, 5).join(', ');
769
+ return choices.length > 5 ? `${shown}, and ${choices.length - 5} more` : shown;
770
+ }
771
+
772
+ /** `quote-form` → `quote form`. */
773
+ const humanize = (name: string): string => name.replace(/[-_]+/g, ' ').trim();
774
+
687
775
  /**
688
776
  * The arguments the form itself takes.
689
777
  *
@@ -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 {
@@ -194,7 +194,7 @@ export function pageForEntity(entity: Obj, pages: SitePage[], origin: string): S
194
194
  /**
195
195
  * The page an id formed from it names, or null.
196
196
  *
197
- * nxis-sdn-api forms a page entity's id as the page URL plus a fragment, so the page is the id with
197
+ * The NxisAI index forms a page entity's id as the page URL plus a fragment, so the page is the id with
198
198
  * the fragment removed.
199
199
  */
200
200
  function pageOfId(id: string | undefined, origin: string): string | null {
@@ -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
@@ -60,6 +60,7 @@ export type {
60
60
  HarnessRenderer,
61
61
  AskRequest,
62
62
  ConfirmRequest,
63
+ ResultsExtra,
63
64
  StreamingBubble,
64
65
  } from './harness/renderer.js';
65
66
 
@@ -171,7 +172,9 @@ export {
171
172
  setConversationSource,
172
173
  } from './harness/tools/pending-action.js';
173
174
  export type { PendingAction, PendingVisit } from './harness/tools/pending-action.js';
174
- export { loadSiteActions, parseAction } from './harness/tools/site-actions.js';
175
+ export { findEmbeddedAction, loadSiteActions, parseAction, type EmbeddedActionQuery } from './harness/tools/site-actions.js';
176
+ export { extractRecords, openStatus, recordFacts, factLines, type OpenStatus, type RecordFacts } from './harness/records.js';
177
+ export { recommend, grounded, RECOMMEND_TOOL, type ResultPick, type RecommendRequest } from './harness/recommend.js';
175
178
  export type { ConnectResult, ConnectOptions, SiteActionsOptions } from './harness/tools/source.js';
176
179
  export type { BridgeOptions } from './harness/tools/bridge.js';
177
180