@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
@@ -34,14 +34,19 @@ import { AgentHarness, isAbort } from '../harness/harness.js';
34
34
  import { WebMcpTools } from '../harness/tools/webmcp.js';
35
35
  import { extractResult, proseAnswer, missingRequired, toolSchema, } from '../harness/tools/schema.js';
36
36
  import { connectTools } from '../harness/tools/source.js';
37
- import { handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
37
+ import { chooserOf, formOptions, handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
38
+ import { resolveSlots } from '../harness/slots.js';
39
+ import { isHandoffTool } from '../handoff/tools.js';
40
+ import { actionCard, fieldLabel, fieldsFromSlots, ACTION_CARD_CSS, } from './action-card.js';
38
41
  import { resumePendingAction } from '../harness/tools/resume.js';
39
42
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
40
43
  import { pageForEntity } from '../harness/tools/site-pages.js';
41
44
  import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
42
- import { onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
45
+ import { currentJourney, onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
43
46
  import { viewJourney } from './journey.js';
44
47
  import { AgentCoordinator } from '../harness/router/coordinator.js';
48
+ import { HandoffController, isHandoffOver, } from '../handoff/controller.js';
49
+ import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
45
50
  import { LocalGemmaProvider, humanize, escapeHtml, NXIS_LOGO_SVG, NXIS_SITE_URL, ICON_CHECK, ICON_WARN, ICON_ARROW, ICON_PLUS, ICON_MENU, ICON_TRASH, ICON_STOP, ICON_COPY, ICON_PENCIL, ICON_REDO, ICON_CHECK_MARK, } from '../engine.js';
46
51
  import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
47
52
  // Prefixed keys so state never collides with other Nxis examples on the same origin.
@@ -76,6 +81,11 @@ const fmt = (n) => n.toLocaleString();
76
81
  * be disabled under `prefers-reduced-motion` while the bubble still reads as
77
82
  * pending. The label and byte spans are filled per phase and hidden while empty.
78
83
  */
84
+ /**
85
+ * How long the running-step line stays once the conversation moves on past
86
+ * its card. A glance's worth: long enough to notice and open it.
87
+ */
88
+ const JSTATUS_TIMEOUT_MS = 10000;
79
89
  const THINKING_MARKUP = '<span class="think-row">' +
80
90
  '<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
81
91
  '<span class="think-label"></span>' +
@@ -186,6 +196,35 @@ function fillMessage(el, text) {
186
196
  }
187
197
  }
188
198
  }
199
+ /**
200
+ * A handoff's state as a line in the thread, or '' for a state the bar alone
201
+ * shows.
202
+ */
203
+ function handoffLine(session) {
204
+ const who = session.label;
205
+ switch (session.state) {
206
+ case 'connecting':
207
+ return `Connecting you to ${who}…`;
208
+ case 'queued':
209
+ // The bar says where they are in line; a desk usually says it in words too.
210
+ return '';
211
+ case 'active':
212
+ return session.operator && session.operator !== who ? `${session.operator} has joined` : `You're talking with ${who}`;
213
+ case 'awaiting-visitor':
214
+ return '';
215
+ case 'handed-back':
216
+ return 'Back with the assistant';
217
+ case 'ended':
218
+ return `${who} has finished. Back with the assistant.`;
219
+ case 'failed':
220
+ return `${session.error ?? `${who} couldn't continue.`} Back with the assistant.`;
221
+ }
222
+ }
223
+ function ordinal(n) {
224
+ const s = ['th', 'st', 'nd', 'rd'];
225
+ const v = n % 100;
226
+ return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
227
+ }
189
228
  class NxisAgentFullscreen extends HTMLElement {
190
229
  constructor() {
191
230
  super();
@@ -214,6 +253,12 @@ class NxisAgentFullscreen extends HTMLElement {
214
253
  };
215
254
  /** Watches whether the running journey's card is on screen. */
216
255
  this._journeyObserver = null;
256
+ /** The journey the status line is about, while one is running. */
257
+ this._jstatusJourney = null;
258
+ /** Journeys whose status line was opened once or timed out: it does not come back. */
259
+ this._jstatusDismissed = new Set();
260
+ /** Dismisses the status line a while after the thread moves on past the card. */
261
+ this._jstatusTimer = null;
217
262
  /** Built only when the host defines agents — otherwise there is one harness. */
218
263
  this._coordinator = null;
219
264
  this._agentDefs = null;
@@ -221,6 +266,14 @@ class NxisAgentFullscreen extends HTMLElement {
221
266
  this._fallbackAgent = '';
222
267
  /** The live ask/confirm surface, if one is open. */
223
268
  this._prompt = null;
269
+ /**
270
+ * The action card still waiting on the visitor or a result, and what it can
271
+ * do: the harness's ask or confirm request, or, after a page change, a call
272
+ * of the widget's own.
273
+ */
274
+ this._live = null;
275
+ /** Handoff targets as the relay last reported them, for a card's availability line. */
276
+ this._handoffTargets = new Map();
224
277
  this._persistPrefs = false;
225
278
  this._systemPrompt = DEFAULT_SYSTEM_PROMPT;
226
279
  this._suggestions = DEFAULT_SUGGESTIONS;
@@ -249,6 +302,9 @@ class NxisAgentFullscreen extends HTMLElement {
249
302
  this._emptyLoading = null;
250
303
  /** A provider the host supplied, used instead of the local model. */
251
304
  this._provider = null;
305
+ /** Runs handoffs to agents and people, when the host configured any. */
306
+ this._handoff = null;
307
+ this._offHandoff = null;
252
308
  this.attachShadow({ mode: 'open' });
253
309
  this._turnActive = false;
254
310
  this._tool = null; // tool of the live inline form (if any)
@@ -279,6 +335,9 @@ class NxisAgentFullscreen extends HTMLElement {
279
335
  status: root.querySelector('.statusline'),
280
336
  bar: root.querySelector('.prog > i'),
281
337
  jstatus: root.querySelector('.jstatus'),
338
+ handoffBar: root.querySelector('.handoff-bar'),
339
+ handoffLabel: root.querySelector('.handoff-bar .hb-label'),
340
+ handoffBack: root.querySelector('.handoff-bar .hb-back'),
282
341
  input: root.querySelector('.composer textarea'),
283
342
  go: root.querySelector('.composer .go'),
284
343
  toolsBtn: root.querySelector('.tools-btn'),
@@ -312,13 +371,7 @@ class NxisAgentFullscreen extends HTMLElement {
312
371
  this._buildAgents();
313
372
  // The element renders; the harness decides. Everything from choosing a
314
373
  // tool to running it happens behind harness.turn().
315
- this._harness = new AgentHarness({
316
- provider: this.provider,
317
- toolSource: this.toolSource,
318
- renderer: this._renderer(),
319
- profile: (schema) => this._profileForSchema(schema),
320
- onMessages: () => this._saveConversations(),
321
- });
374
+ this._harness = this._singleHarness();
322
375
  // Preferences are supplied by the host page through `element.preferences`.
323
376
  // Anything a previous build persisted is picked up so upgrading does not
324
377
  // silently drop a user's saved details; `persist` then stays true so they
@@ -350,6 +403,8 @@ class NxisAgentFullscreen extends HTMLElement {
350
403
  this._els.jstatus.addEventListener('click', () => {
351
404
  const cards = this._els.thread.querySelectorAll('.journey.is-running');
352
405
  cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
406
+ // One look is what it is for; after that it would only be in the way.
407
+ this._dismissJourneyStatus();
353
408
  });
354
409
  // Whoever builds the tools, a call that leaves this page reports back to
355
410
  // the thread it was made in.
@@ -359,6 +414,11 @@ class NxisAgentFullscreen extends HTMLElement {
359
414
  void this._resumePendingAction();
360
415
  this._resumePendingVisit();
361
416
  this._els.launcher.addEventListener('click', () => this._open());
417
+ this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
418
+ // A handoff left open on the last page carries on here.
419
+ this._paintHandoffBar();
420
+ if (this._activeId)
421
+ void this._handoff?.resume(this._activeId);
362
422
  this._els.close.addEventListener('click', () => {
363
423
  this._els.overlay.classList.remove('show');
364
424
  // A remote turn can outlive the panel, and its answer would go unread.
@@ -440,6 +500,9 @@ class NxisAgentFullscreen extends HTMLElement {
440
500
  this._offJourney = null;
441
501
  this._journeyObserver?.disconnect();
442
502
  this._journeyObserver = null;
503
+ this._clearJourneyStatusTimer();
504
+ // Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
505
+ this._handoff?.dispose();
443
506
  }
444
507
  /** Programmatic open (used by pages that dock the agent full-page). */
445
508
  open() {
@@ -507,6 +570,7 @@ class NxisAgentFullscreen extends HTMLElement {
507
570
  this._renderHistory();
508
571
  this._renderThread();
509
572
  this._saveConversations();
573
+ this._paintHandoffBar();
510
574
  }
511
575
  if (this._els.input)
512
576
  this._els.input.focus();
@@ -521,6 +585,8 @@ class NxisAgentFullscreen extends HTMLElement {
521
585
  this._setNav(false);
522
586
  this._renderHistory();
523
587
  this._renderThread();
588
+ this._paintHandoffBar();
589
+ void this._handoff?.resume(id);
524
590
  }
525
591
  _renderHistory() {
526
592
  this._els.history.innerHTML = this._conversations
@@ -620,8 +686,15 @@ class NxisAgentFullscreen extends HTMLElement {
620
686
  this._els.thread.appendChild(this._resultsEl(it));
621
687
  else if (it.kind === 'metrics')
622
688
  this._els.thread.appendChild(this._metricsEl(it));
623
- else if (it.kind === 'journey')
624
- this._els.thread.appendChild(this._journeyEl(it));
689
+ else if (it.kind === 'journey') {
690
+ // A request drawn as an action card shows its own steps.
691
+ if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id))
692
+ this._els.thread.appendChild(this._journeyEl(it));
693
+ }
694
+ else if (it.kind === 'action')
695
+ this._paintAction(it);
696
+ else if (it.kind === 'remote' || it.kind === 'handoff')
697
+ this._paintHandoffItem(it);
625
698
  }
626
699
  this._scroll();
627
700
  this._watchRunningJourney();
@@ -989,6 +1062,14 @@ class NxisAgentFullscreen extends HTMLElement {
989
1062
  item = { kind: 'journey', id: view.id, steps: all };
990
1063
  conv.items.push(item);
991
1064
  }
1065
+ // A request with an action card: the steps belong in the card.
1066
+ const action = conv.items.find((it) => it.kind === 'action' && it.id === view.id);
1067
+ if (action) {
1068
+ this._actionFollows(action, all);
1069
+ this._watchRunningJourney();
1070
+ this._saveConversations();
1071
+ return;
1072
+ }
992
1073
  if (conv.id === this._activeId && this._els?.thread) {
993
1074
  const fresh = this._journeyEl(item);
994
1075
  const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
@@ -1022,7 +1103,12 @@ class NxisAgentFullscreen extends HTMLElement {
1022
1103
  return;
1023
1104
  const cards = this._els.thread.querySelectorAll('.journey.is-running');
1024
1105
  const card = cards[cards.length - 1];
1025
- if (!card || typeof IntersectionObserver === 'undefined') {
1106
+ const id = card?.dataset.journey ?? null;
1107
+ if (id !== this._jstatusJourney) {
1108
+ this._clearJourneyStatusTimer();
1109
+ this._jstatusJourney = id;
1110
+ }
1111
+ if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
1026
1112
  bar.hidden = true;
1027
1113
  return;
1028
1114
  }
@@ -1032,6 +1118,37 @@ class NxisAgentFullscreen extends HTMLElement {
1032
1118
  }, { root: this._els.thread, threshold: 0.2 });
1033
1119
  this._journeyObserver.observe(card);
1034
1120
  }
1121
+ /**
1122
+ * The thread grew: start the clock on the status line.
1123
+ *
1124
+ * A journey can stay running while the conversation goes on without it, and a
1125
+ * line that spins through all of that says nothing. So once new messages
1126
+ * arrive, the line gets a while longer, then goes.
1127
+ */
1128
+ _noteThreadGrew() {
1129
+ const id = this._jstatusJourney;
1130
+ if (!id || this._jstatusTimer || this._jstatusDismissed.has(id))
1131
+ return;
1132
+ this._jstatusTimer = setTimeout(() => {
1133
+ this._jstatusTimer = null;
1134
+ this._dismissJourneyStatus();
1135
+ }, JSTATUS_TIMEOUT_MS);
1136
+ }
1137
+ /** Hide the status line for the running journey, for good. */
1138
+ _dismissJourneyStatus() {
1139
+ this._clearJourneyStatusTimer();
1140
+ if (this._jstatusJourney)
1141
+ this._jstatusDismissed.add(this._jstatusJourney);
1142
+ this._journeyObserver?.disconnect();
1143
+ this._journeyObserver = null;
1144
+ if (this._els?.jstatus)
1145
+ this._els.jstatus.hidden = true;
1146
+ }
1147
+ _clearJourneyStatusTimer() {
1148
+ if (this._jstatusTimer)
1149
+ clearTimeout(this._jstatusTimer);
1150
+ this._jstatusTimer = null;
1151
+ }
1035
1152
  _paintToolResult(name, text, isError) {
1036
1153
  this._clearEmpty();
1037
1154
  const el = this._toolEl(name, text, isError);
@@ -1088,6 +1205,7 @@ class NxisAgentFullscreen extends HTMLElement {
1088
1205
  const el = this._paintMsg(role, text, conv.items.length - 1);
1089
1206
  this._saveConversations();
1090
1207
  this._scroll();
1208
+ this._noteThreadGrew();
1091
1209
  return el;
1092
1210
  }
1093
1211
  /**
@@ -1107,6 +1225,7 @@ class NxisAgentFullscreen extends HTMLElement {
1107
1225
  // without the focus moving to it.
1108
1226
  el.setAttribute('aria-live', 'polite');
1109
1227
  this._scroll();
1228
+ this._noteThreadGrew();
1110
1229
  const setLabel = (label) => {
1111
1230
  const node = el.querySelector('.think-label');
1112
1231
  if (node)
@@ -1196,6 +1315,18 @@ class NxisAgentFullscreen extends HTMLElement {
1196
1315
  this._renderHistory();
1197
1316
  this._saveConversations();
1198
1317
  }
1318
+ // Handed off: this message is for the target, not the assistant.
1319
+ const handed = this._handoff?.active(this._activeId ?? undefined);
1320
+ if (handed && this._activeId) {
1321
+ try {
1322
+ await this._handoff.send(this._activeId, goal);
1323
+ }
1324
+ catch (err) {
1325
+ const why = this._handoff.session(this._activeId)?.error ?? String(err?.message || err);
1326
+ this._appendHandoffItem(this._activeId, { kind: 'handoff', text: why, tone: 'error' });
1327
+ }
1328
+ return;
1329
+ }
1199
1330
  this._turnActive = true;
1200
1331
  this._setStoppable(true);
1201
1332
  let settle;
@@ -1237,8 +1368,10 @@ class NxisAgentFullscreen extends HTMLElement {
1237
1368
  message: (role, text) => this._appendMsg(role, text),
1238
1369
  streaming: () => this._appendStreaming(),
1239
1370
  status: (text, isError) => this._setStatus(text, isError),
1240
- ask: (req) => this._renderAsk(req),
1241
- confirm: (req) => this._renderConfirm(req),
1371
+ inlinePrompts: !this._classicPrompts,
1372
+ ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
1373
+ confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
1374
+ left: (tool, destination) => this._cardLeft(tool, destination),
1242
1375
  results: (tool, res) => {
1243
1376
  const conv = this._active();
1244
1377
  const item = this._buildResultsItem(tool, res);
@@ -1260,6 +1393,8 @@ class NxisAgentFullscreen extends HTMLElement {
1260
1393
  this._scroll();
1261
1394
  },
1262
1395
  detail: (tool, values, text) => {
1396
+ if (this._cardDone(tool, values, text))
1397
+ return;
1263
1398
  const conv = this._active();
1264
1399
  const item = this._buildDetailItem(tool, values, text);
1265
1400
  conv?.items.push(item);
@@ -1284,8 +1419,24 @@ class NxisAgentFullscreen extends HTMLElement {
1284
1419
  this._saveConversations();
1285
1420
  this._scroll();
1286
1421
  },
1287
- toolError: (tool, message) => this._paintToolResult(tool.name, message, true),
1288
- dismissPrompt: () => this._dismissPrompt(),
1422
+ toolError: (tool, message) => {
1423
+ if (this._cardFailed(tool, message))
1424
+ return;
1425
+ this._paintToolResult(tool.name, message, true);
1426
+ },
1427
+ dismissPrompt: () => {
1428
+ // A sent card stays, and shows the steps; one never sent says so.
1429
+ const live = this._live;
1430
+ if (live) {
1431
+ if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
1432
+ live.item.stage = 'working';
1433
+ this._paintAction(live.item);
1434
+ this._saveConversations();
1435
+ }
1436
+ return;
1437
+ }
1438
+ this._dismissPrompt();
1439
+ },
1289
1440
  // The harness says when stopping stops being possible, because it is the
1290
1441
  // part that knows a tool has been invoked. Once a call is out, offering
1291
1442
  // to stop would be offering to un-send a booking.
@@ -1417,6 +1568,378 @@ class NxisAgentFullscreen extends HTMLElement {
1417
1568
  this._formEl = wrap;
1418
1569
  this._scroll();
1419
1570
  }
1571
+ // ── Action cards ─────────────────────────────────────────────────────────────
1572
+ /** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
1573
+ get _classicPrompts() {
1574
+ return this.getAttribute('prompt-style') === 'classic';
1575
+ }
1576
+ /** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
1577
+ _usesCard(tool) {
1578
+ return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
1579
+ }
1580
+ /** Details are missing: the card asks for them, beside what it already has. */
1581
+ _cardAsk(req) {
1582
+ const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
1583
+ const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
1584
+ this._live.ask = req;
1585
+ this._live.confirm = undefined;
1586
+ item.stage = 'collect';
1587
+ this._paintAction(item);
1588
+ this._saveConversations();
1589
+ }
1590
+ /** Everything is known: the card says what will happen, and waits for Send. */
1591
+ _cardConfirm(req) {
1592
+ const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
1593
+ const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
1594
+ this._live.confirm = req;
1595
+ item.stage = 'review';
1596
+ this._paintAction(item);
1597
+ this._saveConversations();
1598
+ }
1599
+ /**
1600
+ * The live card for a tool's prompt: the one already open for this request,
1601
+ * or a new one, which takes over from the journey card of the same request.
1602
+ */
1603
+ _liveCard(tool, slots, values, needed, destination) {
1604
+ const conv = this._active();
1605
+ const id = currentJourney() ?? `a${Date.now().toString(36)}`;
1606
+ const live = this._live;
1607
+ const described = this._describeAction(tool, values, destination);
1608
+ const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
1609
+ if (live && live.item.id === id && live.tool.name === tool.name) {
1610
+ // The same request, a step on: keep what was typed, and anything the harness has since learned.
1611
+ live.item.fields = fields.map((f) => {
1612
+ const had = live.item.fields.find((o) => o.name === f.name);
1613
+ return had && f.value === '' ? { ...f, value: had.value } : f;
1614
+ });
1615
+ Object.assign(live.item, described.view);
1616
+ live.tool = tool;
1617
+ return live.item;
1618
+ }
1619
+ // A request left waiting is replaced by the new one.
1620
+ if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
1621
+ live.item.stage = 'declined';
1622
+ this._paintAction(live.item);
1623
+ }
1624
+ const item = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
1625
+ conv.items.push(item);
1626
+ this._live = { item, tool, sent: false, declining: false };
1627
+ this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
1628
+ this._watchRunningJourney();
1629
+ if (isHandoffTool(tool.name))
1630
+ void this._refreshHandoffTargets(item);
1631
+ return item;
1632
+ }
1633
+ /**
1634
+ * The slots with each field's options cut to the form the call means: one
1635
+ * business's services rather than every business's.
1636
+ */
1637
+ _narrowed(tool, slots, values) {
1638
+ const options = formOptions(tool, values);
1639
+ return slots.map((slot) => {
1640
+ const only = options[slot.name];
1641
+ if (!only)
1642
+ return slot;
1643
+ // Keep a value the call already gave, even one the form does not list: the form decides.
1644
+ const current = values[slot.name];
1645
+ const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
1646
+ return { ...slot, schema: { ...slot.schema, enum: list } };
1647
+ });
1648
+ }
1649
+ /** Who a request goes to, and how the card words it. */
1650
+ _describeAction(tool, values, destination) {
1651
+ const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
1652
+ if (isHandoffTool(tool.name)) {
1653
+ const name = String(values.target ?? '');
1654
+ const config = this._handoff?.config?.targets[name];
1655
+ const known = this._handoffTargets.get(name);
1656
+ const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
1657
+ const person = (known?.kind ?? config?.kind) !== 'agent';
1658
+ const away = known ? !known.available : false;
1659
+ // "The directory team" leads a line, but mid-sentence it is "the directory team".
1660
+ const inline = label.replace(/^The\s/, 'the ');
1661
+ const about = known?.description ?? config?.description;
1662
+ const status = !known
1663
+ ? undefined
1664
+ : away
1665
+ ? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
1666
+ : person
1667
+ ? known.waitMinutes
1668
+ ? `Available · replies in ~${known.waitMinutes} min`
1669
+ : 'Available'
1670
+ : 'Available';
1671
+ const sentence = !person
1672
+ ? `I'll hand this conversation to ${inline}. They'll take it from here.`
1673
+ : away
1674
+ ? email
1675
+ ? `${label} is away right now. They'll reply to ${email}.`
1676
+ : `${label} is away right now. Leave an email and they'll reply there.`
1677
+ : `I'll pass this to ${inline} so you don't have to repeat yourself.`;
1678
+ return {
1679
+ view: {
1680
+ variant: person ? 'handoff-person' : 'handoff-agent',
1681
+ noun: person ? 'handoff' : 'handover',
1682
+ identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
1683
+ sentence,
1684
+ note: undefined,
1685
+ sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
1686
+ },
1687
+ hidden: new Set(['target']),
1688
+ labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
1689
+ };
1690
+ }
1691
+ const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
1692
+ // Which business or place it is for: the site tool's own chooser, else a field the form names it in.
1693
+ const chooser = chooserOf(tool) ?? Object.keys(values).find((k) => /^(business|company|organi[sz]ation|provider|vendor|store|restaurant|practice|venue)(_name)?$/i.test(k)) ?? null;
1694
+ const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
1695
+ const leaving = destination ?? handoffFor(tool, values)?.label;
1696
+ const name = chosen || leaving || this._pageName();
1697
+ // What the conversation already showed about them: their card from a search.
1698
+ const card = this._conversations
1699
+ .find((c) => c.id === this._activeId)
1700
+ ?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
1701
+ .find((c) => c.title.toLowerCase() === name.toLowerCase());
1702
+ const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
1703
+ .filter(Boolean)
1704
+ .join(' · ');
1705
+ return {
1706
+ view: {
1707
+ variant: 'form',
1708
+ noun,
1709
+ identity: { name, sub },
1710
+ sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
1711
+ note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
1712
+ sendLabel: 'Send',
1713
+ },
1714
+ hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
1715
+ labels: {},
1716
+ };
1717
+ }
1718
+ /** The site's name for this page: its title, without the site's own name after it. */
1719
+ _pageName() {
1720
+ const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
1721
+ return title || 'this page';
1722
+ }
1723
+ /** Read availability for a handoff card, and redraw it once known. */
1724
+ async _refreshHandoffTargets(item) {
1725
+ const targets = await this._handoff?.targets().catch(() => []);
1726
+ for (const t of targets ?? [])
1727
+ this._handoffTargets.set(t.name, t);
1728
+ if (item.stage !== 'collect' && item.stage !== 'review')
1729
+ return;
1730
+ const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
1731
+ Object.assign(item, this._describeAction({ name: item.tool }, values).view);
1732
+ this._paintAction(item);
1733
+ }
1734
+ /** What the live card's buttons do. */
1735
+ _actionHandlers() {
1736
+ return {
1737
+ continue: (values) => {
1738
+ const live = this._live;
1739
+ if (!live)
1740
+ return;
1741
+ if (live.ask && !live.confirm) {
1742
+ live.ask.onComplete(values);
1743
+ return;
1744
+ }
1745
+ // Back from a change, or a card of the widget's own: review again, here.
1746
+ Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
1747
+ live.item.stage = 'review';
1748
+ this._paintAction(live.item);
1749
+ this._saveConversations();
1750
+ },
1751
+ change: () => {
1752
+ const live = this._live;
1753
+ if (!live)
1754
+ return;
1755
+ live.item.fields.forEach((f) => {
1756
+ if (f.group === 'needed' && f.value.trim() !== '')
1757
+ f.group = 'known';
1758
+ });
1759
+ live.sent = false;
1760
+ live.item.stage = 'collect';
1761
+ live.item.open = true;
1762
+ this._paintAction(live.item);
1763
+ this._saveConversations();
1764
+ },
1765
+ send: (values) => {
1766
+ const live = this._live;
1767
+ if (!live)
1768
+ return;
1769
+ live.sent = true;
1770
+ if (live.confirm)
1771
+ live.confirm.onSubmit(values);
1772
+ else if (live.run)
1773
+ void live.run(values);
1774
+ },
1775
+ decline: () => {
1776
+ const live = this._live;
1777
+ if (!live)
1778
+ return;
1779
+ live.declining = true;
1780
+ if (live.confirm)
1781
+ live.confirm.onCancel();
1782
+ else if (live.ask)
1783
+ live.ask.onCancel();
1784
+ // A card the harness is not waiting on declines itself.
1785
+ if (this._live === live) {
1786
+ live.item.stage = 'declined';
1787
+ this._paintAction(live.item);
1788
+ this._live = null;
1789
+ this._saveConversations();
1790
+ }
1791
+ },
1792
+ };
1793
+ }
1794
+ /** Draw a card where it was, or at the end of the thread. */
1795
+ _paintAction(item) {
1796
+ if (!this._els?.thread)
1797
+ return;
1798
+ const conv = this._active();
1799
+ if (!conv?.items.includes(item))
1800
+ return;
1801
+ const live = this._live?.item === item;
1802
+ const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
1803
+ const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
1804
+ if (old)
1805
+ old.replaceWith(fresh);
1806
+ else {
1807
+ this._clearEmpty();
1808
+ this._els.thread.appendChild(fresh);
1809
+ this._scroll();
1810
+ }
1811
+ }
1812
+ /** A request's steps, as its card shows them once sent. */
1813
+ _actionFollows(item, all) {
1814
+ const live = this._live?.item === item ? this._live : null;
1815
+ const failed = [...all].reverse().find((st) => st.status === 'failed');
1816
+ const done = all.some((st) => st.id === 'submit' && st.status === 'done');
1817
+ const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
1818
+ item.steps = all
1819
+ .filter((st) => running.includes(st.id))
1820
+ .map((st) => ({
1821
+ label: st.id === 'fill' && st.status === 'active' && item.variant === 'form'
1822
+ ? `Filling in the ${item.noun}`
1823
+ : st.id === 'submit' && st.status === 'done' && item.variant === 'form'
1824
+ ? 'Sent'
1825
+ : st.label,
1826
+ status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
1827
+ }));
1828
+ if (failed) {
1829
+ if (live?.declining || failed.label === 'Cancelled') {
1830
+ item.stage = 'declined';
1831
+ if (live)
1832
+ this._live = null;
1833
+ }
1834
+ else {
1835
+ item.stage = 'failed';
1836
+ item.message = failed.detail || failed.label;
1837
+ }
1838
+ }
1839
+ else if (done) {
1840
+ item.stage = 'done';
1841
+ if (live)
1842
+ this._live = null;
1843
+ }
1844
+ else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
1845
+ item.stage = 'working';
1846
+ }
1847
+ this._paintAction(item);
1848
+ }
1849
+ /** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
1850
+ _cardLeft(tool, destination) {
1851
+ const live = this._live;
1852
+ if (!live || live.tool.name !== tool.name) {
1853
+ this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
1854
+ return;
1855
+ }
1856
+ live.item.stage = 'left';
1857
+ live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
1858
+ this._live = null;
1859
+ this._paintAction(live.item);
1860
+ this._saveConversations();
1861
+ }
1862
+ /** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
1863
+ _cardDone(tool, values, text) {
1864
+ const conv = this._active();
1865
+ const id = currentJourney();
1866
+ const item = conv?.items.find((it) => it.kind === 'action' && it.id === id && it.tool === tool.name);
1867
+ if (!item || this._classicPrompts)
1868
+ return false;
1869
+ const note = this._buildDetailItem(tool, values, text).note;
1870
+ // The default note says nothing the card's "Sent" does not.
1871
+ item.message = /submitted successfully/.test(note) ? undefined : note;
1872
+ item.stage = 'done';
1873
+ item.fields.forEach((f) => {
1874
+ if (values[f.name] !== undefined)
1875
+ f.value = String(values[f.name]);
1876
+ });
1877
+ if (this._live?.item === item)
1878
+ this._live = null;
1879
+ this._paintAction(item);
1880
+ this._saveConversations();
1881
+ return true;
1882
+ }
1883
+ /** It did not go through: the card says why and offers to change it. False when no card is waiting. */
1884
+ _cardFailed(tool, message) {
1885
+ const live = this._live;
1886
+ if (!live || live.tool.name !== tool.name)
1887
+ return false;
1888
+ live.sent = false;
1889
+ live.item.stage = 'failed';
1890
+ live.item.message = message;
1891
+ this._paintAction(live.item);
1892
+ this._saveConversations();
1893
+ return true;
1894
+ }
1895
+ /**
1896
+ * After a page change: the card of the request that travelled, found by its
1897
+ * journey, now finished or asked about here.
1898
+ */
1899
+ _resumedCard(journeyId) {
1900
+ if (!journeyId || this._classicPrompts)
1901
+ return null;
1902
+ return this._active()?.items.find((it) => it.kind === 'action' && it.id === journeyId) ?? null;
1903
+ }
1904
+ /** Let a resumed card be changed and sent again from this page, with the page's own tool. */
1905
+ _reviveCard(item, tool, values, needed) {
1906
+ const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
1907
+ const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
1908
+ item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
1909
+ this._live = {
1910
+ item,
1911
+ tool,
1912
+ sent: false,
1913
+ declining: false,
1914
+ run: async (vals) => {
1915
+ item.stage = 'working';
1916
+ item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
1917
+ this._paintAction(item);
1918
+ try {
1919
+ const res = await this.toolSource.execTool(tool, tool.name, vals);
1920
+ const text = extractResult(res);
1921
+ if (res?.isError) {
1922
+ this._cardFailed(tool, text || 'That form did not accept the details.');
1923
+ return;
1924
+ }
1925
+ this._active()?.messages.push({
1926
+ role: 'user',
1927
+ content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
1928
+ });
1929
+ this._live = null;
1930
+ const note = this._buildDetailItem(tool, vals, text).note;
1931
+ item.message = /submitted successfully/.test(note) ? undefined : note;
1932
+ item.stage = 'done';
1933
+ item.steps = [];
1934
+ this._paintAction(item);
1935
+ this._saveConversations();
1936
+ }
1937
+ catch (err) {
1938
+ this._cardFailed(tool, String(err?.message || err));
1939
+ }
1940
+ },
1941
+ };
1942
+ }
1420
1943
  // ── Inline generated form (from JSON-Schema) ───────────────────────────────
1421
1944
  _renderForm(tool, args) {
1422
1945
  if (this._useA2ui) {
@@ -1626,6 +2149,19 @@ class NxisAgentFullscreen extends HTMLElement {
1626
2149
  submitBtn.disabled = false;
1627
2150
  return;
1628
2151
  }
2152
+ // A handoff started: what happens next arrives from the target, and the
2153
+ // controller's events draw it. There is nothing to show as "Submitted".
2154
+ const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT];
2155
+ if (started) {
2156
+ this._dismissPrompt();
2157
+ this._active()?.messages.push({
2158
+ role: 'user',
2159
+ content: `[${tool.name}: the conversation was handed to ${started.label ?? 'another agent'}. Their replies go to the visitor directly.]`,
2160
+ });
2161
+ this._saveConversations();
2162
+ this._paintHandoffBar();
2163
+ return;
2164
+ }
1629
2165
  // A call that left for another page has filled in nothing yet, so it
1630
2166
  // must not become a "Submitted" card. The page it is opening finishes
1631
2167
  // the job and draws the card there.
@@ -2293,6 +2829,16 @@ class NxisAgentFullscreen extends HTMLElement {
2293
2829
  * `fallback`. Inexpensive: each agent is a role over the one shared provider,
2294
2830
  * so no additional model is loaded.
2295
2831
  */
2832
+ /** The one agent a page without `agents` runs, on the current provider. */
2833
+ _singleHarness() {
2834
+ return new AgentHarness({
2835
+ provider: this.provider,
2836
+ toolSource: this.toolSource,
2837
+ renderer: this._renderer(),
2838
+ profile: (schema) => this._profileForSchema(schema),
2839
+ onMessages: () => this._saveConversations(),
2840
+ });
2841
+ }
2296
2842
  _buildAgents() {
2297
2843
  if (!this.provider || !this._agentDefs) {
2298
2844
  this._coordinator = null;
@@ -2332,6 +2878,172 @@ class NxisAgentFullscreen extends HTMLElement {
2332
2878
  onRoute: (routed) => this._paintRoute(routed.agent, routed.via),
2333
2879
  });
2334
2880
  }
2881
+ // ── Handoff ────────────────────────────────────────────────────────────────
2882
+ /**
2883
+ * Handing conversations to other agents and to people.
2884
+ *
2885
+ * Set to `{ relay, targets }` to offer `transfer_to_agent` and
2886
+ * `talk_to_a_person` for those targets (see docs/handoff.md). While a
2887
+ * conversation is handed off, what the visitor types goes to the target,
2888
+ * and a bar above the input shows who is listening and the way back.
2889
+ */
2890
+ get handoff() {
2891
+ return this._handoff?.config ?? null;
2892
+ }
2893
+ set handoff(next) {
2894
+ this._offHandoff?.();
2895
+ this._offHandoff = null;
2896
+ this._handoff?.dispose();
2897
+ this._handoff = null;
2898
+ if (!next?.relay || !next.targets) {
2899
+ if (this._els?.thread)
2900
+ this._paintHandoffBar();
2901
+ return;
2902
+ }
2903
+ const controller = new HandoffController(next);
2904
+ const listener = (e) => this._onHandoffEvent(e.detail);
2905
+ controller.addEventListener('handoff', listener);
2906
+ this._offHandoff = () => controller.removeEventListener('handoff', listener);
2907
+ this._handoff = controller;
2908
+ // Tools already connecting or connected: add the handoff tools, and make the next turn wait for them.
2909
+ if (this._connect)
2910
+ this._connect = this._connect.then(() => this._registerHandoffBounded());
2911
+ if (this._els?.thread) {
2912
+ this._paintHandoffBar();
2913
+ if (this._activeId)
2914
+ void controller.resume(this._activeId);
2915
+ }
2916
+ }
2917
+ /** {@link _registerHandoff}, given at most four seconds: a slow relay must not hold up the conversation. */
2918
+ _registerHandoffBounded() {
2919
+ return Promise.race([this._registerHandoff(), new Promise((r) => setTimeout(r, 4000))]).catch((err) => console.warn('[nxis-agent] handoff tools unavailable:', err));
2920
+ }
2921
+ /** Offer the handoff tools on the page's registry. */
2922
+ async _registerHandoff() {
2923
+ const controller = this._handoff;
2924
+ const mc = this.toolSource?.modelContext();
2925
+ if (!controller || !mc)
2926
+ return;
2927
+ const added = await registerHandoffTools(mc, controller, {
2928
+ conversationId: () => this._activeId ?? undefined,
2929
+ transcript: () => this._transcript(),
2930
+ });
2931
+ if (added.length)
2932
+ void this._refreshToolCount();
2933
+ }
2934
+ /** The conversation's last turns, text only, for a target allowed to see them. */
2935
+ _transcript() {
2936
+ const conv = this._active();
2937
+ if (!conv)
2938
+ return [];
2939
+ return conv.items
2940
+ .filter((it) => it.kind === 'msg' && (it.role === 'user' || it.role === 'assistant'))
2941
+ .slice(-10)
2942
+ .map((it) => ({ role: it.role === 'user' ? 'visitor' : 'assistant', text: it.text }));
2943
+ }
2944
+ /** What a handoff reported, into the conversation it belongs to. */
2945
+ _onHandoffEvent(e) {
2946
+ const conv = this._conversations.find((c) => c.id === e.conversationId);
2947
+ if (!conv)
2948
+ return;
2949
+ if (e.type === 'message') {
2950
+ this._appendHandoffItem(conv.id, { kind: 'remote', from: e.from, fromKind: e.fromKind, text: e.text });
2951
+ }
2952
+ else if (e.type === 'handback') {
2953
+ const { outcome, note, references } = e.handBack;
2954
+ const refs = references.map((r) => `${r.label}: ${r.value}`).join(' · ');
2955
+ // The assistant carries on from here, so it learns what happened: the note, and nothing else the
2956
+ // target said. A remote reply is shown to the visitor and never read as an instruction.
2957
+ conv.messages.push({
2958
+ role: 'user',
2959
+ content: `[${e.session.label} handed the conversation back (${outcome})${note ? `: ${note}` : ''}${refs ? `. ${refs}` : ''}]`,
2960
+ });
2961
+ if (refs)
2962
+ this._appendHandoffItem(conv.id, { kind: 'handoff', text: refs, tone: 'done' });
2963
+ }
2964
+ else {
2965
+ const text = handoffLine(e.session);
2966
+ const last = [...conv.items].reverse().find((it) => it.kind === 'handoff');
2967
+ if (text && last?.text !== text) {
2968
+ const tone = e.state === 'failed' ? 'error' : isHandoffOver(e.state) ? 'done' : 'info';
2969
+ this._appendHandoffItem(conv.id, { kind: 'handoff', text, tone });
2970
+ }
2971
+ if (e.state === 'ended' || e.state === 'failed') {
2972
+ conv.messages.push({
2973
+ role: 'user',
2974
+ content: `[The conversation with ${e.session.label} ${e.state === 'failed' ? `failed${e.session.error ? `: ${e.session.error}` : ''}` : 'ended'}. You are handling it again.]`,
2975
+ });
2976
+ }
2977
+ }
2978
+ this._saveConversations();
2979
+ if (conv.id === this._activeId)
2980
+ this._paintHandoffBar();
2981
+ }
2982
+ /** Add a handoff item to a conversation, and draw it when that conversation is on screen. */
2983
+ _appendHandoffItem(conversationId, item) {
2984
+ const conv = this._conversations.find((c) => c.id === conversationId);
2985
+ if (!conv)
2986
+ return;
2987
+ conv.items.push(item);
2988
+ this._saveConversations();
2989
+ if (conversationId === this._activeId && this._els?.thread) {
2990
+ this._paintHandoffItem(item);
2991
+ this._scroll();
2992
+ this._noteThreadGrew();
2993
+ }
2994
+ }
2995
+ _paintHandoffItem(item) {
2996
+ this._clearEmpty();
2997
+ let el;
2998
+ if (item.kind === 'remote') {
2999
+ el = document.createElement('div');
3000
+ el.className = 'remote';
3001
+ const who = document.createElement('div');
3002
+ who.className = 'remote-from';
3003
+ who.textContent = item.fromKind === 'agent' ? `${item.from} · agent` : item.from;
3004
+ el.append(who, this._msgEl('assistant', item.text));
3005
+ }
3006
+ else {
3007
+ el = document.createElement('div');
3008
+ el.className = `handoff-note ${item.tone}`;
3009
+ el.textContent = item.text;
3010
+ }
3011
+ this._els.thread.appendChild(el);
3012
+ return el;
3013
+ }
3014
+ /** The bar above the input: shown while the conversation is handed off. */
3015
+ _paintHandoffBar() {
3016
+ if (!this._els?.handoffBar)
3017
+ return;
3018
+ const session = this._handoff?.active(this._activeId ?? undefined) ?? null;
3019
+ this._els.handoffBar.hidden = !session;
3020
+ const input = this._els.input;
3021
+ if (!session) {
3022
+ input.placeholder = 'Message the agent…';
3023
+ input.setAttribute('aria-label', 'Message the agent');
3024
+ return;
3025
+ }
3026
+ const who = session.operator ?? session.label;
3027
+ const q = session.queue;
3028
+ const inLine = q?.position ? ` · ${ordinal(q.position)} in line${q.waitMinutes ? `, about ${q.waitMinutes} min` : ''}` : ' · waiting in line';
3029
+ const doing = session.state === 'connecting'
3030
+ ? ' · connecting…'
3031
+ : session.state === 'queued'
3032
+ ? inLine
3033
+ : session.state === 'awaiting-visitor'
3034
+ ? ' · waiting for your reply'
3035
+ : '';
3036
+ this._els.handoffLabel.textContent = `Talking with ${who}${doing}`;
3037
+ input.placeholder = `Message ${who}…`;
3038
+ input.setAttribute('aria-label', `Message ${who}`);
3039
+ }
3040
+ /** "Back to the assistant": end the handoff for this conversation. */
3041
+ async _endHandoff() {
3042
+ if (!this._activeId || !this._handoff)
3043
+ return;
3044
+ await this._handoff.end(this._activeId);
3045
+ this._els.input.focus();
3046
+ }
2335
3047
  /** Zero when the provider cannot count, which hides the token metrics. */
2336
3048
  async countTokens(text) {
2337
3049
  return (await this.provider.countTokens?.(text)) ?? 0;
@@ -2424,6 +3136,42 @@ class NxisAgentFullscreen extends HTMLElement {
2424
3136
  });
2425
3137
  if (outcome.status !== 'none')
2426
3138
  this._setStatus('');
3139
+ // The card that travelled with the request finishes here, when there is one.
3140
+ const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
3141
+ if (card && outcome.status !== 'none') {
3142
+ switch (outcome.status) {
3143
+ case 'no-registry':
3144
+ case 'no-tool':
3145
+ card.stage = 'failed';
3146
+ card.message = `I couldn't find the ${card.noun} on this page, so nothing was sent. Ask me again and I'll fill it in from here.`;
3147
+ break;
3148
+ case 'incomplete':
3149
+ this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
3150
+ card.stage = 'collect';
3151
+ card.message = undefined;
3152
+ break;
3153
+ case 'rejected':
3154
+ case 'failed':
3155
+ this._reviveCard(card, outcome.tool, outcome.pending.values, []);
3156
+ card.stage = 'failed';
3157
+ card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
3158
+ break;
3159
+ case 'submitted': {
3160
+ const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
3161
+ card.message = /submitted successfully/.test(note) ? undefined : note;
3162
+ card.stage = 'done';
3163
+ this._active()?.messages.push({
3164
+ role: 'user',
3165
+ content: `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
3166
+ 'Keep these unless I ask to change them.',
3167
+ });
3168
+ break;
3169
+ }
3170
+ }
3171
+ this._paintAction(card);
3172
+ this._saveConversations();
3173
+ return;
3174
+ }
2427
3175
  switch (outcome.status) {
2428
3176
  case 'none':
2429
3177
  return;
@@ -2511,7 +3259,10 @@ class NxisAgentFullscreen extends HTMLElement {
2511
3259
  void this._renderTools();
2512
3260
  });
2513
3261
  })
2514
- .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
3262
+ .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
3263
+ // Part of connecting, so the first turn waits for the handoff tools: a turn that ran without
3264
+ // them could only claim, in words, to be handing the visitor over.
3265
+ .then(() => this._registerHandoffBounded());
2515
3266
  return this._connect;
2516
3267
  }
2517
3268
  // ── Tools menu ─────────────────────────────────────────────────────────────
@@ -2742,6 +3493,9 @@ class NxisAgentFullscreen extends HTMLElement {
2742
3493
  if (this._els?.thread) {
2743
3494
  this.provider = next ?? this.provider;
2744
3495
  this._paintProvenance();
3496
+ // Both the single agent and any configured ones: a page without `agents` kept the old
3497
+ // provider's harness, and answered with the model it had just replaced.
3498
+ this._harness = this._singleHarness();
2745
3499
  this._buildAgents();
2746
3500
  }
2747
3501
  }
@@ -2945,6 +3699,9 @@ class NxisAgentFullscreen extends HTMLElement {
2945
3699
  /* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
2946
3700
  ${A2UI_THEME_CSS}
2947
3701
 
3702
+ /* The action card: forms and handoffs as one message. */
3703
+ ${ACTION_CARD_CSS}
3704
+
2948
3705
  /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
2949
3706
  .launcher {
2950
3707
  position: fixed; right: 20px; bottom: 20px; z-index: 9998;
@@ -3396,6 +4153,23 @@ class NxisAgentFullscreen extends HTMLElement {
3396
4153
  padding: 2px 0 6px;
3397
4154
  letter-spacing: .01em;
3398
4155
  }
4156
+
4157
+ /* Handoffs. A target's message is a bubble with its name above; where the
4158
+ handoff stands is a quiet centred line; the bar says who is listening. */
4159
+ .remote { align-self: flex-start; display: flex; flex-direction: column; gap: 4px; max-width: 76%; }
4160
+ .remote .msg { max-width: 100%; }
4161
+ .remote-from { font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; color: var(--ink-3); padding-left: 4px; }
4162
+ .handoff-note { align-self: center; font-size: 12px; color: var(--ink-3); padding: 2px 12px 6px; text-align: center; letter-spacing: .01em; }
4163
+ .handoff-note.done { color: var(--green-strong); }
4164
+ .handoff-note.error { color: var(--red); }
4165
+ .handoff-bar { display: flex; align-items: center; gap: 8px; margin: 0 20px 6px; padding: 6px 6px 6px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--green-tint); color: var(--ink); font-family: var(--sans); font-size: 12.5px; }
4166
+ .handoff-bar[hidden] { display: none; }
4167
+ .handoff-bar .hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex: none; }
4168
+ .handoff-bar .hb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
4169
+ .handoff-bar .hb-back { flex: none; border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); border-radius: var(--radius-pill); padding: 4px 11px; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
4170
+ .handoff-bar .hb-back:hover { border-color: var(--ink-3); }
4171
+ .handoff-bar .hb-back:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
4172
+ .overlay.window .handoff-bar, .overlay.inline .handoff-bar { margin: 0 14px 6px; }
3399
4173
  .composer .go svg { width: 17px; height: 17px; }
3400
4174
 
3401
4175
  /* Attribution. Quiet enough to sit under a customer's own chat surface
@@ -3609,6 +4383,10 @@ class NxisAgentFullscreen extends HTMLElement {
3609
4383
  </button>
3610
4384
  <div class="thread"></div>
3611
4385
  <div class="statusline"></div>
4386
+ <div class="handoff-bar" role="status" hidden>
4387
+ <span class="hb-dot" aria-hidden="true"></span><span class="hb-label"></span>
4388
+ <button class="hb-back" type="button">Back to the assistant</button>
4389
+ </div>
3612
4390
  <div class="composer">
3613
4391
  <div class="cbox">
3614
4392
  <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>