@nxisai/agent-harness 0.4.1 → 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.
- package/README.md +25 -2
- package/dist/handoff/a2a.d.ts +152 -0
- package/dist/handoff/a2a.d.ts.map +1 -0
- package/dist/handoff/a2a.js +227 -0
- package/dist/handoff/a2a.js.map +1 -0
- package/dist/handoff/controller.d.ts +181 -0
- package/dist/handoff/controller.d.ts.map +1 -0
- package/dist/handoff/controller.js +543 -0
- package/dist/handoff/controller.js.map +1 -0
- package/dist/handoff/envelope.d.ts +130 -0
- package/dist/handoff/envelope.d.ts.map +1 -0
- package/dist/handoff/envelope.js +178 -0
- package/dist/handoff/envelope.js.map +1 -0
- package/dist/handoff/index.d.ts +16 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/tools.d.ts +36 -0
- package/dist/handoff/tools.d.ts.map +1 -0
- package/dist/handoff/tools.js +148 -0
- package/dist/handoff/tools.js.map +1 -0
- package/dist/harness/harness.d.ts +4 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +61 -16
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/router/coordinator.d.ts +13 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +7 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +7 -5
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +33 -12
- package/dist/harness/tools/bridge.js.map +1 -1
- package/dist/harness/tools/discovery.d.ts +12 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +19 -1
- package/dist/harness/tools/discovery.js.map +1 -1
- package/dist/harness/tools/local-context.d.ts +49 -9
- package/dist/harness/tools/local-context.d.ts.map +1 -1
- package/dist/harness/tools/local-context.js +377 -22
- package/dist/harness/tools/local-context.js.map +1 -1
- package/dist/harness/tools/open-page.d.ts +16 -0
- package/dist/harness/tools/open-page.d.ts.map +1 -1
- package/dist/harness/tools/open-page.js +45 -9
- package/dist/harness/tools/open-page.js.map +1 -1
- package/dist/harness/tools/retry.d.ts +53 -0
- package/dist/harness/tools/retry.d.ts.map +1 -0
- package/dist/harness/tools/retry.js +96 -0
- package/dist/harness/tools/retry.js.map +1 -0
- package/dist/harness/tools/schema.d.ts +18 -0
- package/dist/harness/tools/schema.d.ts.map +1 -1
- package/dist/harness/tools/schema.js +38 -0
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +3 -2
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/site-nav.d.ts +168 -0
- package/dist/harness/tools/site-nav.d.ts.map +1 -0
- package/dist/harness/tools/site-nav.js +316 -0
- package/dist/harness/tools/site-nav.js.map +1 -0
- package/dist/harness/tools/site-pages.d.ts +4 -0
- package/dist/harness/tools/site-pages.d.ts.map +1 -1
- package/dist/harness/tools/site-pages.js +19 -3
- package/dist/harness/tools/site-pages.js.map +1 -1
- package/dist/harness/tools/source.d.ts +9 -0
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +54 -14
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/harness/tools/webmcp.d.ts +39 -3
- package/dist/harness/tools/webmcp.d.ts.map +1 -1
- package/dist/harness/tools/webmcp.js +93 -14
- package/dist/harness/tools/webmcp.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +276 -2
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +2 -1
- package/src/a2ui/cards.ts +177 -0
- package/src/a2ui/emit.ts +147 -0
- package/src/a2ui/form.ts +117 -0
- package/src/a2ui/host.ts +73 -0
- package/src/a2ui/index.ts +22 -0
- package/src/a2ui/stepper.ts +211 -0
- package/src/a2ui/theme.ts +134 -0
- package/src/a2ui/to-v09.ts +118 -0
- package/src/engine.ts +701 -0
- package/src/handoff/a2a.ts +326 -0
- package/src/handoff/controller.ts +661 -0
- package/src/handoff/envelope.ts +216 -0
- package/src/handoff/index.ts +53 -0
- package/src/handoff/tools.ts +178 -0
- package/src/harness/harness.ts +981 -0
- package/src/harness/plan.ts +91 -0
- package/src/harness/policy.ts +59 -0
- package/src/harness/renderer.ts +208 -0
- package/src/harness/router/coordinator.ts +241 -0
- package/src/harness/router/rules.ts +65 -0
- package/src/harness/slots.ts +117 -0
- package/src/harness/steps.ts +156 -0
- package/src/harness/tools/action-tools.ts +870 -0
- package/src/harness/tools/bridge.ts +393 -0
- package/src/harness/tools/client.ts +26 -0
- package/src/harness/tools/discovery.ts +73 -0
- package/src/harness/tools/local-context.ts +480 -0
- package/src/harness/tools/open-page.ts +273 -0
- package/src/harness/tools/pending-action.ts +232 -0
- package/src/harness/tools/resume.ts +134 -0
- package/src/harness/tools/retry.ts +117 -0
- package/src/harness/tools/schema.ts +245 -0
- package/src/harness/tools/site-actions.ts +356 -0
- package/src/harness/tools/site-nav.ts +432 -0
- package/src/harness/tools/site-pages.ts +288 -0
- package/src/harness/tools/source.ts +353 -0
- package/src/harness/tools/webmcp.ts +200 -0
- package/src/index.ts +196 -0
- package/src/providers/capability.ts +167 -0
- package/src/providers/proxy.ts +191 -0
- package/src/providers/types.ts +150 -0
- package/src/types.ts +258 -0
- package/src/ui/agent-fullscreen.ts +4262 -0
- package/src/ui/agent.tw.css +49 -0
- package/src/ui/config.ts +138 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/journey.ts +57 -0
- package/src/ui/tailwind.generated.ts +2 -0
- package/src/webmcp-contract.ts +65 -0
|
@@ -32,7 +32,7 @@ import { TAILWIND_CSS } from './tailwind.generated.js';
|
|
|
32
32
|
import { emitAsk, emitStep } from '../a2ui/stepper.js';
|
|
33
33
|
import { AgentHarness, isAbort } from '../harness/harness.js';
|
|
34
34
|
import { WebMcpTools } from '../harness/tools/webmcp.js';
|
|
35
|
-
import { extractResult, missingRequired, toolSchema, } from '../harness/tools/schema.js';
|
|
35
|
+
import { extractResult, proseAnswer, missingRequired, toolSchema, } from '../harness/tools/schema.js';
|
|
36
36
|
import { connectTools } from '../harness/tools/source.js';
|
|
37
37
|
import { handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
|
|
38
38
|
import { resumePendingAction } from '../harness/tools/resume.js';
|
|
@@ -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;
|
|
@@ -1242,6 +1303,17 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1242
1303
|
results: (tool, res) => {
|
|
1243
1304
|
const conv = this._active();
|
|
1244
1305
|
const item = this._buildResultsItem(tool, res);
|
|
1306
|
+
// A read that answers in words — "you are on the home page", "already open" — has no records
|
|
1307
|
+
// to draw, and an empty result card reading "No results found." contradicts the answer it
|
|
1308
|
+
// just gave. Say what it said.
|
|
1309
|
+
const prose = item.cards.length ? '' : proseAnswer(res);
|
|
1310
|
+
if (prose) {
|
|
1311
|
+
this._appendMsg('assistant', prose);
|
|
1312
|
+
this._upsertMetrics();
|
|
1313
|
+
this._saveConversations();
|
|
1314
|
+
this._scroll();
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1245
1317
|
conv?.items.push(item);
|
|
1246
1318
|
this._els.thread.appendChild(this._resultsEl(item));
|
|
1247
1319
|
this._upsertMetrics();
|
|
@@ -1615,6 +1687,19 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
1615
1687
|
submitBtn.disabled = false;
|
|
1616
1688
|
return;
|
|
1617
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
|
+
}
|
|
1618
1703
|
// A call that left for another page has filled in nothing yet, so it
|
|
1619
1704
|
// must not become a "Submitted" card. The page it is opening finishes
|
|
1620
1705
|
// the job and draws the card there.
|
|
@@ -2321,6 +2406,171 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2321
2406
|
onRoute: (routed) => this._paintRoute(routed.agent, routed.via),
|
|
2322
2407
|
});
|
|
2323
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
|
+
}
|
|
2324
2574
|
/** Zero when the provider cannot count, which hides the token metrics. */
|
|
2325
2575
|
async countTokens(text) {
|
|
2326
2576
|
return (await this.provider.countTokens?.(text)) ?? 0;
|
|
@@ -2500,7 +2750,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
2500
2750
|
void this._renderTools();
|
|
2501
2751
|
});
|
|
2502
2752
|
})
|
|
2503
|
-
.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());
|
|
2504
2757
|
return this._connect;
|
|
2505
2758
|
}
|
|
2506
2759
|
// ── Tools menu ─────────────────────────────────────────────────────────────
|
|
@@ -3385,6 +3638,23 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3385
3638
|
padding: 2px 0 6px;
|
|
3386
3639
|
letter-spacing: .01em;
|
|
3387
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; }
|
|
3388
3658
|
.composer .go svg { width: 17px; height: 17px; }
|
|
3389
3659
|
|
|
3390
3660
|
/* Attribution. Quiet enough to sit under a customer's own chat surface
|
|
@@ -3598,6 +3868,10 @@ class NxisAgentFullscreen extends HTMLElement {
|
|
|
3598
3868
|
</button>
|
|
3599
3869
|
<div class="thread"></div>
|
|
3600
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>
|
|
3601
3875
|
<div class="composer">
|
|
3602
3876
|
<div class="cbox">
|
|
3603
3877
|
<textarea rows="1" placeholder="Message the agent…" aria-label="Message the agent"></textarea>
|