@nxisai/agent-harness 0.6.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 (46) hide show
  1. package/README.md +16 -3
  2. package/dist/handoff/tools.d.ts.map +1 -1
  3. package/dist/handoff/tools.js +11 -4
  4. package/dist/handoff/tools.js.map +1 -1
  5. package/dist/harness/harness.d.ts.map +1 -1
  6. package/dist/harness/harness.js +26 -8
  7. package/dist/harness/harness.js.map +1 -1
  8. package/dist/harness/renderer.d.ts +20 -0
  9. package/dist/harness/renderer.d.ts.map +1 -1
  10. package/dist/harness/tools/action-tools.d.ts +29 -0
  11. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  12. package/dist/harness/tools/action-tools.js +64 -4
  13. package/dist/harness/tools/action-tools.js.map +1 -1
  14. package/dist/harness/tools/site-actions.d.ts +27 -0
  15. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  16. package/dist/harness/tools/site-actions.js +47 -0
  17. package/dist/harness/tools/site-actions.js.map +1 -1
  18. package/dist/harness/tools/source.d.ts.map +1 -1
  19. package/dist/harness/tools/source.js +5 -2
  20. package/dist/harness/tools/source.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/ui/action-card.d.ts +141 -0
  26. package/dist/ui/action-card.d.ts.map +1 -0
  27. package/dist/ui/action-card.js +384 -0
  28. package/dist/ui/action-card.js.map +1 -0
  29. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  30. package/dist/ui/agent-fullscreen.js +531 -16
  31. package/dist/ui/agent-fullscreen.js.map +1 -1
  32. package/dist/ui/tailwind.generated.d.ts +1 -1
  33. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  34. package/dist/ui/tailwind.generated.js +1 -1
  35. package/dist/ui/tailwind.generated.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/handoff/tools.ts +11 -7
  38. package/src/harness/harness.ts +31 -15
  39. package/src/harness/renderer.ts +22 -0
  40. package/src/harness/tools/action-tools.ts +74 -4
  41. package/src/harness/tools/site-actions.ts +58 -0
  42. package/src/harness/tools/source.ts +5 -2
  43. package/src/index.ts +1 -1
  44. package/src/ui/action-card.ts +496 -0
  45. package/src/ui/agent-fullscreen.ts +563 -15
  46. package/src/ui/tailwind.generated.ts +1 -1
@@ -34,12 +34,15 @@ import { AgentHarness, isAbort } from '../harness/harness.js';
34
34
  import { WebMcpTools } from '../harness/tools/webmcp.js';
35
35
  import { extractResult, proseAnswer, missingRequired, toolSchema, } from '../harness/tools/schema.js';
36
36
  import { connectTools } from '../harness/tools/source.js';
