@nxisai/agent-harness 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +22 -1
  2. package/dist/handoff/a2a.d.ts +152 -0
  3. package/dist/handoff/a2a.d.ts.map +1 -0
  4. package/dist/handoff/a2a.js +227 -0
  5. package/dist/handoff/a2a.js.map +1 -0
  6. package/dist/handoff/controller.d.ts +181 -0
  7. package/dist/handoff/controller.d.ts.map +1 -0
  8. package/dist/handoff/controller.js +543 -0
  9. package/dist/handoff/controller.js.map +1 -0
  10. package/dist/handoff/envelope.d.ts +130 -0
  11. package/dist/handoff/envelope.d.ts.map +1 -0
  12. package/dist/handoff/envelope.js +178 -0
  13. package/dist/handoff/envelope.js.map +1 -0
  14. package/dist/handoff/index.d.ts +16 -0
  15. package/dist/handoff/index.d.ts.map +1 -0
  16. package/dist/handoff/index.js +12 -0
  17. package/dist/handoff/index.js.map +1 -0
  18. package/dist/handoff/tools.d.ts +36 -0
  19. package/dist/handoff/tools.d.ts.map +1 -0
  20. package/dist/handoff/tools.js +148 -0
  21. package/dist/handoff/tools.js.map +1 -0
  22. package/dist/harness/harness.d.ts +4 -2
  23. package/dist/harness/harness.d.ts.map +1 -1
  24. package/dist/harness/harness.js +30 -7
  25. package/dist/harness/harness.js.map +1 -1
  26. package/dist/harness/router/coordinator.d.ts +13 -0
  27. package/dist/harness/router/coordinator.d.ts.map +1 -1
  28. package/dist/harness/router/coordinator.js +7 -0
  29. package/dist/harness/router/coordinator.js.map +1 -1
  30. package/dist/harness/tools/discovery.d.ts +12 -1
  31. package/dist/harness/tools/discovery.d.ts.map +1 -1
  32. package/dist/harness/tools/discovery.js +19 -1
  33. package/dist/harness/tools/discovery.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/ui/agent-fullscreen.d.ts.map +1 -1
  39. package/dist/ui/agent-fullscreen.js +264 -1
  40. package/dist/ui/agent-fullscreen.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/handoff/a2a.ts +326 -0
  43. package/src/handoff/controller.ts +661 -0
  44. package/src/handoff/envelope.ts +216 -0
  45. package/src/handoff/index.ts +53 -0
  46. package/src/handoff/tools.ts +178 -0
  47. package/src/harness/harness.ts +28 -3
  48. package/src/harness/router/coordinator.ts +21 -0
  49. package/src/harness/tools/discovery.ts +20 -2
  50. package/src/index.ts +3 -0
  51. package/src/ui/agent-fullscreen.ts +280 -2
@@ -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,178 @@
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, ask for an email so they can reply.`;
81
+
82
+ const properties: Record<string, JsonSchema> = {
83
+ target: {
84
+ type: 'string',
85
+ enum: targets.map((t) => t.name),
86
+ description: 'Who to hand the conversation to.',
87
+ ...(targets.length === 1 ? { default: targets[0].name } : {}),
88
+ },
89
+ reason: { type: 'string', description: "Why, in one short line, e.g. 'Wants to book a boiler inspection'." },
90
+ summary: {
91
+ type: 'string',
92
+ description:
93
+ '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.',
94
+ },
95
+ };
96
+ if (kind === 'person') {
97
+ properties.email = {
98
+ type: 'string',
99
+ format: 'email',
100
+ description: 'Only when the team is away: an email address the visitor gave for a reply.',
101
+ };
102
+ }
103
+
104
+ return {
105
+ name,
106
+ description,
107
+ inputSchema: { type: 'object', properties, required: ['target', 'reason', 'summary'] },
108
+ // A write: what the visitor said leaves the site. The harness confirms before it runs.
109
+ annotations: { readOnlyHint: false, consequentialHint: true },
110
+ execute: async (input: Record<string, unknown>) => {
111
+ const conversationId = opts.conversationId();
112
+ if (!conversationId) return text('There is no conversation to hand off.', true);
113
+ const current = (await controller.targets()).find((t) => t.name === String(input?.target ?? ''));
114
+ if (!current || current.kind !== kind) {
115
+ return text(`"${String(input?.target ?? '')}" is not someone this conversation can be handed to.`, true);
116
+ }
117
+ const email = typeof input.email === 'string' ? input.email.trim() : '';
118
+ if (!current.available && !email) {
119
+ return text(
120
+ `${current.label} is ${availability(current)}. Ask the visitor for an email address to reply to, then call ${name} again with it.`,
121
+ true,
122
+ );
123
+ }
124
+ const envelope = buildEnvelope({
125
+ target: current.name,
126
+ reason: String(input.reason ?? ''),
127
+ summary: String(input.summary ?? ''),
128
+ conversationId,
129
+ contact: email ? { email } : undefined,
130
+ transcript: current.transcript ? opts.transcript?.() : undefined,
131
+ });
132
+ try {
133
+ await controller.start(conversationId, current.name, envelope);
134
+ } catch {
135
+ const session = controller.session(conversationId);
136
+ return text(session?.error ?? `I couldn't reach ${current.label}.`, true);
137
+ }
138
+ const waiting = current.kind === 'person' && current.available ? ` ${current.label} will reply here.` : '';
139
+ const later = !current.available ? ` ${current.label} will reply to ${email}.` : '';
140
+ return {
141
+ content: [{ type: 'text', text: `Connecting you to ${current.label}.${waiting}${later}` }],
142
+ structuredContent: {
143
+ [HANDOFF_SESSION_RESULT]: { target: current.name, label: current.label, kind: current.kind },
144
+ },
145
+ };
146
+ },
147
+ } as ModelContextTool;
148
+ }
149
+
150
+ /**
151
+ * Register the handoff tools, replacing none that are already there.
152
+ *
153
+ * @returns The names registered.
154
+ */
155
+ export async function registerHandoffTools(
156
+ mc: ModelContext,
157
+ controller: HandoffController,
158
+ opts: HandoffToolOptions,
159
+ ): Promise<string[]> {
160
+ const tools = await buildHandoffTools(controller, opts);
161
+ let present = new Set<string>();
162
+ try {
163
+ present = new Set((await mc.getTools()).map((t) => t.name));
164
+ } catch {
165
+ // A registry that cannot list may still register.
166
+ }
167
+ const registered: string[] = [];
168
+ for (const tool of tools) {
169
+ if (present.has(tool.name)) continue;
170
+ try {
171
+ await mc.registerTool(tool);
172
+ registered.push(tool.name);
173
+ } catch (err) {
174
+ console.warn(`[nxis-handoff] could not register ${tool.name}:`, err);
175
+ }
176
+ }
177
+ return registered;
178
+ }
@@ -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
  }
