@nxisai/agent-harness 0.8.0 → 0.10.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 (57) hide show
  1. package/dist/a2ui/cards.d.ts +3 -0
  2. package/dist/a2ui/cards.d.ts.map +1 -1
  3. package/dist/a2ui/cards.js +11 -2
  4. package/dist/a2ui/cards.js.map +1 -1
  5. package/dist/harness/cta.d.ts +27 -0
  6. package/dist/harness/cta.d.ts.map +1 -0
  7. package/dist/harness/cta.js +43 -0
  8. package/dist/harness/cta.js.map +1 -0
  9. package/dist/harness/harness.d.ts +17 -0
  10. package/dist/harness/harness.d.ts.map +1 -1
  11. package/dist/harness/harness.js +40 -0
  12. package/dist/harness/harness.js.map +1 -1
  13. package/dist/harness/router/coordinator.d.ts +6 -0
  14. package/dist/harness/router/coordinator.d.ts.map +1 -1
  15. package/dist/harness/router/coordinator.js +14 -0
  16. package/dist/harness/router/coordinator.js.map +1 -1
  17. package/dist/harness/tools/action-tools.d.ts +28 -2
  18. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  19. package/dist/harness/tools/action-tools.js +48 -2
  20. package/dist/harness/tools/action-tools.js.map +1 -1
  21. package/dist/harness/tools/bridge.d.ts.map +1 -1
  22. package/dist/harness/tools/bridge.js +2 -1
  23. package/dist/harness/tools/bridge.js.map +1 -1
  24. package/dist/harness/tools/inbound.d.ts +92 -0
  25. package/dist/harness/tools/inbound.d.ts.map +1 -0
  26. package/dist/harness/tools/inbound.js +170 -0
  27. package/dist/harness/tools/inbound.js.map +1 -0
  28. package/dist/harness/tools/report.d.ts +55 -0
  29. package/dist/harness/tools/report.d.ts.map +1 -0
  30. package/dist/harness/tools/report.js +75 -0
  31. package/dist/harness/tools/report.js.map +1 -0
  32. package/dist/harness/tools/source.d.ts.map +1 -1
  33. package/dist/harness/tools/source.js +17 -1
  34. package/dist/harness/tools/source.js.map +1 -1
  35. package/dist/harness/tools/webmcp.d.ts.map +1 -1
  36. package/dist/harness/tools/webmcp.js +5 -1
  37. package/dist/harness/tools/webmcp.js.map +1 -1
  38. package/dist/index.d.ts +5 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  43. package/dist/ui/agent-fullscreen.js +78 -12
  44. package/dist/ui/agent-fullscreen.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/a2ui/cards.ts +14 -2
  47. package/src/harness/cta.ts +54 -0
  48. package/src/harness/harness.ts +42 -0
  49. package/src/harness/router/coordinator.ts +14 -0
  50. package/src/harness/tools/action-tools.ts +51 -2
  51. package/src/harness/tools/bridge.ts +2 -1
  52. package/src/harness/tools/inbound.ts +204 -0
  53. package/src/harness/tools/report.ts +119 -0
  54. package/src/harness/tools/source.ts +18 -1
  55. package/src/harness/tools/webmcp.ts +5 -1
  56. package/src/index.ts +14 -0
  57. package/src/ui/agent-fullscreen.ts +72 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxisai/agent-harness",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "An on-device agent for the browser: a tool-calling harness over WebMCP and remote MCP, plus a drop-in chat UI. Decides whether to run a tool, ask for what's missing, or confirm before writing.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/a2ui/cards.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { A2uiMessage } from './emit.js';
15
+ import { actionCta, type CardAction } from '../harness/cta.js';
15
16
 
16
17
  const CATALOG = 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json';
17
18
 
@@ -27,6 +28,8 @@ export interface ResultCardData {
27
28
  sub: string;
28
29
  tags: CardTag[];
29
30
  rows: CardRow[];
31
+ /** The record's own action, as at most one button. A list so a card without one draws nothing. */
32
+ actions: CardAction[];
30
33
  }
31
34
 
32
35
  const str = (v: unknown): string => (v == null ? '' : String(Array.isArray(v) ? v[0] : v));
@@ -93,7 +96,8 @@ export function toCardData(entity: Record<string, unknown>): ResultCardData {
93
96
  }
94
97
  }
95
98
 
96
- return { title, sub, tags, rows };
99
+ const action = actionCta(entity, title);
100
+ return { title, sub, tags, rows, actions: action ? [action] : [] };
97
101
  }
98
102
 
