@nxisai/agent-harness 0.5.0 → 0.6.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 (51) hide show
  1. package/README.md +22 -1
  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 +148 -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 +30 -7
  25. package/dist/harness/harness.js.map +1 -1
  26. package/dist/harness/router/coordinator.d.ts +13 -0
  27. package/dist/harness/router/coordinator.d.ts.map +1 -1
  28. package/dist/harness/router/coordinator.js +7 -0
  29. package/dist/harness/router/coordinator.js.map +1 -1
  30. package/dist/harness/tools/discovery.d.ts +12 -1
  31. package/dist/harness/tools/discovery.d.ts.map +1 -1
  32. package/dist/harness/tools/discovery.js +19 -1
  33. package/dist/harness/tools/discovery.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  39. package/dist/ui/agent-fullscreen.js +264 -1
  40. package/dist/ui/agent-fullscreen.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/handoff/a2a.ts +326 -0
  43. package/src/handoff/controller.ts +661 -0
  44. package/src/handoff/envelope.ts +216 -0
  45. package/src/handoff/index.ts +53 -0
  46. package/src/handoff/tools.ts +178 -0
  47. package/src/harness/harness.ts +28 -3
  48. package/src/harness/router/coordinator.ts +21 -0
  49. package/src/harness/tools/discovery.ts +20 -2
  50. package/src/index.ts +3 -0
  51. package/src/ui/agent-fullscreen.ts +280 -2
