@nxisai/agent-harness 0.5.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 (78) hide show
  1. package/README.md +38 -4
  2. package/dist/handoff/a2a.d.ts +152 -0
  3. package/dist/handoff/a2a.d.ts.map +1 -0
  4. package/dist/handoff/a2a.js +227 -0
  5. package/dist/handoff/a2a.js.map +1 -0
  6. package/dist/handoff/controller.d.ts +181 -0
  7. package/dist/handoff/controller.d.ts.map +1 -0
  8. package/dist/handoff/controller.js +543 -0
  9. package/dist/handoff/controller.js.map +1 -0
  10. package/dist/handoff/envelope.d.ts +130 -0
  11. package/dist/handoff/envelope.d.ts.map +1 -0
  12. package/dist/handoff/envelope.js +178 -0
  13. package/dist/handoff/envelope.js.map +1 -0
  14. package/dist/handoff/index.d.ts +16 -0
  15. package/dist/handoff/index.d.ts.map +1 -0
  16. package/dist/handoff/index.js +12 -0
  17. package/dist/handoff/index.js.map +1 -0
  18. package/dist/handoff/tools.d.ts +36 -0
  19. package/dist/handoff/tools.d.ts.map +1 -0
  20. package/dist/handoff/tools.js +155 -0
  21. package/dist/handoff/tools.js.map +1 -0
  22. package/dist/harness/harness.d.ts +4 -2
  23. package/dist/harness/harness.d.ts.map +1 -1
  24. package/dist/harness/harness.js +53 -12
  25. package/dist/harness/harness.js.map +1 -1
  26. package/dist/harness/renderer.d.ts +20 -0
  27. package/dist/harness/renderer.d.ts.map +1 -1
  28. package/dist/harness/router/coordinator.d.ts +13 -0
  29. package/dist/harness/router/coordinator.d.ts.map +1 -1
  30. package/dist/harness/router/coordinator.js +7 -0
  31. package/dist/harness/router/coordinator.js.map +1 -1
  32. package/dist/harness/tools/action-tools.d.ts +29 -0
  33. package/dist/harness/tools/action-tools.d.ts.map +1 -1
  34. package/dist/harness/tools/action-tools.js +64 -4
  35. package/dist/harness/tools/action-tools.js.map +1 -1
  36. package/dist/harness/tools/discovery.d.ts +12 -1
  37. package/dist/harness/tools/discovery.d.ts.map +1 -1
  38. package/dist/harness/tools/discovery.js +19 -1
  39. package/dist/harness/tools/discovery.js.map +1 -1
  40. package/dist/harness/tools/site-actions.d.ts +27 -0
  41. package/dist/harness/tools/site-actions.d.ts.map +1 -1
  42. package/dist/harness/tools/site-actions.js +47 -0
  43. package/dist/harness/tools/site-actions.js.map +1 -1
  44. package/dist/harness/tools/source.d.ts.map +1 -1
  45. package/dist/harness/tools/source.js +5 -2
  46. package/dist/harness/tools/source.js.map +1 -1
  47. package/dist/index.d.ts +2 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +3 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/ui/action-card.d.ts +141 -0
  52. package/dist/ui/action-card.d.ts.map +1 -0
  53. package/dist/ui/action-card.js +384 -0
  54. package/dist/ui/action-card.js.map +1 -0
  55. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  56. package/dist/ui/agent-fullscreen.js +795 -17
  57. package/dist/ui/agent-fullscreen.js.map +1 -1
  58. package/dist/ui/tailwind.generated.d.ts +1 -1
  59. package/dist/ui/tailwind.generated.d.ts.map +1 -1
  60. package/dist/ui/tailwind.generated.js +1 -1
  61. package/dist/ui/tailwind.generated.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/handoff/a2a.ts +326 -0
  64. package/src/handoff/controller.ts +661 -0
  65. package/src/handoff/envelope.ts +216 -0
  66. package/src/handoff/index.ts +53 -0
  67. package/src/handoff/tools.ts +182 -0
  68. package/src/harness/harness.ts +55 -14
  69. package/src/harness/renderer.ts +22 -0
  70. package/src/harness/router/coordinator.ts +21 -0
  71. package/src/harness/tools/action-tools.ts +74 -4
  72. package/src/harness/tools/discovery.ts +20 -2
  73. package/src/harness/tools/site-actions.ts +58 -0
  74. package/src/harness/tools/source.ts +5 -2
  75. package/src/index.ts +4 -1
  76. package/src/ui/action-card.ts +496 -0
  77. package/src/ui/agent-fullscreen.ts +842 -16
  78. package/src/ui/tailwind.generated.ts +1 -1
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The handoff extension: what a handoff carries, and how it reads the answers.
3
+ *
4
+ * Everything travels as A2A `data` parts marked with {@link HANDOFF_EXTENSION},
5
+ * so a target that knows nothing of it still works. See `docs/handoff.md`.
6
+ *
7
+ * @module
8
+ */
9
+ import { newMessageId, type A2AMessage, type A2APart } from './a2a.js';
10
+
11
+ /** The extension's URI, announced in `A2A-Extensions` and on every part it adds. */
12
+ export const HANDOFF_EXTENSION = 'https://nxis.ai/a2a/handoff/v1';
13
+
14
+ /** What the first message of a handoff carries. */
15
+ export interface HandoffEnvelope {
16
+ /** Why, in one line. */
17
+ reason: string;
18
+ /** What the target needs to know. The visitor has seen it. */
19
+ summary: string;
20
+ /** The page the visitor is on. */
21
+ page: { url: string; title: string; entity?: string };
22
+ /** Values the visitor gave and agreed to share. */
23
+ facts?: { name: string; value: string }[];
24
+ /** The last turns, text only. Only for a target the relay allows it for. */
25
+ transcript?: { role: 'visitor' | 'assistant'; text: string }[];
26
+ /** How to reach the visitor later, only when they asked to be contacted. */
27
+ contact?: { email?: string; phone?: string; name?: string };
28
+ /** The visitor's language. */
29
+ locale: string;
30
+ /** Where a hand-back belongs. */
31
+ returnTo: { conversationId: string; origin: string };
32
+ /** What was agreed to: the target, the fields sent, and when. */
33
+ consent: { target: string; shared: string[]; at: string };
34
+ }
35
+
36
+ /** How a handoff ended, from the target's `handback` part. */
37
+ export interface HandBack {
38
+ outcome: 'resolved' | 'unresolved' | 'transferred';
39
+ note: string;
40
+ references: { label: string; value: string }[];
41
+ }
42
+
43
+ /** Query parameters that only describe how a page was reached or shown, never which page it is. */
44
+ const DYNAMIC_PARAMS = /^(surface|utm_.*|gclid|fbclid|msclkid|mc_cid|mc_eid|_ga)$/i;
45
+
46
+ /**
47
+ * A page's address without the parameters that don't identify it.
48
+ *
49
+ * `?surface=window` says how the agent is shown, and `utm_*` how the visitor
50
+ * arrived; neither is part of the page a target should look up.
51
+ */
52
+ export function cleanPageUrl(href: string): string {
53
+ try {
54
+ const url = new URL(href);
55
+ for (const key of [...url.searchParams.keys()]) {
56
+ if (DYNAMIC_PARAMS.test(key)) url.searchParams.delete(key);
57
+ }
58
+ url.hash = '';
59
+ return url.href;
60
+ } catch {
61
+ return href;
62
+ }
63
+ }
64
+
65
+ /** The JSON-LD `@id` of what this page is about, if it declares one for itself. */
66
+ export function pageEntity(doc: Document | undefined = typeof document === 'undefined' ? undefined : document): string | undefined {
67
+ if (!doc) return undefined;
68
+ const here = cleanPageUrl(doc.location?.href ?? '');
69
+ for (const script of Array.from(doc.querySelectorAll('script[type="application/ld+json"]'))) {
70
+ let json: unknown;
71
+ try {
72
+ json = JSON.parse(script.textContent || '');
73
+ } catch {
74
+ continue;
75
+ }
76
+ const nodes = Array.isArray(json)
77
+ ? json
78
+ : ((json as { '@graph'?: unknown[] })?.['@graph'] ?? [json]);
79
+ for (const node of nodes as { '@id'?: unknown }[]) {
80
+ const id = typeof node?.['@id'] === 'string' ? node['@id'] : '';
81
+ // The entity the page is about is named `<page url>#…` by the NxisAI crawler.
82
+ if (id && here && id.startsWith(here.split('#')[0] + '#')) return id;
83
+ }
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /** What {@link buildEnvelope} needs beyond the page. */
89
+ export interface EnvelopeInput {
90
+ target: string;
91
+ reason: string;
92
+ summary: string;
93
+ conversationId: string;
94
+ facts?: { name: string; value: string }[];
95
+ transcript?: { role: 'visitor' | 'assistant'; text: string }[];
96
+ contact?: { email?: string; phone?: string; name?: string };
97
+ /** Overrides, for tests and for hosts outside a browser. */
98
+ page?: { url: string; title: string; entity?: string };
99
+ locale?: string;
100
+ origin?: string;
101
+ now?: () => Date;
102
+ }
103
+
104
+ /** The envelope for a handoff, with its consent record filled from what is actually sent. */
105
+ export function buildEnvelope(input: EnvelopeInput): HandoffEnvelope {
106
+ const doc = typeof document === 'undefined' ? undefined : document;
107
+ const page = input.page ?? {
108
+ url: cleanPageUrl(doc?.location?.href ?? ''),
109
+ title: doc?.title ?? '',
110
+ entity: pageEntity(doc),
111
+ };
112
+ if (!page.entity) delete page.entity;
113
+ const envelope: HandoffEnvelope = {
114
+ reason: input.reason.trim(),
115
+ summary: input.summary.trim(),
116
+ page,
117
+ locale: input.locale ?? (typeof navigator === 'undefined' ? 'en' : navigator.language || 'en'),
118
+ returnTo: {
119
+ conversationId: input.conversationId,
120
+ origin: input.origin ?? (typeof location === 'undefined' ? '' : location.origin),
121
+ },
122
+ consent: { target: input.target, shared: [], at: (input.now?.() ?? new Date()).toISOString() },
123
+ };
124
+ if (input.facts?.length) envelope.facts = input.facts;
125
+ if (input.transcript?.length) envelope.transcript = input.transcript;
126
+ const contact = input.contact && Object.fromEntries(Object.entries(input.contact).filter(([, v]) => !!v));
127
+ if (contact && Object.keys(contact).length) envelope.contact = contact;
128
+ envelope.consent.shared = Object.keys(envelope).filter((k) => k !== 'consent' && k !== 'returnTo' && k !== 'locale');
129
+ return envelope;
130
+ }
131
+
132
+ /** A data part marked with the extension. */
133
+ export function extensionPart(data: Record<string, unknown>): A2APart {
134
+ return { data, mediaType: 'application/json', metadata: { extension: HANDOFF_EXTENSION } };
135
+ }
136
+
137
+ /** The first message of a handoff: the summary as text, for any target, and the envelope as data. */
138
+ export function envelopeMessage(envelope: HandoffEnvelope): A2AMessage {
139
+ const lines = [envelope.summary];
140
+ if (envelope.page.url) lines.push(`Page: ${envelope.page.title ? `${envelope.page.title} (${envelope.page.url})` : envelope.page.url}`);
141
+ return {
142
+ messageId: newMessageId(),
143
+ role: 'ROLE_USER',
144
+ parts: [{ text: lines.join('\n\n') }, extensionPart({ handoff: 'envelope', ...envelope })],
145
+ extensions: [HANDOFF_EXTENSION],
146
+ };
147
+ }
148
+
149
+ /** A message the visitor typed during a handoff. */
150
+ export function visitorMessage(text: string, ids: { taskId?: string; contextId?: string }): A2AMessage {
151
+ const message: A2AMessage = { messageId: newMessageId(), role: 'ROLE_USER', parts: [{ text }] };
152
+ if (ids.taskId) message.taskId = ids.taskId;
153
+ if (ids.contextId) message.contextId = ids.contextId;
154
+ return message;
155
+ }
156
+
157
+ /** The extension data in a part, and which kind it is, or null for any other part. */
158
+ export function extensionData(part: A2APart): (Record<string, unknown> & { handoff: string }) | null {
159
+ const data = part.data as Record<string, unknown> | undefined;
160
+ if (!data || typeof data !== 'object' || typeof data.handoff !== 'string') return null;
161
+ // Marked parts are ours; an unmarked one with the same shape is accepted too, since some servers
162
+ // drop part metadata, but only for the kinds we define.
163
+ const marked = part.metadata?.extension === HANDOFF_EXTENSION;
164
+ if (!marked && !['queue', 'operator', 'handback'].includes(data.handoff)) return null;
165
+ return data as Record<string, unknown> & { handoff: string };
166
+ }
167
+
168
+ /** A `handback` part's contents, tidied, or null. */
169
+ export function readHandBack(parts: A2APart[] = []): HandBack | null {
170
+ for (const part of parts) {
171
+ const data = extensionData(part);
172
+ if (data?.handoff !== 'handback') continue;
173
+ const outcome = data.outcome === 'unresolved' || data.outcome === 'transferred' ? data.outcome : 'resolved';
174
+ const references = Array.isArray(data.references)
175
+ ? (data.references as { label?: unknown; value?: unknown }[])
176
+ .filter((r) => r && r.value !== undefined && r.value !== null && String(r.value).trim())
177
+ .map((r) => ({ label: String(r.label ?? 'Reference'), value: String(r.value) }))
178
+ : [];
179
+ return { outcome, note: String(data.note ?? '').trim(), references };
180
+ }
181
+ return null;
182
+ }
183
+
184
+ /** A `queue` part's position and wait, or null. */
185
+ export function readQueue(parts: A2APart[] = []): { position?: number; waitMinutes?: number } | null {
186
+ for (const part of parts) {
187
+ const data = extensionData(part);
188
+ if (data?.handoff !== 'queue') continue;
189
+ const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined);
190
+ return { position: num(data.position), waitMinutes: num(data.waitMinutes) };
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /** An `operator` part's name and kind, or null. */
196
+ export function readOperator(parts: A2APart[] = []): { name: string; kind: 'person' | 'agent' } | null {
197
+ for (const part of parts) {
198
+ const data = extensionData(part);
199
+ if (data?.handoff !== 'operator' || typeof data.name !== 'string' || !data.name.trim()) continue;
200
+ return { name: data.name.trim(), kind: data.kind === 'agent' ? 'agent' : 'person' };
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /** The text a visitor should read from some parts: text parts, and files by name. Data parts are not shown. */
206
+ export function readableText(parts: A2APart[] = []): string {
207
+ return parts
208
+ .map((p) => {
209
+ if (typeof p.text === 'string') return p.text;
210
+ if (p.url) return p.filename ? `${p.filename}: ${p.url}` : p.url;
211
+ return '';
212
+ })
213
+ .filter((t) => t.trim())
214
+ .join('\n\n')
215
+ .trim();
216
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Handing a conversation to another agent or a person, over A2A.
3
+ *
4
+ * See `docs/handoff.md`.
5
+ *
6
+ * @module
7
+ */
8
+ export { A2AClient, A2AError, A2A_VERSION, isTerminal, normaliseResponse, taskState } from './a2a.js';
9
+ export type {
10
+ A2AArtifact,
11
+ A2AClientOptions,
12
+ A2AMessage,
13
+ A2APart,
14
+ A2AReply,
15
+ A2AStream,
16
+ A2AStreamResponse,
17
+ A2ATask,
18
+ A2ATaskState,
19
+ A2ATaskStatus,
20
+ } from './a2a.js';
21
+ export {
22
+ HANDOFF_EXTENSION,
23
+ buildEnvelope,
24
+ cleanPageUrl,
25
+ envelopeMessage,
26
+ pageEntity,
27
+ readHandBack,
28
+ readOperator,
29
+ readQueue,
30
+ readableText,
31
+ } from './envelope.js';
32
+ export type { EnvelopeInput, HandBack, HandoffEnvelope } from './envelope.js';
33
+ export { HANDOFF_KEY_HEADER, HandoffController, isHandoffOver } from './controller.js';
34
+ export type {
35
+ HandoffConfig,
36
+ HandoffControllerOptions,
37
+ HandoffEvent,
38
+ HandoffKind,
39
+ HandoffSession,
40
+ HandoffState,
41
+ HandoffTarget,
42
+ HandoffTargetConfig,
43
+ } from './controller.js';
44
+ export {
45
+ HANDOFF_SESSION_RESULT,
46
+ TALK_TO_A_PERSON_TOOL,
47
+ TRANSFER_TO_AGENT_TOOL,
48
+ availability,
49
+ buildHandoffTools,
50
+ isHandoffTool,
51
+ registerHandoffTools,
52
+ } from './tools.js';
53
+ export type { HandoffToolOptions } from './tools.js';
@@ -0,0 +1,182 @@
1
+ /**
2
+ * The tools that start a handoff: `transfer_to_agent` and `talk_to_a_person`.
3
+ *
4
+ * The model proposes a handoff by calling one, with a reason and a summary. The
5
+ * tools are consequential, so the harness shows the visitor the call (target,
6
+ * reason, summary) and waits for them to press Send before anything leaves the
7
+ * page. Each tool lists only the targets the page's config names and the relay
8
+ * reports, so the model can't send a conversation anywhere else.
9
+ *
10
+ * @module
11
+ */
12
+ import { buildEnvelope } from './envelope.js';
13
+ import type { HandoffController, HandoffKind, HandoffTarget } from './controller.js';
14
+ import type { JsonSchema, ModelContext, ModelContextTool, ModelContextToolResponse } from '../types.js';
15
+
16
+ export const TRANSFER_TO_AGENT_TOOL = 'transfer_to_agent';
17
+ export const TALK_TO_A_PERSON_TOOL = 'talk_to_a_person';
18
+
19
+ /** The key a started handoff is reported under in a tool result's `structuredContent`. */
20
+ export const HANDOFF_SESSION_RESULT = 'ai.nxis/handoff-session';
21
+
22
+ /** Whether a tool is one of the handoff tools. */
23
+ export const isHandoffTool = (name: string): boolean => name === TRANSFER_TO_AGENT_TOOL || name === TALK_TO_A_PERSON_TOOL;
24
+
25
+ /** What the tools need from their host. */
26
+ export interface HandoffToolOptions {
27
+ /** The conversation a call belongs to. Read when the tool runs. */
28
+ conversationId(): string | undefined;
29
+ /**
30
+ * The conversation's recent turns, text only, for a target the relay allows
31
+ * a transcript for. Omit to never send one.
32
+ */
33
+ transcript?(): { role: 'visitor' | 'assistant'; text: string }[];
34
+ }
35
+
36
+ const text = (t: string, isError = false): ModelContextToolResponse => ({
37
+ content: [{ type: 'text', text: t }],
38
+ ...(isError ? { isError: true } : {}),
39
+ });
40
+
41
+ /** How a desk's availability reads, for a tool description and for the visitor. */
42
+ export function availability(target: HandoffTarget): string {
43
+ if (!target.available) return `away now${target.hours ? ` (open ${target.hours})` : ''}`;
44
+ if (target.kind !== 'person') return 'available';
45
+ const wait = target.waitMinutes ? `, about ${target.waitMinutes} min wait` : '';
46
+ return `available${wait}`;
47
+ }
48
+
49
+ /**
50
+ * The handoff tools for the targets that can be offered now.
51
+ *
52
+ * @returns Up to two tools; none when the relay offers no configured target.
53
+ */
54
+ export async function buildHandoffTools(
55
+ controller: HandoffController,
56
+ opts: HandoffToolOptions,
57
+ ): Promise<ModelContextTool[]> {
58
+ const targets = await controller.targets();
59
+ const tools: ModelContextTool[] = [];
60
+ const agents = targets.filter((t) => t.kind === 'agent');
61
+ const people = targets.filter((t) => t.kind === 'person');
62
+ if (agents.length) tools.push(buildTool('agent', agents, controller, opts));
63
+ if (people.length) tools.push(buildTool('person', people, controller, opts));
64
+ return tools;
65
+ }
66
+
67
+ function buildTool(
68
+ kind: HandoffKind,
69
+ targets: HandoffTarget[],
70
+ controller: HandoffController,
71
+ opts: HandoffToolOptions,
72
+ ): ModelContextTool {
73
+ const name = kind === 'agent' ? TRANSFER_TO_AGENT_TOOL : TALK_TO_A_PERSON_TOOL;
74
+ const menu = targets
75
+ .map((t) => `"${t.name}" (${t.label}${t.description ? `: ${t.description}` : ''}; ${availability(t)})`)
76
+ .join('; ');
77
+ const description =
78
+ kind === 'agent'
79
+ ? `Hand this conversation to another agent that can finish what you can't. Use only when the visitor needs something one of these handles: ${menu}. The visitor sees the summary and confirms before anything is sent.`
80
+ : `Hand this conversation to a person on the site's team. Use when the visitor asks for a person, or when you can't help. Targets: ${menu}. The visitor sees the summary and confirms before anything is sent. When the team is away, include the visitor's email if they gave one; the page asks for it otherwise.`;
81
+ // Everyone away: a reply can only go to an email, so the email is required and the page asks for it
82
+ // in the same form as any other missing detail, before the visitor presses Send rather than after.
83
+ const away = kind === 'person' && targets.every((t) => !t.available);
84
+
85
+ const properties: Record<string, JsonSchema> = {
86
+ target: {
87
+ type: 'string',
88
+ enum: targets.map((t) => t.name),
89
+ description: 'Who to hand the conversation to.',
90
+ ...(targets.length === 1 ? { default: targets[0].name } : {}),
91
+ },
92
+ reason: { type: 'string', description: "Why, in one short line, e.g. 'Wants to book a boiler inspection'." },
93
+ summary: {
94
+ type: 'string',
95
+ description:
96
+ 'What they need to know to help, in two or three sentences, from what the visitor said. The visitor reads this before sending. No details the visitor did not give.',
97
+ },
98
+ };
99
+ if (kind === 'person') {
100
+ properties.email = {
101
+ type: 'string',
102
+ format: 'email',
103
+ description: away
104
+ ? 'The team is away: the email address they should reply to.'
105
+ : 'Only when the team is away: an email address the visitor gave for a reply.',
106
+ };
107
+ }
108
+
109
+ return {
110
+ name,
111
+ description,
112
+ inputSchema: { type: 'object', properties, required: ['target', 'reason', 'summary', ...(away ? ['email'] : [])] },
113
+ // A write: what the visitor said leaves the site. The harness confirms before it runs.
114
+ annotations: { readOnlyHint: false, consequentialHint: true },
115
+ execute: async (input: Record<string, unknown>) => {
116
+ const conversationId = opts.conversationId();
117
+ if (!conversationId) return text('There is no conversation to hand off.', true);
118
+ const current = (await controller.targets()).find((t) => t.name === String(input?.target ?? ''));
119
+ if (!current || current.kind !== kind) {
120
+ return text(`"${String(input?.target ?? '')}" is not someone this conversation can be handed to.`, true);
121
+ }
122
+ const email = typeof input.email === 'string' ? input.email.trim() : '';
123
+ // Went away since the tools were built, or one of several desks is away: said to the visitor,
124
+ // who reads a failed call's result, and read by the model on the next turn.
125
+ if (!current.available && !email) {
126
+ return text(`${current.label} is ${availability(current)}. Leave an email address and they'll reply there.`, true);
127
+ }
128
+ const envelope = buildEnvelope({
129
+ target: current.name,
130
+ reason: String(input.reason ?? ''),
131
+ summary: String(input.summary ?? ''),
132
+ conversationId,
133
+ contact: email ? { email } : undefined,
134
+ transcript: current.transcript ? opts.transcript?.() : undefined,
135
+ });
136
+ try {
137
+ await controller.start(conversationId, current.name, envelope);
138
+ } catch {
139
+ const session = controller.session(conversationId);
140
+ return text(session?.error ?? `I couldn't reach ${current.label}.`, true);
141
+ }
142
+ const waiting = current.kind === 'person' && current.available ? ` ${current.label} will reply here.` : '';
143
+ const later = !current.available ? ` ${current.label} will reply to ${email}.` : '';
144
+ return {
145
+ content: [{ type: 'text', text: `Connecting you to ${current.label}.${waiting}${later}` }],
146
+ structuredContent: {
147
+ [HANDOFF_SESSION_RESULT]: { target: current.name, label: current.label, kind: current.kind },
148
+ },
149
+ };
150
+ },
151
+ } as ModelContextTool;
152
+ }
153
+
154
+ /**
155
+ * Register the handoff tools, replacing none that are already there.
156
+ *
157
+ * @returns The names registered.
158
+ */
159
+ export async function registerHandoffTools(
160
+ mc: ModelContext,
161
+ controller: HandoffController,
162
+ opts: HandoffToolOptions,
163
+ ): Promise<string[]> {
164
+ const tools = await buildHandoffTools(controller, opts);
165
+ let present = new Set<string>();
166
+ try {
167
+ present = new Set((await mc.getTools()).map((t) => t.name));
168
+ } catch {
169
+ // A registry that cannot list may still register.
170
+ }
171
+ const registered: string[] = [];
172
+ for (const tool of tools) {
173
+ if (present.has(tool.name)) continue;
174
+ try {
175
+ await mc.registerTool(tool);
176
+ registered.push(tool.name);
177
+ } catch (err) {
178
+ console.warn(`[nxis-handoff] could not register ${tool.name}:`, err);
179
+ }
180
+ }
181
+ return registered;
182
+ }
@@ -39,6 +39,7 @@ import { decide, policyFor, type Decision } from './policy.js';
39
39
  import { buildPlan } from './plan.js';
40
40
  import { resolveSlots, summarySlots, type Resolution } from './slots.js';
41
41
  import { beginJourney, journeySteps, recordStep, type AgentStep } from './steps.js';
42
+ import { HANDOFF_SESSION_RESULT, TALK_TO_A_PERSON_TOOL, isHandoffTool } from '../handoff/tools.js';
42
43
  import type { HarnessRenderer, StreamingBubble } from './renderer.js';
43
44
 
44
45
  /** Configuration for an {@link AgentHarness}. */
@@ -134,9 +135,11 @@ export interface TurnResult {
134
135
  * - `'handback'`, when the model named a tool this agent does not hold, with
135
136
  * the name in {@link TurnResult.wanted};
136
137
  * - `'unavailable'`, when none of this agent's tools are on the page;
137
- * - `'stopped'`, when the user cancelled the turn.
138
+ * - `'stopped'`, when the user cancelled the turn;
139
+ * - `'handoff'`, when the conversation is handed off and the message went to
140
+ * the target instead (see {@link HandoffController}).
138
141
  */
139
- decision: Decision | 'none' | 'handback' | 'unavailable' | 'stopped';
142
+ decision: Decision | 'none' | 'handback' | 'unavailable' | 'stopped' | 'handoff';
140
143
  /** The tool that was called, when one was. */
141
144
  tool?: ModelContextToolInfo;
142
145
  /** The arguments it was called with. */
@@ -180,6 +183,9 @@ export function narrateCall(
180
183
  if (!call) return WORKING_ON_IT;
181
184
  const tool = tools.find((t) => t.name === call.name);
182
185
  if (tool && policyFor(tool).readOnly) return 'Looking that up.';
186
+ if (isHandoffTool(call.name)) {
187
+ return call.name === TALK_TO_A_PERSON_TOOL ? "I can put you through to the team. Here's what I'd send them." : "I can hand you over. Here's what I'd send.";
188
+ }
183
189
  return `Filling in the ${humanize(call.name).toLowerCase()}.`;
184
190
  }
185
191
 
@@ -282,6 +288,8 @@ function handoffPrompt(
282
288
  tool: ModelContextToolInfo,
283
289
  values: Record<string, unknown>,
284
290
  ): { submitLabel?: string; destination?: string } {
291
+ // Handing the conversation to someone: what is pressed is Send, and nothing is submitted.
292
+ if (isHandoffTool(tool.name)) return { submitLabel: 'Send' };
285
293
  const handoff = handoffFor(tool, values);
286
294
  return handoff ? { submitLabel: 'Open & submit', destination: handoff.label } : {};
287
295
  }
@@ -758,12 +766,14 @@ export class AgentHarness {
758
766
  detail: resolution.missing.map((s) => humanize(s.name).toLowerCase()).join(', '),
759
767
  });
760
768
  const plan = buildPlan(resolution.missing, this.opts.groupRelated !== false);
761
- this.renderer.message(
762
- 'assistant',
763
- plan.stepped
764
- ? `I need ${plan.steps.length} more details for ${humanize(tool.name)}.`
765
- : `One more thing for ${humanize(tool.name)}.`,
766
- );
769
+ if (!this.renderer.inlinePrompts) {
770
+ this.renderer.message(
771
+ 'assistant',
772
+ plan.stepped
773
+ ? `I need ${plan.steps.length} more details for ${humanize(tool.name)}.`
774
+ : `One more thing for ${humanize(tool.name)}.`,
775
+ );
776
+ }
767
777
  this.renderer.ask({
768
778
  tool,
769
779
  plan,
@@ -865,15 +875,24 @@ export class AgentHarness {
865
875
  // Recorded before the call, because the call is what navigates: the
866
876
  // courier persists the journey as it leaves, and this step has to be in
867
877
  // it or the page it lands on shows no reason for being there.
868
- this.emitStep(
869
- leaving
878
+ const handing = isHandoffTool(tool.name);
879
+ const inFlight: AgentStep = handing
880
+ ? { id: 'fill', label: 'Handing the conversation over', status: 'active' }
881
+ : leaving
870
882
  ? { id: 'navigate', label: `Opening ${leaving.label}`, status: 'active' }
871
883
  : {
872
884
  id: 'fill',
873
885
  label: readOnly ? 'Looking that up' : `Filling ${humanize(tool.name)}`,
874
886
  status: 'active',
875
- },
876
- );
887
+ };
888
+ this.emitStep(inFlight);
889
+ // A failure ends the step that was running, so the card never shows it still under way.
890
+ const failed = (detail: string) => {
891
+ this.emitStep({ ...inFlight, label: 'It did not go through', status: 'failed', detail });
892
+ // The model reads why on its next turn, and can act on it rather than repeat the call.
893
+ messages.push({ role: 'user', content: `[${tool.name} did not go through: ${detail.slice(0, 400)}]` });
894
+ this.opts.onMessages?.(messages);
895
+ };
877
896
  try {
878
897
  const res: ModelContextToolResponse = await this.toolSource.execTool(tool, tool.name, values);
879
898
  const text = extractResult(res);
@@ -889,7 +908,7 @@ export class AgentHarness {
889
908
  });
890
909
 
891
910
  if (res?.isError) {
892
- this.emitStep({ id: 'submit', label: 'It did not go through', status: 'failed', detail: text });
911
+ failed(text || 'Failed');
893
912
  this.renderer.toolError(tool, text || 'Failed');
894
913
  return;
895
914
  }
@@ -904,6 +923,11 @@ export class AgentHarness {
904
923
  // Said from what we know, not from what came back: a registry may
905
924
  // return the whole response object as JSON, and that is not a
906
925
  // sentence to put in front of anyone.
926
+ if (this.renderer.left) {
927
+ const carried = res?.structuredContent?.[HANDOFF_RESULT] as { label?: string } | undefined;
928
+ this.renderer.left(tool, (visiting ?? leaving)?.label ?? carried?.label ?? null);
929
+ return;
930
+ }
907
931
  this.renderer.message(
908
932
  'assistant',
909
933
  visiting
@@ -915,6 +939,23 @@ export class AgentHarness {
915
939
  return;
916
940
  }
917
941
 
942
+ // Handed off: what the target says arrives through the handoff controller's events, which the
943
+ // renderer draws. There is nothing to show as "Submitted"; the model learns where it went.
944
+ const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
945
+ if (handing && started) {
946
+ const who = started.label ?? 'another agent';
947
+ // Handing over is where this journey ends; the conversation carries on outside it. Without a
948
+ // `submit` step the card would read as running for as long as the handoff lasts.
949
+ this.emitStep({ id: 'fill', label: 'Handed the conversation over', status: 'done' });
950
+ this.emitStep({ id: 'submit', label: `Handed to ${who}`, status: 'done' });
951
+ messages.push({
952
+ role: 'user',
953
+ content: `[${tool.name}: the conversation was handed to ${who}. Their replies go to the visitor directly.]`,
954
+ });
955
+ this.opts.onMessages?.(messages);
956
+ return;
957
+ }
958
+
918
959
  // Opening the page already open, or one that could not be told: said
919
960
  // in words, since there are no records to draw.
920
961
  if (tool.name === OPEN_PAGE_TOOL) {
@@ -945,7 +986,7 @@ export class AgentHarness {
945
986
  this.opts.onMessages?.(messages);
946
987
  } catch (err) {
947
988
  const message = String((err as Error)?.message || err);
948
- this.emitStep({ id: 'submit', label: 'It did not go through', status: 'failed', detail: message });
989
+ failed(message);
949
990
  working.finalize('');
950
991
  this.renderer.status('');
951
992
  // A call that threw is the main reason to open a trace.
@@ -195,6 +195,28 @@ export interface HarnessRenderer {
195
195
  /** Tear down any live ask or confirm UI. */
196
196
  dismissPrompt?(): void;
197
197
 
198
+ /**
199
+ * Whether this renderer's ask and confirm prompts say for themselves what
200
+ * they need and where they go.
201
+ *
202
+ * When true, the harness adds no "One more thing for …" line of its own
203
+ * before an ask: the prompt already reads as the assistant speaking.
204
+ */
205
+ inlinePrompts?: boolean;
206
+
207
+ /**
208
+ * A confirmed call is leaving for the page that can submit it.
209
+ *
210
+ * Called instead of the harness's own "Opening … to submit this…" message,
211
+ * for a renderer that shows the departure on the prompt the visitor just
212
+ * confirmed. The page it opens finishes the call.
213
+ *
214
+ * @param tool - The tool whose call is travelling.
215
+ * @param destination - The page it is going to, named for the visitor, or
216
+ * null when the harness cannot tell which.
217
+ */
218
+ left?(tool: ModelContextToolInfo, destination: string | null): void;
219
+
198
220
  /**
199
221
  * Report whether the turn can still be stopped.
200
222
  *
@@ -23,6 +23,7 @@
23
23
  import type { ChatMessage } from '../../types.js';
24
24
  import type { ModelProvider } from '../../providers/types.js';
25
25
  import type { AgentHarness, TurnResult } from '../harness.js';
26
+ import type { HandoffController } from '../../handoff/controller.js';
26
27
  import { matchRoute, type RouteDecision, type RouteRule } from './rules.js';
27
28
 
28
29
  /** How a turn's agent was chosen. */
@@ -53,6 +54,18 @@ export interface CoordinatorOptions {
53
54
  descriptions?: Record<string, string>;
54
55
  /** Called with every routing decision, including corrections. */
55
56
  onRoute?(decision: RoutedTurn): void;
57
+ /**
58
+ * Handoffs to remote agents and people.
59
+ *
60
+ * While a conversation is handed off, its turns go to the target rather than
61
+ * to any agent here, and resolve with `decision: 'handoff'`. Routing returns
62
+ * to the agents when the target finishes or the visitor ends the handoff.
63
+ */
64
+ handoff?: {
65
+ controller: HandoffController;
66
+ /** The id the controller knows this conversation by. */
67
+ conversationId(messages: ChatMessage[]): string | undefined;
68
+ };
56
69
  }
57
70
 
58
71
  /** A routing decision, reported through {@link CoordinatorOptions.onRoute}. */
@@ -135,6 +148,14 @@ export class AgentCoordinator {
135
148
  * @returns The chosen agent's {@link TurnResult}.
136
149
  */
137
150
  async turn(goal: string, messages: ChatMessage[]): Promise<TurnResult> {
151
+ // A handed-off conversation belongs to its target until it hands back.
152
+ const handoff = this.opts.handoff;
153
+ const conversationId = handoff?.conversationId(messages);
154
+ if (handoff && handoff.controller.active(conversationId)) {
155
+ await handoff.controller.send(conversationId!, goal);
156
+ return { decision: 'handoff' };
157
+ }
158
+
138
159
  let routed = this.route(goal, messages);
139
160
 
140
161
  // The model is only asked when nothing cheaper had an opinion.