@@ -865,8 +873,11 @@ export class AgentHarness {
865
873
  // Recorded before the call, because the call is what navigates: the
866
874
  // courier persists the journey as it leaves, and this step has to be in
867
875
  // it or the page it lands on shows no reason for being there.
876
+ const handing = isHandoffTool(tool.name);
868
877
  this.emitStep(
869
- leaving
878
+ handing
879
+ ? { id: 'fill', label: 'Handing the conversation over', status: 'active' }
880
+ : leaving
870
881
  ? { id: 'navigate', label: `Opening ${leaving.label}`, status: 'active' }
871
882
  : {
872
883
  id: 'fill',
@@ -915,6 +926,20 @@ export class AgentHarness {
915
926
  return;
916
927
  }
917
928
 
929
+ // Handed off: what the target says arrives through the handoff controller's events, which the
930
+ // renderer draws. There is nothing to show as "Submitted"; the model learns where it went.
931
+ const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
932
+ if (handing && started) {
933
+ const who = started.label ?? 'another agent';
934
+ this.emitStep({ id: 'fill', label: `Handed to ${who}`, status: 'done' });
935
+ messages.push({
936
+ role: 'user',
937
+ content: `[${tool.name}: the conversation was handed to ${who}. Their replies go to the visitor directly.]`,
938
+ });
939
+ this.opts.onMessages?.(messages);
940
+ return;
941
+ }
942
+
918
943
  // Opening the page already open, or one that could not be told: said
919
944
  // in words, since there are no records to draw.
920
945
  if (tool.name === OPEN_PAGE_TOOL) {
@@ -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.
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * @module
10
10
  */
11
- import type { CatalogFile, McpServerInfo, ServerCard } from '../../types.js';
11
+ import type { CatalogEntry, CatalogFile, McpServerInfo, ServerCard } from '../../types.js';
12
12
 
13
13
  async function fetchJson<T>(url: string): Promise<T> {
14
14
  const res = await fetch(url, { headers: { Accept: 'application/json' } });
@@ -16,13 +16,31 @@ async function fetchJson<T>(url: string): Promise<T> {
16
16
  return (await res.json()) as T;
17
17
  }
18
18
 
19
+ /**
20
+ * The catalog entry for the MCP server card.
21
+ *
22
+ * A catalog can list more than the MCP server: an A2A Agent Card for a
23
+ * handoff target, say. An entry typed as an MCP server card wins; an entry
24
+ * typed as anything else is never taken for one; an untyped entry is taken
25
+ * only when nothing is typed, as older catalogs have no types at all.
26
+ *
27
+ * @returns The entry, or undefined when the catalog lists no MCP server.
28
+ */
29
+ export function mcpCatalogEntry(catalog: CatalogFile): CatalogEntry | undefined {
30
+ const entries = (catalog.entries ?? []).filter((e) => !!e.url);
31
+ const typeOf = (e: CatalogEntry) => (e.type ?? e.mediaType ?? '').toLowerCase();
32
+ const typed = entries.find((e) => /\bmcp\b|mcp-server/.test(typeOf(e)));
33
+ if (typed) return typed;
34
+ return entries.find((e) => !typeOf(e));
35
+ }
36
+
19
37
  /** Find the server-card URL by reading the well-known catalogs in order. */
20
38
  async function resolveServerCardUrl(): Promise<string> {
21
39
  const catalogs = ['/.well-known/ai-catalog.json', '/.well-known/mcp/catalog.json'];
22
40
  for (const path of catalogs) {
23
41
  try {
24
42
  const catalog = await fetchJson<CatalogFile>(path);
25
- const entry = catalog.entries?.find((e) => !!e.url);
43
+ const entry = mcpCatalogEntry(catalog);
26
44
  if (entry?.url) return entry.url;
27
45
  } catch {
28
46
  /* try the next catalog */
package/src/index.ts CHANGED
@@ -175,6 +175,9 @@ export { loadSiteActions, parseAction } from './harness/tools/site-actions.js';
175
175
  export type { ConnectResult, ConnectOptions, SiteActionsOptions } from './harness/tools/source.js';
176
176
  export type { BridgeOptions } from './harness/tools/bridge.js';
177
177
 
178
+ // Handing a conversation to another agent or a person, over A2A. See docs/handoff.md.
179
+ export * from './handoff/index.js';
180
+
178
181
  export type {
179
182
  JsonSchema,
180
183
  ModelContext,