@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.
- package/README.md +22 -1
- package/dist/handoff/a2a.d.ts +152 -0
- package/dist/handoff/a2a.d.ts.map +1 -0
- package/dist/handoff/a2a.js +227 -0
- package/dist/handoff/a2a.js.map +1 -0
- package/dist/handoff/controller.d.ts +181 -0
- package/dist/handoff/controller.d.ts.map +1 -0
- package/dist/handoff/controller.js +543 -0
- package/dist/handoff/controller.js.map +1 -0
- package/dist/handoff/envelope.d.ts +130 -0
- package/dist/handoff/envelope.d.ts.map +1 -0
- package/dist/handoff/envelope.js +178 -0
- package/dist/handoff/envelope.js.map +1 -0
- package/dist/handoff/index.d.ts +16 -0
- package/dist/handoff/index.d.ts.map +1 -0
- package/dist/handoff/index.js +12 -0
- package/dist/handoff/index.js.map +1 -0
- package/dist/handoff/tools.d.ts +36 -0
- package/dist/handoff/tools.d.ts.map +1 -0
- package/dist/handoff/tools.js +148 -0
- package/dist/handoff/tools.js.map +1 -0
- package/dist/harness/harness.d.ts +4 -2
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +30 -7
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/router/coordinator.d.ts +13 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +7 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/discovery.d.ts +12 -1
- package/dist/harness/tools/discovery.d.ts.map +1 -1
- package/dist/harness/tools/discovery.js +19 -1
- package/dist/harness/tools/discovery.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +264 -1
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/package.json +1 -1
- package/src/handoff/a2a.ts +326 -0
- package/src/handoff/controller.ts +661 -0
- package/src/handoff/envelope.ts +216 -0
- package/src/handoff/index.ts +53 -0
- package/src/handoff/tools.ts +178 -0
- package/src/harness/harness.ts +28 -3
- package/src/harness/router/coordinator.ts +21 -0
- package/src/harness/tools/discovery.ts +20 -2
- package/src/index.ts +3 -0
- 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
|
+
}
|
package/src/harness/harness.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|