@nxisai/agent-harness 0.4.1 → 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 +25 -2
- 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 +61 -16
- 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/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +7 -5
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/bridge.d.ts.map +1 -1
- package/dist/harness/tools/bridge.js +33 -12
- package/dist/harness/tools/bridge.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/harness/tools/local-context.d.ts +49 -9
- package/dist/harness/tools/local-context.d.ts.map +1 -1
- package/dist/harness/tools/local-context.js +377 -22
- package/dist/harness/tools/local-context.js.map +1 -1
- package/dist/harness/tools/open-page.d.ts +16 -0
- package/dist/harness/tools/open-page.d.ts.map +1 -1
- package/dist/harness/tools/open-page.js +45 -9
- package/dist/harness/tools/open-page.js.map +1 -1
- package/dist/harness/tools/retry.d.ts +53 -0
- package/dist/harness/tools/retry.d.ts.map +1 -0
- package/dist/harness/tools/retry.js +96 -0
- package/dist/harness/tools/retry.js.map +1 -0
- package/dist/harness/tools/schema.d.ts +18 -0
- package/dist/harness/tools/schema.d.ts.map +1 -1
- package/dist/harness/tools/schema.js +38 -0
- package/dist/harness/tools/schema.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +3 -2
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/site-nav.d.ts +168 -0
- package/dist/harness/tools/site-nav.d.ts.map +1 -0
- package/dist/harness/tools/site-nav.js +316 -0
- package/dist/harness/tools/site-nav.js.map +1 -0
- package/dist/harness/tools/site-pages.d.ts +4 -0
- package/dist/harness/tools/site-pages.d.ts.map +1 -1
- package/dist/harness/tools/site-pages.js +19 -3
- package/dist/harness/tools/site-pages.js.map +1 -1
- package/dist/harness/tools/source.d.ts +9 -0
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +54 -14
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/harness/tools/webmcp.d.ts +39 -3
- package/dist/harness/tools/webmcp.d.ts.map +1 -1
- package/dist/harness/tools/webmcp.js +93 -14
- package/dist/harness/tools/webmcp.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +276 -2
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +2 -1
- package/src/a2ui/cards.ts +177 -0
- package/src/a2ui/emit.ts +147 -0
- package/src/a2ui/form.ts +117 -0
- package/src/a2ui/host.ts +73 -0
- package/src/a2ui/index.ts +22 -0
- package/src/a2ui/stepper.ts +211 -0
- package/src/a2ui/theme.ts +134 -0
- package/src/a2ui/to-v09.ts +118 -0
- package/src/engine.ts +701 -0
- 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 +981 -0
- package/src/harness/plan.ts +91 -0
- package/src/harness/policy.ts +59 -0
- package/src/harness/renderer.ts +208 -0
- package/src/harness/router/coordinator.ts +241 -0
- package/src/harness/router/rules.ts +65 -0
- package/src/harness/slots.ts +117 -0
- package/src/harness/steps.ts +156 -0
- package/src/harness/tools/action-tools.ts +870 -0
- package/src/harness/tools/bridge.ts +393 -0
- package/src/harness/tools/client.ts +26 -0
- package/src/harness/tools/discovery.ts +73 -0
- package/src/harness/tools/local-context.ts +480 -0
- package/src/harness/tools/open-page.ts +273 -0
- package/src/harness/tools/pending-action.ts +232 -0
- package/src/harness/tools/resume.ts +134 -0
- package/src/harness/tools/retry.ts +117 -0
- package/src/harness/tools/schema.ts +245 -0
- package/src/harness/tools/site-actions.ts +356 -0
- package/src/harness/tools/site-nav.ts +432 -0
- package/src/harness/tools/site-pages.ts +288 -0
- package/src/harness/tools/source.ts +353 -0
- package/src/harness/tools/webmcp.ts +200 -0
- package/src/index.ts +196 -0
- package/src/providers/capability.ts +167 -0
- package/src/providers/proxy.ts +191 -0
- package/src/providers/types.ts +150 -0
- package/src/types.ts +258 -0
- package/src/ui/agent-fullscreen.ts +4262 -0
- package/src/ui/agent.tw.css +49 -0
- package/src/ui/config.ts +138 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/journey.ts +57 -0
- package/src/ui/tailwind.generated.ts +2 -0
- package/src/webmcp-contract.ts +65 -0
|
@@ -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
|
+
}
|