37
- import { handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
37
+ import { chooserOf, formOptions, handoffFor, isSiteTool, withoutSiteSuffix, HANDOFF_RESULT, } from '../harness/tools/action-tools.js';
38
+ import { resolveSlots } from '../harness/slots.js';
39
+ import { isHandoffTool } from '../handoff/tools.js';
40
+ import { actionCard, fieldLabel, fieldsFromSlots, ACTION_CARD_CSS, } from './action-card.js';
38
41
  import { resumePendingAction } from '../harness/tools/resume.js';
39
42
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
40
43
  import { pageForEntity } from '../harness/tools/site-pages.js';
41
44
  import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
42
- import { onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
45
+ import { currentJourney, onJourneyChange, recordStep, restoreJourney } from '../harness/steps.js';
43
46
  import { viewJourney } from './journey.js';
44
47
  import { AgentCoordinator } from '../harness/router/coordinator.js';
45
48
  import { HandoffController, isHandoffOver, } from '../handoff/controller.js';
@@ -78,6 +81,11 @@ const fmt = (n) => n.toLocaleString();
78
81
  * be disabled under `prefers-reduced-motion` while the bubble still reads as
79
82
  * pending. The label and byte spans are filled per phase and hidden while empty.
80
83
  */
84
+ /**
85
+ * How long the running-step line stays once the conversation moves on past
86
+ * its card. A glance's worth: long enough to notice and open it.
87
+ */
88
+ const JSTATUS_TIMEOUT_MS = 10000;
81
89
  const THINKING_MARKUP = '<span class="think-row">' +
82
90
  '<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
83
91
  '<span class="think-label"></span>' +
@@ -245,6 +253,12 @@ class NxisAgentFullscreen extends HTMLElement {
245
253
  };
246
254
  /** Watches whether the running journey's card is on screen. */
247
255
  this._journeyObserver = null;
256
+ /** The journey the status line is about, while one is running. */
257
+ this._jstatusJourney = null;
258
+ /** Journeys whose status line was opened once or timed out: it does not come back. */
259
+ this._jstatusDismissed = new Set();
260
+ /** Dismisses the status line a while after the thread moves on past the card. */
261
+ this._jstatusTimer = null;
248
262
  /** Built only when the host defines agents — otherwise there is one harness. */
249
263
  this._coordinator = null;
250
264
  this._agentDefs = null;
@@ -252,6 +266,14 @@ class NxisAgentFullscreen extends HTMLElement {
252
266
  this._fallbackAgent = '';
253
267
  /** The live ask/confirm surface, if one is open. */
254
268
  this._prompt = null;
269
+ /**
270
+ * The action card still waiting on the visitor or a result, and what it can
271
+ * do: the harness's ask or confirm request, or, after a page change, a call
272
+ * of the widget's own.
273
+ */
274
+ this._live = null;
275
+ /** Handoff targets as the relay last reported them, for a card's availability line. */
276
+ this._handoffTargets = new Map();
255
277
  this._persistPrefs = false;
256
278
  this._systemPrompt = DEFAULT_SYSTEM_PROMPT;
257
279
  this._suggestions = DEFAULT_SUGGESTIONS;
@@ -349,13 +371,7 @@ class NxisAgentFullscreen extends HTMLElement {
349
371
  this._buildAgents();
350
372
  // The element renders; the harness decides. Everything from choosing a
351
373
  // tool to running it happens behind harness.turn().
352
- this._harness = new AgentHarness({
353
- provider: this.provider,
354
- toolSource: this.toolSource,
355
- renderer: this._renderer(),
356
- profile: (schema) => this._profileForSchema(schema),
357
- onMessages: () => this._saveConversations(),
358
- });
374
+ this._harness = this._singleHarness();
359
375
  // Preferences are supplied by the host page through `element.preferences`.
360
376
  // Anything a previous build persisted is picked up so upgrading does not
361
377
  // silently drop a user's saved details; `persist` then stays true so they
@@ -387,6 +403,8 @@ class NxisAgentFullscreen extends HTMLElement {
387
403
  this._els.jstatus.addEventListener('click', () => {
388
404
  const cards = this._els.thread.querySelectorAll('.journey.is-running');
389
405
  cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
406
+ // One look is what it is for; after that it would only be in the way.
407
+ this._dismissJourneyStatus();
390
408
  });
391
409
  // Whoever builds the tools, a call that leaves this page reports back to
392
410
  // the thread it was made in.
@@ -482,6 +500,7 @@ class NxisAgentFullscreen extends HTMLElement {
482
500
  this._offJourney = null;
483
501
  this._journeyObserver?.disconnect();
484
502
  this._journeyObserver = null;
503
+ this._clearJourneyStatusTimer();
485
504
  // Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
486
505
  this._handoff?.dispose();
487
506
  }
@@ -667,8 +686,13 @@ class NxisAgentFullscreen extends HTMLElement {
667
686
  this._els.thread.appendChild(this._resultsEl(it));
668
687
  else if (it.kind === 'metrics')
669
688
  this._els.thread.appendChild(this._metricsEl(it));
670
- else if (it.kind === 'journey')
671
- this._els.thread.appendChild(this._journeyEl(it));
689
+ else if (it.kind === 'journey') {
690
+ // A request drawn as an action card shows its own steps.
691
+ if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id))
692
+ this._els.thread.appendChild(this._journeyEl(it));
693
+ }
694
+ else if (it.kind === 'action')
695
+ this._paintAction(it);
672
696
  else if (it.kind === 'remote' || it.kind === 'handoff')
673
697
  this._paintHandoffItem(it);
674
698
  }
@@ -1038,6 +1062,14 @@ class NxisAgentFullscreen extends HTMLElement {
1038
1062
  item = { kind: 'journey', id: view.id, steps: all };
1039
1063
  conv.items.push(item);
1040
1064
  }
1065
+ // A request with an action card: the steps belong in the card.
1066
+ const action = conv.items.find((it) => it.kind === 'action' && it.id === view.id);
1067
+ if (action) {
1068
+ this._actionFollows(action, all);
1069
+ this._watchRunningJourney();
1070
+ this._saveConversations();
1071
+ return;
1072
+ }
1041
1073
  if (conv.id === this._activeId && this._els?.thread) {
1042
1074
  const fresh = this._journeyEl(item);
1043
1075
  const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
@@ -1071,7 +1103,12 @@ class NxisAgentFullscreen extends HTMLElement {
1071
1103
  return;
1072
1104
  const cards = this._els.thread.querySelectorAll('.journey.is-running');
1073
1105
  const card = cards[cards.length - 1];
1074
- if (!card || typeof IntersectionObserver === 'undefined') {
1106
+ const id = card?.dataset.journey ?? null;
1107
+ if (id !== this._jstatusJourney) {
1108
+ this._clearJourneyStatusTimer();
1109
+ this._jstatusJourney = id;
1110
+ }
1111
+ if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
1075
1112
  bar.hidden = true;
1076
1113
  return;
1077
1114
  }
@@ -1081,6 +1118,37 @@ class NxisAgentFullscreen extends HTMLElement {
1081
1118
  }, { root: this._els.thread, threshold: 0.2 });
1082
1119
  this._journeyObserver.observe(card);
1083
1120
  }
1121
+ /**
1122
+ * The thread grew: start the clock on the status line.
1123
+ *
1124
+ * A journey can stay running while the conversation goes on without it, and a
1125
+ * line that spins through all of that says nothing. So once new messages
1126
+ * arrive, the line gets a while longer, then goes.
1127
+ */
1128
+ _noteThreadGrew() {
1129
+ const id = this._jstatusJourney;
1130
+ if (!id || this._jstatusTimer || this._jstatusDismissed.has(id))
1131
+ return;
1132
+ this._jstatusTimer = setTimeout(() => {
1133
+ this._jstatusTimer = null;
1134
+ this._dismissJourneyStatus();
1135
+ }, JSTATUS_TIMEOUT_MS);
1136
+ }
1137
+ /** Hide the status line for the running journey, for good. */
1138
+ _dismissJourneyStatus() {
1139
+ this._clearJourneyStatusTimer();
1140
+ if (this._jstatusJourney)
1141
+ this._jstatusDismissed.add(this._jstatusJourney);
1142
+ this._journeyObserver?.disconnect();
1143
+ this._journeyObserver = null;
1144
+ if (this._els?.jstatus)
1145
+ this._els.jstatus.hidden = true;
1146
+ }
1147
+ _clearJourneyStatusTimer() {
1148
+ if (this._jstatusTimer)
1149
+ clearTimeout(this._jstatusTimer);
1150
+ this._jstatusTimer = null;
1151
+ }
1084
1152
  _paintToolResult(name, text, isError) {
1085
1153
  this._clearEmpty();
1086
1154
  const el = this._toolEl(name, text, isError);
@@ -1137,6 +1205,7 @@ class NxisAgentFullscreen extends HTMLElement {
1137
1205
  const el = this._paintMsg(role, text, conv.items.length - 1);
1138
1206
  this._saveConversations();
1139
1207
  this._scroll();
1208
+ this._noteThreadGrew();
1140
1209
  return el;
1141
1210
  }
1142
1211
  /**
@@ -1156,6 +1225,7 @@ class NxisAgentFullscreen extends HTMLElement {
1156
1225
  // without the focus moving to it.
1157
1226
  el.setAttribute('aria-live', 'polite');
1158
1227
  this._scroll();
1228
+ this._noteThreadGrew();
1159
1229
  const setLabel = (label) => {
1160
1230
  const node = el.querySelector('.think-label');
1161
1231
  if (node)
@@ -1298,8 +1368,10 @@ class NxisAgentFullscreen extends HTMLElement {
1298
1368
  message: (role, text) => this._appendMsg(role, text),
1299
1369
  streaming: () => this._appendStreaming(),
1300
1370
  status: (text, isError) => this._setStatus(text, isError),
1301
- ask: (req) => this._renderAsk(req),
1302
- confirm: (req) => this._renderConfirm(req),
1371
+ inlinePrompts: !this._classicPrompts,
1372
+ ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
1373
+ confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
1374
+ left: (tool, destination) => this._cardLeft(tool, destination),
1303
1375
  results: (tool, res) => {
1304
1376
  const conv = this._active();
1305
1377
  const item = this._buildResultsItem(tool, res);
@@ -1321,6 +1393,8 @@ class NxisAgentFullscreen extends HTMLElement {
1321
1393
  this._scroll();
1322
1394
  },
1323
1395
  detail: (tool, values, text) => {
1396
+ if (this._cardDone(tool, values, text))
1397
+ return;
1324
1398
  const conv = this._active();
1325
1399
  const item = this._buildDetailItem(tool, values, text);
1326
1400
  conv?.items.push(item);
@@ -1345,8 +1419,24 @@ class NxisAgentFullscreen extends HTMLElement {
1345
1419
  this._saveConversations();
1346
1420
  this._scroll();
1347
1421
  },
1348
- toolError: (tool, message) => this._paintToolResult(tool.name, message, true),
1349
- dismissPrompt: () => this._dismissPrompt(),
1422
+ toolError: (tool, message) => {
1423
+ if (this._cardFailed(tool, message))
1424
+ return;
1425
+ this._paintToolResult(tool.name, message, true);
1426
+ },
1427
+ dismissPrompt: () => {
1428
+ // A sent card stays, and shows the steps; one never sent says so.
1429
+ const live = this._live;
1430
+ if (live) {
1431
+ if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
1432
+ live.item.stage = 'working';
1433
+ this._paintAction(live.item);
1434
+ this._saveConversations();
1435
+ }
1436
+ return;
1437
+ }
1438
+ this._dismissPrompt();
1439
+ },
1350
1440
  // The harness says when stopping stops being possible, because it is the
1351
1441
  // part that knows a tool has been invoked. Once a call is out, offering
1352
1442
  // to stop would be offering to un-send a booking.
@@ -1478,6 +1568,378 @@ class NxisAgentFullscreen extends HTMLElement {
1478
1568
  this._formEl = wrap;
1479
1569
  this._scroll();
1480
1570
  }
1571
+ // ── Action cards ─────────────────────────────────────────────────────────────
1572
+ /** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
1573
+ get _classicPrompts() {
1574
+ return this.getAttribute('prompt-style') === 'classic';
1575
+ }
1576
+ /** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
1577
+ _usesCard(tool) {
1578
+ return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
1579
+ }
1580
+ /** Details are missing: the card asks for them, beside what it already has. */
1581
+ _cardAsk(req) {
1582
+ const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
1583
+ const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
1584
+ this._live.ask = req;
1585
+ this._live.confirm = undefined;
1586
+ item.stage = 'collect';
1587
+ this._paintAction(item);
1588
+ this._saveConversations();
1589
+ }
1590
+ /** Everything is known: the card says what will happen, and waits for Send. */
1591
+ _cardConfirm(req) {
1592
+ const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
1593
+ const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
1594
+ this._live.confirm = req;
1595
+ item.stage = 'review';
1596
+ this._paintAction(item);
1597
+ this._saveConversations();
1598
+ }
1599
+ /**
1600
+ * The live card for a tool's prompt: the one already open for this request,
1601
+ * or a new one, which takes over from the journey card of the same request.
1602
+ */
1603
+ _liveCard(tool, slots, values, needed, destination) {
1604
+ const conv = this._active();
1605
+ const id = currentJourney() ?? `a${Date.now().toString(36)}`;
1606
+ const live = this._live;
1607
+ const described = this._describeAction(tool, values, destination);
1608
+ const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
1609
+ if (live && live.item.id === id && live.tool.name === tool.name) {
1610
+ // The same request, a step on: keep what was typed, and anything the harness has since learned.
1611
+ live.item.fields = fields.map((f) => {
1612
+ const had = live.item.fields.find((o) => o.name === f.name);
1613
+ return had && f.value === '' ? { ...f, value: had.value } : f;
1614
+ });
1615
+ Object.assign(live.item, described.view);
1616
+ live.tool = tool;
1617
+ return live.item;
1618
+ }
1619
+ // A request left waiting is replaced by the new one.
1620
+ if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
1621
+ live.item.stage = 'declined';
1622
+ this._paintAction(live.item);
1623
+ }
1624
+ const item = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
1625
+ conv.items.push(item);
1626
+ this._live = { item, tool, sent: false, declining: false };
1627
+ this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
1628
+ this._watchRunningJourney();
1629
+ if (isHandoffTool(tool.name))
1630
+ void this._refreshHandoffTargets(item);
1631
+ return item;
1632
+ }
1633
+ /**
1634
+ * The slots with each field's options cut to the form the call means: one
1635
+ * business's services rather than every business's.
1636
+ */
1637
+ _narrowed(tool, slots, values) {
1638
+ const options = formOptions(tool, values);
1639
+ return slots.map((slot) => {
1640
+ const only = options[slot.name];
1641
+ if (!only)
1642
+ return slot;
1643
+ // Keep a value the call already gave, even one the form does not list: the form decides.
1644
+ const current = values[slot.name];
1645
+ const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
1646
+ return { ...slot, schema: { ...slot.schema, enum: list } };
1647
+ });
1648
+ }
1649
+ /** Who a request goes to, and how the card words it. */
1650
+ _describeAction(tool, values, destination) {
1651
+ const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
1652
+ if (isHandoffTool(tool.name)) {
1653
+ const name = String(values.target ?? '');
1654
+ const config = this._handoff?.config?.targets[name];
1655
+ const known = this._handoffTargets.get(name);
1656
+ const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
1657
+ const person = (known?.kind ?? config?.kind) !== 'agent';
1658
+ const away = known ? !known.available : false;
1659
+ // "The directory team" leads a line, but mid-sentence it is "the directory team".
1660
+ const inline = label.replace(/^The\s/, 'the ');
1661
+ const about = known?.description ?? config?.description;
1662
+ const status = !known
1663
+ ? undefined
1664
+ : away
1665
+ ? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
1666
+ : person
1667
+ ? known.waitMinutes
1668
+ ? `Available · replies in ~${known.waitMinutes} min`
1669
+ : 'Available'
1670
+ : 'Available';
1671
+ const sentence = !person
1672
+ ? `I'll hand this conversation to ${inline}. They'll take it from here.`
1673
+ : away
1674
+ ? email
1675
+ ? `${label} is away right now. They'll reply to ${email}.`
1676
+ : `${label} is away right now. Leave an email and they'll reply there.`
1677
+ : `I'll pass this to ${inline} so you don't have to repeat yourself.`;
1678
+ return {
1679
+ view: {
1680
+ variant: person ? 'handoff-person' : 'handoff-agent',
1681
+ noun: person ? 'handoff' : 'handover',
1682
+ identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
1683
+ sentence,
1684
+ note: undefined,
1685
+ sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
1686
+ },
1687
+ hidden: new Set(['target']),
1688
+ labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
1689
+ };
1690
+ }
1691
+ const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
1692
+ // Which business or place it is for: the site tool's own chooser, else a field the form names it in.
1693
+ const chooser = chooserOf(tool) ?? Object.keys(values).find((k) => /^(business|company|organi[sz]ation|provider|vendor|store|restaurant|practice|venue)(_name)?$/i.test(k)) ?? null;
1694
+ const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
1695
+ const leaving = destination ?? handoffFor(tool, values)?.label;
1696
+ const name = chosen || leaving || this._pageName();
1697
+ // What the conversation already showed about them: their card from a search.
1698
+ const card = this._conversations
1699
+ .find((c) => c.id === this._activeId)
1700
+ ?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
1701
+ .find((c) => c.title.toLowerCase() === name.toLowerCase());
1702
+ const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
1703
+ .filter(Boolean)
1704
+ .join(' · ');
1705
+ return {
1706
+ view: {
1707
+ variant: 'form',
1708
+ noun,
1709
+ identity: { name, sub },
1710
+ sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
1711
+ note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
1712
+ sendLabel: 'Send',
1713
+ },
1714
+ hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
1715
+ labels: {},
1716
+ };
1717
+ }
1718
+ /** The site's name for this page: its title, without the site's own name after it. */
1719
+ _pageName() {
1720
+ const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
1721
+ return title || 'this page';
1722
+ }
1723
+ /** Read availability for a handoff card, and redraw it once known. */
1724
+ async _refreshHandoffTargets(item) {
1725
+ const targets = await this._handoff?.targets().catch(() => []);
1726
+ for (const t of targets ?? [])
1727
+ this._handoffTargets.set(t.name, t);
1728
+ if (item.stage !== 'collect' && item.stage !== 'review')
1729
+ return;
1730
+ const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
1731
+ Object.assign(item, this._describeAction({ name: item.tool }, values).view);
1732
+ this._paintAction(item);
1733
+ }
1734
+ /** What the live card's buttons do. */
1735
+ _actionHandlers() {
1736
+ return {
1737
+ continue: (values) => {
1738
+ const live = this._live;
1739
+ if (!live)
1740
+ return;
1741
+ if (live.ask && !live.confirm) {
1742
+ live.ask.onComplete(values);
1743
+ return;
1744
+ }
1745
+ // Back from a change, or a card of the widget's own: review again, here.
1746
+ Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
1747
+ live.item.stage = 'review';
1748
+ this._paintAction(live.item);
1749
+ this._saveConversations();
1750
+ },
1751
+ change: () => {
1752
+ const live = this._live;
1753
+ if (!live)
1754
+ return;
1755
+ live.item.fields.forEach((f) => {
1756
+ if (f.group === 'needed' && f.value.trim() !== '')
1757
+ f.group = 'known';
1758
+ });
1759
+ live.sent = false;
1760
+ live.item.stage = 'collect';
1761
+ live.item.open = true;
1762
+ this._paintAction(live.item);
1763
+ this._saveConversations();
1764
+ },
1765
+ send: (values) => {
1766
+ const live = this._live;
1767
+ if (!live)
1768
+ return;
1769
+ live.sent = true;
1770
+ if (live.confirm)
1771
+ live.confirm.onSubmit(values);
1772
+ else if (live.run)
1773
+ void live.run(values);
1774
+ },
1775
+ decline: () => {
1776
+ const live = this._live;
1777
+ if (!live)
1778
+ return;
1779
+ live.declining = true;
1780
+ if (live.confirm)
1781
+ live.confirm.onCancel();
1782
+ else if (live.ask)
1783
+ live.ask.onCancel();
1784
+ // A card the harness is not waiting on declines itself.
1785
+ if (this._live === live) {
1786
+ live.item.stage = 'declined';
1787
+ this._paintAction(live.item);
1788
+ this._live = null;
1789
+ this._saveConversations();
1790
+ }
1791
+ },
1792
+ };
1793
+ }
1794
+ /** Draw a card where it was, or at the end of the thread. */
1795
+ _paintAction(item) {
1796
+ if (!this._els?.thread)
1797
+ return;
1798
+ const conv = this._active();
1799
+ if (!conv?.items.includes(item))
1800
+ return;
1801
+ const live = this._live?.item === item;
1802
+ const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
1803
+ const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
1804
+ if (old)
1805
+ old.replaceWith(fresh);
1806
+ else {
1807
+ this._clearEmpty();
1808
+ this._els.thread.appendChild(fresh);
1809
+ this._scroll();
1810
+ }
1811
+ }
1812
+ /** A request's steps, as its card shows them once sent. */
1813
+ _actionFollows(item, all) {
1814
+ const live = this._live?.item === item ? this._live : null;
1815
+ const failed = [...all].reverse().find((st) => st.status === 'failed');
1816
+ const done = all.some((st) => st.id === 'submit' && st.status === 'done');
1817
+ const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
1818
+ item.steps = all
1819
+ .filter((st) => running.includes(st.id))
1820
+ .map((st) => ({
1821
+ label: st.id === 'fill' && st.status === 'active' && item.variant === 'form'
1822
+ ? `Filling in the ${item.noun}`
1823
+ : st.id === 'submit' && st.status === 'done' && item.variant === 'form'
1824
+ ? 'Sent'
1825
+ : st.label,
1826
+ status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
1827
+ }));
1828
+ if (failed) {
1829
+ if (live?.declining || failed.label === 'Cancelled') {
1830
+ item.stage = 'declined';
1831
+ if (live)
1832
+ this._live = null;
1833
+ }
1834
+ else {
1835
+ item.stage = 'failed';
1836
+ item.message = failed.detail || failed.label;
1837
+ }
1838
+ }
1839
+ else if (done) {
1840
+ item.stage = 'done';
1841
+ if (live)
1842
+ this._live = null;
1843
+ }
1844
+ else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
1845
+ item.stage = 'working';
1846
+ }
1847
+ this._paintAction(item);
1848
+ }
1849
+ /** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
1850
+ _cardLeft(tool, destination) {
1851
+ const live = this._live;
1852
+ if (!live || live.tool.name !== tool.name) {
1853
+ this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
1854
+ return;
1855
+ }
1856
+ live.item.stage = 'left';
1857
+ live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
1858
+ this._live = null;
1859
+ this._paintAction(live.item);
1860
+ this._saveConversations();
1861
+ }
1862
+ /** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
1863
+ _cardDone(tool, values, text) {
1864
+ const conv = this._active();
1865
+ const id = currentJourney();
1866
+ const item = conv?.items.find((it) => it.kind === 'action' && it.id === id && it.tool === tool.name);
1867
+ if (!item || this._classicPrompts)
1868
+ return false;
1869
+ const note = this._buildDetailItem(tool, values, text).note;
1870
+ // The default note says nothing the card's "Sent" does not.
1871
+ item.message = /submitted successfully/.test(note) ? undefined : note;
1872
+ item.stage = 'done';
1873
+ item.fields.forEach((f) => {
1874
+ if (values[f.name] !== undefined)
1875
+ f.value = String(values[f.name]);
1876
+ });
1877
+ if (this._live?.item === item)
1878
+ this._live = null;
1879
+ this._paintAction(item);
1880
+ this._saveConversations();
1881
+ return true;
1882
+ }
1883
+ /** It did not go through: the card says why and offers to change it. False when no card is waiting. */
1884
+ _cardFailed(tool, message) {
1885
+ const live = this._live;
1886
+ if (!live || live.tool.name !== tool.name)
1887
+ return false;
1888
+ live.sent = false;
1889
+ live.item.stage = 'failed';
1890
+ live.item.message = message;
1891
+ this._paintAction(live.item);
1892
+ this._saveConversations();
1893
+ return true;
1894
+ }
1895
+ /**
1896
+ * After a page change: the card of the request that travelled, found by its
1897
+ * journey, now finished or asked about here.
1898
+ */
1899
+ _resumedCard(journeyId) {
1900
+ if (!journeyId || this._classicPrompts)
1901
+ return null;
1902
+ return this._active()?.items.find((it) => it.kind === 'action' && it.id === journeyId) ?? null;
1903
+ }
1904
+ /** Let a resumed card be changed and sent again from this page, with the page's own tool. */
1905
+ _reviveCard(item, tool, values, needed) {
1906
+ const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
1907
+ const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
1908
+ item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
1909
+ this._live = {
1910
+ item,
1911
+ tool,
1912
+ sent: false,
1913
+ declining: false,
1914
+ run: async (vals) => {
1915
+ item.stage = 'working';
1916
+ item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
1917
+ this._paintAction(item);
1918
+ try {
1919
+ const res = await this.toolSource.execTool(tool, tool.name, vals);
1920
+ const text = extractResult(res);
1921
+ if (res?.isError) {
1922
+ this._cardFailed(tool, text || 'That form did not accept the details.');
1923
+ return;
1924
+ }
1925
+ this._active()?.messages.push({
1926
+ role: 'user',
1927
+ content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
1928
+ });
1929
+ this._live = null;
1930
+ const note = this._buildDetailItem(tool, vals, text).note;
1931
+ item.message = /submitted successfully/.test(note) ? undefined : note;
1932
+ item.stage = 'done';
1933
+ item.steps = [];
1934
+ this._paintAction(item);
1935
+ this._saveConversations();
1936
+ }
1937
+ catch (err) {
1938
+ this._cardFailed(tool, String(err?.message || err));
1939
+ }
1940
+ },
1941
+ };
1942
+ }
1481
1943
  // ── Inline generated form (from JSON-Schema) ───────────────────────────────
1482
1944
  _renderForm(tool, args) {
1483
1945
  if (this._useA2ui) {
@@ -2367,6 +2829,16 @@ class NxisAgentFullscreen extends HTMLElement {
2367
2829
  * `fallback`. Inexpensive: each agent is a role over the one shared provider,
2368
2830
  * so no additional model is loaded.
2369
2831
  */
2832
+ /** The one agent a page without `agents` runs, on the current provider. */
2833
+ _singleHarness() {
2834
+ return new AgentHarness({
2835
+ provider: this.provider,
2836
+ toolSource: this.toolSource,
2837
+ renderer: this._renderer(),
2838
+ profile: (schema) => this._profileForSchema(schema),
2839
+ onMessages: () => this._saveConversations(),
2840
+ });
2841
+ }
2370
2842
  _buildAgents() {
2371
2843
  if (!this.provider || !this._agentDefs) {
2372
2844
  this._coordinator = null;
@@ -2517,6 +2989,7 @@ class NxisAgentFullscreen extends HTMLElement {
2517
2989
  if (conversationId === this._activeId && this._els?.thread) {
2518
2990
  this._paintHandoffItem(item);
2519
2991
  this._scroll();
2992
+ this._noteThreadGrew();
2520
2993
  }
2521
2994
  }
2522
2995
  _paintHandoffItem(item) {
@@ -2663,6 +3136,42 @@ class NxisAgentFullscreen extends HTMLElement {
2663
3136
  });
2664
3137
  if (outcome.status !== 'none')
2665
3138
  this._setStatus('');
3139
+ // The card that travelled with the request finishes here, when there is one.
3140
+ const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
3141
+ if (card && outcome.status !== 'none') {
3142
+ switch (outcome.status) {
3143
+ case 'no-registry':
3144
+ case 'no-tool':
3145
+ card.stage = 'failed';
3146
+ card.message = `I couldn't find the ${card.noun} on this page, so nothing was sent. Ask me again and I'll fill it in from here.`;
3147
+ break;
3148
+ case 'incomplete':
3149
+ this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
3150
+ card.stage = 'collect';
3151
+ card.message = undefined;
3152
+ break;
3153
+ case 'rejected':
3154
+ case 'failed':
3155
+ this._reviveCard(card, outcome.tool, outcome.pending.values, []);
3156
+ card.stage = 'failed';
3157
+ card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
3158
+ break;
3159
+ case 'submitted': {
3160
+ const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
3161
+ card.message = /submitted successfully/.test(note) ? undefined : note;
3162
+ card.stage = 'done';
3163
+ this._active()?.messages.push({
3164
+ role: 'user',
3165
+ content: `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
3166
+ 'Keep these unless I ask to change them.',
3167
+ });
3168
+ break;
3169
+ }
3170
+ }
3171
+ this._paintAction(card);
3172
+ this._saveConversations();
3173
+ return;
3174
+ }
2666
3175
  switch (outcome.status) {
2667
3176
  case 'none':
2668
3177
  return;
@@ -2984,6 +3493,9 @@ class NxisAgentFullscreen extends HTMLElement {
2984
3493
  if (this._els?.thread) {
2985
3494
  this.provider = next ?? this.provider;
2986
3495
  this._paintProvenance();
3496
+ // Both the single agent and any configured ones: a page without `agents` kept the old
3497
+ // provider's harness, and answered with the model it had just replaced.
3498
+ this._harness = this._singleHarness();
2987
3499
  this._buildAgents();
2988
3500
  }
2989
3501
  }
@@ -3187,6 +3699,9 @@ class NxisAgentFullscreen extends HTMLElement {
3187
3699
  /* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
3188
3700
  ${A2UI_THEME_CSS}
3189
3701
 
3702
+ /* The action card: forms and handoffs as one message. */
3703
+ ${ACTION_CARD_CSS}
3704
+
3190
3705
  /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
3191
3706
  .launcher {
3192
3707
  position: fixed; right: 20px; bottom: 20px; z-index: 9998;