@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
@@ -41,16 +41,29 @@ import {
41
41
  import type { ModelProvider } from '../providers/types.js';
42
42
  import { connectTools } from '../harness/tools/source.js';
43
43
  import {
44
+ chooserOf,
45
+ formOptions,
44
46
  handoffFor,
45
47
  isSiteTool,
46
48
  withoutSiteSuffix,
47
49
  HANDOFF_RESULT,
48
50
  } from '../harness/tools/action-tools.js';
51
+ import { resolveSlots, type Slot } from '../harness/slots.js';
52
+ import { isHandoffTool } from '../handoff/tools.js';
53
+ import {
54
+ actionCard,
55
+ fieldLabel,
56
+ fieldsFromSlots,
57
+ ACTION_CARD_CSS,
58
+ type ActionHandlers,
59
+ type ActionItem,
60
+ type ActionStep,
61
+ } from './action-card.js';
49
62
  import { resumePendingAction } from '../harness/tools/resume.js';
50
63
  import { savePendingVisit, setConversationSource, takePendingVisit } from '../harness/tools/pending-action.js';
51
64
  import { pageForEntity, type SitePage } from '../harness/tools/site-pages.js';
52
65
  import { OPEN_PAGE_TOOL } from '../harness/tools/open-page.js';
53
- import { onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
66
+ import { currentJourney, onJourneyChange, recordStep, restoreJourney, type AgentStep } from '../harness/steps.js';
54
67
  import { viewJourney } from './journey.js';
55
68
  import { AgentCoordinator } from '../harness/router/coordinator.js';
56
69
  import {
@@ -59,6 +72,7 @@ import {
59
72
  type HandoffConfig,
60
73
  type HandoffEvent,
61
74
  type HandoffSession,
75
+ type HandoffTarget,
62
76
  } from '../handoff/controller.js';
63
77
  import { registerHandoffTools, HANDOFF_SESSION_RESULT } from '../handoff/tools.js';
64
78
  import type { RouteRule } from '../harness/router/rules.js';
@@ -151,6 +165,12 @@ const fmt = (n: number) => n.toLocaleString();
151
165
  * be disabled under `prefers-reduced-motion` while the bubble still reads as
152
166
  * pending. The label and byte spans are filled per phase and hidden while empty.
153
167
  */
168
+ /**
169
+ * How long the running-step line stays once the conversation moves on past
170
+ * its card. A glance's worth: long enough to notice and open it.
171
+ */
172
+ const JSTATUS_TIMEOUT_MS = 10_000;
173
+
154
174
  const THINKING_MARKUP =
155
175
  '<span class="think-row">' +
156
176
  '<span class="think-dots" aria-hidden="true"><i></i><i></i><i></i></span>' +
@@ -331,7 +351,9 @@ type ThreadItem =
331
351
  /** Something a handoff target said: an agent, or a person at a desk. */
332
352
  | { kind: 'remote'; from: string; fromKind: 'agent' | 'person'; text: string }
333
353
  /** Where a handoff stands, as a quiet line in the thread. */
334
- | { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' };
354
+ | { kind: 'handoff'; text: string; tone: 'info' | 'done' | 'error' }
355
+ /** A form or a handoff, asked for, confirmed and sent in one card. See ./action-card.ts. */
356
+ | ActionItem;
335
357
 
336
358
  /**
337
359
  * A handoff's state as a line in the thread, or '' for a state the bar alone
@@ -457,6 +479,12 @@ class NxisAgentFullscreen extends HTMLElement {
457
479
  };
458
480
  /** Watches whether the running journey's card is on screen. */
459
481
  private _journeyObserver: IntersectionObserver | null = null;
482
+ /** The journey the status line is about, while one is running. */
483
+ private _jstatusJourney: string | null = null;
484
+ /** Journeys whose status line was opened once or timed out: it does not come back. */
485
+ private _jstatusDismissed = new Set<string>();
486
+ /** Dismisses the status line a while after the thread moves on past the card. */
487
+ private _jstatusTimer: ReturnType<typeof setTimeout> | null = null;
460
488
  /** The turn loop. This element is only its renderer. */
461
489
  private _harness!: AgentHarness;
462
490
  /** Built only when the host defines agents — otherwise there is one harness. */
@@ -466,6 +494,22 @@ class NxisAgentFullscreen extends HTMLElement {
466
494
  private _fallbackAgent = '';
467
495
  /** The live ask/confirm surface, if one is open. */
468
496
  private _prompt: MountedSurface | null = null;
497
+ /**
498
+ * The action card still waiting on the visitor or a result, and what it can
499
+ * do: the harness's ask or confirm request, or, after a page change, a call
500
+ * of the widget's own.
501
+ */
502
+ private _live: {
503
+ item: ActionItem;
504
+ tool: ModelContextToolInfo;
505
+ ask?: AskRequest;
506
+ confirm?: ConfirmRequest;
507
+ run?: (values: Record<string, unknown>) => Promise<void>;
508
+ sent: boolean;
509
+ declining: boolean;
510
+ } | null = null;
511
+ /** Handoff targets as the relay last reported them, for a card's availability line. */
512
+ private _handoffTargets = new Map<string, HandoffTarget>();
469
513
  private _profile: Record<string, string>;
470
514
  private _persistPrefs = false;
471
515
  private _systemPrompt: string = DEFAULT_SYSTEM_PROMPT;
@@ -579,13 +623,7 @@ class NxisAgentFullscreen extends HTMLElement {
579
623
 
580
624
  // The element renders; the harness decides. Everything from choosing a
581
625
  // tool to running it happens behind harness.turn().
582
- this._harness = new AgentHarness({
583
- provider: this.provider,
584
- toolSource: this.toolSource,
585
- renderer: this._renderer(),
586
- profile: (schema) => this._profileForSchema(schema as JsonSchema),
587
- onMessages: () => this._saveConversations(),
588
- });
626
+ this._harness = this._singleHarness();
589
627
 
590
628
  // Preferences are supplied by the host page through `element.preferences`.
591
629
  // Anything a previous build persisted is picked up so upgrading does not
@@ -620,6 +658,8 @@ class NxisAgentFullscreen extends HTMLElement {
620
658
  this._els.jstatus.addEventListener('click', () => {
621
659
  const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
622
660
  cards[cards.length - 1]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
661
+ // One look is what it is for; after that it would only be in the way.
662
+ this._dismissJourneyStatus();
623
663
  });
624
664
 
625
665
  // Whoever builds the tools, a call that leaves this page reports back to
@@ -715,6 +755,7 @@ class NxisAgentFullscreen extends HTMLElement {
715
755
  this._offJourney = null;
716
756
  this._journeyObserver?.disconnect();
717
757
  this._journeyObserver = null;
758
+ this._clearJourneyStatusTimer();
718
759
  // Streams stop with the element; the handoff itself stays saved, and resumes when it returns.
719
760
  this._handoff?.dispose();
720
761
  }
@@ -903,7 +944,10 @@ class NxisAgentFullscreen extends HTMLElement {
903
944
  else if (it.kind === 'detail') this._els.thread.appendChild(this._detailEl(it));
904
945
  else if (it.kind === 'results') this._els.thread.appendChild(this._resultsEl(it));
905
946
  else if (it.kind === 'metrics') this._els.thread.appendChild(this._metricsEl(it));
906
- else if (it.kind === 'journey') this._els.thread.appendChild(this._journeyEl(it));
947
+ else if (it.kind === 'journey') {
948
+ // A request drawn as an action card shows its own steps.
949
+ if (!conv.items.some((a) => a.kind === 'action' && a.id === it.id)) this._els.thread.appendChild(this._journeyEl(it));
950
+ } else if (it.kind === 'action') this._paintAction(it);
907
951
  else if (it.kind === 'remote' || it.kind === 'handoff') this._paintHandoffItem(it);
908
952
  }
909
953
  this._scroll();
@@ -1288,6 +1332,15 @@ class NxisAgentFullscreen extends HTMLElement {
1288
1332
  conv.items.push(item);
1289
1333
  }
1290
1334
 
1335
+ // A request with an action card: the steps belong in the card.
1336
+ const action = conv.items.find((it): it is ActionItem => it.kind === 'action' && it.id === view.id);
1337
+ if (action) {
1338
+ this._actionFollows(action, all);
1339
+ this._watchRunningJourney();
1340
+ this._saveConversations();
1341
+ return;
1342
+ }
1343
+
1291
1344
  if (conv.id === this._activeId && this._els?.thread) {
1292
1345
  const fresh = this._journeyEl(item);
1293
1346
  const old = this._els.thread.querySelector(`[data-journey="${CSS.escape(view.id)}"]`);
@@ -1319,7 +1372,12 @@ class NxisAgentFullscreen extends HTMLElement {
1319
1372
  if (!bar) return;
1320
1373
  const cards = this._els.thread.querySelectorAll<HTMLElement>('.journey.is-running');
1321
1374
  const card = cards[cards.length - 1];
1322
- if (!card || typeof IntersectionObserver === 'undefined') {
1375
+ const id = card?.dataset.journey ?? null;
1376
+ if (id !== this._jstatusJourney) {
1377
+ this._clearJourneyStatusTimer();
1378
+ this._jstatusJourney = id;
1379
+ }
1380
+ if (!card || !id || this._jstatusDismissed.has(id) || typeof IntersectionObserver === 'undefined') {
1323
1381
  bar.hidden = true;
1324
1382
  return;
1325
1383
  }
@@ -1333,6 +1391,36 @@ class NxisAgentFullscreen extends HTMLElement {
1333
1391
  this._journeyObserver.observe(card);
1334
1392
  }
1335
1393
 
1394
+ /**
1395
+ * The thread grew: start the clock on the status line.
1396
+ *
1397
+ * A journey can stay running while the conversation goes on without it, and a
1398
+ * line that spins through all of that says nothing. So once new messages
1399
+ * arrive, the line gets a while longer, then goes.
1400
+ */
1401
+ private _noteThreadGrew(): void {
1402
+ const id = this._jstatusJourney;
1403
+ if (!id || this._jstatusTimer || this._jstatusDismissed.has(id)) return;
1404
+ this._jstatusTimer = setTimeout(() => {
1405
+ this._jstatusTimer = null;
1406
+ this._dismissJourneyStatus();
1407
+ }, JSTATUS_TIMEOUT_MS);
1408
+ }
1409
+
1410
+ /** Hide the status line for the running journey, for good. */
1411
+ private _dismissJourneyStatus(): void {
1412
+ this._clearJourneyStatusTimer();
1413
+ if (this._jstatusJourney) this._jstatusDismissed.add(this._jstatusJourney);
1414
+ this._journeyObserver?.disconnect();
1415
+ this._journeyObserver = null;
1416
+ if (this._els?.jstatus) this._els.jstatus.hidden = true;
1417
+ }
1418
+
1419
+ private _clearJourneyStatusTimer(): void {
1420
+ if (this._jstatusTimer) clearTimeout(this._jstatusTimer);
1421
+ this._jstatusTimer = null;
1422
+ }
1423
+
1336
1424
  private _paintToolResult(name: string, text: string, isError: boolean): HTMLElement {
1337
1425
  this._clearEmpty();
1338
1426
  const el = this._toolEl(name, text, isError);
@@ -1391,6 +1479,7 @@ class NxisAgentFullscreen extends HTMLElement {
1391
1479
  const el = this._paintMsg(role, text, conv.items.length - 1);
1392
1480
  this._saveConversations();
1393
1481
  this._scroll();
1482
+ this._noteThreadGrew();
1394
1483
  return el;
1395
1484
  }
1396
1485
 
@@ -1411,6 +1500,7 @@ class NxisAgentFullscreen extends HTMLElement {
1411
1500
  // without the focus moving to it.
1412
1501
  el.setAttribute('aria-live', 'polite');
1413
1502
  this._scroll();
1503
+ this._noteThreadGrew();
1414
1504
 
1415
1505
  const setLabel = (label?: string) => {
1416
1506
  const node = el.querySelector('.think-label');
@@ -1552,8 +1642,10 @@ class NxisAgentFullscreen extends HTMLElement {
1552
1642
  message: (role, text) => this._appendMsg(role, text),
1553
1643
  streaming: (): HStreamingBubble => this._appendStreaming(),
1554
1644
  status: (text, isError) => this._setStatus(text, isError),
1555
- ask: (req) => this._renderAsk(req),
1556
- confirm: (req) => this._renderConfirm(req),
1645
+ inlinePrompts: !this._classicPrompts,
1646
+ ask: (req) => (this._usesCard(req.tool) ? this._cardAsk(req) : this._renderAsk(req)),
1647
+ confirm: (req) => (this._usesCard(req.tool) ? this._cardConfirm(req) : this._renderConfirm(req)),
1648
+ left: (tool, destination) => this._cardLeft(tool, destination),
1557
1649
  results: (tool, res) => {
1558
1650
  const conv = this._active();
1559
1651
  const item = this._buildResultsItem(tool, res);
@@ -1575,6 +1667,7 @@ class NxisAgentFullscreen extends HTMLElement {
1575
1667
  this._scroll();
1576
1668
  },
1577
1669
  detail: (tool, values, text) => {
1670
+ if (this._cardDone(tool, values, text)) return;
1578
1671
  const conv = this._active();
1579
1672
  const item = this._buildDetailItem(tool, values, text);
1580
1673
  conv?.items.push(item);
@@ -1598,8 +1691,23 @@ class NxisAgentFullscreen extends HTMLElement {
1598
1691
  this._saveConversations();
1599
1692
  this._scroll();
1600
1693
  },
1601
- toolError: (tool, message) => this._paintToolResult(tool.name, message, true),
1602
- dismissPrompt: () => this._dismissPrompt(),
1694
+ toolError: (tool, message) => {
1695
+ if (this._cardFailed(tool, message)) return;
1696
+ this._paintToolResult(tool.name, message, true);
1697
+ },
1698
+ dismissPrompt: () => {
1699
+ // A sent card stays, and shows the steps; one never sent says so.
1700
+ const live = this._live;
1701
+ if (live) {
1702
+ if (live.sent && (live.item.stage === 'review' || live.item.stage === 'collect')) {
1703
+ live.item.stage = 'working';
1704
+ this._paintAction(live.item);
1705
+ this._saveConversations();
1706
+ }
1707
+ return;
1708
+ }
1709
+ this._dismissPrompt();
1710
+ },
1603
1711
  // The harness says when stopping stops being possible, because it is the
1604
1712
  // part that knows a tool has been invoked. Once a call is out, offering
1605
1713
  // to stop would be offering to un-send a booking.
@@ -1748,6 +1856,390 @@ class NxisAgentFullscreen extends HTMLElement {
1748
1856
  this._scroll();
1749
1857
  }
1750
1858
 
1859
+ // ── Action cards ─────────────────────────────────────────────────────────────
1860
+
1861
+ /** `prompt-style="classic"`: the separate ask, review and submitted cards of earlier versions. */
1862
+ private get _classicPrompts(): boolean {
1863
+ return this.getAttribute('prompt-style') === 'classic';
1864
+ }
1865
+
1866
+ /** Whether a prompt for this tool is drawn as an action card: every write and handoff. */
1867
+ private _usesCard(tool: ModelContextToolInfo): boolean {
1868
+ return !this._classicPrompts && (isHandoffTool(tool.name) || !tool.annotations?.readOnlyHint);
1869
+ }
1870
+
1871
+ /** Details are missing: the card asks for them, beside what it already has. */
1872
+ private _cardAsk(req: AskRequest): void {
1873
+ const needed = new Set(req.plan.steps.flatMap((st) => st.slots.map((sl) => sl.name)));
1874
+ const item = this._liveCard(req.tool, req.resolution.all, req.resolution.values, needed);
1875
+ this._live!.ask = req;
1876
+ this._live!.confirm = undefined;
1877
+ item.stage = 'collect';
1878
+ this._paintAction(item);
1879
+ this._saveConversations();
1880
+ }
1881
+
1882
+ /** Everything is known: the card says what will happen, and waits for Send. */
1883
+ private _cardConfirm(req: ConfirmRequest): void {
1884
+ const all = resolveSlots(toolSchema(req.tool), { modelArgs: req.values }).all;
1885
+ const item = this._liveCard(req.tool, all, req.values, new Set(), req.destination);
1886
+ this._live!.confirm = req;
1887
+ item.stage = 'review';
1888
+ this._paintAction(item);
1889
+ this._saveConversations();
1890
+ }
1891
+
1892
+ /**
1893
+ * The live card for a tool's prompt: the one already open for this request,
1894
+ * or a new one, which takes over from the journey card of the same request.
1895
+ */
1896
+ private _liveCard(
1897
+ tool: ModelContextToolInfo,
1898
+ slots: Slot[],
1899
+ values: Record<string, unknown>,
1900
+ needed: Set<string>,
1901
+ destination?: string,
1902
+ ): ActionItem {
1903
+ const conv = this._active()!;
1904
+ const id = currentJourney() ?? `a${Date.now().toString(36)}`;
1905
+ const live = this._live;
1906
+ const described = this._describeAction(tool, values, destination);
1907
+ const fields = fieldsFromSlots(this._narrowed(tool, slots, values), values, { needed, hidden: described.hidden, labels: described.labels });
1908
+
1909
+ if (live && live.item.id === id && live.tool.name === tool.name) {
1910
+ // The same request, a step on: keep what was typed, and anything the harness has since learned.
1911
+ live.item.fields = fields.map((f) => {
1912
+ const had = live.item.fields.find((o) => o.name === f.name);
1913
+ return had && f.value === '' ? { ...f, value: had.value } : f;
1914
+ });
1915
+ Object.assign(live.item, described.view);
1916
+ live.tool = tool;
1917
+ return live.item;
1918
+ }
1919
+
1920
+ // A request left waiting is replaced by the new one.
1921
+ if (live && (live.item.stage === 'collect' || live.item.stage === 'review')) {
1922
+ live.item.stage = 'declined';
1923
+ this._paintAction(live.item);
1924
+ }
1925
+
1926
+ const item: ActionItem = { kind: 'action', id, tool: tool.name, fields, stage: 'collect', ...described.view };
1927
+ conv.items.push(item);
1928
+ this._live = { item, tool, sent: false, declining: false };
1929
+ this._els?.thread?.querySelector(`[data-journey="${CSS.escape(id)}"]`)?.remove();
1930
+ this._watchRunningJourney();
1931
+ if (isHandoffTool(tool.name)) void this._refreshHandoffTargets(item);
1932
+ return item;
1933
+ }
1934
+
1935
+ /**
1936
+ * The slots with each field's options cut to the form the call means: one
1937
+ * business's services rather than every business's.
1938
+ */
1939
+ private _narrowed(tool: ModelContextToolInfo, slots: Slot[], values: Record<string, unknown>): Slot[] {
1940
+ const options = formOptions(tool, values);
1941
+ return slots.map((slot) => {
1942
+ const only = options[slot.name];
1943
+ if (!only) return slot;
1944
+ // Keep a value the call already gave, even one the form does not list: the form decides.
1945
+ const current = values[slot.name];
1946
+ const list = current !== undefined && current !== '' && !only.includes(String(current)) ? [String(current), ...only] : only;
1947
+ return { ...slot, schema: { ...slot.schema, enum: list } };
1948
+ });
1949
+ }
1950
+
1951
+ /** Who a request goes to, and how the card words it. */
1952
+ private _describeAction(
1953
+ tool: ModelContextToolInfo,
1954
+ values: Record<string, unknown>,
1955
+ destination?: string,
1956
+ ): {
1957
+ view: Pick<ActionItem, 'variant' | 'noun' | 'identity' | 'sentence' | 'note' | 'sendLabel'>;
1958
+ hidden: Set<string>;
1959
+ labels: Record<string, string>;
1960
+ } {
1961
+ const email = String(values.email ?? Object.entries(values).find(([k]) => /e-?mail/i.test(k))?.[1] ?? '').trim();
1962
+
1963
+ if (isHandoffTool(tool.name)) {
1964
+ const name = String(values.target ?? '');
1965
+ const config = this._handoff?.config?.targets[name];
1966
+ const known = this._handoffTargets.get(name);
1967
+ const label = known?.label ?? config?.label ?? fieldLabel(name || 'the team');
1968
+ const person = (known?.kind ?? config?.kind) !== 'agent';
1969
+ const away = known ? !known.available : false;
1970
+ // "The directory team" leads a line, but mid-sentence it is "the directory team".
1971
+ const inline = label.replace(/^The\s/, 'the ');
1972
+ const about = known?.description ?? config?.description;
1973
+ const status = !known
1974
+ ? undefined
1975
+ : away
1976
+ ? `Away now${known.hours ? ` · open ${known.hours}` : ''}`
1977
+ : person
1978
+ ? known.waitMinutes
1979
+ ? `Available · replies in ~${known.waitMinutes} min`
1980
+ : 'Available'
1981
+ : 'Available';
1982
+ const sentence = !person
1983
+ ? `I'll hand this conversation to ${inline}. They'll take it from here.`
1984
+ : away
1985
+ ? email
1986
+ ? `${label} is away right now. They'll reply to ${email}.`
1987
+ : `${label} is away right now. Leave an email and they'll reply there.`
1988
+ : `I'll pass this to ${inline} so you don't have to repeat yourself.`;
1989
+ return {
1990
+ view: {
1991
+ variant: person ? 'handoff-person' : 'handoff-agent',
1992
+ noun: person ? 'handoff' : 'handover',
1993
+ identity: { name: label, sub: about ? about.charAt(0).toUpperCase() + about.slice(1) : undefined, status, tone: away ? 'away' : 'ok' },
1994
+ sentence,
1995
+ note: undefined,
1996
+ sendLabel: !person ? 'Hand over' : away ? `Send to ${inline}` : 'Connect me',
1997
+ },
1998
+ hidden: new Set(['target']),
1999
+ labels: { reason: 'Why', summary: 'What they need to know', email: 'Where should they reply?' },
2000
+ };
2001
+ }
2002
+
2003
+ const noun = fieldLabel(withoutSiteSuffix(tool.name)).toLowerCase();
2004
+ // Which business or place it is for: the site tool's own chooser, else a field the form names it in.
2005
+ const chooser = chooserOf(tool) ?? Object.keys(values).find((k) => /^(business|company|organi[sz]ation|provider|vendor|store|restaurant|practice|venue)(_name)?$/i.test(k)) ?? null;
2006
+ const chosen = chooser ? String(values[chooser] ?? '').trim() : '';
2007
+ const leaving = destination ?? handoffFor(tool, values)?.label;
2008
+ const name = chosen || leaving || this._pageName();
2009
+ // What the conversation already showed about them: their card from a search.
2010
+ const card = this._conversations
2011
+ .find((c) => c.id === this._activeId)
2012
+ ?.items.flatMap((it) => (it.kind === 'results' ? it.cards : []))
2013
+ .find((c) => c.title.toLowerCase() === name.toLowerCase());
2014
+ const sub = [fieldLabel(noun), card?.type && card.type !== 'Thing' ? fieldLabel(card.type) : '', card?.rating ? `★ ${card.rating}` : '']
2015
+ .filter(Boolean)
2016
+ .join(' · ');
2017
+ return {
2018
+ view: {
2019
+ variant: 'form',
2020
+ noun,
2021
+ identity: { name, sub },
2022
+ sentence: `I'll send this ${noun} to ${name}.${email ? ` They'll reply to ${email}.` : ''}`,
2023
+ note: leaving ? 'Their form is on its own page. I’ll open it and send it from there.' : undefined,
2024
+ sendLabel: 'Send',
2025
+ },
2026
+ hidden: new Set(chooser && chooser !== 'target' && chosen ? [chooser] : chooser === 'target' ? ['target'] : []),
2027
+ labels: {},
2028
+ };
2029
+ }
2030
+
2031
+ /** The site's name for this page: its title, without the site's own name after it. */
2032
+ private _pageName(): string {
2033
+ const title = (typeof document === 'undefined' ? '' : document.title).split(/\s+[|—–·-]\s+/)[0].trim();
2034
+ return title || 'this page';
2035
+ }
2036
+
2037
+ /** Read availability for a handoff card, and redraw it once known. */
2038
+ private async _refreshHandoffTargets(item: ActionItem): Promise<void> {
2039
+ const targets = await this._handoff?.targets().catch(() => []);
2040
+ for (const t of targets ?? []) this._handoffTargets.set(t.name, t);
2041
+ if (item.stage !== 'collect' && item.stage !== 'review') return;
2042
+ const values = Object.fromEntries(item.fields.map((f) => [f.name, f.value]));
2043
+ Object.assign(item, this._describeAction({ name: item.tool } as ModelContextToolInfo, values).view);
2044
+ this._paintAction(item);
2045
+ }
2046
+
2047
+ /** What the live card's buttons do. */
2048
+ private _actionHandlers(): ActionHandlers {
2049
+ return {
2050
+ continue: (values) => {
2051
+ const live = this._live;
2052
+ if (!live) return;
2053
+ if (live.ask && !live.confirm) {
2054
+ live.ask.onComplete(values);
2055
+ return;
2056
+ }
2057
+ // Back from a change, or a card of the widget's own: review again, here.
2058
+ Object.assign(live.item, this._describeAction(live.tool, values, live.confirm?.destination).view);
2059
+ live.item.stage = 'review';
2060
+ this._paintAction(live.item);
2061
+ this._saveConversations();
2062
+ },
2063
+ change: () => {
2064
+ const live = this._live;
2065
+ if (!live) return;
2066
+ live.item.fields.forEach((f) => {
2067
+ if (f.group === 'needed' && f.value.trim() !== '') f.group = 'known';
2068
+ });
2069
+ live.sent = false;
2070
+ live.item.stage = 'collect';
2071
+ live.item.open = true;
2072
+ this._paintAction(live.item);
2073
+ this._saveConversations();
2074
+ },
2075
+ send: (values) => {
2076
+ const live = this._live;
2077
+ if (!live) return;
2078
+ live.sent = true;
2079
+ if (live.confirm) live.confirm.onSubmit(values);
2080
+ else if (live.run) void live.run(values);
2081
+ },
2082
+ decline: () => {
2083
+ const live = this._live;
2084
+ if (!live) return;
2085
+ live.declining = true;
2086
+ if (live.confirm) live.confirm.onCancel();
2087
+ else if (live.ask) live.ask.onCancel();
2088
+ // A card the harness is not waiting on declines itself.
2089
+ if (this._live === live) {
2090
+ live.item.stage = 'declined';
2091
+ this._paintAction(live.item);
2092
+ this._live = null;
2093
+ this._saveConversations();
2094
+ }
2095
+ },
2096
+ };
2097
+ }
2098
+
2099
+ /** Draw a card where it was, or at the end of the thread. */
2100
+ private _paintAction(item: ActionItem): void {
2101
+ if (!this._els?.thread) return;
2102
+ const conv = this._active();
2103
+ if (!conv?.items.includes(item)) return;
2104
+ const live = this._live?.item === item;
2105
+ const fresh = actionCard(item, live ? this._actionHandlers() : null, () => this._saveConversations());
2106
+ const old = this._els.thread.querySelector(`[data-action="${CSS.escape(item.id)}"]`);
2107
+ if (old) old.replaceWith(fresh);
2108
+ else {
2109
+ this._clearEmpty();
2110
+ this._els.thread.appendChild(fresh);
2111
+ this._scroll();
2112
+ }
2113
+ }
2114
+
2115
+ /** A request's steps, as its card shows them once sent. */
2116
+ private _actionFollows(item: ActionItem, all: AgentStep[]): void {
2117
+ const live = this._live?.item === item ? this._live : null;
2118
+ const failed = [...all].reverse().find((st) => st.status === 'failed');
2119
+ const done = all.some((st) => st.id === 'submit' && st.status === 'done');
2120
+ const running = ['navigate', 'arrive', 'collect', 'fill', 'submit'];
2121
+ item.steps = all
2122
+ .filter((st) => running.includes(st.id))
2123
+ .map((st): ActionStep => ({
2124
+ label:
2125
+ st.id === 'fill' && st.status === 'active' && item.variant === 'form'
2126
+ ? `Filling in the ${item.noun}`
2127
+ : st.id === 'submit' && st.status === 'done' && item.variant === 'form'
2128
+ ? 'Sent'
2129
+ : st.label,
2130
+ status: st.status === 'failed' ? 'failed' : st.status === 'done' ? 'done' : 'active',
2131
+ }));
2132
+
2133
+ if (failed) {
2134
+ if (live?.declining || failed.label === 'Cancelled') {
2135
+ item.stage = 'declined';
2136
+ if (live) this._live = null;
2137
+ } else {
2138
+ item.stage = 'failed';
2139
+ item.message = failed.detail || failed.label;
2140
+ }
2141
+ } else if (done) {
2142
+ item.stage = 'done';
2143
+ if (live) this._live = null;
2144
+ } else if (item.stage === 'left' || (item.stage === 'working' && item.steps.length)) {
2145
+ item.stage = 'working';
2146
+ }
2147
+ this._paintAction(item);
2148
+ }
2149
+
2150
+ /** The confirmed call is leaving for its page. The card says so, and the new page finishes it. */
2151
+ private _cardLeft(tool: ModelContextToolInfo, destination: string | null): void {
2152
+ const live = this._live;
2153
+ if (!live || live.tool.name !== tool.name) {
2154
+ this._appendMsg('assistant', destination ? `Opening ${destination}…` : 'Opening the page that can submit this…');
2155
+ return;
2156
+ }
2157
+ live.item.stage = 'left';
2158
+ live.item.message = `Opening ${destination ?? live.item.identity.name}’s page to send it from there.`;
2159
+ this._live = null;
2160
+ this._paintAction(live.item);
2161
+ this._saveConversations();
2162
+ }
2163
+
2164
+ /** The form took it: the card becomes the receipt. False when no card is waiting on this tool. */
2165
+ private _cardDone(tool: ModelContextToolInfo, values: Record<string, unknown>, text: string): boolean {
2166
+ const conv = this._active();
2167
+ const id = currentJourney();
2168
+ const item = conv?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === id && it.tool === tool.name);
2169
+ if (!item || this._classicPrompts) return false;
2170
+ const note = this._buildDetailItem(tool, values, text).note;
2171
+ // The default note says nothing the card's "Sent" does not.
2172
+ item.message = /submitted successfully/.test(note) ? undefined : note;
2173
+ item.stage = 'done';
2174
+ item.fields.forEach((f) => {
2175
+ if (values[f.name] !== undefined) f.value = String(values[f.name]);
2176
+ });
2177
+ if (this._live?.item === item) this._live = null;
2178
+ this._paintAction(item);
2179
+ this._saveConversations();
2180
+ return true;
2181
+ }
2182
+
2183
+ /** It did not go through: the card says why and offers to change it. False when no card is waiting. */
2184
+ private _cardFailed(tool: ModelContextToolInfo, message: string): boolean {
2185
+ const live = this._live;
2186
+ if (!live || live.tool.name !== tool.name) return false;
2187
+ live.sent = false;
2188
+ live.item.stage = 'failed';
2189
+ live.item.message = message;
2190
+ this._paintAction(live.item);
2191
+ this._saveConversations();
2192
+ return true;
2193
+ }
2194
+
2195
+ /**
2196
+ * After a page change: the card of the request that travelled, found by its
2197
+ * journey, now finished or asked about here.
2198
+ */
2199
+ private _resumedCard(journeyId: string | undefined): ActionItem | null {
2200
+ if (!journeyId || this._classicPrompts) return null;
2201
+ return this._active()?.items.find((it): it is ActionItem => it.kind === 'action' && it.id === journeyId) ?? null;
2202
+ }
2203
+
2204
+ /** Let a resumed card be changed and sent again from this page, with the page's own tool. */
2205
+ private _reviveCard(item: ActionItem, tool: ModelContextToolInfo, values: Record<string, unknown>, needed: string[]): void {
2206
+ const slots = resolveSlots(toolSchema(tool), { modelArgs: values }).all;
2207
+ const hidden = new Set(item.fields.filter((f) => f.group === 'hidden').map((f) => f.name));
2208
+ item.fields = fieldsFromSlots(slots, values, { needed: new Set(needed), hidden });
2209
+ this._live = {
2210
+ item,
2211
+ tool,
2212
+ sent: false,
2213
+ declining: false,
2214
+ run: async (vals) => {
2215
+ item.stage = 'working';
2216
+ item.steps = [{ label: `Sending the ${item.noun}`, status: 'active' }];
2217
+ this._paintAction(item);
2218
+ try {
2219
+ const res = await this.toolSource.execTool(tool, tool.name, vals);
2220
+ const text = extractResult(res);
2221
+ if (res?.isError) {
2222
+ this._cardFailed(tool, text || 'That form did not accept the details.');
2223
+ return;
2224
+ }
2225
+ this._active()?.messages.push({
2226
+ role: 'user',
2227
+ content: `[${tool.name} submitted with: ${JSON.stringify(vals)}]. Keep these unless I ask to change them.`,
2228
+ });
2229
+ this._live = null;
2230
+ const note = this._buildDetailItem(tool, vals, text).note;
2231
+ item.message = /submitted successfully/.test(note) ? undefined : note;
2232
+ item.stage = 'done';
2233
+ item.steps = [];
2234
+ this._paintAction(item);
2235
+ this._saveConversations();
2236
+ } catch (err) {
2237
+ this._cardFailed(tool, String((err as Error)?.message || err));
2238
+ }
2239
+ },
2240
+ };
2241
+ }
2242
+
1751
2243
  // ── Inline generated form (from JSON-Schema) ───────────────────────────────
1752
2244
  private _renderForm(tool: ModelContextToolInfo, args: Record<string, unknown>): void {
1753
2245
  if (this._useA2ui) {
@@ -2692,6 +3184,17 @@ class NxisAgentFullscreen extends HTMLElement {
2692
3184
  * `fallback`. Inexpensive: each agent is a role over the one shared provider,
2693
3185
  * so no additional model is loaded.
2694
3186
  */
3187
+ /** The one agent a page without `agents` runs, on the current provider. */
3188
+ private _singleHarness(): AgentHarness {
3189
+ return new AgentHarness({
3190
+ provider: this.provider,
3191
+ toolSource: this.toolSource,
3192
+ renderer: this._renderer(),
3193
+ profile: (schema) => this._profileForSchema(schema as JsonSchema),
3194
+ onMessages: () => this._saveConversations(),
3195
+ });
3196
+ }
3197
+
2695
3198
  private _buildAgents(): void {
2696
3199
  if (!this.provider || !this._agentDefs) {
2697
3200
  this._coordinator = null;
@@ -2842,6 +3345,7 @@ class NxisAgentFullscreen extends HTMLElement {
2842
3345
  if (conversationId === this._activeId && this._els?.thread) {
2843
3346
  this._paintHandoffItem(item);
2844
3347
  this._scroll();
3348
+ this._noteThreadGrew();
2845
3349
  }
2846
3350
  }
2847
3351
 
@@ -2991,6 +3495,44 @@ class NxisAgentFullscreen extends HTMLElement {
2991
3495
  });
2992
3496
  if (outcome.status !== 'none') this._setStatus('');
2993
3497
 
3498
+ // The card that travelled with the request finishes here, when there is one.
3499
+ const card = outcome.status === 'none' ? null : this._resumedCard(outcome.pending.steps?.find((st) => st.journey)?.journey);
3500
+ if (card && outcome.status !== 'none') {
3501
+ switch (outcome.status) {
3502
+ case 'no-registry':
3503
+ case 'no-tool':
3504
+ card.stage = 'failed';
3505
+ card.message = `I couldn't find the ${card.noun} on this page, so nothing was sent. Ask me again and I'll fill it in from here.`;
3506
+ break;
3507
+ case 'incomplete':
3508
+ this._reviveCard(card, outcome.tool, outcome.pending.values, outcome.missing);
3509
+ card.stage = 'collect';
3510
+ card.message = undefined;
3511
+ break;
3512
+ case 'rejected':
3513
+ case 'failed':
3514
+ this._reviveCard(card, outcome.tool, outcome.pending.values, []);
3515
+ card.stage = 'failed';
3516
+ card.message = outcome.status === 'failed' ? outcome.error : outcome.text || 'That form did not accept the details.';
3517
+ break;
3518
+ case 'submitted': {
3519
+ const note = this._buildDetailItem(outcome.tool, outcome.pending.values, outcome.text).note;
3520
+ card.message = /submitted successfully/.test(note) ? undefined : note;
3521
+ card.stage = 'done';
3522
+ this._active()?.messages.push({
3523
+ role: 'user',
3524
+ content:
3525
+ `[${outcome.tool.name} submitted with: ${JSON.stringify(outcome.pending.values)}]. ` +
3526
+ 'Keep these unless I ask to change them.',
3527
+ });
3528
+ break;
3529
+ }
3530
+ }
3531
+ this._paintAction(card);
3532
+ this._saveConversations();
3533
+ return;
3534
+ }
3535
+
2994
3536
  switch (outcome.status) {
2995
3537
  case 'none':
2996
3538
  return;
@@ -3348,6 +3890,9 @@ class NxisAgentFullscreen extends HTMLElement {
3348
3890
  if (this._els?.thread) {
3349
3891
  this.provider = next ?? this.provider;
3350
3892
  this._paintProvenance();
3893
+ // Both the single agent and any configured ones: a page without `agents` kept the old
3894
+ // provider's harness, and answered with the model it had just replaced.
3895
+ this._harness = this._singleHarness();
3351
3896
  this._buildAgents();
3352
3897
  }
3353
3898
  }
@@ -3551,6 +4096,9 @@ class NxisAgentFullscreen extends HTMLElement {
3551
4096
  /* A2UI surfaces inherit the tokens above through --a2ui-* bridges. */
3552
4097
  ${A2UI_THEME_CSS}
3553
4098
 
4099
+ /* The action card: forms and handoffs as one message. */
4100
+ ${ACTION_CARD_CSS}
4101
+
3554
4102
  /* App-icon tile: chevron centered in a 56px tile, Soft Gray, hairline. */
3555
4103
  .launcher {
3556
4104
  position: fixed; right: 20px; bottom: 20px; z-index: 9998;