@@ -53,6 +53,14 @@ import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
53
53
  import { onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
54
54
  import { viewJourney } from './journey.js';
55
55
  import { AgentCoordinator } from '../harness/router/coordinator.js';
56
+ import {
57
+ HandoffController,
58
+ isHandoffOver,
59
+ type HandoffConfig,
60
+ type HandoffEvent,
61
+ type HandoffSession,
62
+ } from '../handoff/controller.js';
63
+ import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
56
64
  import type { RouteRule } from '../harness/router/rules.js';
57
65
  import type { AskRequest, ConfirmRequest, HarnessRenderer, StreamingBubble as HStreamingBubble } from '../harness/renderer.js';
58
66
  import {
@@ -319,7 +327,42 @@ type ThreadItem =
319
327
  * so a second request does not erase the first, and so a card survives the
320
328
  * page change a cross-page action makes.
321
329
  */
322
- | { kind: 'journey'; id: string; steps: AgentStep[] };
330
+ | { kind: 'journey'; id: string; steps: AgentStep[] }
331
+ /** Something a handoff target said: an agent, or a person at a desk. */
332
+ | { kind: 'remote'; from: string; fromKind: 'agent' | 'person'; text: string }
333
+ /** Where a handoff stands, as a quiet line in the thread. */
334
+ | { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' };
335
+
336
+ /**
337
+ * A handoff's state as a line in the thread, or '' for a state the bar alone
338
+ * shows.
339
+ */
340
+ function handoffLine(session: HandoffSession): string {
341
+ const who = session.label;
342
+ switch (session.state) {
343
+ case 'connecting':
344
+ return `Connecting you to ${who}…`;
345
+ case 'queued':
346
+ // The bar says where they are in line; a desk usually says it in words too.
347
+ return '';
348
+ case 'active':
349
+ return session.operator && session.operator !== who ? `${session.operator} has joined` : `You're talking with ${who}`;
350
+ case 'awaiting-visitor':
351
+ return '';
352
+ case 'handed-back':
353
+ return 'Back with the assistant';
354
+ case 'ended':
355
+ return `${who} has finished. Back with the assistant.`;
356
+ case 'failed':
357
+ return `${session.error ?? `${who} couldn't continue.`} Back with the assistant.`;
358
+ }
359
+ }
360
+
361
+ function ordinal(n: number): string {
362
+ const s = ['th', 'st', 'nd', 'rd'];
363
+ const v = n % 100;
364
+ return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
365
+ }
323
366
 
324
367
  interface Conversation {
325
368
  id: string;
@@ -344,6 +387,10 @@ interface FullscreenEls {
344
387
  bar: HTMLElement;
345
388
  /** Names the running step while its card is scrolled out of view. */
346
389
  jstatus: HTMLButtonElement;
390
+ /** Shown while the conversation is handed off: who to, and the way back. */
391
+ handoffBar: HTMLElement;
392
+ handoffLabel: HTMLElement;
393
+ handoffBack: HTMLButtonElement;
347
394
  input: HTMLTextAreaElement;
348
395
  go: HTMLButtonElement;
349
396
  /** Opens the list of tools the agent can use. */
@@ -455,6 +502,9 @@ class NxisAgentFullscreen extends HTMLElement {
455
502
  private toolSource!: WebMcpTools;
456
503
  /** A provider the host supplied, used instead of the local model. */
457
504
  private _provider: ModelProvider | null = null;
505
+ /** Runs handoffs to agents and people, when the host configured any. */
506
+ private _handoff: HandoffController | null = null;
507
+ private _offHandoff: (() => void) | null = null;
458
508
 
459
509
  constructor() {
460
510
  super();
@@ -489,6 +539,9 @@ class NxisAgentFullscreen extends HTMLElement {
489
539
  status: root.querySelector('.statusline')!,
490
540
  bar: root.querySelector('.prog > i')!,
491
541
  jstatus: root.querySelector('.jstatus')!,
542
+ handoffBar: root.querySelector('.handoff-bar')!,
543
+ handoffLabel: root.querySelector('.handoff-bar .hb-label')!,
544
+ handoffBack: root.querySelector('.handoff-bar .hb-back')!,
492
545
  input: root.querySelector('.composer textarea')!,
493
546
  go: root.querySelector('.composer .go')!,
494
547
  toolsBtn: root.querySelector('.tools-btn')!,
@@ -579,6 +632,10 @@ class NxisAgentFullscreen extends HTMLElement {
579
632
  this._resumePendingVisit();
580
633
 
581
634
  this._els.launcher.addEventListener('click', () => this._open());
635
+ this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
636
+ // A handoff left open on the last page carries on here.
637
+ this._paintHandoffBar();
638
+ if (this._activeId) void this._handoff?.resume(this._activeId);
582
639
  this._els.close.addEventListener('click', () => {
583
640
  this._els.overlay.classList.remove('show');
584
641
  // A remote turn can outlive the panel, and its answer would go unread.
@@ -658,6 +715,8 @@ class NxisAgentFullscreen extends HTMLElement {
658
715
  this._offJourney = null;
659
716
  this._journeyObserver?.disconnect();
660
717
  this._journeyObserver = null;
718
+ // Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
719
+ this._handoff?.dispose();
661
720
  }
662
721
 
663
722
  /** Programmatic open (used by pages that dock the agent full-page). */
@@ -729,6 +788,7 @@ class NxisAgentFullscreen extends HTMLElement {
729
788
  this._renderHistory();
730
789
  this._renderThread();
731
790
  this._saveConversations();
791
+ this._paintHandoffBar();
732
792
  }
733
793
  if (this._els.input) this._els.input.focus();
734
794
  }
@@ -742,6 +802,8 @@ class NxisAgentFullscreen extends HTMLElement {
742
802
  this._setNav(false);
743
803
  this._renderHistory();
744
804
  this._renderThread();
805
+ this._paintHandoffBar();
806
+ void this._handoff?.resume(id);
745
807
  }
746
808
 
747
809
  private _renderHistory(): void {
@@ -842,6 +904,7 @@ class NxisAgentFullscreen extends HTMLElement {
842
904
  else if (it.kind === 'results') this._els.thread.appendChild(this._resultsEl(it));
843
905
  else if (it.kind === 'metrics') this._els.thread.appendChild(this._metricsEl(it));
844
906
  else if (it.kind === 'journey') this._els.thread.appendChild(this._journeyEl(it));
907
+ else if (it.kind === 'remote' || it.kind === 'handoff') this._paintHandoffItem(it);
845
908
  }
846
909
  this._scroll();
847
910
  this._watchRunningJourney();
@@ -1438,6 +1501,18 @@ class NxisAgentFullscreen extends HTMLElement {
1438
1501
  this._saveConversations();
1439
1502
  }
1440
1503
 
1504
+ // Handed off: this message is for the target, not the assistant.
1505
+ const handed = this._handoff?.active(this._activeId ?? undefined);
1506
+ if (handed && this._activeId) {
1507
+ try {
1508
+ await this._handoff!.send(this._activeId, goal);
1509
+ } catch (err) {
1510
+ const why = this._handoff!.session(this._activeId)?.error ?? String((err as Error)?.message || err);
1511
+ this._appendHandoffItem(this._activeId, { kind: 'handoff', text: why, tone: 'error' });
1512
+ }
1513
+ return;
1514
+ }
1515
+
1441
1516
  this._turnActive = true;
1442
1517
  this._setStoppable(true);
1443
1518
  let settle!: () => void;
@@ -1905,6 +1980,20 @@ class NxisAgentFullscreen extends HTMLElement {
1905
1980
  submitBtn.disabled = false;
1906
1981
  return;
1907
1982
  }
1983
+ // A handoff started: what happens next arrives from the target, and the
1984
+ // controller's events draw it. There is nothing to show as "Submitted".
1985
+ const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
1986
+ if (started) {
1987
+ this._dismissPrompt();
1988
+ this._active()?.messages.push({
1989
+ role: 'user',
1990
+ content: `[${tool.name}: the conversation was handed to ${started.label ?? 'another agent'}. Their replies go to the visitor directly.]`,
1991
+ });
1992
+ this._saveConversations();
1993
+ this._paintHandoffBar();
1994
+ return;
1995
+ }
1996
+
1908
1997
  // A call that left for another page has filled in nothing yet, so it
1909
1998
  // must not become a "Submitted" card. The page it is opening finishes
1910
1999
  // the job and draws the card there.
@@ -2644,6 +2733,171 @@ class NxisAgentFullscreen extends HTMLElement {
2644
2733
  });
2645
2734
  }
2646
2735
 
2736
+ // ── Handoff ────────────────────────────────────────────────────────────────
2737
+
2738
+ /**
2739
+ * Handing conversations to other agents and to people.
2740
+ *
2741
+ * Set to `{ relay, targets }` to offer `transfer_to_agent` and
2742
+ * `talk_to_a_person` for those targets (see docs/handoff.md). While a
2743
+ * conversation is handed off, what the visitor types goes to the target,
2744
+ * and a bar above the input shows who is listening and the way back.
2745
+ */
2746
+ get handoff(): HandoffConfig | null {
2747
+ return this._handoff?.config ?? null;
2748
+ }
2749
+ set handoff(next: HandoffConfig | null) {
2750
+ this._offHandoff?.();
2751
+ this._offHandoff = null;
2752
+ this._handoff?.dispose();
2753
+ this._handoff = null;
2754
+ if (!next?.relay || !next.targets) {
2755
+ if (this._els?.thread) this._paintHandoffBar();
2756
+ return;
2757
+ }
2758
+ const controller = new HandoffController(next);
2759
+ const listener = (e: Event) => this._onHandoffEvent((e as CustomEvent<HandoffEvent>).detail);
2760
+ controller.addEventListener('handoff', listener);
2761
+ this._offHandoff = () => controller.removeEventListener('handoff', listener);
2762
+ this._handoff = controller;
2763
+ // Tools already connecting or connected: add the handoff tools, and make the next turn wait for them.
2764
+ if (this._connect) this._connect = this._connect.then(() => this._registerHandoffBounded());
2765
+ if (this._els?.thread) {
2766
+ this._paintHandoffBar();
2767
+ if (this._activeId) void controller.resume(this._activeId);
2768
+ }
2769
+ }
2770
+
2771
+ /** {@link _registerHandoff}, given at most four seconds: a slow relay must not hold up the conversation. */
2772
+ private _registerHandoffBounded(): Promise<void> {
2773
+ return Promise.race([this._registerHandoff(), new Promise<void>((r) => setTimeout(r, 4000))]).catch((err) =>
2774
+ console.warn('[nxis-agent] handoff tools unavailable:', err),
2775
+ );
2776
+ }
2777
+
2778
+ /** Offer the handoff tools on the page's registry. */
2779
+ private async _registerHandoff(): Promise<void> {
2780
+ const controller = this._handoff;
2781
+ const mc = this.toolSource?.modelContext();
2782
+ if (!controller || !mc) return;
2783
+ const added = await registerHandoffTools(mc, controller, {
2784
+ conversationId: () => this._activeId ?? undefined,
2785
+ transcript: () => this._transcript(),
2786
+ });
2787
+ if (added.length) void this._refreshToolCount();
2788
+ }
2789
+
2790
+ /** The conversation's last turns, text only, for a target allowed to see them. */
2791
+ private _transcript(): { role: 'visitor' | 'assistant'; text: string }[] {
2792
+ const conv = this._active();
2793
+ if (!conv) return [];
2794
+ return conv.items
2795
+ .filter((it): it is Extract<ThreadItem, { kind: 'msg' }> => it.kind === 'msg' && (it.role === 'user' || it.role === 'assistant'))
2796
+ .slice(-10)
2797
+ .map((it) => ({ role: it.role === 'user' ? ('visitor' as const) : ('assistant' as const), text: it.text }));
2798
+ }
2799
+
2800
+ /** What a handoff reported, into the conversation it belongs to. */
2801
+ private _onHandoffEvent(e: HandoffEvent): void {
2802
+ const conv = this._conversations.find((c) => c.id === e.conversationId);
2803
+ if (!conv) return;
2804
+ if (e.type === 'message') {
2805
+ this._appendHandoffItem(conv.id, { kind: 'remote', from: e.from, fromKind: e.fromKind, text: e.text });
2806
+ } else if (e.type === 'handback') {
2807
+ const { outcome, note, references } = e.handBack;
2808
+ const refs = references.map((r) => `${r.label}: ${r.value}`).join(' · ');
2809
+ // The assistant carries on from here, so it learns what happened: the note, and nothing else the
2810
+ // target said. A remote reply is shown to the visitor and never read as an instruction.
2811
+ conv.messages.push({
2812
+ role: 'user',
2813
+ content: `[${e.session.label} handed the conversation back (${outcome})${note ? `: ${note}` : ''}${refs ? `. ${refs}` : ''}]`,
2814
+ });
2815
+ if (refs) this._appendHandoffItem(conv.id, { kind: 'handoff', text: refs, tone: 'done' });
2816
+ } else {
2817
+ const text = handoffLine(e.session);
2818
+ const last = [...conv.items].reverse().find((it) => it.kind === 'handoff') as
2819
+ | Extract<ThreadItem, { kind: 'handoff' }>
2820
+ | undefined;
2821
+ if (text && last?.text !== text) {
2822
+ const tone = e.state === 'failed' ? 'error' : isHandoffOver(e.state) ? 'done' : 'info';
2823
+ this._appendHandoffItem(conv.id, { kind: 'handoff', text, tone });
2824
+ }
2825
+ if (e.state === 'ended' || e.state === 'failed') {
2826
+ conv.messages.push({
2827
+ role: 'user',
2828
+ content: `[The conversation with ${e.session.label} ${e.state === 'failed' ? `failed${e.session.error ? `: ${e.session.error}` : ''}` : 'ended'}. You are handling it again.]`,
2829
+ });
2830
+ }
2831
+ }
2832
+ this._saveConversations();
2833
+ if (conv.id === this._activeId) this._paintHandoffBar();
2834
+ }
2835
+
2836
+ /** Add a handoff item to a conversation, and draw it when that conversation is on screen. */
2837
+ private _appendHandoffItem(conversationId: string, item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): void {
2838
+ const conv = this._conversations.find((c) => c.id === conversationId);
2839
+ if (!conv) return;
2840
+ conv.items.push(item);
2841
+ this._saveConversations();
2842
+ if (conversationId === this._activeId && this._els?.thread) {
2843
+ this._paintHandoffItem(item);
2844
+ this._scroll();
2845
+ }
2846
+ }
2847
+
2848
+ private _paintHandoffItem(item: Extract<ThreadItem, { kind: 'remote' | 'handoff' }>): HTMLElement {
2849
+ this._clearEmpty();
2850
+ let el: HTMLElement;
2851
+ if (item.kind === 'remote') {
2852
+ el = document.createElement('div');
2853
+ el.className = 'remote';
2854
+ const who = document.createElement('div');
2855
+ who.className = 'remote-from';
2856
+ who.textContent = item.fromKind === 'agent' ? `${item.from} · agent` : item.from;
2857
+ el.append(who, this._msgEl('assistant', item.text));
2858
+ } else {
2859
+ el = document.createElement('div');
2860
+ el.className = `handoff-note ${item.tone}`;
2861
+ el.textContent = item.text;
2862
+ }
2863
+ this._els.thread.appendChild(el);
2864
+ return el;
2865
+ }
2866
+
2867
+ /** The bar above the input: shown while the conversation is handed off. */
2868
+ private _paintHandoffBar(): void {
2869
+ if (!this._els?.handoffBar) return;
2870
+ const session = this._handoff?.active(this._activeId ?? undefined) ?? null;
2871
+ this._els.handoffBar.hidden = !session;
2872
+ const input = this._els.input;
2873
+ if (!session) {
2874
+ input.placeholder = 'Message the agent…';
2875
+ input.setAttribute('aria-label', 'Message the agent');
2876
+ return;
2877
+ }
2878
+ const who = session.operator ?? session.label;
2879
+ const q = session.queue;
2880
+ const inLine = q?.position ? ` · ${ordinal(q.position)} in line${q.waitMinutes ? `, about ${q.waitMinutes} min` : ''}` : ' · waiting in line';
2881
+ const doing =
2882
+ session.state === 'connecting'
2883
+ ? ' · connecting…'
2884
+ : session.state === 'queued'
2885
+ ? inLine
2886
+ : session.state === 'awaiting-visitor'
2887
+ ? ' · waiting for your reply'
2888
+ : '';
2889
+ this._els.handoffLabel.textContent = `Talking with ${who}${doing}`;
2890
+ input.placeholder = `Message ${who}…`;
2891
+ input.setAttribute('aria-label', `Message ${who}`);
2892
+ }
2893
+
2894
+ /** "Back to the assistant": end the handoff for this conversation. */
2895
+ private async _endHandoff(): Promise<void> {
2896
+ if (!this._activeId || !this._handoff) return;
2897
+ await this._handoff.end(this._activeId);
2898
+ this._els.input.focus();
2899
+ }
2900
+
2647
2901
  /** Zero when the provider cannot count, which hides the token metrics. */
2648
2902
  private async countTokens(text: string): Promise<number> {
2649
2903
  return (await this.provider.countTokens?.(text)) ?? 0;
@@ -2836,7 +3090,10 @@ class NxisAgentFullscreen extends HTMLElement {
2836
3090
  if (this._toolsOpen) void this._renderTools();
2837
3091
  });
2838
3092
  })
2839
- .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
3093
+ .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
3094
+ // Part of connecting, so the first turn waits for the handoff tools: a turn that ran without
3095
+ // them could only claim, in words, to be handing the visitor over.
3096
+ .then(() => this._registerHandoffBounded());
2840
3097
  return this._connect;
2841
3098
  }
2842
3099
 
@@ -3745,6 +4002,23 @@ class NxisAgentFullscreen extends HTMLElement {
3745
4002
  padding: 2px 0 6px;
3746
4003
  letter-spacing: .01em;
3747
4004
  }
4005
+
4006
+ /* Handoffs. A target's message is a bubble with its name above; where the
4007
+ handoff stands is a quiet centred line; the bar says who is listening. */
4008
+ .remote { align-self: flex-start; display: flex; flex-direction: column; gap: 4px; max-width: 76%; }
4009
+ .remote .msg { max-width: 100%; }
4010
+ .remote-from { font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; color: var(--ink-3); padding-left: 4px; }
4011
+ .handoff-note { align-self: center; font-size: 12px; color: var(--ink-3); padding: 2px 12px 6px; text-align: center; letter-spacing: .01em; }
4012
+ .handoff-note.done { color: var(--green-strong); }
4013
+ .handoff-note.error { color: var(--red); }
4014
+ .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; }
4015
+ .handoff-bar[hidden] { display: none; }
4016
+ .handoff-bar .hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex: none; }
4017
+ .handoff-bar .hb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
4018
+ .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; }
4019
+ .handoff-bar .hb-back:hover { border-color: var(--ink-3); }
4020
+ .handoff-bar .hb-back:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
4021
+ .overlay.window .handoff-bar, .overlay.inline .handoff-bar { margin: 0 14px 6px; }
3748
4022
  .composer .go svg { width: 17px; height: 17px; }
3749
4023
 
3750
4024
  /* Attribution. Quiet enough to sit under a customer's own chat surface
@@ -3958,6 +4232,10 @@ class NxisAgentFullscreen extends HTMLElement {
3958
4232
  </button>
3959
4233
  <div class="thread"></div>
3960
4234
  <div class="statusline"></div>
4235
+ <div class="handoff-bar" role="status" hidden>
4236
+ <span class="hb-dot" aria-hidden="true"></span><span class="hb-label"></span>
4237
+ <button class="hb-back" type="button">Back to the assistant</button>
4238
+ </div>
3961
4239
  <div class="composer">
3962
4240
  <div class="cbox">
3963
4241
  <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>