@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.
- package/LICENSE +201 -0
- package/README.md +32 -7
- package/dist/handoff/tools.d.ts.map +1 -1
- package/dist/handoff/tools.js +11 -4
- package/dist/handoff/tools.js.map +1 -1
- package/dist/harness/harness.d.ts +16 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +70 -10
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/recommend.d.ts +46 -0
- package/dist/harness/recommend.d.ts.map +1 -0
- package/dist/harness/recommend.js +144 -0
- package/dist/harness/recommend.js.map +1 -0
- package/dist/harness/records.d.ts +70 -0
- package/dist/harness/records.d.ts.map +1 -0
- package/dist/harness/records.js +209 -0
- package/dist/harness/records.js.map +1 -0
- package/dist/harness/renderer.d.ts +31 -1
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +39 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +82 -4
- package/dist/harness/tools/action-tools.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/site-pages.js +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 +4 -2
- 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 +615 -88
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/result-pick.d.ts +52 -0
- package/dist/ui/result-pick.d.ts.map +1 -0
- package/dist/ui/result-pick.js +196 -0
- package/dist/ui/result-pick.js.map +1 -0
- 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/tools.ts +11 -7
- package/src/harness/harness.ts +82 -17
- package/src/harness/recommend.ts +194 -0
- package/src/harness/records.ts +237 -0
- package/src/harness/renderer.ts +34 -1
- package/src/harness/tools/action-tools.ts +92 -4
- package/src/harness/tools/site-actions.ts +58 -0
- package/src/harness/tools/site-pages.ts +1 -1
- 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 +655 -78
- package/src/ui/result-pick.ts +233 -0
- 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
|
|
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
|
-
|
|
628
|
-
return text(`Say which one this is for
|
|
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
|
-
*
|
|
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
|
|