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