@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
@@ -42,6 +42,8 @@ import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
42
42
  import { onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
43
43
  import { viewJourney } from './journey.js';
44
44
  import { AgentCoordinator } from '../harness/router/coordinator.js';
45
+ import { HandoffController, isHandoffOver, } from '../handoff/controller.js';
46
+ import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
45
47
  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
48
  import { DEFAULT_SYSTEM_PROMPT, DEFAULT_SUGGESTIONS, DEFAULT_EMPTY_STATE, DEFAULT_AGENT_NAME, DEFAULT_LOGO, THEME_TOKENS, PROFILE_ALIASES, } from './config.js';
47
49
  // Prefixed keys so state never collides with other Nxis examples on the same origin.
@@ -186,6 +188,35 @@ function fillMessage(el, text) {
186
188
  }
187
189
  }
188
190
  }
191
+ /**
192
+ * A handoff's state as a line in the thread, or '' for a state the bar alone
193
+ * shows.
194
+ */
195
+ function handoffLine(session) {
196
+ const who = session.label;
197
+ switch (session.state) {
198
+ case 'connecting':
199
+ return `Connecting you to ${who}…`;
200
+ case 'queued':
201
+ // The bar says where they are in line; a desk usually says it in words too.
202
+ return '';
203
+ case 'active':
204
+ return session.operator && session.operator !== who ? `${session.operator} has joined` : `You're talking with ${who}`;
205
+ case 'awaiting-visitor':
206
+ return '';
207
+ case 'handed-back':
208
+ return 'Back with the assistant';
209
+ case 'ended':
210
+ return `${who} has finished. Back with the assistant.`;
211
+ case 'failed':
212
+ return `${session.error ?? `${who} couldn't continue.`} Back with the assistant.`;
213
+ }
214
+ }
215
+ function ordinal(n) {
216
+ const s = ['th', 'st', 'nd', 'rd'];
217
+ const v = n % 100;
218
+ return `${n}${s[(v - 20) % 10] || s[v] || s[0]}`;
219
+ }
189
220
  class NxisAgentFullscreen extends HTMLElement {
190
221
  constructor() {
191
222
  super();
@@ -249,6 +280,9 @@ class NxisAgentFullscreen extends HTMLElement {
249
280
  this._emptyLoading = null;
250
281
  /** A provider the host supplied, used instead of the local model. */
251
282
  this._provider = null;
283
+ /** Runs handoffs to agents and people, when the host configured any. */
284
+ this._handoff = null;
285
+ this._offHandoff = null;
252
286
  this.attachShadow({ mode: 'open' });
253
287
  this._turnActive = false;
254
288
  this._tool = null; // tool of the live inline form (if any)
@@ -279,6 +313,9 @@ class NxisAgentFullscreen extends HTMLElement {
279
313
  status: root.querySelector('.statusline'),
280
314
  bar: root.querySelector('.prog > i'),
281
315
  jstatus: root.querySelector('.jstatus'),
316
+ handoffBar: root.querySelector('.handoff-bar'),
317
+ handoffLabel: root.querySelector('.handoff-bar .hb-label'),
318
+ handoffBack: root.querySelector('.handoff-bar .hb-back'),
282
319
  input: root.querySelector('.composer textarea'),
283
320
  go: root.querySelector('.composer .go'),
284
321
  toolsBtn: root.querySelector('.tools-btn'),
@@ -359,6 +396,11 @@ class NxisAgentFullscreen extends HTMLElement {
359
396
  void this._resumePendingAction();
360
397
  this._resumePendingVisit();
361
398
  this._els.launcher.addEventListener('click', () => this._open());
399
+ this._els.handoffBack.addEventListener('click', () => void this._endHandoff());
400
+ // A handoff left open on the last page carries on here.
401
+ this._paintHandoffBar();
402
+ if (this._activeId)
403
+ void this._handoff?.resume(this._activeId);
362
404
  this._els.close.addEventListener('click', () => {
363
405
  this._els.overlay.classList.remove('show');
364
406
  // A remote turn can outlive the panel, and its answer would go unread.
@@ -440,6 +482,8 @@ class NxisAgentFullscreen extends HTMLElement {
440
482
  this._offJourney = null;
441
483
  this._journeyObserver?.disconnect();
442
484
  this._journeyObserver = null;
485
+ // Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
486
+ this._handoff?.dispose();
443
487
  }
444
488
  /** Programmatic open (used by pages that dock the agent full-page). */
445
489
  open() {
@@ -507,6 +551,7 @@ class NxisAgentFullscreen extends HTMLElement {
507
551
  this._renderHistory();
508
552
  this._renderThread();
509
553
  this._saveConversations();
554
+ this._paintHandoffBar();
510
555
  }
511
556
  if (this._els.input)
512
557
  this._els.input.focus();
@@ -521,6 +566,8 @@ class NxisAgentFullscreen extends HTMLElement {
521
566
  this._setNav(false);
522
567
  this._renderHistory();
523
568
  this._renderThread();
569
+ this._paintHandoffBar();
570
+ void this._handoff?.resume(id);
524
571
  }
525
572
  _renderHistory() {
526
573
  this._els.history.innerHTML = this._conversations
@@ -622,6 +669,8 @@ class NxisAgentFullscreen extends HTMLElement {
622
669
  this._els.thread.appendChild(this._metricsEl(it));
623
670
  else if (it.kind === 'journey')
624
671
  this._els.thread.appendChild(this._journeyEl(it));
672
+ else if (it.kind === 'remote' || it.kind === 'handoff')
673
+ this._paintHandoffItem(it);
625
674
  }
626
675
  this._scroll();
627
676
  this._watchRunningJourney();
@@ -1196,6 +1245,18 @@ class NxisAgentFullscreen extends HTMLElement {
1196
1245
  this._renderHistory();
1197
1246
  this._saveConversations();
1198
1247
  }
1248
+ // Handed off: this message is for the target, not the assistant.
1249
+ const handed = this._handoff?.active(this._activeId ?? undefined);
1250
+ if (handed && this._activeId) {
1251
+ try {
1252
+ await this._handoff.send(this._activeId, goal);
1253
+ }
1254
+ catch (err) {
1255
+ const why = this._handoff.session(this._activeId)?.error ?? String(err?.message || err);
1256
+ this._appendHandoffItem(this._activeId, { kind: 'handoff', text: why, tone: 'error' });
1257
+ }
1258
+ return;
1259
+ }
1199
1260
  this._turnActive = true;
1200
1261
  this._setStoppable(true);
1201
1262
  let settle;
@@ -1626,6 +1687,19 @@ class NxisAgentFullscreen extends HTMLElement {
1626
1687
  submitBtn.disabled = false;
1627
1688
  return;
1628
1689
  }
1690
+ // A handoff started: what happens next arrives from the target, and the
1691
+ // controller's events draw it. There is nothing to show as "Submitted".
1692
+ const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT];
1693
+ if (started) {
1694
+ this._dismissPrompt();
1695
+ this._active()?.messages.push({
1696
+ role: 'user',
1697
+ content: `[${tool.name}: the conversation was handed to ${started.label ?? 'another agent'}. Their replies go to the visitor directly.]`,
1698
+ });
1699
+ this._saveConversations();
1700
+ this._paintHandoffBar();
1701
+ return;
1702
+ }
1629
1703
  // A call that left for another page has filled in nothing yet, so it
1630
1704
  // must not become a "Submitted" card. The page it is opening finishes
1631
1705
  // the job and draws the card there.
@@ -2332,6 +2406,171 @@ class NxisAgentFullscreen extends HTMLElement {
2332
2406
  onRoute: (routed) => this._paintRoute(routed.agent, routed.via),
2333
2407
  });
2334
2408
  }
2409
+ // ── Handoff ────────────────────────────────────────────────────────────────
2410
+ /**
2411
+ * Handing conversations to other agents and to people.
2412
+ *
2413
+ * Set to `{ relay, targets }` to offer `transfer_to_agent` and
2414
+ * `talk_to_a_person` for those targets (see docs/handoff.md). While a
2415
+ * conversation is handed off, what the visitor types goes to the target,
2416
+ * and a bar above the input shows who is listening and the way back.
2417
+ */
2418
+ get handoff() {
2419
+ return this._handoff?.config ?? null;
2420
+ }
2421
+ set handoff(next) {
2422
+ this._offHandoff?.();
2423
+ this._offHandoff = null;
2424
+ this._handoff?.dispose();
2425
+ this._handoff = null;
2426
+ if (!next?.relay || !next.targets) {
2427
+ if (this._els?.thread)
2428
+ this._paintHandoffBar();
2429
+ return;
2430
+ }
2431
+ const controller = new HandoffController(next);
2432
+ const listener = (e) => this._onHandoffEvent(e.detail);
2433
+ controller.addEventListener('handoff', listener);
2434
+ this._offHandoff = () => controller.removeEventListener('handoff', listener);
2435
+ this._handoff = controller;
2436
+ // Tools already connecting or connected: add the handoff tools, and make the next turn wait for them.
2437
+ if (this._connect)
2438
+ this._connect = this._connect.then(() => this._registerHandoffBounded());
2439
+ if (this._els?.thread) {
2440
+ this._paintHandoffBar();
2441
+ if (this._activeId)
2442
+ void controller.resume(this._activeId);
2443
+ }
2444
+ }
2445
+ /** {@link _registerHandoff}, given at most four seconds: a slow relay must not hold up the conversation. */
2446
+ _registerHandoffBounded() {
2447
+ return Promise.race([this._registerHandoff(), new Promise((r) => setTimeout(r, 4000))]).catch((err) => console.warn('[nxis-agent] handoff tools unavailable:', err));
2448
+ }
2449
+ /** Offer the handoff tools on the page's registry. */
2450
+ async _registerHandoff() {
2451
+ const controller = this._handoff;
2452
+ const mc = this.toolSource?.modelContext();
2453
+ if (!controller || !mc)
2454
+ return;
2455
+ const added = await registerHandoffTools(mc, controller, {
2456
+ conversationId: () => this._activeId ?? undefined,
2457
+ transcript: () => this._transcript(),
2458
+ });
2459
+ if (added.length)
2460
+ void this._refreshToolCount();
2461
+ }
2462
+ /** The conversation's last turns, text only, for a target allowed to see them. */
2463
+ _transcript() {
2464
+ const conv = this._active();
2465
+ if (!conv)
2466
+ return [];
2467
+ return conv.items
2468
+ .filter((it) => it.kind === 'msg' && (it.role === 'user' || it.role === 'assistant'))
2469
+ .slice(-10)
2470
+ .map((it) => ({ role: it.role === 'user' ? 'visitor' : 'assistant', text: it.text }));
2471
+ }
2472
+ /** What a handoff reported, into the conversation it belongs to. */
2473
+ _onHandoffEvent(e) {
2474
+ const conv = this._conversations.find((c) => c.id === e.conversationId);
2475
+ if (!conv)
2476
+ return;
2477
+ if (e.type === 'message') {
2478
+ this._appendHandoffItem(conv.id, { kind: 'remote', from: e.from, fromKind: e.fromKind, text: e.text });
2479
+ }
2480
+ else if (e.type === 'handback') {
2481
+ const { outcome, note, references } = e.handBack;
2482
+ const refs = references.map((r) => `${r.label}: ${r.value}`).join(' · ');
2483
+ // The assistant carries on from here, so it learns what happened: the note, and nothing else the
2484
+ // target said. A remote reply is shown to the visitor and never read as an instruction.
2485
+ conv.messages.push({
2486
+ role: 'user',
2487
+ content: `[${e.session.label} handed the conversation back (${outcome})${note ? `: ${note}` : ''}${refs ? `. ${refs}` : ''}]`,
2488
+ });
2489
+ if (refs)
2490
+ this._appendHandoffItem(conv.id, { kind: 'handoff', text: refs, tone: 'done' });
2491
+ }
2492
+ else {
2493
+ const text = handoffLine(e.session);
2494
+ const last = [...conv.items].reverse().find((it) => it.kind === 'handoff');
2495
+ if (text && last?.text !== text) {
2496
+ const tone = e.state === 'failed' ? 'error' : isHandoffOver(e.state) ? 'done' : 'info';
2497
+ this._appendHandoffItem(conv.id, { kind: 'handoff', text, tone });
2498
+ }
2499
+ if (e.state === 'ended' || e.state === 'failed') {
2500
+ conv.messages.push({
2501
+ role: 'user',
2502
+ content: `[The conversation with ${e.session.label} ${e.state === 'failed' ? `failed${e.session.error ? `: ${e.session.error}` : ''}` : 'ended'}. You are handling it again.]`,
2503
+ });
2504
+ }
2505
+ }
2506
+ this._saveConversations();
2507
+ if (conv.id === this._activeId)
2508
+ this._paintHandoffBar();
2509
+ }
2510
+ /** Add a handoff item to a conversation, and draw it when that conversation is on screen. */
2511
+ _appendHandoffItem(conversationId, item) {
2512
+ const conv = this._conversations.find((c) => c.id === conversationId);
2513
+ if (!conv)
2514
+ return;
2515
+ conv.items.push(item);
2516
+ this._saveConversations();
2517
+ if (conversationId === this._activeId && this._els?.thread) {
2518
+ this._paintHandoffItem(item);
2519
+ this._scroll();
2520
+ }
2521
+ }
2522
+ _paintHandoffItem(item) {
2523
+ this._clearEmpty();
2524
+ let el;
2525
+ if (item.kind === 'remote') {
2526
+ el = document.createElement('div');
2527
+ el.className = 'remote';
2528
+ const who = document.createElement('div');
2529
+ who.className = 'remote-from';
2530
+ who.textContent = item.fromKind === 'agent' ? `${item.from} · agent` : item.from;
2531
+ el.append(who, this._msgEl('assistant', item.text));
2532
+ }
2533
+ else {
2534
+ el = document.createElement('div');
2535
+ el.className = `handoff-note ${item.tone}`;
2536
+ el.textContent = item.text;
2537
+ }
2538
+ this._els.thread.appendChild(el);
2539
+ return el;
2540
+ }
2541
+ /** The bar above the input: shown while the conversation is handed off. */
2542
+ _paintHandoffBar() {
2543
+ if (!this._els?.handoffBar)
2544
+ return;
2545
+ const session = this._handoff?.active(this._activeId ?? undefined) ?? null;
2546
+ this._els.handoffBar.hidden = !session;
2547
+ const input = this._els.input;
2548
+ if (!session) {
2549
+ input.placeholder = 'Message the agent…';
2550
+ input.setAttribute('aria-label', 'Message the agent');
2551
+ return;
2552
+ }
2553
+ const who = session.operator ?? session.label;
2554
+ const q = session.queue;
2555
+ const inLine = q?.position ? ` · ${ordinal(q.position)} in line${q.waitMinutes ? `, about ${q.waitMinutes} min` : ''}` : ' · waiting in line';
2556
+ const doing = session.state === 'connecting'
2557
+ ? ' · connecting…'
2558
+ : session.state === 'queued'
2559
+ ? inLine
2560
+ : session.state === 'awaiting-visitor'
2561
+ ? ' · waiting for your reply'
2562
+ : '';
2563
+ this._els.handoffLabel.textContent = `Talking with ${who}${doing}`;
2564
+ input.placeholder = `Message ${who}…`;
2565
+ input.setAttribute('aria-label', `Message ${who}`);
2566
+ }
2567
+ /** "Back to the assistant": end the handoff for this conversation. */
2568
+ async _endHandoff() {
2569
+ if (!this._activeId || !this._handoff)
2570
+ return;
2571
+ await this._handoff.end(this._activeId);
2572
+ this._els.input.focus();
2573
+ }
2335
2574
  /** Zero when the provider cannot count, which hides the token metrics. */
2336
2575
  async countTokens(text) {
2337
2576
  return (await this.provider.countTokens?.(text)) ?? 0;
@@ -2511,7 +2750,10 @@ class NxisAgentFullscreen extends HTMLElement {
2511
2750
  void this._renderTools();
2512
2751
  });
2513
2752
  })
2514
- .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err));
2753
+ .catch((err) => console.warn('[nxis-agent] tool discovery failed:', err))
2754
+ // Part of connecting, so the first turn waits for the handoff tools: a turn that ran without
2755
+ // them could only claim, in words, to be handing the visitor over.
2756
+ .then(() => this._registerHandoffBounded());
2515
2757
  return this._connect;
2516
2758
  }
2517
2759
  // ── Tools menu ─────────────────────────────────────────────────────────────
@@ -3396,6 +3638,23 @@ class NxisAgentFullscreen extends HTMLElement {
3396
3638
  padding: 2px 0 6px;
3397
3639
  letter-spacing: .01em;
3398
3640
  }
3641
+
3642
+ /* Handoffs. A target's message is a bubble with its name above; where the
3643
+ handoff stands is a quiet centred line; the bar says who is listening. */
3644
+ .remote { align-self: flex-start; display: flex; flex-direction: column; gap: 4px; max-width: 76%; }
3645
+ .remote .msg { max-width: 100%; }
3646
+ .remote-from { font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em; color: var(--ink-3); padding-left: 4px; }
3647
+ .handoff-note { align-self: center; font-size: 12px; color: var(--ink-3); padding: 2px 12px 6px; text-align: center; letter-spacing: .01em; }
3648
+ .handoff-note.done { color: var(--green-strong); }
3649
+ .handoff-note.error { color: var(--red); }
3650
+ .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; }
3651
+ .handoff-bar[hidden] { display: none; }
3652
+ .handoff-bar .hb-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex: none; }
3653
+ .handoff-bar .hb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
3654
+ .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; }
3655
+ .handoff-bar .hb-back:hover { border-color: var(--ink-3); }
3656
+ .handoff-bar .hb-back:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
3657
+ .overlay.window .handoff-bar, .overlay.inline .handoff-bar { margin: 0 14px 6px; }
3399
3658
  .composer .go svg { width: 17px; height: 17px; }
3400
3659
 
3401
3660
  /* Attribution. Quiet enough to sit under a customer's own chat surface
@@ -3609,6 +3868,10 @@ class NxisAgentFullscreen extends HTMLElement {
3609
3868
  </button>
3610
3869
  <div class="thread"></div>
3611
3870
  <div class="statusline"></div>
3871
+ <div class="handoff-bar" role="status" hidden>
3872
+ <span class="hb-dot" aria-hidden="true"></span><span class="hb-label"></span>
3873
+ <button class="hb-back" type="button">Back to the assistant</button>
3874
+ </div>
3612
3875
  <div class="composer">
3613
3876
  <div class="cbox">
3614
3877
  <textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>