@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
@@ -41,18 +41,40 @@ import {
41
41
  import type { ModelProvider } from '../providers/types.js';
42
42
  import { connectTools } from '../harness/tools/source.js';
43
43
  import {
44
+ chooserOf,
45
+ formOptions,
44
46
  handoffFor,
45
47
  isSiteTool,
46
48
  withoutSiteSuffix,
47
49
  HANDOFF_RESULT,
48
50
  } from '../harness/tools/action-tools.js';
51
+ import { resolveSlots, type Slot } from '../harness/slots.js';
52
+ import { isHandoffTool } from '../handoff/tools.js';
53
+ import {
54
+ actionCard,
55
+ fieldLabel,
56
+ fieldsFromSlots,
57
+ ACTION_CARD_CSS,
58
+ type ActionHandlers,
59
+ type ActionItem,
60
+ type ActionStep,
61
+ } from './action-card.js';
49
62
  import { resumePendingAction } from '../harness/tools/resume.js';
50
63
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
51
64
  import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
52
65
  import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
53
- import { onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
66
+ import { currentJourney, onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
54
67
  import { viewJourney } from './journey.js';
55
68
  import { AgentCoordinator } from '../harness/router/coordinator.js';
69
+ import {
70
+ HandoffController,
71
+ isHandoffOver,
72
+ type HandoffConfig,
73
+ type HandoffEvent,
74
+ type HandoffSession,
75
+ type HandoffTarget,
76
+ } from '../handoff/controller.js';
77
+ import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
56
78
  import type { RouteRule } from '../harness/router/rules.js';
57
79
  import type { AskRequest, ConfirmRequest, HarnessRenderer, StreamingBubble as HStreamingBubble } from '../harness/renderer.js';
58
80
  import {
@@ -143,6 +165,12 @@ const fmt = (n: number) => n.toLocaleString();
143
165
  * be disabled under `prefers-reduced-motion` while the bubble still reads as
144
166
  * pending. The label and byte spans are filled per phase and hidden while empty.
145
167
  */
168
+ /**
169
+ * How long the running-step line stays once the conversation moves on past
170
+ * its card. A glance's worth: long enough to notice and open it.
171
+ */
172
+ const JSTATUS_TIMEOUT_MS = 10_000;
173
+
146
174
  const THINKING_MARKUP =
147
175
  '<span class="think-row">' +
148
176
  '<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
@@ -319,7 +347,44 @@ type ThreadItem =
319
347
  * so a second request does not erase the first, and so a card survives the
320
348
  * page change a cross-page action makes.
321
349
  */
322
- | { kind: 'journey'; id: string; steps: AgentStep[] };
350
+ | { kind: 'journey'; id: string; steps: AgentStep[] }
351
+ /** Something a handoff target said: an agent, or a person at a desk. */
352
+ | { kind: 'remote'; from: string; fromKind: 'agent' | 'person'; text: string }
353
+ /** Where a handoff stands, as a quiet line in the thread. */
354
+ | { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' }
355
+ /** A form or a handoff, asked for, confirmed and sent in one card. See ./action-card.ts. */
356
+ | ActionItem;
357
+
358
+ /**
359
+ * A handoff's state as a line in the thread, or '' for a state the bar alone
360
+ * shows.
361
+ */
362
+ function handoffLine(session: HandoffSession): string {
363
+ const who = session.label;
364
+ switch (session.state) {
365
+ case 'connecting':
366
+ return `Connecting you to ${who}…`;
367
+ case 'queued':
368
+ // The bar says where they are in line; a desk usually says it in words too.
369
+ return '';
370
+ case 'active':
371
+ return session.operator && session.operator !== who ? `${session.operator} has joined` : `You're talking with ${who}`;
372
+ case 'awaiting-visitor':
373
+ return '';
374
+ case 'handed-back':
375
+ return 'Back with the assistant';
376
+ case 'ended':
377
+ return `${who} has finished. Back with the assistant.`;
378
+ case 'failed':
379
+ return `${session.error ?? `${who} couldn't continue.`} Back with the assistant.`;
380
+ }
381
+ }
382
+
383
+ function ordinal(n: number): string {
384
+ const s = ['th', 'st', 'nd', 'rd'];
385
+ const v = n % 100;
386
+ return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
387
+ }
323
388
 
324
389
  interface Conversation {
325
390
  id: string;
@@ -344,6 +409,10 @@ interface FullscreenEls {
344
409
  bar: HTMLElement;
345
410
  /** Names the running step while its card is scrolled out of view. */
346
411
  jstatus: HTMLButtonElement;
412
+ /** Shown while the conversation is handed off: who to, and the way back. */
413
+ handoffBar: HTMLElement;
414
+ handoffLabel: HTMLElement;
415
+ handoffBack: HTMLButtonElement;
347
416
  input: HTMLTextAreaElement;
348
417
  go: HTMLButtonElement;
349
418
  /** Opens the list of tools the agent can use. */
@@ -410,6 +479,12 @@ class NxisAgentFullscreen extends HTMLElement {
410
479
  };
411
480
  /** Watches whether the running journey's card is on screen. */
412
481
  private _journeyObserver: IntersectionObserver | null = null;
482
+ /** The journey the status line is about, while one is running. */
483
+ private _jstatusJourney: string | null = null;
484
+ /** Journeys whose status line was opened once or timed out: it does not come back. */
485
+ private _jstatusDismissed = new Set<string>();
486
+ /** Dismisses the status line a while after the thread moves on past the card. */
487
+ private _jstatusTimer: ReturnType<typeof setTimeout> | null = null;
413
488
  /** The turn loop. This element is only its renderer. */
414
489
  private _harness!: AgentHarness;
415
490
  /** Built only when the host defines agents — otherwise there is one harness. */
@@ -419,6 +494,22 @@ class NxisAgentFullscreen extends HTMLElement {
419
494
  private _fallbackAgent = '';
420
495
  /** The live ask/confirm surface, if one is open. */
421
496
  private _prompt: MountedSurface | null = null;
497
+ /**
498
+ * The action card still waiting on the visitor or a result, and what it can
499
+ * do: the harness's ask or confirm request, or, after a page change, a call
500
+ * of the widget's own.
501
+ */
502
+ private _live: {
503
+ item: ActionItem;
504
+ tool: ModelContextToolInfo;
505
+ ask?: AskRequest;
506
+ confirm?: ConfirmRequest;
507
+ run?: (values: Record<string, unknown>) => Promise<void>;
508
+ sent: boolean;
509
+ declining: boolean;
510
+ } | null = null;
511
+ /** Handoff targets as the relay last reported them, for a card's availability line. */
512
+ private _handoffTargets = new Map<string, HandoffTarget>();
422
513
  private _profile: Record<string, string>;
423
514
  private _persistPrefs = false;
424
515
  private _systemPrompt: string = DEFAULT_SYSTEM_PROMPT;
@@ -455,6 +546,9 @@ class NxisAgentFullscreen extends HTMLElement {
455
546
  private toolSource!: WebMcpTools;
456
547
  /** A provider the host supplied, used instead of the local model. */
457
548
  private _provider: ModelProvider | null = null;
549
+ /** Runs handoffs to agents and people, when the host configured any. */
550
+ private _handoff: HandoffController | null = null;
551
+ private _offHandoff: (() => void) | null = null;
458
552
 
459
553
  constructor() {
460
554
  super();
@@ -489,6 +583,9 @@ class NxisAgentFullscreen extends HTMLElement {
489
583
  status: root.querySelector('.statusline')!,
490
584
  bar: root.querySelector('.prog > i')!,
491
585
  jstatus: root.querySelector('.jstatus')!,
586
+ handoffBar: root.querySelector('.handoff-bar')!,
587
+ handoffLabel: root.querySelector('.handoff-bar .hb-label')!,
588
+ handoffBack: root.querySelector('.handoff-bar .hb-back')!,
492
589
  input: root.querySelector('.composer textarea')!,
493
590
  go: root.querySelector('.composer .go')!,
494
591
  toolsBtn: root.querySelector('.tools-btn')!,
@@ -526,13 +623,7 @@ class NxisAgentFullscreen extends HTMLElement {
526
623
 
527
624
  // The element renders; the harness decides. Everything from choosing a
528
625
  // tool to running it happens behind harness.turn().
529
- this._harness = new AgentHarness({
530
- provider: this.provider,
531
- toolSource: this.toolSource,
532
- renderer: this._renderer(),
533
- profile: (schema) => this._profileForSchema(schema as JsonSchema),
534
- onMessages: () => this._saveConversations(),
535
- });
626
+ this._harness = this._singleHarness();
536
627
 
537
628
  // Preferences are supplied by the host page through `element.preferences`.
538
629
  // Anything a previous build persisted is picked up so upgrading does not
@@ -567,6 +658,8 @@ class NxisAgentFullscreen extends HTMLElement {
567
658
  this._els.jstatus.addEventListener('click', () => {
568
659
  const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
569
660
  cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
661
+ // One look is what it is for; after that it would only be in the way.
662
+ this._dismissJourneyStatus();
570
663
  });
571
664
 
572
665
  // Whoever builds the tools, a call that leaves this page reports back to
@@ -579,6 +672,10 @@ class NxisAgentFullscreen extends HTMLElement {
579
672
  this._resumePendingVisit();
580
673
 
581
674
  this._els.launcher.addEventListener('click', () => this._open());
675
+ this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
676
+ // A handoff left open on the last page carries on here.
677
+ this._paintHandoffBar();
678
+ if (this._activeId) void this._handoff?.resume(this._activeId);
582
679
  this._els.close.addEventListener('click', () => {
583
680
  this._els.overlay.classList.remove('show');
584
681
  // A remote turn can outlive the panel, and its answer would go unread.
@@ -658,6 +755,9 @@ class NxisAgentFullscreen extends HTMLElement {
658
755
  this._offJourney = null;
659
756
  this._journeyObserver?.disconnect();
660
757
  this._journeyObserver = null;
758
+ this._clearJourneyStatusTimer();
759
+ // Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
760
+ this._handoff?.dispose();
661
761
  }
662
762
 
663
763
  /** Programmatic open (used by pages that dock the agent full-page). */
@@ -729,6 +829,7 @@ class NxisAgentFullscreen extends HTMLElement {
729
829
  this._renderHistory();
730
830
  this._renderThread();
731
831
  this._saveConversations();
832
+ this._paintHandoffBar();
732
833
  }
733
834
  if (this._els.input) this._els.input.focus();
734
835
  }
@@ -742,6 +843,8 @@ class NxisAgentFullscreen extends HTMLElement {
742
843
  this._setNav(false);
743
844
  this._renderHistory();
744
845
  this._renderThread();
846
+ this._paintHandoffBar();
847
+ void this._handoff?.resume(id);
745
848
  }
746
849
 
747
850
  private _renderHistory(): void {
@@ -841,7 +944,11 @@ class NxisAgentFullscreen extends HTMLElement {
841
944
  else if (it.kind === 'detail') this._els.thread.appendChild(this._detailEl(it));
842
945
  else if (it.kind === 'results') this._els.thread.appendChild(this._resultsEl(it));
843
946
  else if (it.kind === 'metrics') this._els.thread.appendChild(this._metricsEl(it));
844
- else if (it.kind === 'journey') this._els.thread.appendChild(this._journeyEl(it));
947
+ else if (it.kind === 'journey') {
948
+ // A request drawn as an action card shows its own steps.
949
+ if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id)) this._els.thread.appendChild(this._journeyEl(it));
950
+ } else if (it.kind === 'action') this._paintAction(it);
951
+ else if (it.kind === 'remote' || it.kind === 'handoff') this._paintHandoffItem(it);
845
952
  }
846
953
  this._scroll();
847
954
  this._watchRunningJourney();
@@ -1225,6 +1332,15 @@ class NxisAgentFullscreen extends HTMLElement {
1225
1332
  conv.items.push(item);
1226
1333
  }
1227
1334
 
1335
+ // A request with an action card: the steps belong in the card.
1336
+ const action = conv.items.find((it): it is ActionItem => it.kind === 'action' && it.id === view.id);
1337
+ if (action) {
1338
+ this._actionFollows(action, all);
1339
+ this._watchRunningJourney();
1340
+ this._saveConversations();
1341
+ return;
1342
+ }
1343
+
1228
1344
  if (conv.id === this._activeId && this._els?.thread) {
1229
1345
  const fresh = this._journeyEl(item);
1230
1346
  const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
@@ -1256,7 +1372,12 @@ class NxisAgentFullscreen extends HTMLElement {
1256
1372
  if (!bar) return;
1257
1373
  const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
1258
1374
  const card = cards[cards.length - 1];
1259
- if (!card || typeof IntersectionObserver === 'undefined') {
1375
+ const id = card?.dataset.journey ?? null;
1376
+ if (id !== this._jstatusJourney) {
1377
+ this._clearJourneyStatusTimer();
1378
+ this._jstatusJourney = id;
1379
+ }
1380
+ if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
1260
1381
  bar.hidden = true;
1261
1382
  return;
1262
1383
  }
@@ -1270,6 +1391,36 @@ class NxisAgentFullscreen extends HTMLElement {
1270
1391
  this._journeyObserver.observe(card);
1271
1392
  }
1272
1393
 
1394
+ /**
1395
+ * The thread grew: start the clock on the status line.
1396
+ *
1397
+ * A journey can stay running while the conversation goes on without it, and a
1398
+ * line that spins through all of that says nothing. So once new messages
1399
+ * arrive, the line gets a while longer, then goes.
1400
+ */
1401
+ private _noteThreadGrew(): void {
1402
+ const id = this._jstatusJourney;
1403
+ if (!id || this._jstatusTimer || this._jstatusDismissed.has(id)) return;
1404
+ this._jstatusTimer = setTimeout(() => {
1405
+ this._jstatusTimer = null;
1406
+ this._dismissJourneyStatus();
1407
+ }, JSTATUS_TIMEOUT_MS);
1408
+ }
1409
+
1410
+ /** Hide the status line for the running journey, for good. */
1411
+ private _dismissJourneyStatus(): void {
1412
+ this._clearJourneyStatusTimer();
1413
+ if (this._jstatusJourney) this._jstatusDismissed.add(this._jstatusJourney);
1414
+ this._journeyObserver?.disconnect();
1415
+ this._journeyObserver = null;
1416
+ if (this._els?.jstatus) this._els.jstatus.hidden = true;
1417
+ }
1418
+
1419
+ private _clearJourneyStatusTimer(): void {
1420
+ if (this._jstatusTimer) clearTimeout(this._jstatusTimer);
1421
+ this._jstatusTimer = null;
1422
+ }
1423
+
1273
1424
  private _paintToolResult(name: string, text: string, isError: boolean): HTMLElement {
1274
1425
  this._clearEmpty();
1275
1426
  const el = this._toolEl(name, text, isError);
@@ -1328,6 +1479,7 @@ class NxisAgentFullscreen extends HTMLElement {
1328
1479
  const el = this._paintMsg(role, text, conv.items.length - 1);
1329
1480
  this._saveConversations();
1330
1481
  this._scroll();
1482
+ this._noteThreadGrew();
1331
1483
  return el;
1332
1484
  }
1333
1485
 
@@ -1348,6 +1500,7 @@ class NxisAgentFullscreen extends HTMLElement {
1348
1500
  // without the focus moving to it.
1349
1501
  el.setAttribute('aria-live', 'polite');
1350
1502
  this._scroll();
1503
+ this._noteThreadGrew();
1351
1504
 
1352
1505
  const setLabel = (label?: string) => {
1353
1506
  const node = el.querySelector('.think-label');
@@ -1438,6 +1591,18 @@ class NxisAgentFullscreen extends HTMLElement {
1438
1591
  this._saveConversations();
1439
1592
  }
1440
1593
 
1594
+ // Handed off: this message is for the target, not the assistant.
1595
+ const handed = this._handoff?.active(this._activeId ?? undefined);
1596
+ if (handed && this._activeId) {
1597
+ try {
1598
+ await this._handoff!.send(this._activeId, goal);
1599
+ } catch (err) {
1600
+ const why = this._handoff!.session(this._activeId)?.error ?? String((err as Error)?.message || err);
1601
+ this._appendHandoffItem(this._activeId, { kind: 'handoff', text: why, tone: 'error' });
1602
+ }
1603
+ return;
1604
+ }
1605
+
1441
1606
  this._turnActive = true;
1442
1607
  this._setStoppable(true);
1443
1608
  let settle!: () => void;
@@ -1477,8 +1642,10 @@ class NxisAgentFullscreen extends HTMLElement {
1477
1642
  message: (role, text) => this._appendMsg(role, text),
1478
1643
  streaming: (): HStreamingBubble => this._appendStreaming(),
1479
1644
  status: (text, isError) => this._setStatus(text, isError),
1480
- ask: (req) => this._renderAsk(req),
1481
- confirm: (req) => this._renderConfirm(req),
1645
+ inlinePrompts: !this._classicPrompts,
1646
+ ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
1647
+ confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
1648
+ left: (tool, destination) => this._cardLeft(tool, destination),
1482
1649
  results: (tool, res) => {
1483
1650
  const conv = this._active();
1484
1651
  const item = this._buildResultsItem(tool, res);
@@ -1500,6 +1667,7 @@ class NxisAgentFullscreen extends HTMLElement {
1500
1667
  this._scroll();
1501
1668
  },
1502
1669
  detail: (tool, values, text) => {
1670
+ if (this._cardDone(tool, values, text)) return;
1503
1671
  const conv = this._active();
1504
1672
  const item = this._buildDetailItem(tool, values, text);
1505
1673
  conv?.items.push(item);
@@ -1523,8 +1691,23 @@ class NxisAgentFullscreen extends HTMLElement {
1523
1691
  this._saveConversations();
1524
1692
  this._scroll();
1525
1693
  },
1526
- toolError: (tool, message) => this._paintToolResult(tool.name, message, true),
1527
- dismissPrompt: () => this._dismissPrompt(),
1694
+ toolError: (tool, message) => {
1695
+ if (this._cardFailed(tool, message)) return;
1696
+ this._paintToolResult(tool.name, message, true);
1697
+ },
1698
+ dismissPrompt: () => {
1699
+ // A sent card stays, and shows the steps; one never sent says so.
1700
+ const live = this._live;
1701
+ if (live) {
1702
+ if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
1703
+ live.item.stage = 'working';
1704
+ this._paintAction(live.item);
1705
+ this._saveConversations();
1706
+ }
1707
+ return;
1708
+ }
1709
+ this._dismissPrompt();
1710
+ },
1528
1711
  // The harness says when stopping stops being possible, because it is the
1529
1712
  // part that knows a tool has been invoked. Once a call is out, offering
1530
1713
  // to stop would be offering to un-send a booking.
@@ -1673,6 +1856,390 @@ class NxisAgentFullscreen extends HTMLElement {
1673
1856
  this._scroll();
1674
1857
  }
1675
1858
 
1859
+ // ── Action cards ─────────────────────────────────────────────────────────────
1860
+
1861
+ /** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
1862
+ private get _classicPrompts(): boolean {
1863
+ return this.getAttribute('prompt-style') === 'classic';
1864
+ }
1865
+
1866
+ /** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
1867
+ private _usesCard(tool: ModelContextToolInfo): boolean {
1868
+ return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
1869
+ }
1870
+
1871
+ /** Details are missing: the card asks for them, beside what it already has. */
1872
+ private _cardAsk(req: AskRequest): void {
1873
+ const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
1874
+ const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
1875
+ this._live!.ask = req;
1876
+ this._live!.confirm = undefined;
1877
+ item.stage = 'collect';
1878
+ this._paintAction(item);
1879
+ this._saveConversations();
1880
+ }
1881
+
1882
+ /** Everything is known: the card says what will happen, and waits for Send. */
1883
+ private _cardConfirm(req: ConfirmRequest): void {
1884
+ const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
1885
+ const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
1886
+ this._live!.confirm = req;
1887
+ item.stage = 'review';
1888
+ this._paintAction(item);
1889
+ this._saveConversations();
1890
+ }
1891
+
1892
+ /**
1893
+ * The live card for a tool's prompt: the one already open for this request,
1894
+ * or a new one, which takes over from the journey card of the same request.
1895
+ */
1896
+ private _liveCard(
1897
+ tool: ModelContextToolInfo,
1898
+ slots: Slot[],
1899
+ values: Record<string, unknown>,
1900
+ needed: Set<string>,
1901
+ destination?: string,
1902
+ ): ActionItem {
1903
+ const conv = this._active()!;
1904
+ const id = currentJourney() ?? `a${Date.now().toString(36)}`;
1905
+ const live = this._live;
1906
+ const described = this._describeAction(tool, values, destination);
1907
+ const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
1908
+
1909
+ if (live && live.item.id === id && live.tool.name === tool.name) {
1910
+ // The same request, a step on: keep what was typed, and anything the harness has since learned.
1911
+ live.item.fields = fields.map((f) => {
1912
+ const had = live.item.fields.find((o) => o.name === f.name);
1913
+ return had && f.value === '' ? { ...f, value: had.value } : f;
1914
+ });
1915
+ Object.assign(live.item, described.view);
1916
+ live.tool = tool;
1917
+ return live.item;
1918
+ }
1919
+
1920
+ // A request left waiting is replaced by the new one.
1921
+ if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
1922
+ live.item.stage = 'declined';
1923
+ this._paintAction(live.item);
1924
+ }
1925
+
1926
+ const item: ActionItem = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
1927
+ conv.items.push(item);
1928
+ this._live = { item, tool, sent: false, declining: false };
1929
+ this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
1930
+ this._watchRunningJourney();
1931
+ if (isHandoffTool(tool.name)) void this._refreshHandoffTargets(item);
1932
+ return item;
1933
+ }
1934
+
1935
+ /**
1936
+ * The slots with each field's options cut to the form the call means: one
1937
+ * business's services rather than every business's.
1938
+ */
1939
+ private _narrowed(tool: ModelContextToolInfo, slots: Slot[], values: Record<string, unknown>): Slot[] {
1940
+ const options = formOptions(tool, values);
1941
+ return slots.map((slot) => {
1942
+ const only = options[slot.name];
1943
+ if (!only) return slot;
1944
+ // Keep a value the call already gave, even one the form does not list: the form decides.
1945
+ const current = values[slot.name];
1946
+ const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
1947
+ return { ...slot, schema: { ...slot.schema, enum: list } };
1948
+ });
1949
+ }
1950
+
1951
+ /** Who a request goes to, and how the card words it. */
1952
+ private _describeAction(
1953
+ tool: ModelContextToolInfo,
1954
+ values: Record<string, unknown>,
1955
+ destination?: string,
1956
+ ): {
1957
+ view: Pick<ActionItem, 'variant' | 'noun' | 'identity' | 'sentence' | 'note' | 'sendLabel'>;
1958
+ hidden: Set<string>;
1959
+ labels: Record<string, string>;
1960
+ } {
1961
+ const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
1962
+
1963
+ if (isHandoffTool(tool.name)) {
1964
+ const name = String(values.target ?? '');
1965
+ const config = this._handoff?.config?.targets[name];
1966
+ const known = this._handoffTargets.get(name);
1967
+ const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
1968
+ const person = (known?.kind ?? config?.kind) !== 'agent';
1969
+ const away = known ? !known.available : false;
1970
+ // "The directory team" leads a line, but mid-sentence it is "the directory team".
1971
+ const inline = label.replace(/^The\s/, 'the ');
1972
+ const about = known?.description ?? config?.description;
1973
+ const status = !known
1974
+ ? undefined
1975
+ : away
1976
+ ? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
1977
+ : person
1978
+ ? known.waitMinutes
1979
+ ? `Available · replies in ~${known.waitMinutes} min`
1980
+ : 'Available'
1981
+ : 'Available';
1982
+ const sentence = !person
1983
+ ? `I'll hand this conversation to ${inline}. They'll take it from here.`
1984
+ : away
1985
+ ? email
1986
+ ? `${label} is away right now. They'll reply to ${email}.`
1987
+ : `${label} is away right now. Leave an email and they'll reply there.`
1988
+ : `I'll pass this to ${inline} so you don't have to repeat yourself.`;
1989
+ return {
1990
+ view: {
1991
+ variant: person ? 'handoff-person' : 'handoff-agent',
1992
+ noun: person ? 'handoff' : 'handover',
1993
+ identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
1994
+ sentence,
1995
+ note: undefined,
1996
+ sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
1997
+ },
1998
+ hidden: new Set(['target']),
1999
+ labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
2000
+ };
2001
+ }
2002
+
2003
+ const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
2004
+ // Which business or place it is for: the site tool's own chooser, else a field the form names it in.
2005
+ 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;
2006
+ const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
2007
+ const leaving = destination ?? handoffFor(tool, values)?.label;
2008
+ const name = chosen || leaving || this._pageName();
2009
+ // What the conversation already showed about them: their card from a search.
2010
+ const card = this._conversations
2011
+ .find((c) => c.id === this._activeId)
2012
+ ?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
2013
+ .find((c) => c.title.toLowerCase() === name.toLowerCase());
2014
+ const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
2015
+ .filter(Boolean)
2016
+ .join(' · ');
2017
+ return {
2018
+ view: {
2019
+ variant: 'form',
2020
+ noun,
2021
+ identity: { name, sub },
2022
+ sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
2023
+ note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
2024
+ sendLabel: 'Send',
2025
+ },
2026
+ hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
2027
+ labels: {},
2028
+ };
2029
+ }
2030
+
2031
+ /** The site's name for this page: its title, without the site's own name after it. */
2032
+ private _pageName(): string {
2033
+ const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
2034
+ return title || 'this page';
2035
+ }
2036
+
2037
+ /** Read availability for a handoff card, and redraw it once known. */
2038
+ private async _refreshHandoffTargets(item: ActionItem): Promise<void> {
2039
+ const targets = await this._handoff?.targets().catch(() => []);
2040
+ for (const t of targets ?? []) this._handoffTargets.set(t.name, t);
2041
+ if (item.stage !== 'collect' && item.stage !== 'review') return;
2042
+ const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
2043
+ Object.assign(item, this._describeAction({ name: item.tool } as ModelContextToolInfo, values).view);
2044
+ this._paintAction(item);
2045
+ }
2046
+
2047
+ /** What the live card's buttons do. */
2048
+ private _actionHandlers(): ActionHandlers {
2049
+ return {
2050
+ continue: (values) => {
2051
+ const live = this._live;
2052
+ if (!live) return;
2053
+ if (live.ask && !live.confirm) {
2054
+ live.ask.onComplete(values);
2055
+ return;
2056
+ }
2057
+ // Back from a change, or a card of the widget's own: review again, here.
2058
+ Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
2059
+ live.item.stage = 'review';
2060
+ this._paintAction(live.item);
2061
+ this._saveConversations();
2062
+ },
2063
+ change: () => {
2064
+ const live = this._live;
2065
+ if (!live) return;
2066
+ live.item.fields.forEach((f) => {
2067
+ if (f.group === 'needed' && f.value.trim() !== '') f.group = 'known';
2068
+ });
2069
+ live.sent = false;
2070
+ live.item.stage = 'collect';
2071
+ live.item.open = true;
2072
+ this._paintAction(live.item);
2073
+ this._saveConversations();
2074
+ },
2075
+ send: (values) => {
2076
+ const live = this._live;
2077
+ if (!live) return;
2078
+ live.sent = true;
2079
+ if (live.confirm) live.confirm.onSubmit(values);
2080
+ else if (live.run) void live.run(values);
2081
+ },
2082
+ decline: () => {
2083
+ const live = this._live;
2084
+ if (!live) return;
2085
+ live.declining = true;
2086
+ if (live.confirm) live.confirm.onCancel();
2087
+ else if (live.ask) live.ask.onCancel();
2088
+ // A card the harness is not waiting on declines itself.
2089
+ if (this._live === live) {
2090
+ live.item.stage = 'declined';
2091
+ this._paintAction(live.item);
2092
+ this._live = null;
2093
+ this._saveConversations();
2094
+ }
2095
+ },
2096
+ };
2097
+ }
2098
+
2099
+ /** Draw a card where it was, or at the end of the thread. */
2100
+ private _paintAction(item: ActionItem): void {
2101
+ if (!this._els?.thread) return;
2102
+ const conv = this._active();
2103
+ if (!conv?.items.includes(item)) return;
2104
+ const live = this._live?.item === item;
2105
+ const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
2106
+ const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
2107
+ if (old) old.replaceWith(fresh);
2108
+ else {
2109
+ this._clearEmpty();
2110
+ this._els.thread.appendChild(fresh);
2111
+ this._scroll();
2112
+ }
2113
+ }
2114
+
2115
+ /** A request's steps, as its card shows them once sent. */
2116
+ private _actionFollows(item: ActionItem, all: AgentStep[]): void {
2117
+ const live = this._live?.item === item ? this._live : null;
2118
+ const failed = [...all].reverse().find((st) => st.status === 'failed');
2119
+ const done = all.some((st) => st.id === 'submit' && st.status === 'done');
2120
+ const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
2121
+ item.steps = all
2122
+ .filter((st) => running.includes(st.id))
2123
+ .map((st): ActionStep => ({
2124
+ label:
2125
+ st.id === 'fill' && st.status === 'active' && item.variant === 'form'
2126
+ ? `Filling in the ${item.noun}`
2127
+ : st.id === 'submit' && st.status === 'done' && item.variant === 'form'
2128
+ ? 'Sent'
2129
+ : st.label,
2130
+ status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
2131
+ }));
2132
+
2133
+ if (failed) {
2134
+ if (live?.declining || failed.label === 'Cancelled') {
2135
+ item.stage = 'declined';
2136
+ if (live) this._live = null;
2137
+ } else {
2138
+ item.stage = 'failed';
2139
+ item.message = failed.detail || failed.label;
2140
+ }
2141
+ } else if (done) {
2142
+ item.stage = 'done';
2143
+ if (live) this._live = null;
2144
+ } else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
2145
+ item.stage = 'working';
2146
+ }
2147
+ this._paintAction(item);
2148
+ }
2149
+
2150
+ /** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
2151
+ private _cardLeft(tool: ModelContextToolInfo, destination: string | null): void {
2152
+ const live = this._live;
2153
+ if (!live || live.tool.name !== tool.name) {
2154
+ this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
2155
+ return;
2156
+ }
2157
+ live.item.stage = 'left';
2158
+ live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
2159
+ this._live = null;
2160
+ this._paintAction(live.item);
2161
+ this._saveConversations();
2162
+ }
2163
+
2164
+ /** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
2165
+ private _cardDone(tool: ModelContextToolInfo, values: Record<string, unknown>, text: string): boolean {
2166
+ const conv = this._active();
2167
+ const id = currentJourney();
2168
+ const item = conv?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === id && it.tool === tool.name);
2169
+ if (!item || this._classicPrompts) return false;
2170
+ const note = this._buildDetailItem(tool, values, text).note;
2171
+ // The default note says nothing the card's "Sent" does not.
2172
+ item.message = /submitted successfully/.test(note) ? undefined : note;
2173
+ item.stage = 'done';
2174
+ item.fields.forEach((f) => {
2175
+ if (values[f.name] !== undefined) f.value = String(values[f.name]);
2176
+ });
2177
+ if (this._live?.item === item) this._live = null;
2178
+ this._paintAction(item);
2179
+ this._saveConversations();
2180
+ return true;
2181
+ }
2182
+
2183
+ /** It did not go through: the card says why and offers to change it. False when no card is waiting. */
2184
+ private _cardFailed(tool: ModelContextToolInfo, message: string): boolean {
2185
+ const live = this._live;
2186
+ if (!live || live.tool.name !== tool.name) return false;
2187
+ live.sent = false;
2188
+ live.item.stage = 'failed';
2189
+ live.item.message = message;
2190
+ this._paintAction(live.item);
2191
+ this._saveConversations();
2192
+ return true;
2193
+ }
2194
+
2195
+ /**
2196
+ * After a page change: the card of the request that travelled, found by its
2197
+ * journey, now finished or asked about here.
2198
+ */
2199
+ private _resumedCard(journeyId: string | undefined): ActionItem | null {
2200
+ if (!journeyId || this._classicPrompts) return null;
2201
+ return this._active()?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === journeyId) ?? null;
2202
+ }
2203
+
2204
+ /** Let a resumed card be changed and sent again from this page, with the page's own tool. */
2205
+ private _reviveCard(item: ActionItem, tool: ModelContextToolInfo, values: Record<string, unknown>, needed: string[]): void {
2206
+ const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
2207
+ const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
2208
+ item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
2209
+ this._live = {
2210
+ item,
2211
+ tool,
2212
+ sent: false,
2213
+ declining: false,
2214
+ run: async (vals) => {
2215
+ item.stage = 'working';
2216
+ item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
2217
+ this._paintAction(item);
2218
+ try {
2219
+ const res = await this.toolSource.execTool(tool, tool.name, vals);
2220
+ const text = extractResult(res);
2221
+ if (res?.isError) {
2222
+ this._cardFailed(tool, text || 'That form did not accept the details.');
2223
+ return;
2224
+ }
2225
+ this._active()?.messages.push({
2226
+ role: 'user',
2227
+ content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
2228
+ });
2229
+ this._live = null;
2230
+ const note = this._buildDetailItem(tool, vals, text).note;
2231
+ item.message = /submitted successfully/.test(note) ? undefined : note;
2232
+ item.stage = 'done';
2233
+ item.steps = [];
2234
+ this._paintAction(item);
2235
+ this._saveConversations();
2236
+ } catch (err) {
2237
+ this._cardFailed(tool, String((err as Error)?.message || err));
2238
+ }
2239
+ },
2240
+ };
2241
+ }
2242
+
1676
2243
  // ── Inline generated form (from JSON-Schema) ───────────────────────────────
1677
2244
  private _renderForm(tool: ModelContextToolInfo, args: Record<string, unknown>): void {
1678
2245
  if (this._useA2ui) {
@@ -1905,6 +2472,20 @@ class NxisAgentFullscreen extends HTMLElement {
1905
2472
  submitBtn.disabled = false;
1906
2473
  return;
1907
2474
  }
2475
+ // A handoff started: what happens next arrives from the target, and the
2476
+ // controller's events draw it. There is nothing to show as "Submitted".
2477
+ const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
2478
+ if (started) {
2479
+ this._dismissPrompt();
2480
+ this._active()?.messages.push({
2481
+ role: 'user',
2482
+ content: `[${tool.name}: the conversation was handed to ${started.label ?? 'another agent'}. Their replies go to the visitor directly.]`,
2483
+ });
2484
+ this._saveConversations();
2485
+ this._paintHandoffBar();
2486
+ return;
2487
+ }
2488
+
1908
2489
  // A call that left for another page has filled in nothing yet, so it
1909
2490
  // must not become a "Submitted" card. The page it is opening finishes
1910
2491
  // the job and draws the card there.
@@ -2603,6 +3184,17 @@ class NxisAgentFullscreen extends HTMLElement {
2603
3184
  * `fallback`. Inexpensive: each agent is a role over the one shared provider,
2604
3185
  * so no additional model is loaded.
2605
3186
  */
3187
+ /** The one agent a page without `agents` runs, on the current provider. */
3188
+ private _singleHarness(): AgentHarness {
3189
+ return new AgentHarness({
3190
+ provider: this.provider,
3191
+ toolSource: this.toolSource,
3192
+ renderer: this._renderer(),
3193
+ profile: (schema) => this._profileForSchema(schema as JsonSchema),
3194
+ onMessages: () => this._saveConversations(),
3195
+ });
3196
+ }
3197
+
2606
3198
  private _buildAgents(): void {
2607
3199
  if (!this.provider || !this._agentDefs) {
2608
3200
  this._coordinator = null;
@@ -2644,6 +3236,172 @@ class NxisAgentFullscreen extends HTMLElement {
2644
3236
  });
2645
3237
  }
2646
3238
 
3239
+ // ── Handoff ────────────────────────────────────────────────────────────────
3240
+
3241
+ /**
3242
+ * Handing conversations to other agents and to people.
3243
+ *
3244
+ * Set to `{ relay, targets }` to offer `transfer_to_agent` and
3245
+ * `talk_to_a_person` for those targets (see docs/handoff.md). While a
3246
+ * conversation is handed off, what the visitor types goes to the target,
3247
+ * and a bar above the input shows who is listening and the way back.
3248
+ */
3249
+ get handoff(): HandoffConfig | null {
3250
+ return this._handoff?.config ?? null;
3251
+ }
3252
+ set handoff(next: HandoffConfig | null) {
3253
+ this._offHandoff?.();
3254
+ this._offHandoff = null;
3255
+ this._handoff?.dispose();
3256
+ this._handoff = null;
3257
+ if (!next?.relay || !next.targets) {
3258
+ if (this._els?.thread) this._paintHandoffBar();
3259
+ return;
3260
+ }
3261
+ const controller = new HandoffController(next);
3262
+ const listener = (e: Event) => this._onHandoffEvent((e as CustomEvent<HandoffEvent>).detail);
3263
+ controller.addEventListener('handoff', listener);
3264
+ this._offHandoff = () => controller.removeEventListener('handoff', listener);
3265
+ this._handoff = controller;
3266
+ // Tools already connecting or connected: add the handoff tools, and make the next turn wait for them.
3267
+ if (this._connect) this._connect = this._connect.then(() => this._registerHandoffBounded());
3268
+ if (this._els?.thread) {
3269
+ this._paintHandoffBar();
3270
+ if (this._activeId) void controller.resume(this._activeId);
3271
+ }
3272
+ }
3273
+
3274
+ /** {@link _registerHandoff}, given at most four seconds: a slow relay must not hold up the conversation. */
3275
+ private _registerHandoffBounded(): Promise<void> {
3276
+ return Promise.race([this._registerHandoff(), new Promise<void>((r) => setTimeout(r, 4000))]).catch((err) =>
3277
+ console.warn('[nxis-agent] handoff tools unavailable:', err),
3278
+ );
3279
+ }
3280
+
3281
+ /** Offer the handoff tools on the page's registry. */
3282
+ private async _registerHandoff(): Promise<void> {
3283
+ const controller = this._handoff;
3284
+ const mc = this.toolSource?.modelContext();
3285
+ if (!controller || !mc) return;
3286
+ const added = await registerHandoffTools(mc, controller, {
3287
+ conversationId: () => this._activeId ?? undefined,
3288
+ transcript: () => this._transcript(),
3289
+ });
3290
+ if (added.length) void this._refreshToolCount();
3291
+ }
3292
+
3293
+ /** The conversation's last turns, text only, for a target allowed to see them. */
3294
+ private _transcript(): { role: 'visitor' | 'assistant'; text: string }[] {
3295
+ const conv = this._active();
3296
+ if (!conv) return [];
3297
+ return conv.items
3298
+ .filter((it): it is Extract<ThreadItem, { kind: 'msg' }> => it.kind === 'msg' && (it.role === 'user' || it.role === 'assistant'))
3299
+ .slice(-10)
3300
+ .map((it) => ({ role: it.role === 'user' ? ('visitor' as const) : ('assistant' as const), text: it.text }));
3301
+ }
3302
+
3303
+ /** What a handoff reported, into the conversation it belongs to. */
3304
+ private _onHandoffEvent(e: HandoffEvent): void {
3305
+ const conv = this._conversations.find((c) => c.id === e.conversationId);
3306
+ if (!conv) return;
3307
+ if (e.type === 'message') {
3308
+ this._appendHandoffItem(conv.id, { kind: 'remote', from: e.from, fromKind: e.fromKind, text: e.text });
3309
+ } else if (e.type === 'handback') {
3310
+ const { outcome, note, references } = e.handBack;
3311
+ const refs = references.map((r) => `${r.label}: ${r.value}`).join(' · ');
3312
+ // The assistant carries on from here, so it learns what happened: the note, and nothing else the
3313
+ // target said. A remote reply is shown to the visitor and never read as an instruction.
3314
+ conv.messages.push({
3315
+ role: 'user',
3316
+ content: `[${e.session.label} handed the conversation back (${outcome})${note ? `: ${note}` : ''}${refs ? `. ${refs}` : ''}]`,
3317
+ });
3318
+ if (refs) this._appendHandoffItem(conv.id, { kind: 'handoff', text: refs, tone: 'done' });
3319
+ } else {
3320
+ const text = handoffLine(e.session);
3321
+ const last = [...conv.items].reverse().find((it) => it.kind === 'handoff') as
3322
+ | Extract<ThreadItem, { kind: 'handoff' }>
3323
+ | undefined;
3324
+ if (text && last?.text !== text) {
3325
+ const tone = e.state === 'failed' ? 'error' : isHandoffOver(e.state) ? 'done' : 'info';
3326
+ this._appendHandoffItem(conv.id, { kind: 'handoff', text, tone });
3327
+ }
3328
+ if (e.state === 'ended' || e.state === 'failed') {
3329
+ conv.messages.push({
3330
+ role: 'user',
3331
+ content: `[The conversation with ${e.session.label} ${e.state === 'failed' ? `failed${e.session.error ? `: ${e.session.error}` : ''}` : 'ended'}. You are handling it again.]`,
3332
+ });
3333
+ }
3334
+ }
3335
+ this._saveConversations();
3336
+ if (conv.id === this._activeId) this._paintHandoffBar();
3337
+ }
3338
+
3339
+ /** Add a handoff item to a conversation, and draw it when that conversation is on screen. */
3340
+ private _appendHandoffItem(conversationId: string, item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): void {
3341
+ const conv = this._conversations.find((c) => c.id === conversationId);
3342
+ if (!conv) return;
3343
+ conv.items.push(item);
3344
+ this._saveConversations();
3345
+ if (conversationId === this._activeId && this._els?.thread) {
3346
+ this._paintHandoffItem(item);
3347
+ this._scroll();
3348
+ this._noteThreadGrew();
3349
+ }
3350
+ }
3351
+
3352
+ private _paintHandoffItem(item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): HTMLElement {
3353
+ this._clearEmpty();
3354
+ let el: HTMLElement;
3355
+ if (item.kind === 'remote') {
3356
+ el = document.createElement('div');
3357
+ el.className = 'remote';
3358
+ const who = document.createElement('div');
3359
+ who.className = 'remote-from';
3360
+ who.textContent = item.fromKind === 'agent' ? `${item.from} · agent` : item.from;
3361
+ el.append(who, this._msgEl('assistant', item.text));
3362
+ } else {
3363
+ el = document.createElement('div');
3364
+ el.className = `handoff-note ${item.tone}`;
3365
+ el.textContent = item.text;
3366
+ }
3367
+ this._els.thread.appendChild(el);
3368
+ return el;
3369
+ }
3370
+
3371
+ /** The bar above the input: shown while the conversation is handed off. */
3372
+ private _paintHandoffBar(): void {
3373
+ if (!this._els?.handoffBar) return;
3374
+ const session = this._handoff?.active(this._activeId ?? undefined) ?? null;
3375
+ this._els.handoffBar.hidden = !session;
3376
+ const input = this._els.input;
3377
+ if (!session) {
3378
+ input.placeholder = 'Message the agent…';
3379
+ input.setAttribute('aria-label', 'Message the agent');
3380
+ return;
3381
+ }
3382
+ const who = session.operator ?? session.label;
3383
+ const q = session.queue;
3384
+ const inLine = q?.position ? ` · ${ordinal(q.position)} in line${q.waitMinutes ? `, about ${q.waitMinutes} min` : ''}` : ' · waiting in line';
3385
+ const doing =
3386
+ session.state === 'connecting'
3387
+ ? ' · connecting…'
3388
+ : session.state === 'queued'
3389
+ ? inLine
3390
+ : session.state === 'awaiting-visitor'
3391
+ ? ' · waiting for your reply'
3392
+ : '';
3393
+ this._els.handoffLabel.textContent = `Talking with ${who}${doing}`;
3394
+ input.placeholder = `Message ${who}…`;
3395
+ input.setAttribute('aria-label', `Message ${who}`);
3396
+ }
3397
+
3398
+ /** "Back to the assistant": end the handoff for this conversation. */
3399
+ private async _endHandoff(): Promise<void> {
3400
+ if (!this._activeId || !this._handoff) return;
3401
+ await this._handoff.end(this._activeId);
3402
+ this._els.input.focus();
3403
+ }
3404
+
2647
3405
  /** Zero when the provider cannot count, which hides the token metrics. */
2648
3406
  private async countTokens(text: string): Promise<number> {
2649
3407
  return (await this.provider.countTokens?.(text)) ?? 0;
@@ -2737,6 +3495,44 @@ class NxisAgentFullscreen extends HTMLElement {
2737
3495
  });
2738
3496
  if (outcome.status !== 'none') this._setStatus('');
2739
3497
 
3498
+ // The card that travelled with the request finishes here, when there is one.
3499
+ const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
3500
+ if (card && outcome.status !== 'none') {
3501
+ switch (outcome.status) {
3502
+ case 'no-registry':
3503
+ case 'no-tool':
3504
+ card.stage = 'failed';
3505
+ 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.`;
3506
+ break;
3507
+ case 'incomplete':
3508
+ this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
3509
+ card.stage = 'collect';
3510
+ card.message = undefined;
3511
+ break;
3512
+ case 'rejected':
3513
+ case 'failed':
3514
+ this._reviveCard(card, outcome.tool, outcome.pending.values, []);
3515
+ card.stage = 'failed';
3516
+ card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
3517
+ break;
3518
+ case 'submitted': {
3519
+ const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
3520
+ card.message = /submitted successfully/.test(note) ? undefined : note;
3521
+ card.stage = 'done';
3522
+ this._active()?.messages.push({
3523
+ role: 'user',
3524
+ content:
3525
+ `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
3526
+ 'Keep these unless I ask to change them.',
3527
+ });
3528
+ break;
3529
+ }
3530
+ }
3531
+ this._paintAction(card);
3532
+ this._saveConversations();
3533
+ return;
3534
+ }
3535
+
2740
3536
  switch (outcome.status) {
2741
3537
  case 'none':
2742
3538
  return;
@@ -2836,7 +3632,10 @@ class NxisAgentFullscreen extends HTMLElement {
2836
3632
  if (this._toolsOpen) void this._renderTools();
2837
3633
  });
2838
3634
  })
2839
- .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
3635
+ .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
3636
+ // Part of connecting, so the first turn waits for the handoff tools: a turn that ran without
3637
+ // them could only claim, in words, to be handing the visitor over.
3638
+ .then(() => this._registerHandoffBounded());
2840
3639
  return this._connect;
2841
3640
  }
2842
3641
 
@@ -3091,6 +3890,9 @@ class NxisAgentFullscreen extends HTMLElement {
3091
3890
  if (this._els?.thread) {
3092
3891
  this.provider = next ?? this.provider;
3093
3892
  this._paintProvenance();
3893
+ // Both the single agent and any configured ones: a page without `agents` kept the old
3894
+ // provider's harness, and answered with the model it had just replaced.
3895
+ this._harness = this._singleHarness();
3094
3896
  this._buildAgents();
3095
3897
  }
3096
3898
  }
@@ -3294,6 +4096,9 @@ class NxisAgentFullscreen extends HTMLElement {
3294
4096
  /* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
3295
4097
  ${A2UI_THEME_CSS}
3296
4098
 
4099
+ /* The action card: forms and handoffs as one message. */
4100
+ ${ACTION_CARD_CSS}
4101
+
3297
4102
  /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
3298
4103
  .launcher {
3299
4104
  position: fixed; right: 20px; bottom: 20px; z-index: 9998;
@@ -3745,6 +4550,23 @@ class NxisAgentFullscreen extends HTMLElement {
3745
4550
  padding: 2px 0 6px;
3746
4551
  letter-spacing: .01em;
3747
4552
  }
4553
+
4554
+ /* Handoffs. A target's message is a bubble with its name above; where the
4555
+ handoff stands is a quiet centred line; the bar says who is listening. */
4556
+ .remote { align-self: flex-start; display: flex; flex-direction: column; gap: 4px; max-width: 76%; }
4557
+ .remote .msg { max-width: 100%; }
4558
+ .remote-from { font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; color: var(--ink-3); padding-left: 4px; }
4559
+ .handoff-note { align-self: center; font-size: 12px; color: var(--ink-3); padding: 2px 12px 6px; text-align: center; letter-spacing: .01em; }
4560
+ .handoff-note.done { color: var(--green-strong); }
4561
+ .handoff-note.error { color: var(--red); }
4562
+ .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; }
4563
+ .handoff-bar[hidden] { display: none; }
4564
+ .handoff-bar .hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex: none; }
4565
+ .handoff-bar .hb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
4566
+ .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; }
4567
+ .handoff-bar .hb-back:hover { border-color: var(--ink-3); }
4568
+ .handoff-bar .hb-back:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
4569
+ .overlay.window .handoff-bar, .overlay.inline .handoff-bar { margin: 0 14px 6px; }
3748
4570
  .composer .go svg { width: 17px; height: 17px; }
3749
4571
 
3750
4572
  /* Attribution. Quiet enough to sit under a customer's own chat surface
@@ -3958,6 +4780,10 @@ class NxisAgentFullscreen extends HTMLElement {
3958
4780
  </button>
3959
4781
  <div class="thread"></div>
3960
4782
  <div class="statusline"></div>
4783
+ <div class="handoff-bar" role="status" hidden>
4784
+ <span class="hb-dot" aria-hidden="true"></span><span class="hb-label"></span>
4785
+ <button class="hb-back" type="button">Back to the assistant</button>
4786
+ </div>
3961
4787
  <div class="composer">
3962
4788
  <div class="cbox">
3963
4789
  <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>