99
103
  /** Fixed component set — independent of how many results there are. */
@@ -106,7 +110,7 @@ function cardComponents(heading: string): Component[] {
106
110
  { id: 'results', component: 'List',
107
111
  children: { path: '/results', componentId: 'card_tpl' }, direction: 'vertical' },
108
112
  { id: 'card_tpl', component: 'Card', child: 'card_body' },
109
- { id: 'card_body', component: 'Column', children: ['card_title', 'card_tags', 'card_sub', 'card_rows'] },
113
+ { id: 'card_body', component: 'Column', children: ['card_title', 'card_tags', 'card_sub', 'card_rows', 'card_actions'] },
110
114
  // No bold: `weight` in this catalog is a flex weight (number), and Text
111
115
  // offers only variant caption/body. Title emphasis is not expressible in
112
116
  // the basic catalog: it needs a custom catalog or renderer styling.
@@ -124,6 +128,14 @@ function cardComponents(heading: string): Component[] {
124
128
  { id: 'row_tpl', component: 'Row', children: ['row_label', 'row_value'] },
125
129
  { id: 'row_label', component: 'Text', text: '${label}', variant: 'caption' },
126
130
  { id: 'row_value', component: 'Text', text: '${value}', variant: 'caption' },
131
+
132
+ // The record's action: zero or one per card, so a card without one draws nothing. Pressing it
133
+ // dispatches nxis.ask with the message to send as the visitor; it never submits anything itself.
134
+ { id: 'card_actions', component: 'List',
135
+ children: { path: 'actions', componentId: 'action_tpl' }, direction: 'horizontal' },
136
+ { id: 'action_tpl', component: 'Button', child: 'action_label', variant: 'primary',
137
+ action: { event: { name: 'nxis.ask', context: { message: '${message}' } } } },
138
+ { id: 'action_label', component: 'Text', text: '${label}', variant: 'body' },
127
139
  ];
128
140
  }
