@nxisai/agent-harness 0.7.0 → 0.9.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 (62) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -5
  3. package/dist/a2ui/cards.d.ts +3 -0
  4. package/dist/a2ui/cards.d.ts.map +1 -1
  5. package/dist/a2ui/cards.js +11 -2
  6. package/dist/a2ui/cards.js.map +1 -1
  7. package/dist/harness/cta.d.ts +27 -0
  8. package/dist/harness/cta.d.ts.map +1 -0
  9. package/dist/harness/cta.js +43 -0
  10. package/dist/harness/cta.js.map +1 -0
  11. package/dist/harness/harness.d.ts +33 -0
  12. package/dist/harness/harness.d.ts.map +1 -1
  13. package/dist/harness/harness.js +84 -2
  14. package/dist/harness/harness.js.map +1 -1
  15. package/dist/harness/recommend.d.ts +46 -0
  16. package/dist/harness/recommend.d.ts.map +1 -0
  17. package/dist/harness/recommend.js +144 -0
  18. package/dist/harness/recommend.js.map +1 -0
  19. package/dist/harness/records.d.ts +70 -0
  20. package/dist/harness/records.d.ts.map +1 -0
  21. package/dist/harness/records.js +209 -0
  22. package/dist/harness/records.js.map +1 -0
  23. package/dist/harness/renderer.d.ts +11 -1
  24. package/dist/harness/renderer.d.ts.map +1 -1
  25. package/dist/harness/router/coordinator.d.ts +6 -0
  26. package/dist/harness/router/coordinator.d.ts.map +1 -1
  27. package/dist/harness/router/coordinator.js +14 -0
  28. package/dist/harness/router/coordinator.js.map +1 -1
  29. package/dist/harness/tools/action-tools.d.ts +25 -0
  30. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  31. package/dist/harness/tools/action-tools.js +39 -0
  32. package/dist/harness/tools/action-tools.js.map +1 -1
  33. package/dist/harness/tools/inbound.d.ts +88 -0
  34. package/dist/harness/tools/inbound.d.ts.map +1 -0
  35. package/dist/harness/tools/inbound.js +156 -0
  36. package/dist/harness/tools/inbound.js.map +1 -0
  37. package/dist/harness/tools/site-pages.js +1 -1
  38. package/dist/index.d.ts +7 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +5 -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 +154 -76
  44. package/dist/ui/agent-fullscreen.js.map +1 -1
  45. package/dist/ui/result-pick.d.ts +52 -0
  46. package/dist/ui/result-pick.d.ts.map +1 -0
  47. package/dist/ui/result-pick.js +196 -0
  48. package/dist/ui/result-pick.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/a2ui/cards.ts +14 -2
  51. package/src/harness/cta.ts +54 -0
  52. package/src/harness/harness.ts +93 -2
  53. package/src/harness/recommend.ts +194 -0
  54. package/src/harness/records.ts +237 -0
  55. package/src/harness/renderer.ts +12 -1
  56. package/src/harness/router/coordinator.ts +14 -0
  57. package/src/harness/tools/action-tools.ts +41 -0
  58. package/src/harness/tools/inbound.ts +189 -0
  59. package/src/harness/tools/site-pages.ts +1 -1
  60. package/src/index.ts +16 -0
  61. package/src/ui/agent-fullscreen.ts +162 -66
  62. package/src/ui/result-pick.ts +233 -0
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import { mountSurface } from '../a2ui/host.js';
24
24
  import { emitCardsSurface } from '../a2ui/cards.js';
25
+ import { actionCta } from '../harness/cta.js';
25
26
  import { emitToolForm } from '../a2ui/form.js';
26
27
  import { A2UI_THEME_CSS } from '../a2ui/theme.js';
27
28
  // Compiled from agent.tw.css at build time (scripts/css-to-ts.mjs). It is a
@@ -34,11 +35,14 @@ import { AgentHarness, isAbort } from '../harness/harness.js';
34
35
  import { WebMcpTools } from '../harness/tools/webmcp.js';
35
36
  import { extractResult, proseAnswer, missingRequired, toolSchema, } from '../harness/tools/schema.js';
