@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,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs handoffs: one per conversation, to an agent or a person, over A2A
|
|
3
|
+
* through the site's relay.
|
|
4
|
+
*
|
|
5
|
+
* The controller is headless. It starts a handoff, sends what the visitor types
|
|
6
|
+
* while one is open, ends it, and turns what the target sends back into
|
|
7
|
+
* {@link HandoffEvent}s, dispatched as `handoff` events. A UI renders those; the
|
|
8
|
+
* packaged element does.
|
|
9
|
+
*
|
|
10
|
+
* A handoff outlives the page it started on. Its task id, and the key the
|
|
11
|
+
* relay tied it to, are kept per conversation in `localStorage`, and
|
|
12
|
+
* {@link HandoffController.resume} picks it up on the next page. A stream the
|
|
13
|
+
* host cuts off mid-handoff is picked up the same way, after a short wait.
|
|
14
|
+
*
|
|
15
|
+
* @module
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
A2AClient,
|
|
19
|
+
A2AError,
|
|
20
|
+
isTerminal,
|
|
21
|
+
type A2AMessage,
|
|
22
|
+
type A2AStream,
|
|
23
|
+
type A2AStreamResponse,
|
|
24
|
+
type A2ATask,
|
|
25
|
+
type A2ATaskStatus,
|
|
26
|
+
type A2ATaskState,
|
|
27
|
+
} from './a2a.js';
|
|
28
|
+
import {
|
|
29
|
+
HANDOFF_EXTENSION,
|
|
30
|
+
envelopeMessage,
|
|
31
|
+
readHandBack,
|
|
32
|
+
readOperator,
|
|
33
|
+
readQueue,
|
|
34
|
+
readableText,
|
|
35
|
+
visitorMessage,
|
|
36
|
+
type HandBack,
|
|
37
|
+
type HandoffEnvelope,
|
|
38
|
+
} from './envelope.js';
|
|
39
|
+
|
|
40
|
+
/** Who a target is: software, or a person behind a desk. */
|
|
41
|
+
export type HandoffKind = 'agent' | 'person';
|
|
42
|
+
|
|
43
|
+
/** A target as the page knows it: a name, and how to present it. */
|
|
44
|
+
export interface HandoffTargetConfig {
|
|
45
|
+
kind: HandoffKind;
|
|
46
|
+
/** What the visitor reads: "You're talking with *Booking*". */
|
|
47
|
+
label: string;
|
|
48
|
+
/** What it handles, for the model choosing between targets. */
|
|
49
|
+
description?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Where handoffs go. */
|
|
53
|
+
export interface HandoffConfig {
|
|
54
|
+
/** The relay on the site's own origin, `/api/handoff`. */
|
|
55
|
+
relay: string;
|
|
56
|
+
/** The targets, by the name the relay knows them by. */
|
|
57
|
+
targets: Record<string, HandoffTargetConfig>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A target as the relay reports it, merged with the page's config. */
|
|
61
|
+
export interface HandoffTarget extends HandoffTargetConfig {
|
|
62
|
+
name: string;
|
|
63
|
+
/** Whether it can take a handoff now. A desk outside its hours cannot. */
|
|
64
|
+
available: boolean;
|
|
65
|
+
/** When a desk is staffed, as a person should read it. */
|
|
66
|
+
hours?: string;
|
|
67
|
+
/** How many are waiting at a desk. */
|
|
68
|
+
queueLength?: number;
|
|
69
|
+
/** Expected wait at a desk, in minutes. */
|
|
70
|
+
waitMinutes?: number | null;
|
|
71
|
+
/** Whether the relay allows the recent transcript to be shared with it. */
|
|
72
|
+
transcript: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Where a handoff stands. See the lifecycle table in `docs/handoff.md`. */
|
|
76
|
+
export type HandoffState =
|
|
77
|
+
| 'connecting'
|
|
78
|
+
| 'queued'
|
|
79
|
+
| 'active'
|
|
80
|
+
| 'awaiting-visitor'
|
|
81
|
+
| 'handed-back'
|
|
82
|
+
| 'ended'
|
|
83
|
+
| 'failed';
|
|
84
|
+
|
|
85
|
+
/** Whether a handoff in this state is over. */
|
|
86
|
+
export const isHandoffOver = (state: HandoffState): boolean =>
|
|
87
|
+
state === 'handed-back' || state === 'ended' || state === 'failed';
|
|
88
|
+
|
|
89
|
+
/** One conversation's handoff, as kept between pages. */
|
|
90
|
+
export interface HandoffSession {
|
|
91
|
+
conversationId: string;
|
|
92
|
+
target: string;
|
|
93
|
+
label: string;
|
|
94
|
+
kind: HandoffKind;
|
|
95
|
+
state: HandoffState;
|
|
96
|
+
taskId?: string;
|
|
97
|
+
contextId?: string;
|
|
98
|
+
/** The relay's `Nxis-Handoff-Key`, proving this browser started the task. */
|
|
99
|
+
key?: string;
|
|
100
|
+
/** Who is speaking at the target, once a person or agent has said. */
|
|
101
|
+
operator?: string;
|
|
102
|
+
/** Place in a desk's queue, while queued. */
|
|
103
|
+
queue?: { position?: number; waitMinutes?: number };
|
|
104
|
+
handBack?: HandBack;
|
|
105
|
+
/** Why it failed, in words for the visitor. */
|
|
106
|
+
error?: string;
|
|
107
|
+
startedAt: number;
|
|
108
|
+
updatedAt: number;
|
|
109
|
+
/** Message ids already shown, so a resumed task's history is not shown twice. */
|
|
110
|
+
seen: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** What the controller reports, as the `detail` of a `handoff` event. */
|
|
114
|
+
export type HandoffEvent =
|
|
115
|
+
| { type: 'state'; conversationId: string; session: HandoffSession; state: HandoffState }
|
|
116
|
+
| {
|
|
117
|
+
type: 'message';
|
|
118
|
+
conversationId: string;
|
|
119
|
+
session: HandoffSession;
|
|
120
|
+
/** Who said it: the operator's name, or the target's label. */
|
|
121
|
+
from: string;
|
|
122
|
+
fromKind: HandoffKind;
|
|
123
|
+
text: string;
|
|
124
|
+
}
|
|
125
|
+
| { type: 'handback'; conversationId: string; session: HandoffSession; handBack: HandBack };
|
|
126
|
+
|
|
127
|
+
/** Options for {@link HandoffController}. */
|
|
128
|
+
export interface HandoffControllerOptions extends HandoffConfig {
|
|
129
|
+
/** `fetch` to use. Defaults to the global. */
|
|
130
|
+
fetchImpl?: typeof fetch;
|
|
131
|
+
/** Where sessions are kept. Defaults to `localStorage`; null keeps them in memory only. */
|
|
132
|
+
storage?: Storage | null;
|
|
133
|
+
/** The clock. */
|
|
134
|
+
now?: () => number;
|
|
135
|
+
/** First wait before picking up a stream the host cut off. Doubles up to 15 s. */
|
|
136
|
+
resumeDelayMs?: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const STORAGE_KEY = 'nxis:handoffs:v1';
|
|
140
|
+
/** A finished handoff is kept a day, so its hand-back survives a reload; an open one a month. */
|
|
141
|
+
const KEEP_FINISHED_MS = 24 * 60 * 60 * 1000;
|
|
142
|
+
const KEEP_OPEN_MS = 30 * 24 * 60 * 60 * 1000;
|
|
143
|
+
const TARGETS_TTL_MS = 30 * 1000;
|
|
144
|
+
const MAX_SEEN = 200;
|
|
145
|
+
const MAX_RESUME_DELAY_MS = 15_000;
|
|
146
|
+
|
|
147
|
+
/** The response header a relay ties a task to this browser with. */
|
|
148
|
+
export const HANDOFF_KEY_HEADER = 'Nxis-Handoff-Key';
|
|
149
|
+
|
|
150
|
+
function defaultStorage(): Storage | null {
|
|
151
|
+
try {
|
|
152
|
+
return typeof localStorage === 'undefined' ? null : localStorage;
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A failure in words a visitor can read. */
|
|
159
|
+
function describeFailure(err: unknown, label: string): string {
|
|
160
|
+
if (err instanceof A2AError) {
|
|
161
|
+
if (err.status === 429) {
|
|
162
|
+
return `${label} is busy right now.${err.retryAfter ? ` Try again in about ${Math.ceil(err.retryAfter / 60)} minute(s).` : ''}`;
|
|
163
|
+
}
|
|
164
|
+
if (err.status === 404) return `${label} isn't available on this site.`;
|
|
165
|
+
if (err.status === 403) return `This conversation can't reach ${label} from here.`;
|
|
166
|
+
}
|
|
167
|
+
return `I couldn't reach ${label}.`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Runs handoffs for every conversation on the page. Dispatches `handoff` events. */
|
|
171
|
+
export class HandoffController extends EventTarget {
|
|
172
|
+
private readonly sessions = new Map<string, HandoffSession>();
|
|
173
|
+
/** The stream open for each conversation, so a new one can close it first. */
|
|
174
|
+
private readonly live = new Map<string, AbortController>();
|
|
175
|
+
/** Consecutive resumes that brought nothing, per conversation, for backoff. */
|
|
176
|
+
private readonly idle = new Map<string, number>();
|
|
177
|
+
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
178
|
+
private targetsCache: { at: number; list: Promise<HandoffTarget[]> } | null = null;
|
|
179
|
+
/** Events reported so far, so a stream can tell whether it brought anything new. */
|
|
180
|
+
private reported = 0;
|
|
181
|
+
private readonly storage: Storage | null;
|
|
182
|
+
private loaded = false;
|
|
183
|
+
|
|
184
|
+
constructor(private readonly opts: HandoffControllerOptions) {
|
|
185
|
+
super();
|
|
186
|
+
this.storage = opts.storage === undefined ? defaultStorage() : opts.storage;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The page's target config. */
|
|
190
|
+
get config(): HandoffConfig {
|
|
191
|
+
return { relay: this.opts.relay, targets: this.opts.targets };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The targets that can be offered: those both in the page's config and on
|
|
196
|
+
* the relay's list, with the relay's availability.
|
|
197
|
+
*
|
|
198
|
+
* Cached for thirty seconds. A relay that can't be reached offers none.
|
|
199
|
+
*/
|
|
200
|
+
targets(): Promise<HandoffTarget[]> {
|
|
201
|
+
const now = this.now();
|
|
202
|
+
if (this.targetsCache && now - this.targetsCache.at < TARGETS_TTL_MS) return this.targetsCache.list;
|
|
203
|
+
const list = this.fetchTargets().catch((err) => {
|
|
204
|
+
console.warn('[nxis-handoff] could not read the relay:', err);
|
|
205
|
+
this.targetsCache = null;
|
|
206
|
+
return [];
|
|
207
|
+
});
|
|
208
|
+
this.targetsCache = { at: now, list };
|
|
209
|
+
return list;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async fetchTargets(): Promise<HandoffTarget[]> {
|
|
213
|
+
const doFetch = this.opts.fetchImpl ?? fetch;
|
|
214
|
+
const res = await doFetch(this.opts.relay, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
|
|
215
|
+
if (!res.ok) throw new Error(`GET ${this.opts.relay} → ${res.status}`);
|
|
216
|
+
const body = (await res.json()) as { targets?: Partial<HandoffTarget & { name: string }>[] };
|
|
217
|
+
const out: HandoffTarget[] = [];
|
|
218
|
+
for (const t of body.targets ?? []) {
|
|
219
|
+
const name = String(t.name ?? '');
|
|
220
|
+
const mine = this.opts.targets[name];
|
|
221
|
+
// The page decides what is offered and how it is named; the relay decides what is reachable.
|
|
222
|
+
if (!mine) continue;
|
|
223
|
+
out.push({
|
|
224
|
+
name,
|
|
225
|
+
kind: mine.kind,
|
|
226
|
+
label: mine.label,
|
|
227
|
+
description: mine.description ?? t.description,
|
|
228
|
+
available: t.available !== false,
|
|
229
|
+
hours: t.hours,
|
|
230
|
+
queueLength: t.queueLength,
|
|
231
|
+
waitMinutes: t.waitMinutes ?? null,
|
|
232
|
+
transcript: t.transcript === true,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A conversation's handoff, open or finished, if it has one. */
|
|
239
|
+
session(conversationId: string): HandoffSession | null {
|
|
240
|
+
this.load();
|
|
241
|
+
return this.sessions.get(conversationId) ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** A conversation's handoff while it is open, else null. */
|
|
245
|
+
active(conversationId: string | undefined): HandoffSession | null {
|
|
246
|
+
if (!conversationId) return null;
|
|
247
|
+
const s = this.session(conversationId);
|
|
248
|
+
return s && !isHandoffOver(s.state) ? s : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Hand a conversation to a target.
|
|
253
|
+
*
|
|
254
|
+
* Resolves once the relay has accepted it; what the target says arrives as
|
|
255
|
+
* events. Call only after the visitor has confirmed what is being sent.
|
|
256
|
+
*
|
|
257
|
+
* @throws When the target is not configured, the conversation is already
|
|
258
|
+
* handed off, or the relay refuses. The session is marked failed first.
|
|
259
|
+
*/
|
|
260
|
+
async start(conversationId: string, target: string, envelope: HandoffEnvelope): Promise<HandoffSession> {
|
|
261
|
+
const config = this.opts.targets[target];
|
|
262
|
+
if (!config) throw new Error(`"${target}" is not a handoff target on this page.`);
|
|
263
|
+
if (this.active(conversationId)) throw new Error('This conversation is already handed off.');
|
|
264
|
+
|
|
265
|
+
const now = this.now();
|
|
266
|
+
const session: HandoffSession = {
|
|
267
|
+
conversationId,
|
|
268
|
+
target,
|
|
269
|
+
label: config.label,
|
|
270
|
+
kind: config.kind,
|
|
271
|
+
state: 'connecting',
|
|
272
|
+
startedAt: now,
|
|
273
|
+
updatedAt: now,
|
|
274
|
+
seen: [],
|
|
275
|
+
};
|
|
276
|
+
this.sessions.set(conversationId, session);
|
|
277
|
+
this.setState(session, 'connecting');
|
|
278
|
+
|
|
279
|
+
const message = envelopeMessage(envelope);
|
|
280
|
+
session.seen.push(message.messageId);
|
|
281
|
+
let stream: A2AStream;
|
|
282
|
+
const abort = this.openLive(conversationId);
|
|
283
|
+
try {
|
|
284
|
+
stream = await this.client(target).stream(
|
|
285
|
+
'SendStreamingMessage',
|
|
286
|
+
{ message, configuration: { acceptedOutputModes: ['text/plain', 'application/json'] } },
|
|
287
|
+
{ signal: abort.signal },
|
|
288
|
+
);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
this.live.delete(conversationId);
|
|
291
|
+
session.error = describeFailure(err, config.label);
|
|
292
|
+
this.setState(session, 'failed');
|
|
293
|
+
throw err;
|
|
294
|
+
}
|
|
295
|
+
const key = stream.headers.get(HANDOFF_KEY_HEADER);
|
|
296
|
+
if (key) session.key = key;
|
|
297
|
+
this.save();
|
|
298
|
+
void this.pump(session, stream, abort);
|
|
299
|
+
return session;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Send what the visitor typed to the target handling this conversation.
|
|
304
|
+
*
|
|
305
|
+
* @throws When nothing is handed off, or the target hasn't answered yet.
|
|
306
|
+
*/
|
|
307
|
+
async send(conversationId: string, text: string): Promise<void> {
|
|
308
|
+
const session = this.active(conversationId);
|
|
309
|
+
if (!session) throw new Error('This conversation is not handed off.');
|
|
310
|
+
if (!session.taskId && !session.contextId) throw new Error(`Still connecting to ${session.label}.`);
|
|
311
|
+
const message = visitorMessage(text, { taskId: session.taskId, contextId: session.contextId });
|
|
312
|
+
this.remember(session, message.messageId);
|
|
313
|
+
const abort = this.openLive(conversationId);
|
|
314
|
+
try {
|
|
315
|
+
const stream = await this.client(session.target).stream(
|
|
316
|
+
'SendStreamingMessage',
|
|
317
|
+
{ message, configuration: { acceptedOutputModes: ['text/plain', 'application/json'] } },
|
|
318
|
+
{ signal: abort.signal, headers: this.keyHeader(session) },
|
|
319
|
+
);
|
|
320
|
+
void this.pump(session, stream, abort);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (abort.signal.aborted) return;
|
|
323
|
+
this.live.delete(conversationId);
|
|
324
|
+
session.error = describeFailure(err, session.label);
|
|
325
|
+
this.setState(session, 'failed');
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* End a handoff at the visitor's request: "Back to the assistant".
|
|
332
|
+
*
|
|
333
|
+
* Cancels the task at the target. A target that won't cancel still loses the
|
|
334
|
+
* visitor: the handoff is ended here either way.
|
|
335
|
+
*/
|
|
336
|
+
async end(conversationId: string): Promise<void> {
|
|
337
|
+
const session = this.active(conversationId);
|
|
338
|
+
if (!session) return;
|
|
339
|
+
this.closeLive(conversationId);
|
|
340
|
+
if (session.taskId) {
|
|
341
|
+
try {
|
|
342
|
+
await this.client(session.target).call('CancelTask', { id: session.taskId }, { headers: this.keyHeader(session) });
|
|
343
|
+
} catch {
|
|
344
|
+
// Already finished, or unreachable: the visitor has left it regardless.
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
this.setState(session, 'ended');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Pick up an open handoff after a page change or reload.
|
|
352
|
+
*
|
|
353
|
+
* Subscribes to the task while it runs, or reads it once when it is waiting
|
|
354
|
+
* on the visitor. Safe to call for any conversation, at any time: it does
|
|
355
|
+
* nothing when there is no open handoff or a stream is already open.
|
|
356
|
+
*/
|
|
357
|
+
async resume(conversationId: string): Promise<void> {
|
|
358
|
+
const session = this.active(conversationId);
|
|
359
|
+
if (!session || this.live.has(conversationId)) return;
|
|
360
|
+
if (!session.taskId) {
|
|
361
|
+
// It never got as far as a task: the page left before the target answered.
|
|
362
|
+
session.error = `The connection to ${session.label} was lost before it started.`;
|
|
363
|
+
this.setState(session, 'failed');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// An agent waiting on the visitor has nothing more to say until they answer. A person might.
|
|
367
|
+
if (session.state === 'awaiting-visitor' && session.kind === 'agent') {
|
|
368
|
+
await this.readOnce(session);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const abort = this.openLive(conversationId);
|
|
372
|
+
try {
|
|
373
|
+
const stream = await this.client(session.target).stream(
|
|
374
|
+
'SubscribeToTask',
|
|
375
|
+
{ id: session.taskId },
|
|
376
|
+
{ signal: abort.signal, headers: this.keyHeader(session) },
|
|
377
|
+
);
|
|
378
|
+
void this.pump(session, stream, abort);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
this.live.delete(conversationId);
|
|
381
|
+
if (abort.signal.aborted) return;
|
|
382
|
+
if (err instanceof A2AError && (err.status === 403 || err.status === 404)) {
|
|
383
|
+
session.error = `The conversation with ${session.label} has expired.`;
|
|
384
|
+
this.setState(session, 'failed');
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
// Finished while we were away (subscribing to a finished task is refused), or a blip.
|
|
388
|
+
await this.readOnce(session);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Stop every stream and pending resume. The sessions stay saved for the next page. */
|
|
393
|
+
dispose(): void {
|
|
394
|
+
for (const id of [...this.live.keys()]) this.closeLive(id);
|
|
395
|
+
for (const t of this.timers.values()) clearTimeout(t);
|
|
396
|
+
this.timers.clear();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── Streams ────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
private client(target: string): A2AClient {
|
|
402
|
+
const relay = this.opts.relay.replace(/\/+$/, '');
|
|
403
|
+
return new A2AClient({
|
|
404
|
+
endpoint: `${relay}/${encodeURIComponent(target)}`,
|
|
405
|
+
extensions: [HANDOFF_EXTENSION],
|
|
406
|
+
fetchImpl: this.opts.fetchImpl,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private keyHeader(session: HandoffSession): Record<string, string> {
|
|
411
|
+
return session.key ? { [HANDOFF_KEY_HEADER]: session.key } : {};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A fresh abort signal for a conversation's stream, closing the one before it. */
|
|
415
|
+
private openLive(conversationId: string): AbortController {
|
|
416
|
+
this.closeLive(conversationId);
|
|
417
|
+
const abort = new AbortController();
|
|
418
|
+
this.live.set(conversationId, abort);
|
|
419
|
+
return abort;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private closeLive(conversationId: string): void {
|
|
423
|
+
this.live.get(conversationId)?.abort();
|
|
424
|
+
this.live.delete(conversationId);
|
|
425
|
+
const timer = this.timers.get(conversationId);
|
|
426
|
+
if (timer) clearTimeout(timer);
|
|
427
|
+
this.timers.delete(conversationId);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Read a stream to its end, then resume it if the task is still running. */
|
|
431
|
+
private async pump(session: HandoffSession, stream: A2AStream, abort: AbortController): Promise<void> {
|
|
432
|
+
const id = session.conversationId;
|
|
433
|
+
// Progress is what the visitor would notice: a new message, a new state. A resubscribe that only
|
|
434
|
+
// repeats the task as it stood is not progress, and must back off like an empty one.
|
|
435
|
+
const before = this.reported;
|
|
436
|
+
try {
|
|
437
|
+
for await (const event of stream.events) {
|
|
438
|
+
if (abort.signal.aborted) return;
|
|
439
|
+
this.apply(session, event);
|
|
440
|
+
}
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (abort.signal.aborted) return;
|
|
443
|
+
console.warn(`[nxis-handoff] stream from ${session.label} broke:`, err);
|
|
444
|
+
}
|
|
445
|
+
if (abort.signal.aborted) return;
|
|
446
|
+
if (this.live.get(id) === abort) this.live.delete(id);
|
|
447
|
+
if (isHandoffOver(session.state)) return;
|
|
448
|
+
// Waiting on the visitor: their next message opens the next stream.
|
|
449
|
+
if (session.state === 'awaiting-visitor' && session.kind === 'agent') return;
|
|
450
|
+
|
|
451
|
+
// The stream ended with the task still running: the host cut it off. Come back for the rest.
|
|
452
|
+
const quiet = this.reported > before ? 0 : (this.idle.get(id) ?? 0) + 1;
|
|
453
|
+
this.idle.set(id, quiet);
|
|
454
|
+
const base = this.opts.resumeDelayMs ?? 1000;
|
|
455
|
+
const delay = Math.min(base * 2 ** Math.max(0, quiet - 1), MAX_RESUME_DELAY_MS);
|
|
456
|
+
this.timers.set(
|
|
457
|
+
id,
|
|
458
|
+
setTimeout(() => {
|
|
459
|
+
this.timers.delete(id);
|
|
460
|
+
void this.resume(id);
|
|
461
|
+
}, delay),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Read a task once, for a handoff that can't or needn't be streamed. */
|
|
466
|
+
private async readOnce(session: HandoffSession): Promise<void> {
|
|
467
|
+
if (!session.taskId) return;
|
|
468
|
+
try {
|
|
469
|
+
const { result } = await this.client(session.target).call<A2ATask | { task: A2ATask }>(
|
|
470
|
+
'GetTask',
|
|
471
|
+
{ id: session.taskId, historyLength: 50 },
|
|
472
|
+
{ headers: this.keyHeader(session) },
|
|
473
|
+
);
|
|
474
|
+
const task = (result as { task?: A2ATask }).task ?? (result as A2ATask);
|
|
475
|
+
this.apply(session, { task });
|
|
476
|
+
} catch (err) {
|
|
477
|
+
session.error =
|
|
478
|
+
err instanceof A2AError && (err.status === 403 || err.status === 404)
|
|
479
|
+
? `The conversation with ${session.label} has expired.`
|
|
480
|
+
: describeFailure(err, session.label);
|
|
481
|
+
this.setState(session, 'failed');
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Events ─────────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
private apply(session: HandoffSession, event: A2AStreamResponse): void {
|
|
488
|
+
if (event.task) {
|
|
489
|
+
const task = event.task;
|
|
490
|
+
this.adopt(session, task.id, task.contextId);
|
|
491
|
+
for (const message of task.history ?? []) this.message(session, message);
|
|
492
|
+
for (const artifact of task.artifacts ?? []) this.parts(session, artifact.parts, artifact.artifactId);
|
|
493
|
+
this.status(session, task.status);
|
|
494
|
+
} else if (event.statusUpdate) {
|
|
495
|
+
this.adopt(session, event.statusUpdate.taskId, event.statusUpdate.contextId);
|
|
496
|
+
this.status(session, event.statusUpdate.status);
|
|
497
|
+
} else if (event.artifactUpdate) {
|
|
498
|
+
this.adopt(session, event.artifactUpdate.taskId, event.artifactUpdate.contextId);
|
|
499
|
+
const a = event.artifactUpdate.artifact;
|
|
500
|
+
this.parts(session, a.parts, a.artifactId);
|
|
501
|
+
} else if (event.message) {
|
|
502
|
+
// A reply with no task: the target answered at once. The context carries the conversation on.
|
|
503
|
+
this.adopt(session, event.message.taskId, event.message.contextId);
|
|
504
|
+
this.message(session, event.message);
|
|
505
|
+
if (session.state === 'connecting' || session.state === 'queued') this.setState(session, 'active');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
private adopt(session: HandoffSession, taskId?: string, contextId?: string): void {
|
|
510
|
+
let changed = false;
|
|
511
|
+
if (taskId && session.taskId !== taskId) {
|
|
512
|
+
session.taskId = taskId;
|
|
513
|
+
changed = true;
|
|
514
|
+
}
|
|
515
|
+
if (contextId && session.contextId !== contextId) {
|
|
516
|
+
session.contextId = contextId;
|
|
517
|
+
changed = true;
|
|
518
|
+
}
|
|
519
|
+
if (changed) this.save();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private status(session: HandoffSession, status: A2ATaskStatus): void {
|
|
523
|
+
const queue = readQueue(status.message?.parts);
|
|
524
|
+
if (queue) session.queue = queue;
|
|
525
|
+
// Who is speaking is known before what they say, so "Sam has joined" comes first.
|
|
526
|
+
const operator = readOperator(status.message?.parts);
|
|
527
|
+
if (operator) session.operator = operator.name;
|
|
528
|
+
// A hand-back is read before the state is decided: it is what makes COMPLETED "handed back".
|
|
529
|
+
const handBack = readHandBack(status.message?.parts);
|
|
530
|
+
const next = handBack && status.state === 'TASK_STATE_COMPLETED' ? 'handed-back' : this.stateFor(status.state, session);
|
|
531
|
+
const changed = !!next && (next !== session.state || !!queue);
|
|
532
|
+
// A state that continues the conversation is announced before the message that comes with it;
|
|
533
|
+
// one that ends it after the last word.
|
|
534
|
+
if (changed && !isHandoffOver(next!)) this.setState(session, next!);
|
|
535
|
+
if (status.message) this.message(session, status.message);
|
|
536
|
+
if (!next) return;
|
|
537
|
+
if (changed && isHandoffOver(next)) this.setState(session, next);
|
|
538
|
+
if (isHandoffOver(next)) this.closeLive(session.conversationId);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private stateFor(state: A2ATaskState, session: HandoffSession): HandoffState | null {
|
|
542
|
+
switch (state) {
|
|
543
|
+
case 'TASK_STATE_SUBMITTED':
|
|
544
|
+
return session.kind === 'person' ? 'queued' : 'connecting';
|
|
545
|
+
case 'TASK_STATE_WORKING':
|
|
546
|
+
return 'active';
|
|
547
|
+
case 'TASK_STATE_INPUT_REQUIRED':
|
|
548
|
+
return 'awaiting-visitor';
|
|
549
|
+
case 'TASK_STATE_COMPLETED':
|
|
550
|
+
return session.handBack ? 'handed-back' : 'ended';
|
|
551
|
+
case 'TASK_STATE_CANCELED':
|
|
552
|
+
return 'ended';
|
|
553
|
+
case 'TASK_STATE_AUTH_REQUIRED':
|
|
554
|
+
session.error = `${session.label} needs you to sign in, which isn't supported here yet.`;
|
|
555
|
+
return 'failed';
|
|
556
|
+
case 'TASK_STATE_FAILED':
|
|
557
|
+
case 'TASK_STATE_REJECTED':
|
|
558
|
+
session.error ??= `${session.label} couldn't take this conversation.`;
|
|
559
|
+
return 'failed';
|
|
560
|
+
default:
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** One message from the target: who is speaking, a hand-back, and the words. */
|
|
566
|
+
private message(session: HandoffSession, message: A2AMessage): void {
|
|
567
|
+
if (message.role === 'ROLE_USER') {
|
|
568
|
+
this.remember(session, message.messageId);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (message.messageId && session.seen.includes(message.messageId)) return;
|
|
572
|
+
this.remember(session, message.messageId);
|
|
573
|
+
this.parts(session, message.parts);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private parts(session: HandoffSession, parts: A2AMessage['parts'] = [], artifactId?: string): void {
|
|
577
|
+
if (artifactId) {
|
|
578
|
+
const seenKey = `artifact:${artifactId}`;
|
|
579
|
+
if (session.seen.includes(seenKey)) return;
|
|
580
|
+
this.remember(session, seenKey);
|
|
581
|
+
}
|
|
582
|
+
const operator = readOperator(parts);
|
|
583
|
+
if (operator) {
|
|
584
|
+
session.operator = operator.name;
|
|
585
|
+
this.save();
|
|
586
|
+
}
|
|
587
|
+
const handBack = readHandBack(parts);
|
|
588
|
+
const text = readableText(parts);
|
|
589
|
+
// The last words first, then what they hand back: the references follow the goodbye.
|
|
590
|
+
if (text) {
|
|
591
|
+
this.emit({
|
|
592
|
+
type: 'message',
|
|
593
|
+
conversationId: session.conversationId,
|
|
594
|
+
session,
|
|
595
|
+
from: session.operator ?? session.label,
|
|
596
|
+
fromKind: operator?.kind ?? session.kind,
|
|
597
|
+
text,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (handBack && !session.handBack) {
|
|
601
|
+
session.handBack = handBack;
|
|
602
|
+
this.save();
|
|
603
|
+
this.emit({ type: 'handback', conversationId: session.conversationId, session, handBack });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
private remember(session: HandoffSession, id: string | undefined): void {
|
|
608
|
+
if (!id || session.seen.includes(id)) return;
|
|
609
|
+
session.seen.push(id);
|
|
610
|
+
if (session.seen.length > MAX_SEEN) session.seen.splice(0, session.seen.length - MAX_SEEN);
|
|
611
|
+
this.save();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private setState(session: HandoffSession, state: HandoffState): void {
|
|
615
|
+
session.state = state;
|
|
616
|
+
session.updatedAt = this.now();
|
|
617
|
+
if (state !== 'queued') delete session.queue;
|
|
618
|
+
if (isHandoffOver(state)) this.idle.delete(session.conversationId);
|
|
619
|
+
this.save();
|
|
620
|
+
this.emit({ type: 'state', conversationId: session.conversationId, session, state });
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
private emit(detail: HandoffEvent): void {
|
|
624
|
+
this.reported++;
|
|
625
|
+
this.dispatchEvent(new CustomEvent<HandoffEvent>('handoff', { detail }));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ── Storage ────────────────────────────────────────────────────────────────
|
|
629
|
+
|
|
630
|
+
private now(): number {
|
|
631
|
+
return this.opts.now?.() ?? Date.now();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private load(): void {
|
|
635
|
+
if (this.loaded) return;
|
|
636
|
+
this.loaded = true;
|
|
637
|
+
if (!this.storage) return;
|
|
638
|
+
try {
|
|
639
|
+
const raw = JSON.parse(this.storage.getItem(STORAGE_KEY) || '{}') as Record<string, HandoffSession>;
|
|
640
|
+
const now = this.now();
|
|
641
|
+
for (const [id, s] of Object.entries(raw)) {
|
|
642
|
+
const keep = isHandoffOver(s.state) ? KEEP_FINISHED_MS : KEEP_OPEN_MS;
|
|
643
|
+
if (s && typeof s.target === 'string' && now - (s.updatedAt ?? 0) < keep && !this.sessions.has(id)) {
|
|
644
|
+
this.sessions.set(id, { ...s, seen: Array.isArray(s.seen) ? s.seen : [] });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
} catch {
|
|
648
|
+
// Unreadable: start afresh.
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private save(): void {
|
|
653
|
+
if (!this.storage) return;
|
|
654
|
+
this.load();
|
|
655
|
+
try {
|
|
656
|
+
this.storage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(this.sessions)));
|
|
657
|
+
} catch {
|
|
658
|
+
// Full or blocked: the handoff still runs, it just won't survive a reload.
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|