129
141
 
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The action a result card offers, chosen from the record's own `potentialAction`.
3
+ *
4
+ * One map, shared with the Typed Retrieval skill (nxis-skills) and the NxisAI app's widgets (nxis-mcp
5
+ * `HubWidgets`): the action's `@type` alone decides the button, and the button sends the same prompt
6
+ * everywhere ("Get a quote from Hatzel & Sons Plumbing"), so the funnel cannot tell which surface it came
7
+ * from. Commerce, read and search actions get no button here.
8
+ *
9
+ * On the site's own page every mapped action sends the prompt, a self-page form included: the on-page agent
10
+ * is what runs that form. Only the NxisAI app, which cannot reach the page, opens it instead.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ /** What a card's action button says and sends. */
16
+ export interface CardAction {
17
+ label: string;
18
+ /** The message sent as the visitor when the button is pressed. */
19
+ message: string;
20
+ }
21
+
22
+ const MAP: Record<string, { label: string; message: (name: string) => string }> = {
23
+ QuoteAction: { label: 'Get a quote', message: (n) => `Get a quote from ${n}` },
24
+ AskAction: { label: 'Contact', message: (n) => `Contact ${n}` },
25
+ ReserveAction: { label: 'Reserve', message: (n) => `Reserve at ${n}` },
26
+ ScheduleAction: { label: 'Book an appointment', message: (n) => `Book an appointment at ${n}` },
27
+ ApplyAction: { label: 'Apply', message: (n) => `Apply to ${n}` },
28
+ };
29
+
30
+ /** When a record carries several, the first of these wins. */
31
+ const ORDER = Object.keys(MAP);
32
+
33
+ const shortType = (t: unknown): string =>
34
+ typeof t === 'string' ? t.replace(/^https?:\/\/schema\.org\//, '') : '';
35
+
36
+ const list = (v: unknown): unknown[] => (v == null ? [] : Array.isArray(v) ? v : [v]);
37
+
38
+ /**
39
+ * The button for a record, or none.
40
+ *
41
+ * @param record - The record as a tool returned it; action `@type`s may be full IRIs.
42
+ * @param name - What to call it in the message; the record's own name by default.
43
+ */
44
+ export function actionCta(record: Record<string, unknown>, name?: string): CardAction | undefined {
45
+ const types = list(record.potentialAction)
46
+ .filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
47
+ .flatMap((a) => list(a['@type']).map(shortType))
48
+ .filter((t) => t in MAP)
49
+ .sort((a, b) => ORDER.indexOf(a) - ORDER.indexOf(b));
50
+ if (!types.length) return undefined;
51
+ const who = (name ?? (typeof record.name === 'string' ? record.name : '')).trim() || 'them';
52
+ const entry = MAP[types[0]];
53
+ return { label: entry.label, message: entry.message(who) };
54
+ }
@@ -575,6 +575,48 @@ export class AgentHarness {
575
575
  return this.act(tool, call.args, messages);
576
576
  }
577
577
 
578
+ /**
579
+ * Run a turn whose tool call is already decided, without asking the model.
580
+ *
581
+ * For a request that names its own action, such as a link from the NxisAI
582
+ * app to one business's quote form: the tool and the business are known, so
583
+ * the model has nothing to choose, and a model left to choose sometimes
584
+ * asks for the details in prose instead of opening the form. What follows
585
+ * the call is an ordinary turn's: the same slots, prompts and confirmation,
586
+ * and the call goes into the transcript as if the model had made it.
587
+ *
588
+ * @param goal - The request as the person would have said it, for the thread and transcript.
589
+ * @param name - The tool to call.
590
+ * @param args - Its arguments, e.g. which business.
591
+ * @param messages - The live conversation, appended to in place.
592
+ * @returns The decision taken, or `'unavailable'` when this agent has no such tool.
593
+ */
594
+ async begin(goal: string, name: string, args: Record<string, unknown>, messages: ChatMessage[]): Promise<TurnResult> {
595
+ this.abort?.abort();
596
+ this.abort = new AbortController();
597
+ this.goal = goal;
598
+ this.renderer.stoppable?.(true);
599
+ this.applySystemPrompt(messages);
600
+ beginJourney();
601
+
602
+ const bubble = this.renderer.streaming();
603
+ bubble.thinking('Finding tools…');
604
+ const tools = this.visibleTools(await this.toolSource.discoverTools(this.abort.signal), goal);
605
+ if (this.stopped) return this.stopTurn(bubble, messages, goal);
606
+ const tool = tools.find((t) => t.name === name);
607
+ if (!tool) {
608
+ bubble.finalize('');
609
+ return this.finished({ decision: 'unavailable' as const });
610
+ }
611
+
612
+ const call = { name, args };
613
+ messages.push({ role: 'user', content: goal });
614
+ bubble.finalize(narrateCall(call, tools));
615
+ messages.push({ role: 'assistant', content: this.transcribe({ text: '', call } as Completion) });
616
+ this.opts.onMessages?.(messages);
617
+ return this.act(tool, args, messages);
618
+ }
619
+
578
620
  /**
579
621
  * Narrow the page's tools to this agent's slice.
580
622
  *
@@ -188,6 +188,20 @@ export class AgentCoordinator {
188
188
  return this.working.turn(goal, messages);
189
189
  }
190
190
 
191
+ /**
192
+ * Run a turn whose tool call is already decided, on the agent that holds the tool.
193
+ *
194
+ * @see AgentHarness.begin
195
+ */
196
+ async begin(goal: string, name: string, args: Record<string, unknown>, messages: ChatMessage[]): Promise<TurnResult> {
197
+ const owner = this.ownerOf(name, '');
198
+ if (!owner) return { decision: 'unavailable' };
199
+ this.opts.onRoute?.({ ...this.route(goal, messages), agent: owner, via: 'handback', fellBack: false });
200
+ this.sticky.set(messages, owner);
201
+ this.working = this.opts.agents[owner];
202
+ return this.working.begin(goal, name, args, messages);
203
+ }
204
+
191
205
  /** The agent holding a tool, ignoring the one that handed the turn back. */
192
206
  private ownerOf(tool: string, except: string): string | null {
193
207
  for (const [name, agent] of Object.entries(this.opts.agents)) {
@@ -25,6 +25,7 @@ import { samePage, sameActionPage } from './site-actions.js';
25
25
  import { savePendingAction } from './pending-action.js';
26
26
  import { journeySteps } from '../steps.js';
27
27
  import { executeRegistered } from './webmcp.js';
28
+ import { ACTION_ID_HINT, type PageFormRun, type SiteActionReport } from './report.js';
28
29
  import type { JsonSchema, ModelContext, ModelContextTool, ModelContextToolInfo, ModelContextToolResponse } from '../../types.js';
29
30
  import type { NxisAction, NxisPropertyValueSpecification, NxisValueType } from '../../webmcp-contract.js';
30
31
 
@@ -47,8 +48,7 @@ export const HANDOFF_RESULT = 'ai.nxis/handoff';
47
48
  export const CHOOSER_HINT = 'ai.nxis/chooser';
48
49
  /** That field's values, one per covered action, in the same order. */
49
50
  export const CHOICES_HINT = 'ai.nxis/choices';
50
- /** The `@id` of the action a page tool came from, where the page records it. */
51
- export const ACTION_ID_HINT = 'ai.nxis/actionId';
51
+ export { ACTION_ID_HINT };
52
52
 
53
53
  /** Appended to the action's own name, keeping it clear of the page's tool. */
54
54
  const NAME_SUFFIX = '_anywhere';
@@ -538,6 +538,29 @@ export function formsFor(name: string): { tool: string; noun: string }[] {
538
538
  return out;
539
539
  }
540
540
 
541
+ /**
542
+ * The site form that runs an action, by the action's `@id`: the site-wide tool
543
+ * built on this page that covers it, and which business it is for.
544
+ *
545
+ * @returns The tool's name, what the form is called ("quote form"), the
546
+ * choice that picks this action when the tool covers several, and the
547
+ * arguments that make a call to it this action; null when no site tool built
548
+ * here covers it.
549
+ */
550
+ export function siteFormFor(
551
+ actionId: string,
552
+ ): { tool: string; noun: string; choice?: string; args: Record<string, unknown> } | null {
553
+ for (const [toolName, t] of built) {
554
+ const index = t.actions.findIndex((a) => a['@id'] === actionId);
555
+ if (index < 0) continue;
556
+ const noun = withoutSiteSuffix(toolName).replace(/[_-]+/g, ' ').trim().toLowerCase();
557
+ const choice = t.choices?.[index];
558
+ const args = choice && t.discriminator ? { [t.discriminator]: choice } : {};
559
+ return choice ? { tool: toolName, noun, choice, args } : { tool: toolName, noun, args };
560
+ }
561
+ return null;
562
+ }
563
+
541
564
  /** An option that only asks to choose: "Select…", "Choose a service…". */
542
565
  export const isPlaceholder = (value: string): boolean => /^\s*(select|choose|pick)\b/i.test(value) || /…\s*$|\.\.\.\s*$/.test(value);
543
566
 
@@ -956,3 +979,29 @@ export function withoutShadowedTools(
956
979
  return !coveredNames.has(tool.name);
957
980
  });
958
981
  }
982
+
983
+ /**
984
+ * The report a page form's run makes, or none when it was no action of the
985
+ * site's. Matched the way {@link findPageTool} finds the form: by `@id` where
986
+ * the registry kept it, else by name, and then only on the action's own page —
987
+ * elsewhere a tool of the same name belongs to a different form.
988
+ *
989
+ * @param actions - The site's actions.
990
+ * @param run - The page tool that went through.
991
+ * @param href - The current page. Defaults to the browser's.
992
+ */
993
+ export function reportFor(actions: NxisAction[], run: PageFormRun, href?: string): SiteActionReport | null {
994
+ const action = run.actionId
995
+ ? actions.find((a) => a['@id'] === run.actionId)
996
+ : actions.find((a) => sanitizeToolName(a.name) === run.toolName && sameActionPage(a, href));
997
+ const actionId = action?.['@id'] ?? run.actionId;
998
+ if (!actionId) return null;
999
+ const type = shortActionType(action?.['@type']);
1000
+ return { outcome: 'submitted', action_id: actionId, ...(type ? { action_type: type } : {}), source: 'on_page_agent' };
1001
+ }
1002
+
1003
+ /** `QuoteAction`, however the record spelled it. */
1004
+ function shortActionType(type: string | undefined): string | undefined {
1005
+ const name = typeof type === 'string' ? type.split(/[/:#]/).pop() : undefined;
1006
+ return name && /^[A-Z][A-Za-z]*Action$/.test(name) ? name : undefined;
1007
+ }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
16
16
  import { isRateLimited, retryAfterMs, withRetry } from './retry.js';
17
+ import { TRACK_TOOL } from './report.js';
17
18
  import type {
18
19
  JsonSchema,
19
20
  McpServerInfo,
@@ -294,7 +295,7 @@ async function registeredNames(mc: ModelContext): Promise<Set<string>> {
294
295
  *
295
296
  * Override with {@link BridgeOptions.accept}.
296
297
  */
297
- const SERVER_ONLY_TOOLS = new Set(['nxis_action']);
298
+ const SERVER_ONLY_TOOLS = new Set(['nxis_action', TRACK_TOOL]);
298
299
 
299
300
  /** Options for {@link registerRemoteTools}. */
300
301
  export interface BridgeOptions {
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Arriving from somewhere else to use one of this page's forms.
3
+ *
4
+ * The NxisAI app in Claude or ChatGPT cannot fill a form that runs in the
5
+ * browser, so for such an action it sends the person to the page instead:
6
+ * `?nxis_handoff=<the action's @id>`, and optionally `&nxis_intent=<what they
7
+ * want>`. On arrival the agent opens on that form.
8
+ *
9
+ * In the query, so the fragment, which some sites route by, is left alone. A
10
+ * page's structured data is found by its registered address, so the site tag
11
+ * leaves these two parameters out of the address it asks for.
12
+ *
13
+ * A link can be written by anyone, so it only ever chooses which of the page's
14
+ * own forms to start. The words that start it are the agent's own, built from
15
+ * the form; the intent is left in the composer for the person to send, change
16
+ * or delete, never sent on their behalf. None of the parameters carries
17
+ * anything personal, and all are taken out of the address bar once read.
18
+ *
19
+ * `&nxis_report=<token>` is the site's own signature on the answer the link came
20
+ * from. It is held for the form, and sent back with the report that the form
21
+ * went through, so the site counts the conversion as verified.
22
+ *
23
+ * @module
24
+ */
25
+ import { awaitPageTool, withoutSiteSuffix } from './action-tools.js';
26
+ import { rememberReportToken } from './report.js';
27
+ import { waitForModelContext } from './source.js';
28
+ import type { ModelContext, ModelContextToolInfo } from '../../types.js';
29
+
30
+ /** The action to open on: its `@id`. */
31
+ export const HANDOFF_PARAM = 'nxis_handoff';
32
+ /** What the person wants, in a few words. Optional. */
33
+ export const INTENT_PARAM = 'nxis_intent';
34
+ /** The site's report token for the action, from the answer the link came from. Optional. */
35
+ export const REPORT_PARAM = 'nxis_report';
36
+
37
+ /** Longer than any `@id` the crawler writes; a longer value is not one. */
38
+ const MAX_ID = 512;
39
+ /** Longer than any token a site signs; a longer value is not one. */
40
+ const MAX_TOKEN = 1024;
41
+ /** A few words: enough for "a quote for a panel upgrade", not a message. */
42
+ export const MAX_INTENT = 120;
43
+
44
+ /** How long to wait for the registry, and then for the form's own tool. */
45
+ const WAIT_MS = 8000;
46
+
47
+ /** A handoff read from the address. */
48
+ export interface InboundHandoff {
49
+ /** The `@id` of the action whose form to open. */
50
+ actionId: string;
51
+ /** What the person wants, cleaned up; absent when none was given. */
52
+ intent?: string;
53
+ /** The site's report token for the action; absent when the link had none. */
54
+ reportToken?: string;
55
+ }
56
+
57
+ /**
58
+ * The handoff in an address, if there is one.
59
+ *
60
+ * @param href - The page's address.
61
+ * @returns The handoff, or null when there is none or it is malformed.
62
+ */
63
+ export function readInboundHandoff(href: string): InboundHandoff | null {
64
+ let url: URL;
65
+ try {
66
+ url = new URL(href);
67
+ } catch {
68
+ return null;
69
+ }
70
+ const actionId = (url.searchParams.get(HANDOFF_PARAM) ?? '').trim();
71
+ if (!actionId || actionId.length > MAX_ID || /\s/.test(actionId)) return null;
72
+ const intent = cleanIntent(url.searchParams.get(INTENT_PARAM) ?? '');
73
+ const token = (url.searchParams.get(REPORT_PARAM) ?? '').trim();
74
+ const reportToken = token && token.length <= MAX_TOKEN && /^[A-Za-z0-9_.-]+$/.test(token) ? token : '';
75
+ return { actionId, ...(intent ? { intent } : {}), ...(reportToken ? { reportToken } : {}) };
76
+ }
77
+
78
+ /** One line of plain text, at most {@link MAX_INTENT} characters. */
79
+ export function cleanIntent(raw: string): string {
80
+ // Control characters, newlines included, become spaces: a draft is one line.
81
+ const flat = raw.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
82
+ return flat.length > MAX_INTENT ? flat.slice(0, MAX_INTENT - 1).trimEnd() + '…' : flat;
83
+ }
84
+
85
+ /**
86
+ * The address without the handoff parameters, everything else kept.
87
+ *
88
+ * A reload, a bookmark or a copied link would otherwise open the form again.
89
+ */
90
+ export function withoutHandoff(href: string): string {
91
+ const url = new URL(href);
92
+ if (!url.search) return url.toString();
93
+ // Pair by pair on the raw text: re-serializing would re-encode the other
94
+ // parameters, and the address would no longer be the one the page had.
95
+ const kept = url.search
96
+ .slice(1)
97
+ .split('&')
98
+ .filter((pair) => ![HANDOFF_PARAM, INTENT_PARAM, REPORT_PARAM].includes(decodeName(pair.split('=')[0])));
99
+ url.search = kept.length ? `?${kept.join('&')}` : '';
100
+ return url.toString();
101
+ }
102
+
103
+ /** A query parameter's name, decoded; as written when it can't be. */
104
+ function decodeName(raw: string): string {
105
+ try {
106
+ return decodeURIComponent(raw.replace(/\+/g, ' '));
107
+ } catch {
108
+ return raw;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * The address that hands someone to an action's page.
114
+ *
115
+ * @param pageUrl - The page the action's form is on.
116
+ * @param actionId - The action's `@id`.
117
+ * @param intent - What they want, in a few words. Nothing personal.
118
+ */
119
+ export function handoffUrl(pageUrl: string, actionId: string, intent?: string): string {
120
+ const url = new URL(pageUrl);
121
+ // Appended to the raw query, so the page's own parameters keep their encoding.
122
+ const added = new URLSearchParams({ [HANDOFF_PARAM]: actionId });
123
+ const said = intent ? cleanIntent(intent) : '';
124
+ if (said) added.set(INTENT_PARAM, said);
125
+ url.search = url.search ? `${url.search}&${added}` : `?${added}`;
126
+ return url.toString();
127
+ }
128
+
129
+ /**
130
+ * Read the handoff from this page's address and take it out.
131
+ *
132
+ * @param opts - The address and the history to rewrite; the browser's by default.
133
+ */
134
+ export function takeInboundHandoff(
135
+ opts: { href?: string; replace?: (href: string) => void } = {},
136
+ ): InboundHandoff | null {
137
+ const href = opts.href ?? (typeof location !== 'undefined' ? location.href : '');
138
+ const handoff = href ? readInboundHandoff(href) : null;
139
+ if (!handoff) return null;
140
+ const replace =
141
+ opts.replace ??
142
+ ((next: string) => {
143
+ try {
144
+ history.replaceState(history.state, '', next);
145
+ } catch {
146
+ // A sandboxed frame can refuse; the handoff is still taken once, below.
147
+ }
148
+ });
149
+ replace(withoutHandoff(href));
150
+ // Held for the form this opens, wherever it is sent from; see report.ts.
151
+ if (handoff.reportToken) rememberReportToken(handoff.actionId, handoff.reportToken);
152
+ return handoff;
153
+ }
154
+
155
+ /** What the agent says to start a form: "Start the quote form". */
156
+ export function startMessage(tool: ModelContextToolInfo): string {
157
+ return startFormMessage(withoutSiteSuffix(tool.name).replace(/[_-]+/g, ' ').trim().toLowerCase());
158
+ }
159
+
160
+ /**
161
+ * What the agent says to start a site form for one business, in the words a
162
+ * result card's button uses: "Get a quote from McManus Electric".
163
+ */
164
+ export function startFormMessage(noun: string, choice?: string): string {
165
+ const named = noun || 'form';
166
+ const form = /\bform\b/.test(named) ? named : `${named} form`;
167
+ if (!choice) return `Start the ${form}`;
168
+ return /quote/.test(named) ? `Get a quote from ${choice}` : `Start the ${form} for ${choice}`;
169
+ }
170
+
171
+ /** Where the form a handoff names stands, once the page has loaded. */
172
+ export type ArrivalOutcome =
173
+ /** WebMCP never appeared, so the page's forms cannot be reached. */
174
+ | { status: 'no-registry' }
175
+ /** The page has no form for that action. */
176
+ | { status: 'no-tool' }
177
+ /** The form is here. */
178
+ | { status: 'ready'; tool: ModelContextToolInfo };
179
+
180
+ /** Overrides, so this can be exercised without a page. */
181
+ export interface ArrivalOptions {
182
+ /** How long to wait for the registry, and then for the tool. */
183
+ timeoutMs?: number;
184
+ /** Wait for a WebMCP registry. */
185
+ registry?(timeoutMs: number): Promise<ModelContext | null>;
186
+ /** Wait for the page's own tool for this action. */
187
+ pageTool?(mc: ModelContext, ref: { id: string; name: string }, timeoutMs: number): Promise<ModelContextToolInfo | null>;
188
+ }
189
+
190
+ /**
191
+ * Find the form a handoff names, waiting for the page to register it.
192
+ *
193
+ * Runs while the page is still loading, as the cross-page resume does: the
194
+ * registry and the form's tool usually appear a moment after the agent.
195
+ */
196
+ export async function findHandoffForm(handoff: InboundHandoff, opts: ArrivalOptions = {}): Promise<ArrivalOutcome> {
197
+ const timeoutMs = opts.timeoutMs ?? WAIT_MS;
198
+ const mc = await (opts.registry ?? ((ms: number) => waitForModelContext(ms)))(timeoutMs);
199
+ if (!mc) return { status: 'no-registry' };
200
+ // By id only: a name match could be a different form on this page.
201
+ const find = opts.pageTool ?? ((c, ref, ms) => awaitPageTool(c, ref, ms));
202
+ const tool = await find(mc, { id: handoff.actionId, name: '' }, timeoutMs);
203
+ return tool ? { status: 'ready', tool } : { status: 'no-tool' };
204
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Tells the site's MCP server that a form the agent filled went through.
3
+ *
4
+ * A page's own form submits to the site, not to NxisAI, so the server never
5
+ * sees it happen. Agent Analytics counts these reports as conversions
6
+ * (`nxis_track_site_action`, outcome `submitted`), next to the actions the
7
+ * server runs itself.
8
+ *
9
+ * A report names the action and nothing else: never a value the visitor typed.
10
+ * It is best effort — a site whose server cannot take it, or a report that
11
+ * fails, never changes what the visitor sees.
12
+ *
13
+ * @module
14
+ */
15
+ import type { ModelContextToolInfo, ModelContextToolResponse } from '../../types.js';
16
+
17
+ /** The server tool a report goes to. Kept from the model: it reports what happened, it is not something to do. */
18
+ export const TRACK_TOOL = 'nxis_track_site_action';
19
+
20
+ /** The `@id` of the action a page tool came from, where the page records it. */
21
+ export const ACTION_ID_HINT = 'ai.nxis/actionId';
22
+
23
+ /** What a report says. Closed fields only, so nothing the visitor typed can ride along. */
24
+ export interface SiteActionReport {
25
+ outcome: 'submitted';
26
+ action_id: string;
27
+ action_type?: string;
28
+ source: 'on_page_agent';
29
+ /** The site's signature on the answer that led here, when the visitor came by a handoff link. */
30
+ report_token?: string;
31
+ }
32
+
33
+ /**
34
+ * A page tool that ran and went through. The action's `@id` when the registry
35
+ * kept it: a spec registry keeps only the four standard hints, so on native
36
+ * Chrome the tool's name, on its own page, is all there is to go on.
37
+ */
38
+ export interface PageFormRun {
39
+ actionId?: string;
40
+ toolName: string;
41
+ }
42
+
43
+ /** Turns a run into a report and sends it, or ignores a run that was no action of the site's. */
44
+ export type SiteActionReporter = (run: PageFormRun) => Promise<unknown> | unknown;
45
+
46
+ let reporter: SiteActionReporter | null = null;
47
+
48
+ /**
49
+ * Where reports go: the site's server, once it is connected. `null` stops
50
+ * reporting, which is the state until a server is found.
51
+ */
52
+ export function setSiteActionReporter(next: SiteActionReporter | null): void {
53
+ reporter = next;
54
+ }
55
+
56
+ /**
57
+ * Report a page tool's run if it went through. Whether it was one of the
58
+ * site's actions is the reporter's to decide; a form that refused the input,
59
+ * or failed, is never reported.
60
+ */
61
+ export function reportSubmitted(
62
+ tool: ModelContextToolInfo | null,
63
+ name: string,
64
+ res: ModelContextToolResponse | null,
65
+ ): void {
66
+ if (!reporter || !res || res.isError) return;
67
+ const hint = tool?.annotations?.[ACTION_ID_HINT];
68
+ const run: PageFormRun = { toolName: tool?.name || name, ...(typeof hint === 'string' && hint ? { actionId: hint } : {}) };
69
+ const send = reporter;
70
+ try {
71
+ void Promise.resolve(send(run)).catch(() => {});
72
+ } catch {
73
+ // The visitor's submission stands whether or not it was counted.
74
+ }
75
+ }
76
+
77
+ /** Where held report tokens live: the tab's session, so a form finished on another page still has its token. */
78
+ const TOKENS_KEY = 'nxis:report-tokens';
79
+
80
+ function sessionStore(): Storage | null {
81
+ try {
82
+ return typeof sessionStorage === 'undefined' ? null : sessionStorage;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function readTokens(storage: Storage | null): Record<string, string> {
89
+ try {
90
+ const parsed = JSON.parse(storage?.getItem(TOKENS_KEY) ?? '{}') as unknown;
91
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, string>) : {};
92
+ } catch {
93
+ return {};
94
+ }
95
+ }
96
+
97
+ function writeTokens(storage: Storage | null, tokens: Record<string, string>): void {
98
+ try {
99
+ if (Object.keys(tokens).length) storage?.setItem(TOKENS_KEY, JSON.stringify(tokens));
100
+ else storage?.removeItem(TOKENS_KEY);
101
+ } catch {
102
+ // Unverified is still counted; a token that cannot be held is only lost proof.
103
+ }
104
+ }
105
+
106
+ /** Hold the token a handoff link brought for an action, until its form is sent. */
107
+ export function rememberReportToken(actionId: string, token: string, storage: Storage | null = sessionStore()): void {
108
+ writeTokens(storage, { ...readTokens(storage), [actionId]: token });
109
+ }
110
+
111
+ /** Take the token held for an action, if any: it goes with one report and is then let go. */
112
+ export function takeReportToken(actionId: string, storage: Storage | null = sessionStore()): string | undefined {
113
+ const tokens = readTokens(storage);
114
+ const token = tokens[actionId];
115
+ if (token === undefined) return undefined;
116
+ delete tokens[actionId];
117
+ writeTokens(storage, tokens);
118
+ return typeof token === 'string' && token ? token : undefined;
119
+ }
@@ -18,7 +18,8 @@ import { ensureModelContext } from './local-context.js';
18
18
  import { connectMcp } from './client.js';
19
19
  import { discoverMcpServer } from './discovery.js';
20
20
  import { findEmbeddedAction, loadSiteActions, type SiteActionsOptions } from './site-actions.js';
21
- import { registerSiteActionTools } from './action-tools.js';
21
+ import { registerSiteActionTools, reportFor } from './action-tools.js';
22
+ import { setSiteActionReporter, takeReportToken, TRACK_TOOL } from './report.js';
22
23
  import { loadSitePages, mergePages, pagesFromActions, type SitePage } from './site-pages.js';
23
24
  import { registerOpenPageTool, OPEN_PAGE_TOOL, type OpenPageOptions } from './open-page.js';
24
25
  import {
@@ -227,10 +228,26 @@ async function connectRegistry(
227
228
  console.warn(`[nxis-mcp] ${warning}`);
228
229
  }
229
230
 
231
+ reportTo(client, siteActions);
232
+
230
233
  const site = await connectPages(mc, client, info, siteActions, siteOpts, opts.openPage);
231
234
  return { modelContext: mc, remoteTools, siteActions, siteTools, sitePages: site.pages, siteNav: site.nav, warning };
232
235
  }
233
236
 
237
+ /**
238
+ * Count the site's forms the agent sends in its Agent Analytics, through its
239
+ * own server.
240
+ */
241
+ function reportTo(client: Client, actions: NxisAction[]): void {
242
+ setSiteActionReporter((run) => {
243
+ const report = reportFor(actions, run);
244
+ if (!report) return undefined;
245
+ // A visitor handed here from the NxisAI app brings the site's token for the action: the report counts as verified.
246
+ const token = takeReportToken(report.action_id);
247
+ return client.callTool({ name: TRACK_TOOL, arguments: { ...report, ...(token ? { report_token: token } : {}) } });
248
+ });
249
+ }
250
+
234
251
  /**
235
252
  * The site's pages, and the tool that opens them.
236
253
  *
@@ -7,6 +7,7 @@
7
7
  * @module
8
8
  */
9
9
  import { TOOLS_TIMEOUT_MS } from '../../engine.js';
10
+ import { reportSubmitted } from './report.js';
10
11
  import type {
11
12
  ModelContext,
12
13
  ModelContextToolInfo,
@@ -159,7 +160,10 @@ export async function executeRegistered(
159
160
  let refused: unknown;
160
161
  for (const attempt of attempts) {
161
162
  try {
162
- return asResponse(await attempt());
163
+ const res = asResponse(await attempt());
164
+ // Every page form runs through here, whichever way it was reached: the one place to count it.
165
+ reportSubmitted(toolObj, name, res);
166
+ return res;
163
167
  } catch (err) {
164
168
  if (!REFUSED_SHAPE.test(String((err as Error)?.message || err))) throw err;
165
169
  refused = err;