36
37
  import { connectTools } from '../harness/tools/source.js';
37
- import { chooserOf, formOptions, handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
38
+ import { chooserOf, formOptions, formsFor, handoffFor, isSiteTool, siteFormFor, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
38
39
  import { resolveSlots } from '../harness/slots.js';
39
40
  import { isHandoffTool } from '../handoff/tools.js';
40
41
  import { actionCard, fieldLabel, fieldsFromSlots, ACTION_CARD_CSS, } from './action-card.js';
42
+ import { resultsAnswer, RESULTS_ANSWER_CSS } from './result-pick.js';
43
+ import { extractRecords, openStatus, recordFacts } from '../harness/records.js';
41
44
  import { resumePendingAction } from '../harness/tools/resume.js';
45
+ import { findHandoffForm, startFormMessage, startMessage, takeInboundHandoff } from '../harness/tools/inbound.js';
42
46
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
43
47
  import { pageForEntity } from '../harness/tools/site-pages.js';
44
48
  import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
@@ -413,6 +417,8 @@ class NxisAgentFullscreen extends HTMLElement {
413
417
  // belongs to is the one open.
414
418
  void this._resumePendingAction();
415
419
  this._resumePendingVisit();
420
+ // Sent here from the NxisAI app to use one of this page's forms.
421
+ void this._arriveFromHandoff();
416
422
  this._els.launcher.addEventListener('click', () => this._open());
417
423
  this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
418
424
  // A handoff left open on the last page carries on here.
@@ -1302,7 +1308,14 @@ class NxisAgentFullscreen extends HTMLElement {
1302
1308
  return bubble;
1303
1309
  }
1304
1310
  // ── Turn loop ──────────────────────────────────────────────────────────────
1305
- async _onSend() {
1311
+ /**
1312
+ * Send what is in the composer.
1313
+ *
1314
+ * @param call - A tool call already decided, run without asking the model
1315
+ * (a handoff names its form). Falls back to an ordinary turn when no agent
1316
+ * here holds the tool.
1317
+ */
1318
+ async _onSend(call) {
1306
1319
  const goal = this._els.input.value.trim();
1307
1320
  if (!goal || this._turnActive)
1308
1321
  return;
@@ -1338,7 +1351,10 @@ class NxisAgentFullscreen extends HTMLElement {
1338
1351
  await this._connectTools();
1339
1352
  this._syncProfileContext();
1340
1353
  // One agent, or several with a router in front: the signatures match.
1341
- await (this._coordinator ?? this._harness).turn(goal, conv.messages);
1354
+ const engine = this._coordinator ?? this._harness;
1355
+ const begun = call ? await engine.begin(goal, call.name, call.args, conv.messages) : null;
1356
+ if (!begun || begun.decision === 'unavailable')
1357
+ await engine.turn(goal, conv.messages);
1342
1358
  // A remote endpoint reports which model answered; the name beside the
1343
1359
  // input follows it rather than keeping whatever was configured.
1344
1360
  this._paintProvenance();
@@ -1372,9 +1388,9 @@ class NxisAgentFullscreen extends HTMLElement {
1372
1388
  ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
1373
1389
  confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
1374
1390
  left: (tool, destination) => this._cardLeft(tool, destination),
1375
- results: (tool, res) => {
1391
+ results: (tool, res, extra) => {
1376
1392
  const conv = this._active();
1377
- const item = this._buildResultsItem(tool, res);
1393
+ const item = this._buildResultsItem(tool, res, extra?.pick);
1378
1394
  // A read that answers in words — "you are on the home page", "already open" — has no records
1379
1395
  // to draw, and an empty result card reading "No results found." contradicts the answer it
1380
1396
  // just gave. Say what it said.
@@ -2254,70 +2270,10 @@ class NxisAgentFullscreen extends HTMLElement {
2254
2270
  * return the first that yields records. Shapes covered: query → results[],
2255
2271
  * list_types → types[], get → result{}, or a bare entity/array. */
2256
2272
  _extractItems(res) {
2257
- const sources = [];
2258
- // Collect every candidate payload, recursively unwrapping tool-response
2259
- // envelopes. `res` can be an object, a JSON string, or (with a WebMCP browser
2260
- // extension in the loop) the whole `{content, structuredContent}` envelope
2261
- // serialized to a string, possibly more than once. Diving into both
2262
- // `structuredContent` and each `content[].text` covers all of them.
2263
- const visit = (raw, depth = 0) => {
2264
- if (raw == null || depth > 5)
2265
- return;
2266
- const val = raw;
2267
- if (typeof val === 'string') {
2268
- const t = val.trim();
2269
- if (!t)
2270
- return;
2271
- let parsed;
2272
- try {
2273
- parsed = JSON.parse(t);
2274
- }
2275
- catch {
2276
- return; // plain (non-JSON) text — nothing to extract
2277
- }
2278
- // Parsed value may itself be an object/array OR another JSON string
2279
- // (double-encoded); recurse either way.
2280
- return visit(parsed, depth + 1);
2281
- }
2282
- if (!val || typeof val !== 'object')
2283
- return;
2284
- sources.push(val);
2285
- const v = val;
2286
- if (v.structuredContent != null)
2287
- visit(v.structuredContent, depth + 1);
2288
- if (Array.isArray(v.content)) {
2289
- for (const c of v.content) {
2290
- if (c && typeof c === 'object')
2291
- visit(c.text, depth + 1);
2292
- }
2293
- }
2294
- };
2295
- visit(res);
2296
- for (const data of sources) {
2297
- const items = this._itemsFrom(data);
2298
- if (items.length)
2299
- return items;
2300
- }
2301
- console.warn('[nxis-mcp] no items extracted from tool result', { sourcesTried: sources.length, res });
2302
- return [];
2303
- }
2304
- /** Find the record array within one parsed payload. */
2305
- _itemsFrom(data) {
2306
- const objs = (arr) => arr.filter((x) => x && typeof x === 'object');
2307
- if (Array.isArray(data))
2308
- return objs(data);
2309
- if (!data || typeof data !== 'object')
2310
- return [];
2311
- const d = data;
2312
- for (const key of ['results', 'types', 'items', 'entities', 'data']) {
2313
- if (Array.isArray(d[key]))
2314
- return objs(d[key]);
2315
- }
2316
- if (d.result && typeof d.result === 'object')
2317
- return [d.result];
2318
- if (d['@id'] != null || d['@type'] != null || d.name != null)
2319
- return [d];
2320
- return [];
2273
+ const items = extractRecords(res);
2274
+ if (!items.length)
2275
+ console.warn('[nxis-mcp] no items extracted from tool result', { res });
2276
+ return items;
2321
2277
  }
2322
2278
  /** Build a display card from a single record (a JSON-LD entity or a type row). */
2323
2279
  _entityCard(entity) {
@@ -2376,15 +2332,83 @@ class NxisAgentFullscreen extends HTMLElement {
2376
2332
  const id = entity['@id'] != null ? str(entity['@id']) : undefined;
2377
2333
  const found = pageForEntity(entity, this._sitePages, location.origin);
2378
2334
  const page = found ? { url: found.url, label: found.name } : undefined;
2379
- return { title, type, rating, emergency, sub, rows, id, ...(page ? { page } : {}) };
2335
+ // What the answer view compares by, beyond the rating and hours its heading already shows.
2336
+ const facts = recordFacts(entity);
2337
+ const status = openStatus(entity) ?? undefined;
2338
+ const lines = [
2339
+ facts.services.length ? facts.services.slice(0, 3).map((x) => x.name).join(', ') : facts.knows.slice(0, 3).join(', '),
2340
+ facts.since ? `In business since ${facts.since}` : '',
2341
+ facts.reviews[0] ? `\u201c${facts.reviews[0].text}\u201d${facts.reviews[0].author ? ` \u2014 ${facts.reviews[0].author}` : ''}` : '',
2342
+ ].filter((l) => !!l);
2343
+ const action = actionCta(entity, title);
2344
+ return {
2345
+ title,
2346
+ type,
2347
+ rating,
2348
+ emergency,
2349
+ sub,
2350
+ rows,
2351
+ id,
2352
+ ...(page ? { page } : {}),
2353
+ ...(facts.rating !== undefined ? { stars: facts.rating } : {}),
2354
+ ...(facts.reviewCount !== undefined ? { reviews: facts.reviewCount } : {}),
2355
+ ...(status ? { status: { open: status.open, label: status.label } } : {}),
2356
+ ...(facts.phone ? { phone: facts.phone } : {}),
2357
+ ...(action ? { action } : {}),
2358
+ facts: lines,
2359
+ };
2380
2360
  }
2381
- _buildResultsItem(tool, res) {
2361
+ _buildResultsItem(tool, res, pick) {
2382
2362
  const items = this._extractItems(res);
2383
2363
  const cards = items.slice(0, 12).map((e) => this._entityCard(e));
2384
2364
  const note = !cards.length
2385
2365
  ? 'No results found.'
2386
2366
  : `${cards.length} result${cards.length === 1 ? '' : 's'}`;
2387
- return { kind: 'results', title: humanize(tool.name), note, cards };
2367
+ return { kind: 'results', title: humanize(tool.name), note, cards, ...(pick && pick.index < cards.length ? { pick } : {}) };
2368
+ }
2369
+ /**
2370
+ * Results as an answer: the recommended pick with its reasons and the others
2371
+ * to compare, or a short list that opens per result. Records of things
2372
+ * only: a listing of types or counts keeps the plain cards.
2373
+ */
2374
+ _answerEl(item) {
2375
+ // Records of things: a pick, or at least one result with a rating, hours, a phone or a page.
2376
+ // A sparse record beside them (an index entry with only a name) still shows, by name.
2377
+ const things = item.cards.filter((c) => c.stars !== undefined || c.status || c.phone || c.page);
2378
+ if (!item.pick && !things.length)
2379
+ return null;
2380
+ const cards = item.cards.map((c) => {
2381
+ // The record's own action first: the same map and prompt as the Typed Retrieval skill and the NxisAI
2382
+ // app. A record that states none falls back to a form found on this page for it.
2383
+ const form = c.action ? undefined : formsFor(c.title)[0];
2384
+ const quote = form && /quote/.test(form.noun);
2385
+ return {
2386
+ title: c.title,
2387
+ stars: c.stars,
2388
+ reviews: c.reviews,
2389
+ status: c.status,
2390
+ phone: c.phone,
2391
+ page: c.page,
2392
+ facts: c.facts ?? [],
2393
+ ...(c.action
2394
+ ? { form: { label: c.action.label, message: c.action.message } }
2395
+ : form
2396
+ ? {
2397
+ form: quote
2398
+ ? { label: 'Get a quote', message: `Get a quote from ${c.title}` }
2399
+ : { label: `Use their ${form.noun}`, message: `Start the ${form.noun} for ${c.title}` },
2400
+ }
2401
+ : {}),
2402
+ };
2403
+ });
2404
+ return resultsAnswer(cards, item.pick, {
2405
+ ask: (text) => {
2406
+ if (this._turnActive)
2407
+ return;
2408
+ this._els.input.value = text;
2409
+ void this._onSend();
2410
+ },
2411
+ });
2388
2412
  }
2389
2413
  /** Feature-card pattern: tinted icon chip, tags, tight Manrope headline, one
2390
2414
  * supporting line, then mono-labelled detail rows. */
@@ -2406,6 +2430,7 @@ class NxisAgentFullscreen extends HTMLElement {
2406
2430
  ...(c.emergency ? [{ label: '24/7' }] : []),
2407
2431
  ],
2408
2432
  rows: c.rows.map((r) => ({ label: r.label, value: r.value })),
2433
+ actions: c.action ? [c.action] : [],
2409
2434
  }));
2410
2435
  }
2411
2436
  /** Results rendered through A2UI instead of the bespoke card markup. */
@@ -2418,7 +2443,15 @@ class NxisAgentFullscreen extends HTMLElement {
2418
2443
  }
2419
2444
  try {
2420
2445
  const messages = emitCardsSurface(item.note, this._toA2uiCards(item.cards));
2421
- const mounted = mountSurface(wrap, messages);
2446
+ const mounted = mountSurface(wrap, messages, (event) => {
2447
+ const name = event?.event?.name ?? event?.name ?? '';
2448
+ const ctx = (event?.event?.context ?? event?.context ?? {});
2449
+ // A card's action button: send its message as the visitor, as the inline card does.
2450
+ if (name === 'nxis.ask' && typeof ctx.message === 'string' && !this._turnActive) {
2451
+ this._els.input.value = ctx.message;
2452
+ void this._onSend();
2453
+ }
2454
+ });
2422
2455
  if (!mounted)
2423
2456
  throw new Error('no surface produced');
2424
2457
  this._surfaces.push(mounted);
@@ -2444,9 +2477,14 @@ class NxisAgentFullscreen extends HTMLElement {
2444
2477
  * surface; a2ui="a2ui-results" opts this element back into it for comparison.
2445
2478
  */
2446
2479
  _resultsEl(item) {
2447
- return this.getAttribute('a2ui') === 'a2ui-results'
2448
- ? this._resultsElA2ui(item)
2449
- : this._resultsElLegacy(item);
2480
+ if (this.getAttribute('a2ui') === 'a2ui-results')
2481
+ return this._resultsElA2ui(item);
2482
+ if (!this._classicPrompts && item.cards.length) {
2483
+ const answer = this._answerEl(item);
2484
+ if (answer)
2485
+ return answer;
2486
+ }
2487
+ return this._resultsElLegacy(item);
2450
2488
  }
2451
2489
  _resultsElLegacy(item) {
2452
2490
  const wrap = document.createElement('div');
@@ -3118,6 +3156,43 @@ class NxisAgentFullscreen extends HTMLElement {
3118
3156
  if (this.mode !== 'fullscreen')
3119
3157
  this._open();
3120
3158
  }
3159
+ /**
3160
+ * Open on the form a link from the NxisAI app names.
3161
+ *
3162
+ * In a new conversation: the person arrives from another app, and nothing
3163
+ * on this site led here. The agent starts the form in its own words, so the
3164
+ * link only chooses which form; what the person wanted waits in the
3165
+ * composer for them to send, change or delete.
3166
+ */
3167
+ async _arriveFromHandoff() {
3168
+ const handoff = takeInboundHandoff();
3169
+ if (!handoff)
3170
+ return;
3171
+ this._newConversation();
3172
+ this._open();
3173
+ this._setStatus('Opening the form…');
3174
+ // The site's own tools first: they reach a form from any page, from the
3175
+ // site's records, so they work where the page registers nothing itself.
3176
+ await this._connectTools();
3177
+ const site = siteFormFor(handoff.actionId);
3178
+ const start = site
3179
+ ? startFormMessage(site.noun, site.choice)
3180
+ : await findHandoffForm(handoff).then((o) => (o.status === 'ready' ? startMessage(o.tool) : null));
3181
+ this._setStatus('');
3182
+ if (!start) {
3183
+ this._appendMsg('assistant', "I couldn't find that form on this page. Tell me what you need and I'll help from here.");
3184
+ }
3185
+ else if (!this._turnActive) {
3186
+ this._els.input.value = start;
3187
+ // The form is known, so it opens without the model choosing it: left to
3188
+ // choose, a model sometimes asks for the details in prose instead.
3189
+ await this._onSend(site ? { name: site.tool, args: site.args } : undefined);
3190
+ }
3191
+ if (handoff.intent && !this._els.input.value) {
3192
+ this._els.input.value = handoff.intent;
3193
+ this._autosize();
3194
+ }
3195
+ }
3121
3196
  async _resumePendingAction() {
3122
3197
  const outcome = await resumePendingAction({
3123
3198
  onStart: (pending) => {
@@ -3702,6 +3777,9 @@ class NxisAgentFullscreen extends HTMLElement {
3702
3777
  /* The action card: forms and handoffs as one message. */
3703
3778
  ${ACTION_CARD_CSS}
3704
3779
 
3780
+ /* Search results as an answer: a pick and why, or a short list. */
3781
+ ${RESULTS_ANSWER_CSS}
3782
+
3705
3783
  /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
3706
3784
  .launcher {
3707
3785
  position: fixed; right: 20px; bottom: 20px; z-index: 9998;