@agentick/client 0.0.1
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/LICENSE +21 -0
- package/README.md +228 -0
- package/dist/client.d.ts +351 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1381 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +54 -0
- package/dist/index.js.map +1 -0
- package/dist/sse-transport.d.ts +73 -0
- package/dist/sse-transport.d.ts.map +1 -0
- package/dist/sse-transport.js +415 -0
- package/dist/sse-transport.js.map +1 -0
- package/dist/transport.d.ts +69 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +6 -0
- package/dist/transport.js.map +1 -0
- package/dist/transports/http.d.ts +213 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +309 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/index.d.ts +16 -0
- package/dist/transports/index.d.ts.map +1 -0
- package/dist/transports/index.js +18 -0
- package/dist/transports/index.js.map +1 -0
- package/dist/transports/websocket.d.ts +114 -0
- package/dist/transports/websocket.d.ts.map +1 -0
- package/dist/transports/websocket.js +239 -0
- package/dist/transports/websocket.js.map +1 -0
- package/dist/types.d.ts +179 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +28 -0
- package/dist/types.js.map +1 -0
- package/dist/ws-transport.d.ts +67 -0
- package/dist/ws-transport.d.ts.map +1 -0
- package/dist/ws-transport.js +408 -0
- package/dist/ws-transport.js.map +1 -0
- package/package.json +45 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,1381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentickClient - Multiplexed session client
|
|
3
|
+
*
|
|
4
|
+
* Connects to a Agentick server with a single SSE connection
|
|
5
|
+
* that multiplexes events for multiple sessions.
|
|
6
|
+
*
|
|
7
|
+
* @module @agentick/client
|
|
8
|
+
*/
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Channel Accessor Implementation
|
|
11
|
+
// ============================================================================
|
|
12
|
+
class ChannelAccessorImpl {
|
|
13
|
+
sendFn;
|
|
14
|
+
name;
|
|
15
|
+
handlers = new Set();
|
|
16
|
+
pendingRequests = new Map();
|
|
17
|
+
constructor(name, sendFn) {
|
|
18
|
+
this.sendFn = sendFn;
|
|
19
|
+
this.name = name;
|
|
20
|
+
}
|
|
21
|
+
subscribe(handler) {
|
|
22
|
+
this.handlers.add(handler);
|
|
23
|
+
return () => {
|
|
24
|
+
this.handlers.delete(handler);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
async publish(type, payload) {
|
|
28
|
+
await this.sendFn({
|
|
29
|
+
channel: this.name,
|
|
30
|
+
type,
|
|
31
|
+
payload,
|
|
32
|
+
metadata: { timestamp: Date.now() },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async request(type, payload, timeoutMs = 30000) {
|
|
36
|
+
const requestId = generateId();
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const timeout = setTimeout(() => {
|
|
39
|
+
this.pendingRequests.delete(requestId);
|
|
40
|
+
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
this.pendingRequests.set(requestId, {
|
|
43
|
+
resolve: resolve,
|
|
44
|
+
reject,
|
|
45
|
+
timeout,
|
|
46
|
+
});
|
|
47
|
+
this.sendFn({
|
|
48
|
+
channel: this.name,
|
|
49
|
+
type,
|
|
50
|
+
payload,
|
|
51
|
+
id: requestId,
|
|
52
|
+
metadata: { timestamp: Date.now() },
|
|
53
|
+
}).catch((error) => {
|
|
54
|
+
this.pendingRequests.delete(requestId);
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
reject(error);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** @internal */
|
|
61
|
+
_handleEvent(event) {
|
|
62
|
+
// Check for response to pending request
|
|
63
|
+
if (event.type === "response" && event.id) {
|
|
64
|
+
const pending = this.pendingRequests.get(event.id);
|
|
65
|
+
if (pending) {
|
|
66
|
+
clearTimeout(pending.timeout);
|
|
67
|
+
this.pendingRequests.delete(event.id);
|
|
68
|
+
pending.resolve(event.payload);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Check for error response
|
|
73
|
+
if (event.type === "error" && event.id) {
|
|
74
|
+
const pending = this.pendingRequests.get(event.id);
|
|
75
|
+
if (pending) {
|
|
76
|
+
clearTimeout(pending.timeout);
|
|
77
|
+
this.pendingRequests.delete(event.id);
|
|
78
|
+
pending.reject(new Error(event.payload?.message ?? "Request failed"));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Notify subscribers
|
|
83
|
+
for (const handler of this.handlers) {
|
|
84
|
+
try {
|
|
85
|
+
handler(event.payload, event);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
console.error(`Error in channel handler for ${this.name}:`, error);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** @internal */
|
|
93
|
+
_destroy() {
|
|
94
|
+
for (const [, pending] of this.pendingRequests) {
|
|
95
|
+
clearTimeout(pending.timeout);
|
|
96
|
+
pending.reject(new Error("Channel destroyed"));
|
|
97
|
+
}
|
|
98
|
+
this.pendingRequests.clear();
|
|
99
|
+
this.handlers.clear();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Async Event Queue (single-consumer)
|
|
104
|
+
// ============================================================================
|
|
105
|
+
class AsyncEventQueue {
|
|
106
|
+
buffer = [];
|
|
107
|
+
resolvers = [];
|
|
108
|
+
closed = false;
|
|
109
|
+
push(value) {
|
|
110
|
+
if (this.closed)
|
|
111
|
+
return;
|
|
112
|
+
const resolver = this.resolvers.shift();
|
|
113
|
+
if (resolver) {
|
|
114
|
+
resolver({ value, done: false });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
this.buffer.push(value);
|
|
118
|
+
}
|
|
119
|
+
close() {
|
|
120
|
+
if (this.closed)
|
|
121
|
+
return;
|
|
122
|
+
this.closed = true;
|
|
123
|
+
while (this.resolvers.length > 0) {
|
|
124
|
+
const resolver = this.resolvers.shift();
|
|
125
|
+
if (resolver) {
|
|
126
|
+
resolver({ value: undefined, done: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
[Symbol.asyncIterator]() {
|
|
131
|
+
return {
|
|
132
|
+
next: () => {
|
|
133
|
+
if (this.buffer.length > 0) {
|
|
134
|
+
const value = this.buffer.shift();
|
|
135
|
+
return Promise.resolve({ value, done: false });
|
|
136
|
+
}
|
|
137
|
+
if (this.closed) {
|
|
138
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
139
|
+
}
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
this.resolvers.push(resolve);
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// ============================================================================
|
|
148
|
+
// Client Execution Handle
|
|
149
|
+
// ============================================================================
|
|
150
|
+
class ClientExecutionHandleImpl {
|
|
151
|
+
client;
|
|
152
|
+
abortController;
|
|
153
|
+
queue = new AsyncEventQueue();
|
|
154
|
+
resultPromise;
|
|
155
|
+
resolveResult;
|
|
156
|
+
rejectResult;
|
|
157
|
+
_status = "running";
|
|
158
|
+
_sessionId;
|
|
159
|
+
_executionId = "pending";
|
|
160
|
+
hasResult = false;
|
|
161
|
+
constructor(client, abortController, sessionId) {
|
|
162
|
+
this.client = client;
|
|
163
|
+
this.abortController = abortController;
|
|
164
|
+
this._sessionId = sessionId ?? "pending";
|
|
165
|
+
this.resultPromise = new Promise((resolve, reject) => {
|
|
166
|
+
this.resolveResult = resolve;
|
|
167
|
+
this.rejectResult = reject;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
get sessionId() {
|
|
171
|
+
return this._sessionId;
|
|
172
|
+
}
|
|
173
|
+
get executionId() {
|
|
174
|
+
return this._executionId;
|
|
175
|
+
}
|
|
176
|
+
get status() {
|
|
177
|
+
return this._status;
|
|
178
|
+
}
|
|
179
|
+
get result() {
|
|
180
|
+
return this.resultPromise;
|
|
181
|
+
}
|
|
182
|
+
[Symbol.asyncIterator]() {
|
|
183
|
+
return this.queue[Symbol.asyncIterator]();
|
|
184
|
+
}
|
|
185
|
+
abort(reason) {
|
|
186
|
+
if (this._status !== "running")
|
|
187
|
+
return;
|
|
188
|
+
this._status = "aborted";
|
|
189
|
+
this.abortController.abort(reason ?? "Client aborted execution");
|
|
190
|
+
if (this._sessionId !== "pending") {
|
|
191
|
+
void this.client.abort(this._sessionId, reason).catch(() => { });
|
|
192
|
+
}
|
|
193
|
+
this.queue.close();
|
|
194
|
+
this.rejectResult(new Error(reason ?? "Execution aborted"));
|
|
195
|
+
}
|
|
196
|
+
queueMessage(message) {
|
|
197
|
+
if (this._sessionId === "pending") {
|
|
198
|
+
throw new Error("Cannot queue message before sessionId is known");
|
|
199
|
+
}
|
|
200
|
+
const handle = this.client.send({ messages: [message] }, { sessionId: this._sessionId });
|
|
201
|
+
void handle.result.catch(() => { });
|
|
202
|
+
}
|
|
203
|
+
submitToolResult(toolUseId, result) {
|
|
204
|
+
if (this._sessionId === "pending") {
|
|
205
|
+
throw new Error("Cannot submit tool result before sessionId is known");
|
|
206
|
+
}
|
|
207
|
+
void this.client.submitToolResult(this._sessionId, toolUseId, result).catch(() => { });
|
|
208
|
+
}
|
|
209
|
+
/** @internal */
|
|
210
|
+
_handleEvent(event) {
|
|
211
|
+
if (event.sessionId) {
|
|
212
|
+
this._sessionId = event.sessionId;
|
|
213
|
+
}
|
|
214
|
+
if ("executionId" in event && event.executionId) {
|
|
215
|
+
this._executionId = event.executionId;
|
|
216
|
+
}
|
|
217
|
+
const streamEvent = event;
|
|
218
|
+
this.queue.push(streamEvent);
|
|
219
|
+
if (event.type === "result") {
|
|
220
|
+
this.hasResult = true;
|
|
221
|
+
this._status = "completed";
|
|
222
|
+
this.resolveResult(event.result);
|
|
223
|
+
}
|
|
224
|
+
// Handle server-side errors
|
|
225
|
+
if (event.type === "error") {
|
|
226
|
+
this.hasResult = true; // Prevent "completed without result" error
|
|
227
|
+
this._status = "error";
|
|
228
|
+
const errorMessage = event.error ?? "Server error";
|
|
229
|
+
this.rejectResult(new Error(errorMessage));
|
|
230
|
+
}
|
|
231
|
+
if (event.type === "execution_end") {
|
|
232
|
+
if (this._status === "running") {
|
|
233
|
+
this._status = "completed";
|
|
234
|
+
}
|
|
235
|
+
this.queue.close();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** @internal */
|
|
239
|
+
_fail(error) {
|
|
240
|
+
if (this._status === "running") {
|
|
241
|
+
this._status = "error";
|
|
242
|
+
}
|
|
243
|
+
this.queue.close();
|
|
244
|
+
if (!this.hasResult) {
|
|
245
|
+
this.rejectResult(error);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/** @internal */
|
|
249
|
+
_complete() {
|
|
250
|
+
if (this._status === "running") {
|
|
251
|
+
this._status = "completed";
|
|
252
|
+
}
|
|
253
|
+
this.queue.close();
|
|
254
|
+
if (!this.hasResult) {
|
|
255
|
+
this.rejectResult(new Error("Execution completed without result"));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ============================================================================
|
|
260
|
+
// Session Accessor Implementation
|
|
261
|
+
// ============================================================================
|
|
262
|
+
class SessionAccessorImpl {
|
|
263
|
+
client;
|
|
264
|
+
sessionId;
|
|
265
|
+
_isSubscribed = false;
|
|
266
|
+
eventHandlers = new Set();
|
|
267
|
+
resultHandlers = new Set();
|
|
268
|
+
toolConfirmationHandlers = new Set();
|
|
269
|
+
channels = new Map();
|
|
270
|
+
constructor(sessionId, client) {
|
|
271
|
+
this.client = client;
|
|
272
|
+
this.sessionId = sessionId;
|
|
273
|
+
}
|
|
274
|
+
get isSubscribed() {
|
|
275
|
+
return this._isSubscribed;
|
|
276
|
+
}
|
|
277
|
+
subscribe() {
|
|
278
|
+
if (this._isSubscribed)
|
|
279
|
+
return;
|
|
280
|
+
this._isSubscribed = true;
|
|
281
|
+
this.client._subscribeToSession(this.sessionId).catch((error) => {
|
|
282
|
+
this._isSubscribed = false;
|
|
283
|
+
console.error(`Failed to subscribe to session ${this.sessionId}:`, error);
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
unsubscribe() {
|
|
287
|
+
if (!this._isSubscribed)
|
|
288
|
+
return;
|
|
289
|
+
this._isSubscribed = false;
|
|
290
|
+
this.client._unsubscribeFromSession(this.sessionId).catch((error) => {
|
|
291
|
+
console.error(`Failed to unsubscribe from session ${this.sessionId}:`, error);
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
send(input) {
|
|
295
|
+
return this.client.send(input, { sessionId: this.sessionId });
|
|
296
|
+
}
|
|
297
|
+
async abort(reason) {
|
|
298
|
+
await this.client.abort(this.sessionId, reason);
|
|
299
|
+
}
|
|
300
|
+
async close() {
|
|
301
|
+
await this.client.closeSession(this.sessionId);
|
|
302
|
+
}
|
|
303
|
+
submitToolResult(toolUseId, result) {
|
|
304
|
+
void this.client.submitToolResult(this.sessionId, toolUseId, result).catch(() => { });
|
|
305
|
+
}
|
|
306
|
+
onEvent(handler) {
|
|
307
|
+
this.eventHandlers.add(handler);
|
|
308
|
+
return () => {
|
|
309
|
+
this.eventHandlers.delete(handler);
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
onResult(handler) {
|
|
313
|
+
this.resultHandlers.add(handler);
|
|
314
|
+
return () => {
|
|
315
|
+
this.resultHandlers.delete(handler);
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
onToolConfirmation(handler) {
|
|
319
|
+
this.toolConfirmationHandlers.add(handler);
|
|
320
|
+
return () => {
|
|
321
|
+
this.toolConfirmationHandlers.delete(handler);
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** @internal - Called by client when event is received for this session */
|
|
325
|
+
_handleEvent(event) {
|
|
326
|
+
for (const handler of this.eventHandlers) {
|
|
327
|
+
try {
|
|
328
|
+
handler(event);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
console.error("Error in session event handler:", error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** @internal - Called by client when result is received for this session */
|
|
336
|
+
_handleResult(result) {
|
|
337
|
+
for (const handler of this.resultHandlers) {
|
|
338
|
+
try {
|
|
339
|
+
handler(result);
|
|
340
|
+
}
|
|
341
|
+
catch (error) {
|
|
342
|
+
console.error("Error in session result handler:", error);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
/** @internal - Called by client when channel event is received for this session */
|
|
347
|
+
_handleChannelEvent(channelName, event) {
|
|
348
|
+
const channelAccessor = this.channels.get(channelName);
|
|
349
|
+
if (channelAccessor) {
|
|
350
|
+
channelAccessor._handleEvent(event);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** @internal - Called by client when tool confirmation is requested */
|
|
354
|
+
_handleToolConfirmation(request) {
|
|
355
|
+
const respond = (response) => {
|
|
356
|
+
this.submitToolResult(request.toolUseId, response);
|
|
357
|
+
};
|
|
358
|
+
for (const handler of this.toolConfirmationHandlers) {
|
|
359
|
+
try {
|
|
360
|
+
handler(request, respond);
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
console.error("Error in tool confirmation handler:", error);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
channel(name) {
|
|
368
|
+
let channelAccessor = this.channels.get(name);
|
|
369
|
+
if (!channelAccessor) {
|
|
370
|
+
channelAccessor = new ChannelAccessorImpl(name, async (event) => {
|
|
371
|
+
await this.client._publishToChannel(this.sessionId, name, event);
|
|
372
|
+
});
|
|
373
|
+
this.channels.set(name, channelAccessor);
|
|
374
|
+
// Subscribe to this channel on the server
|
|
375
|
+
this.client._subscribeToChannel(this.sessionId, name).catch((err) => {
|
|
376
|
+
console.error(`Failed to subscribe to channel ${name}:`, err);
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
return channelAccessor;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Invoke a custom method with auto-injected sessionId.
|
|
383
|
+
*
|
|
384
|
+
* @example
|
|
385
|
+
* ```typescript
|
|
386
|
+
* const session = client.session("main");
|
|
387
|
+
* const tasks = await session.invoke("tasks:list");
|
|
388
|
+
* const newTask = await session.invoke("tasks:create", { title: "Buy groceries" });
|
|
389
|
+
* ```
|
|
390
|
+
*/
|
|
391
|
+
async invoke(method, params = {}) {
|
|
392
|
+
return this.client.invoke(method, {
|
|
393
|
+
...params,
|
|
394
|
+
sessionId: this.sessionId,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Invoke a streaming method with auto-injected sessionId.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```typescript
|
|
402
|
+
* const session = client.session("main");
|
|
403
|
+
* for await (const change of session.stream("tasks:watch")) {
|
|
404
|
+
* console.log("Task changed:", change);
|
|
405
|
+
* }
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
async *stream(method, params = {}) {
|
|
409
|
+
yield* this.client.stream(method, {
|
|
410
|
+
...params,
|
|
411
|
+
sessionId: this.sessionId,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
/** @internal */
|
|
415
|
+
_destroy() {
|
|
416
|
+
this.eventHandlers.clear();
|
|
417
|
+
this.resultHandlers.clear();
|
|
418
|
+
this.toolConfirmationHandlers.clear();
|
|
419
|
+
for (const channel of this.channels.values()) {
|
|
420
|
+
channel._destroy();
|
|
421
|
+
}
|
|
422
|
+
this.channels.clear();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
// ============================================================================
|
|
426
|
+
// Utilities
|
|
427
|
+
// ============================================================================
|
|
428
|
+
function generateId() {
|
|
429
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
430
|
+
return crypto.randomUUID();
|
|
431
|
+
}
|
|
432
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
433
|
+
const r = (Math.random() * 16) | 0;
|
|
434
|
+
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
|
435
|
+
return v.toString(16);
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
// ============================================================================
|
|
439
|
+
// AgentickClient
|
|
440
|
+
// ============================================================================
|
|
441
|
+
/**
|
|
442
|
+
* AgentickClient - Multiplexed session client.
|
|
443
|
+
*
|
|
444
|
+
* Connects to a Agentick server with a single SSE connection that
|
|
445
|
+
* can manage multiple session subscriptions.
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```typescript
|
|
449
|
+
* const client = createClient({
|
|
450
|
+
* baseUrl: 'https://api.example.com',
|
|
451
|
+
* });
|
|
452
|
+
*
|
|
453
|
+
* // Get session accessor (cold - no subscription)
|
|
454
|
+
* const session = client.session('conv-123');
|
|
455
|
+
*
|
|
456
|
+
* // Subscribe to receive events (hot)
|
|
457
|
+
* session.subscribe();
|
|
458
|
+
*
|
|
459
|
+
* // Listen for events
|
|
460
|
+
* session.onEvent((event) => {
|
|
461
|
+
* console.log(event);
|
|
462
|
+
* });
|
|
463
|
+
*
|
|
464
|
+
* // Send a message
|
|
465
|
+
* const handle = session.send({ messages: [{ role: 'user', content: [...] }] });
|
|
466
|
+
* await handle.result;
|
|
467
|
+
*
|
|
468
|
+
* // Or use ephemeral send (creates session, executes, closes)
|
|
469
|
+
* const ephemeral = client.send({ messages: [{...}] });
|
|
470
|
+
* await ephemeral.result;
|
|
471
|
+
* ```
|
|
472
|
+
*/
|
|
473
|
+
export class AgentickClient {
|
|
474
|
+
config;
|
|
475
|
+
fetchFn;
|
|
476
|
+
EventSourceCtor;
|
|
477
|
+
requestHeaders;
|
|
478
|
+
// Custom transport (when provided)
|
|
479
|
+
customTransport = null;
|
|
480
|
+
transportCleanup = null;
|
|
481
|
+
_state = "disconnected";
|
|
482
|
+
_connectionId;
|
|
483
|
+
eventSource;
|
|
484
|
+
connectionPromise;
|
|
485
|
+
stateHandlers = new Set();
|
|
486
|
+
eventHandlers = new Set();
|
|
487
|
+
streamingTextHandlers = new Set();
|
|
488
|
+
_streamingText = { text: "", isStreaming: false };
|
|
489
|
+
sessions = new Map();
|
|
490
|
+
subscriptions = new Set();
|
|
491
|
+
seenEventIds = new Set();
|
|
492
|
+
seenEventIdsOrder = [];
|
|
493
|
+
maxSeenEventIds = 5000;
|
|
494
|
+
constructor(config) {
|
|
495
|
+
this.config = config;
|
|
496
|
+
// Build request headers
|
|
497
|
+
this.requestHeaders = { "Content-Type": "application/json", ...config.headers };
|
|
498
|
+
if (config.token && !this.requestHeaders["Authorization"]) {
|
|
499
|
+
this.requestHeaders["Authorization"] = `Bearer ${config.token}`;
|
|
500
|
+
}
|
|
501
|
+
// Use custom implementations or fall back to globals
|
|
502
|
+
this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
503
|
+
this.EventSourceCtor = config.EventSource ?? globalThis.EventSource;
|
|
504
|
+
// Check if a custom transport was provided
|
|
505
|
+
if (config.transport && typeof config.transport === "object") {
|
|
506
|
+
this.customTransport = config.transport;
|
|
507
|
+
this.setupTransportHandlers();
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Setup handlers for custom transport events.
|
|
512
|
+
*/
|
|
513
|
+
setupTransportHandlers() {
|
|
514
|
+
if (!this.customTransport)
|
|
515
|
+
return;
|
|
516
|
+
// Forward transport events to our handlers
|
|
517
|
+
const cleanupEvent = this.customTransport.onEvent((event) => {
|
|
518
|
+
this.handleIncomingEvent(event);
|
|
519
|
+
});
|
|
520
|
+
// Map transport state to connection state
|
|
521
|
+
const cleanupState = this.customTransport.onStateChange((state) => {
|
|
522
|
+
const connectionState = state === "disconnected"
|
|
523
|
+
? "disconnected"
|
|
524
|
+
: state === "connecting"
|
|
525
|
+
? "connecting"
|
|
526
|
+
: state === "connected"
|
|
527
|
+
? "connected"
|
|
528
|
+
: "error";
|
|
529
|
+
this.setState(connectionState);
|
|
530
|
+
});
|
|
531
|
+
this.transportCleanup = () => {
|
|
532
|
+
cleanupEvent();
|
|
533
|
+
cleanupState();
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
537
|
+
// Connection State
|
|
538
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
539
|
+
/** Current connection state */
|
|
540
|
+
get state() {
|
|
541
|
+
return this._state;
|
|
542
|
+
}
|
|
543
|
+
setState(state) {
|
|
544
|
+
if (this._state === state)
|
|
545
|
+
return;
|
|
546
|
+
this._state = state;
|
|
547
|
+
for (const handler of this.stateHandlers) {
|
|
548
|
+
try {
|
|
549
|
+
handler(state);
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
console.error("Error in state handler:", error);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Subscribe to connection state changes.
|
|
558
|
+
*/
|
|
559
|
+
onConnectionChange(handler) {
|
|
560
|
+
this.stateHandlers.add(handler);
|
|
561
|
+
return () => {
|
|
562
|
+
this.stateHandlers.delete(handler);
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
566
|
+
// Connection Lifecycle
|
|
567
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
568
|
+
/**
|
|
569
|
+
* Ensure the connection is established.
|
|
570
|
+
* This is called lazily when subscribing to sessions.
|
|
571
|
+
*/
|
|
572
|
+
async ensureConnection() {
|
|
573
|
+
// Use custom transport if provided
|
|
574
|
+
if (this.customTransport) {
|
|
575
|
+
if (this.customTransport.state === "connected") {
|
|
576
|
+
this._connectionId = this.customTransport.connectionId;
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
await this.customTransport.connect();
|
|
580
|
+
this._connectionId = this.customTransport.connectionId;
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
// Default SSE connection logic
|
|
584
|
+
if (this._state === "connected") {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (this.connectionPromise) {
|
|
588
|
+
await this.connectionPromise;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
this.setState("connecting");
|
|
592
|
+
this.connectionPromise = this.openEventSource();
|
|
593
|
+
try {
|
|
594
|
+
await this.connectionPromise;
|
|
595
|
+
this.setState("connected");
|
|
596
|
+
}
|
|
597
|
+
catch (error) {
|
|
598
|
+
this.setState("error");
|
|
599
|
+
throw error;
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
this.connectionPromise = undefined;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
async openEventSource() {
|
|
606
|
+
this.closeEventSource();
|
|
607
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
608
|
+
const eventsPath = this.config.paths?.events ?? "/events";
|
|
609
|
+
const url = `${baseUrl}${eventsPath}`;
|
|
610
|
+
return new Promise((resolve, reject) => {
|
|
611
|
+
try {
|
|
612
|
+
this.eventSource = new this.EventSourceCtor(url, {
|
|
613
|
+
withCredentials: this.config.withCredentials,
|
|
614
|
+
});
|
|
615
|
+
const onMessage = (event) => {
|
|
616
|
+
try {
|
|
617
|
+
const data = JSON.parse(event.data);
|
|
618
|
+
this.handleIncomingEvent(data);
|
|
619
|
+
if (data.type === "connection" && data.connectionId) {
|
|
620
|
+
this._connectionId = data.connectionId;
|
|
621
|
+
if (data.subscriptions) {
|
|
622
|
+
for (const sessionId of data.subscriptions) {
|
|
623
|
+
this.subscriptions.add(sessionId);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
resolve();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
console.error("Failed to parse SSE event:", error);
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
const onError = () => {
|
|
634
|
+
if (this._state === "connecting") {
|
|
635
|
+
this.closeEventSource();
|
|
636
|
+
reject(new Error("SSE connection failed"));
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
this.setState("error");
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
this.eventSource.addEventListener("message", onMessage);
|
|
643
|
+
this.eventSource.addEventListener("error", onError);
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
reject(error);
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
closeEventSource() {
|
|
651
|
+
if (!this.eventSource)
|
|
652
|
+
return;
|
|
653
|
+
this.eventSource.close();
|
|
654
|
+
this.eventSource = undefined;
|
|
655
|
+
this._connectionId = undefined;
|
|
656
|
+
this.subscriptions.clear();
|
|
657
|
+
this.setState("disconnected");
|
|
658
|
+
}
|
|
659
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
660
|
+
// Session Management
|
|
661
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
662
|
+
/**
|
|
663
|
+
* Get a session accessor (cold - no subscription).
|
|
664
|
+
*
|
|
665
|
+
* Call `accessor.subscribe()` to receive events.
|
|
666
|
+
*/
|
|
667
|
+
session(sessionId) {
|
|
668
|
+
let accessor = this.sessions.get(sessionId);
|
|
669
|
+
if (!accessor) {
|
|
670
|
+
accessor = new SessionAccessorImpl(sessionId, this);
|
|
671
|
+
this.sessions.set(sessionId, accessor);
|
|
672
|
+
}
|
|
673
|
+
return accessor;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Subscribe to a session and get accessor (hot).
|
|
677
|
+
*/
|
|
678
|
+
subscribe(sessionId) {
|
|
679
|
+
const accessor = this.session(sessionId);
|
|
680
|
+
accessor.subscribe();
|
|
681
|
+
return accessor;
|
|
682
|
+
}
|
|
683
|
+
/** @internal - Called by SessionAccessor */
|
|
684
|
+
async _subscribeToSession(sessionId) {
|
|
685
|
+
await this.ensureConnection();
|
|
686
|
+
if (this.subscriptions.has(sessionId)) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
// Use custom transport if provided
|
|
690
|
+
if (this.customTransport) {
|
|
691
|
+
await this.customTransport.subscribeToSession(sessionId);
|
|
692
|
+
this.subscriptions.add(sessionId);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (!this._connectionId) {
|
|
696
|
+
throw new Error("Connection not established");
|
|
697
|
+
}
|
|
698
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
699
|
+
const subscribePath = this.config.paths?.subscribe ?? "/subscribe";
|
|
700
|
+
const response = await this.fetchFn(`${baseUrl}${subscribePath}`, {
|
|
701
|
+
method: "POST",
|
|
702
|
+
headers: this.requestHeaders,
|
|
703
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
704
|
+
body: JSON.stringify({
|
|
705
|
+
connectionId: this._connectionId,
|
|
706
|
+
add: [sessionId],
|
|
707
|
+
}),
|
|
708
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
709
|
+
});
|
|
710
|
+
if (!response.ok) {
|
|
711
|
+
const text = await response.text();
|
|
712
|
+
throw new Error(`Failed to subscribe: ${response.status} ${text}`);
|
|
713
|
+
}
|
|
714
|
+
this.subscriptions.add(sessionId);
|
|
715
|
+
}
|
|
716
|
+
/** @internal - Called by SessionAccessor */
|
|
717
|
+
async _unsubscribeFromSession(sessionId) {
|
|
718
|
+
if (!this.subscriptions.has(sessionId)) {
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
// Use custom transport if provided
|
|
722
|
+
if (this.customTransport) {
|
|
723
|
+
await this.customTransport.unsubscribeFromSession(sessionId);
|
|
724
|
+
this.subscriptions.delete(sessionId);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
if (!this._connectionId) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
731
|
+
const subscribePath = this.config.paths?.subscribe ?? "/subscribe";
|
|
732
|
+
await this.fetchFn(`${baseUrl}${subscribePath}`, {
|
|
733
|
+
method: "POST",
|
|
734
|
+
headers: this.requestHeaders,
|
|
735
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
736
|
+
body: JSON.stringify({
|
|
737
|
+
connectionId: this._connectionId,
|
|
738
|
+
remove: [sessionId],
|
|
739
|
+
}),
|
|
740
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
741
|
+
});
|
|
742
|
+
this.subscriptions.delete(sessionId);
|
|
743
|
+
}
|
|
744
|
+
/** @internal - Called by SessionAccessor to publish to a channel */
|
|
745
|
+
async _publishToChannel(sessionId, channelName, event) {
|
|
746
|
+
// Use custom transport if provided
|
|
747
|
+
if (this.customTransport) {
|
|
748
|
+
await this.customTransport.publishToChannel(sessionId, channelName, event);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
752
|
+
const channelPath = this.config.paths?.channel ?? "/channel";
|
|
753
|
+
const response = await this.fetchFn(`${baseUrl}${channelPath}`, {
|
|
754
|
+
method: "POST",
|
|
755
|
+
headers: this.requestHeaders,
|
|
756
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
757
|
+
body: JSON.stringify({
|
|
758
|
+
sessionId,
|
|
759
|
+
channel: channelName,
|
|
760
|
+
type: event.type,
|
|
761
|
+
payload: event.payload,
|
|
762
|
+
id: event.id,
|
|
763
|
+
metadata: event.metadata,
|
|
764
|
+
}),
|
|
765
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
766
|
+
});
|
|
767
|
+
if (!response.ok) {
|
|
768
|
+
const text = await response.text();
|
|
769
|
+
throw new Error(`Failed to publish to channel: ${response.status} ${text}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/** @internal - Called by SessionAccessor to subscribe to a channel */
|
|
773
|
+
async _subscribeToChannel(sessionId, channelName) {
|
|
774
|
+
// Ensure we have a connection before subscribing
|
|
775
|
+
await this.ensureConnection();
|
|
776
|
+
// Use custom transport if provided
|
|
777
|
+
if (this.customTransport) {
|
|
778
|
+
await this.customTransport.subscribeToChannel(sessionId, channelName);
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
782
|
+
const channelPath = this.config.paths?.channel ?? "/channel";
|
|
783
|
+
const response = await this.fetchFn(`${baseUrl}${channelPath}/subscribe`, {
|
|
784
|
+
method: "POST",
|
|
785
|
+
headers: this.requestHeaders,
|
|
786
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
787
|
+
body: JSON.stringify({
|
|
788
|
+
sessionId,
|
|
789
|
+
channel: channelName,
|
|
790
|
+
clientId: this._connectionId,
|
|
791
|
+
}),
|
|
792
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
793
|
+
});
|
|
794
|
+
if (!response.ok) {
|
|
795
|
+
const text = await response.text();
|
|
796
|
+
throw new Error(`Failed to subscribe to channel: ${response.status} ${text}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
800
|
+
// Message Operations
|
|
801
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
802
|
+
/**
|
|
803
|
+
* Send a message.
|
|
804
|
+
*
|
|
805
|
+
* @param input - Message input (message or messages)
|
|
806
|
+
* @param options - Options including optional sessionId
|
|
807
|
+
*/
|
|
808
|
+
send(input, options) {
|
|
809
|
+
const payload = this.normalizeSendInput(input);
|
|
810
|
+
const abortController = new AbortController();
|
|
811
|
+
const handle = new ClientExecutionHandleImpl(this, abortController, options?.sessionId);
|
|
812
|
+
void this.performSend(payload, options, handle, abortController);
|
|
813
|
+
return handle;
|
|
814
|
+
}
|
|
815
|
+
async performSend(payload, options, handle, abortController) {
|
|
816
|
+
try {
|
|
817
|
+
// Use custom transport if provided
|
|
818
|
+
if (this.customTransport) {
|
|
819
|
+
const stream = this.customTransport.send(payload, options?.sessionId);
|
|
820
|
+
// Wire up abort
|
|
821
|
+
abortController.signal.addEventListener("abort", () => {
|
|
822
|
+
stream.abort(abortController.signal.reason ?? "Aborted");
|
|
823
|
+
});
|
|
824
|
+
for await (const event of stream) {
|
|
825
|
+
if (event.type === "channel" || event.type === "connection") {
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
handle._handleEvent(event);
|
|
829
|
+
this.handleIncomingEvent(event);
|
|
830
|
+
}
|
|
831
|
+
handle._complete();
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
// Default HTTP fetch logic
|
|
835
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
836
|
+
const sendPath = this.config.paths?.send ?? "/send";
|
|
837
|
+
const body = { ...payload };
|
|
838
|
+
if (options?.sessionId) {
|
|
839
|
+
body.sessionId = options.sessionId;
|
|
840
|
+
}
|
|
841
|
+
const response = await this.fetchFn(`${baseUrl}${sendPath}`, {
|
|
842
|
+
method: "POST",
|
|
843
|
+
headers: this.requestHeaders,
|
|
844
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
845
|
+
body: JSON.stringify(body),
|
|
846
|
+
signal: abortController.signal,
|
|
847
|
+
});
|
|
848
|
+
if (!response.ok) {
|
|
849
|
+
const text = await response.text();
|
|
850
|
+
throw new Error(`Failed to send: ${response.status} ${text}`);
|
|
851
|
+
}
|
|
852
|
+
if (!response.body) {
|
|
853
|
+
throw new Error("No response body for send");
|
|
854
|
+
}
|
|
855
|
+
const reader = response.body.getReader();
|
|
856
|
+
const decoder = new TextDecoder();
|
|
857
|
+
let buffer = "";
|
|
858
|
+
while (true) {
|
|
859
|
+
const { done, value } = await reader.read();
|
|
860
|
+
if (done)
|
|
861
|
+
break;
|
|
862
|
+
buffer += decoder.decode(value, { stream: true });
|
|
863
|
+
const lines = buffer.split("\n");
|
|
864
|
+
buffer = lines.pop() ?? "";
|
|
865
|
+
for (const line of lines) {
|
|
866
|
+
if (!line.startsWith("data: "))
|
|
867
|
+
continue;
|
|
868
|
+
try {
|
|
869
|
+
const data = JSON.parse(line.slice(6));
|
|
870
|
+
if (data.type === "channel" || data.type === "connection") {
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const event = data;
|
|
874
|
+
handle._handleEvent(event);
|
|
875
|
+
this.handleIncomingEvent(data);
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
console.error("Failed to parse send event:", error);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
handle._complete();
|
|
883
|
+
}
|
|
884
|
+
catch (error) {
|
|
885
|
+
handle._fail(error instanceof Error ? error : new Error(String(error)));
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
normalizeSendInput(input) {
|
|
889
|
+
if (typeof input === "string") {
|
|
890
|
+
return { messages: [{ role: "user", content: [{ type: "text", text: input }] }] };
|
|
891
|
+
}
|
|
892
|
+
if (Array.isArray(input)) {
|
|
893
|
+
if (input.length === 0) {
|
|
894
|
+
return { messages: [] };
|
|
895
|
+
}
|
|
896
|
+
if (typeof input[0].role === "string") {
|
|
897
|
+
return { messages: input };
|
|
898
|
+
}
|
|
899
|
+
return { messages: [{ role: "user", content: input }] };
|
|
900
|
+
}
|
|
901
|
+
if (typeof input === "object" && input && "role" in input && "content" in input) {
|
|
902
|
+
return { messages: [input] };
|
|
903
|
+
}
|
|
904
|
+
return input;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Abort a session's current execution.
|
|
908
|
+
*/
|
|
909
|
+
async abort(sessionId, reason) {
|
|
910
|
+
// Use custom transport if provided
|
|
911
|
+
if (this.customTransport) {
|
|
912
|
+
await this.customTransport.abortSession(sessionId, reason);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
916
|
+
const abortPath = this.config.paths?.abort ?? "/abort";
|
|
917
|
+
const response = await this.fetchFn(`${baseUrl}${abortPath}`, {
|
|
918
|
+
method: "POST",
|
|
919
|
+
headers: this.requestHeaders,
|
|
920
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
921
|
+
body: JSON.stringify({ sessionId, reason }),
|
|
922
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
923
|
+
});
|
|
924
|
+
if (!response.ok) {
|
|
925
|
+
const text = await response.text();
|
|
926
|
+
throw new Error(`Failed to abort: ${response.status} ${text}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
/**
|
|
930
|
+
* Close a session.
|
|
931
|
+
*/
|
|
932
|
+
async closeSession(sessionId) {
|
|
933
|
+
// Use custom transport if provided
|
|
934
|
+
if (this.customTransport) {
|
|
935
|
+
await this.customTransport.closeSession(sessionId);
|
|
936
|
+
}
|
|
937
|
+
else {
|
|
938
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
939
|
+
const closePath = this.config.paths?.close ?? "/close";
|
|
940
|
+
const response = await this.fetchFn(`${baseUrl}${closePath}`, {
|
|
941
|
+
method: "POST",
|
|
942
|
+
headers: this.requestHeaders,
|
|
943
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
944
|
+
body: JSON.stringify({ sessionId }),
|
|
945
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
946
|
+
});
|
|
947
|
+
if (!response.ok) {
|
|
948
|
+
const text = await response.text();
|
|
949
|
+
throw new Error(`Failed to close: ${response.status} ${text}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
// Clean up accessor
|
|
953
|
+
const accessor = this.sessions.get(sessionId);
|
|
954
|
+
if (accessor) {
|
|
955
|
+
accessor._destroy();
|
|
956
|
+
this.sessions.delete(sessionId);
|
|
957
|
+
}
|
|
958
|
+
this.subscriptions.delete(sessionId);
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Submit a tool confirmation response.
|
|
962
|
+
*/
|
|
963
|
+
async submitToolResult(sessionId, toolUseId, result) {
|
|
964
|
+
// Use custom transport if provided
|
|
965
|
+
if (this.customTransport) {
|
|
966
|
+
await this.customTransport.submitToolResult(sessionId, toolUseId, result);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
970
|
+
const toolResponsePath = this.config.paths?.toolResponse ?? "/tool-response";
|
|
971
|
+
const response = await this.fetchFn(`${baseUrl}${toolResponsePath}`, {
|
|
972
|
+
method: "POST",
|
|
973
|
+
headers: this.requestHeaders,
|
|
974
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
975
|
+
body: JSON.stringify({ sessionId, toolUseId, result }),
|
|
976
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
977
|
+
});
|
|
978
|
+
if (!response.ok) {
|
|
979
|
+
const text = await response.text();
|
|
980
|
+
throw new Error(`Failed to submit tool result: ${response.status} ${text}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
984
|
+
// Event Handling
|
|
985
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
986
|
+
/**
|
|
987
|
+
* Subscribe to all stream events (from all sessions).
|
|
988
|
+
*/
|
|
989
|
+
onEvent(handler) {
|
|
990
|
+
this.eventHandlers.add(handler);
|
|
991
|
+
return () => {
|
|
992
|
+
this.eventHandlers.delete(handler);
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Ergonomic event subscription.
|
|
997
|
+
*/
|
|
998
|
+
on(eventName, handler) {
|
|
999
|
+
switch (eventName) {
|
|
1000
|
+
case "event":
|
|
1001
|
+
return this.onEvent(handler);
|
|
1002
|
+
case "state":
|
|
1003
|
+
return this.onConnectionChange(handler);
|
|
1004
|
+
default: {
|
|
1005
|
+
const streamType = eventName;
|
|
1006
|
+
const streamHandler = handler;
|
|
1007
|
+
return this.onEvent((event) => {
|
|
1008
|
+
if (event.type === streamType) {
|
|
1009
|
+
streamHandler(event);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
handleIncomingEvent(data) {
|
|
1016
|
+
const sessionId = data.sessionId;
|
|
1017
|
+
const type = data.type;
|
|
1018
|
+
// Handle connection event
|
|
1019
|
+
if (type === "connection") {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
// Handle channel events (from server → client)
|
|
1023
|
+
if (type === "channel" && sessionId) {
|
|
1024
|
+
const channelName = data.channel;
|
|
1025
|
+
const channelEvent = data.event;
|
|
1026
|
+
if (channelName && channelEvent) {
|
|
1027
|
+
const accessor = this.sessions.get(sessionId);
|
|
1028
|
+
if (accessor) {
|
|
1029
|
+
accessor._handleChannelEvent(channelName, channelEvent);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
// Route stream events
|
|
1035
|
+
const streamEvent = data;
|
|
1036
|
+
const eventId = streamEvent.id;
|
|
1037
|
+
if (eventId) {
|
|
1038
|
+
if (this.seenEventIds.has(eventId)) {
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
this.seenEventIds.add(eventId);
|
|
1042
|
+
this.seenEventIdsOrder.push(eventId);
|
|
1043
|
+
if (this.seenEventIdsOrder.length > this.maxSeenEventIds) {
|
|
1044
|
+
const oldest = this.seenEventIdsOrder.shift();
|
|
1045
|
+
if (oldest) {
|
|
1046
|
+
this.seenEventIds.delete(oldest);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
// Update streaming text state
|
|
1051
|
+
this.updateStreamingText(streamEvent);
|
|
1052
|
+
// Notify global handlers
|
|
1053
|
+
if (sessionId) {
|
|
1054
|
+
const sessionEvent = streamEvent;
|
|
1055
|
+
for (const handler of this.eventHandlers) {
|
|
1056
|
+
try {
|
|
1057
|
+
handler(sessionEvent);
|
|
1058
|
+
}
|
|
1059
|
+
catch (error) {
|
|
1060
|
+
console.error("Error in event handler:", error);
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
// Notify session-specific handlers
|
|
1065
|
+
if (sessionId) {
|
|
1066
|
+
const accessor = this.sessions.get(sessionId);
|
|
1067
|
+
if (accessor) {
|
|
1068
|
+
accessor._handleEvent(streamEvent);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
// Handle result events
|
|
1072
|
+
if (type === "result" && "result" in data) {
|
|
1073
|
+
if (sessionId) {
|
|
1074
|
+
const accessor = this.sessions.get(sessionId);
|
|
1075
|
+
if (accessor) {
|
|
1076
|
+
accessor._handleResult(data.result);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// Handle tool confirmation requests
|
|
1081
|
+
if (type === "tool_confirmation_required" && sessionId) {
|
|
1082
|
+
const required = data;
|
|
1083
|
+
const request = {
|
|
1084
|
+
toolUseId: required.callId,
|
|
1085
|
+
name: required.name,
|
|
1086
|
+
arguments: required.input,
|
|
1087
|
+
message: required.message,
|
|
1088
|
+
};
|
|
1089
|
+
const accessor = this.sessions.get(sessionId);
|
|
1090
|
+
if (accessor) {
|
|
1091
|
+
accessor._handleToolConfirmation(request);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1096
|
+
// Streaming Text
|
|
1097
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1098
|
+
/** Current streaming text state */
|
|
1099
|
+
get streamingText() {
|
|
1100
|
+
return this._streamingText;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Subscribe to streaming text state changes.
|
|
1104
|
+
*/
|
|
1105
|
+
onStreamingText(handler) {
|
|
1106
|
+
this.streamingTextHandlers.add(handler);
|
|
1107
|
+
handler(this._streamingText);
|
|
1108
|
+
return () => {
|
|
1109
|
+
this.streamingTextHandlers.delete(handler);
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/** Clear the accumulated streaming text */
|
|
1113
|
+
clearStreamingText() {
|
|
1114
|
+
this.setStreamingText({ text: "", isStreaming: false });
|
|
1115
|
+
}
|
|
1116
|
+
setStreamingText(state) {
|
|
1117
|
+
this._streamingText = state;
|
|
1118
|
+
for (const handler of this.streamingTextHandlers) {
|
|
1119
|
+
try {
|
|
1120
|
+
handler(state);
|
|
1121
|
+
}
|
|
1122
|
+
catch (error) {
|
|
1123
|
+
console.error("Error in streaming text handler:", error);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
updateStreamingText(event) {
|
|
1128
|
+
switch (event.type) {
|
|
1129
|
+
case "tick_start":
|
|
1130
|
+
this.setStreamingText({ text: "", isStreaming: true });
|
|
1131
|
+
break;
|
|
1132
|
+
case "content_delta":
|
|
1133
|
+
this.setStreamingText({
|
|
1134
|
+
text: this._streamingText.text + event.delta,
|
|
1135
|
+
isStreaming: true,
|
|
1136
|
+
});
|
|
1137
|
+
break;
|
|
1138
|
+
case "tick_end":
|
|
1139
|
+
case "execution_end":
|
|
1140
|
+
this.setStreamingText({
|
|
1141
|
+
text: this._streamingText.text,
|
|
1142
|
+
isStreaming: false,
|
|
1143
|
+
});
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1148
|
+
// Custom Method Invocation
|
|
1149
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1150
|
+
/**
|
|
1151
|
+
* Invoke a custom Gateway method.
|
|
1152
|
+
* For session-scoped methods, use session.invoke() instead.
|
|
1153
|
+
*
|
|
1154
|
+
* @example
|
|
1155
|
+
* ```typescript
|
|
1156
|
+
* // Invoke a custom method
|
|
1157
|
+
* const result = await client.invoke("tasks:list", { status: "active" });
|
|
1158
|
+
*
|
|
1159
|
+
* // Invoke with admin method
|
|
1160
|
+
* const stats = await client.invoke("admin:stats");
|
|
1161
|
+
* ```
|
|
1162
|
+
*/
|
|
1163
|
+
async invoke(method, params = {}) {
|
|
1164
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
1165
|
+
const invokePath = this.config.paths?.invoke ?? "/invoke";
|
|
1166
|
+
const response = await this.fetchFn(`${baseUrl}${invokePath}`, {
|
|
1167
|
+
method: "POST",
|
|
1168
|
+
headers: this.requestHeaders,
|
|
1169
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
1170
|
+
body: JSON.stringify({
|
|
1171
|
+
method,
|
|
1172
|
+
params,
|
|
1173
|
+
}),
|
|
1174
|
+
signal: AbortSignal.timeout(this.config.timeout ?? 30000),
|
|
1175
|
+
});
|
|
1176
|
+
if (!response.ok) {
|
|
1177
|
+
const text = await response.text();
|
|
1178
|
+
throw new Error(`Failed to invoke method: ${response.status} ${text}`);
|
|
1179
|
+
}
|
|
1180
|
+
const result = await response.json();
|
|
1181
|
+
return result;
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Invoke a streaming method, returns async iterator.
|
|
1185
|
+
* Yields values as they arrive from the server.
|
|
1186
|
+
*
|
|
1187
|
+
* @example
|
|
1188
|
+
* ```typescript
|
|
1189
|
+
* // Stream task updates
|
|
1190
|
+
* for await (const change of client.stream("tasks:watch")) {
|
|
1191
|
+
* console.log("Task changed:", change);
|
|
1192
|
+
* }
|
|
1193
|
+
* ```
|
|
1194
|
+
*/
|
|
1195
|
+
async *stream(method, params = {}) {
|
|
1196
|
+
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
1197
|
+
const invokePath = this.config.paths?.invoke ?? "/invoke";
|
|
1198
|
+
const response = await this.fetchFn(`${baseUrl}${invokePath}`, {
|
|
1199
|
+
method: "POST",
|
|
1200
|
+
headers: this.requestHeaders,
|
|
1201
|
+
credentials: this.config.withCredentials ? "include" : "same-origin",
|
|
1202
|
+
body: JSON.stringify({
|
|
1203
|
+
method,
|
|
1204
|
+
params,
|
|
1205
|
+
}),
|
|
1206
|
+
});
|
|
1207
|
+
if (!response.ok) {
|
|
1208
|
+
const text = await response.text();
|
|
1209
|
+
throw new Error(`Failed to invoke streaming method: ${response.status} ${text}`);
|
|
1210
|
+
}
|
|
1211
|
+
if (!response.body) {
|
|
1212
|
+
throw new Error("No response body for streaming method");
|
|
1213
|
+
}
|
|
1214
|
+
const reader = response.body.getReader();
|
|
1215
|
+
const decoder = new TextDecoder();
|
|
1216
|
+
let buffer = "";
|
|
1217
|
+
while (true) {
|
|
1218
|
+
const { done, value } = await reader.read();
|
|
1219
|
+
if (done)
|
|
1220
|
+
break;
|
|
1221
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1222
|
+
const lines = buffer.split("\n");
|
|
1223
|
+
buffer = lines.pop() ?? "";
|
|
1224
|
+
for (const line of lines) {
|
|
1225
|
+
if (!line.startsWith("data: "))
|
|
1226
|
+
continue;
|
|
1227
|
+
try {
|
|
1228
|
+
const data = JSON.parse(line.slice(6));
|
|
1229
|
+
if (data.type === "method:chunk") {
|
|
1230
|
+
yield data.chunk;
|
|
1231
|
+
}
|
|
1232
|
+
else if (data.type === "method:end") {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
console.error("Failed to parse stream event:", error);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Get authorization headers for use with fetch.
|
|
1244
|
+
* Useful for making authenticated requests to custom routes.
|
|
1245
|
+
*
|
|
1246
|
+
* @example
|
|
1247
|
+
* ```typescript
|
|
1248
|
+
* // Make authenticated request to custom API
|
|
1249
|
+
* const response = await fetch("/api/custom", {
|
|
1250
|
+
* headers: client.getAuthHeaders(),
|
|
1251
|
+
* });
|
|
1252
|
+
* ```
|
|
1253
|
+
*/
|
|
1254
|
+
getAuthHeaders() {
|
|
1255
|
+
const headers = {};
|
|
1256
|
+
if (this.config.token) {
|
|
1257
|
+
headers["Authorization"] = `Bearer ${this.config.token}`;
|
|
1258
|
+
}
|
|
1259
|
+
return headers;
|
|
1260
|
+
}
|
|
1261
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1262
|
+
// Cleanup
|
|
1263
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
1264
|
+
/**
|
|
1265
|
+
* Cleanup and close the client.
|
|
1266
|
+
*/
|
|
1267
|
+
destroy() {
|
|
1268
|
+
// Clean up custom transport
|
|
1269
|
+
if (this.customTransport) {
|
|
1270
|
+
this.transportCleanup?.();
|
|
1271
|
+
this.customTransport.disconnect();
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
this.closeEventSource();
|
|
1275
|
+
}
|
|
1276
|
+
for (const accessor of this.sessions.values()) {
|
|
1277
|
+
accessor._destroy();
|
|
1278
|
+
}
|
|
1279
|
+
this.sessions.clear();
|
|
1280
|
+
this.stateHandlers.clear();
|
|
1281
|
+
this.eventHandlers.clear();
|
|
1282
|
+
this.streamingTextHandlers.clear();
|
|
1283
|
+
this._streamingText = { text: "", isStreaming: false };
|
|
1284
|
+
this.seenEventIds.clear();
|
|
1285
|
+
this.seenEventIdsOrder = [];
|
|
1286
|
+
this.subscriptions.clear();
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Get the underlying transport (if using custom transport).
|
|
1290
|
+
* Useful for accessing transport-specific features like leadership status.
|
|
1291
|
+
*/
|
|
1292
|
+
getTransport() {
|
|
1293
|
+
return this.customTransport ?? undefined;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
// ============================================================================
|
|
1297
|
+
// Transport Detection
|
|
1298
|
+
// ============================================================================
|
|
1299
|
+
/**
|
|
1300
|
+
* Detect the appropriate transport based on URL scheme.
|
|
1301
|
+
*/
|
|
1302
|
+
function detectTransport(baseUrl) {
|
|
1303
|
+
const url = baseUrl.toLowerCase();
|
|
1304
|
+
if (url.startsWith("ws://") || url.startsWith("wss://")) {
|
|
1305
|
+
return "websocket";
|
|
1306
|
+
}
|
|
1307
|
+
return "sse";
|
|
1308
|
+
}
|
|
1309
|
+
// ============================================================================
|
|
1310
|
+
// Factory Function
|
|
1311
|
+
// ============================================================================
|
|
1312
|
+
/**
|
|
1313
|
+
* Create a new AgentickClient.
|
|
1314
|
+
*
|
|
1315
|
+
* Transport is auto-detected from the URL scheme:
|
|
1316
|
+
* - http:// or https:// -> SSE transport
|
|
1317
|
+
* - ws:// or wss:// -> WebSocket transport
|
|
1318
|
+
*
|
|
1319
|
+
* You can also explicitly set the transport in the config, or provide
|
|
1320
|
+
* a custom ClientTransport instance (e.g., SharedTransport for multi-tab).
|
|
1321
|
+
*
|
|
1322
|
+
* @example
|
|
1323
|
+
* ```typescript
|
|
1324
|
+
* // Auto-detect transport (SSE for http://)
|
|
1325
|
+
* const client = createClient({
|
|
1326
|
+
* baseUrl: 'https://api.example.com',
|
|
1327
|
+
* });
|
|
1328
|
+
*
|
|
1329
|
+
* // Auto-detect transport (WebSocket for ws://)
|
|
1330
|
+
* const wsClient = createClient({
|
|
1331
|
+
* baseUrl: 'ws://localhost:18789',
|
|
1332
|
+
* });
|
|
1333
|
+
*
|
|
1334
|
+
* // Force WebSocket transport
|
|
1335
|
+
* const wsClient2 = createClient({
|
|
1336
|
+
* baseUrl: 'http://localhost:3000',
|
|
1337
|
+
* transport: 'websocket',
|
|
1338
|
+
* });
|
|
1339
|
+
*
|
|
1340
|
+
* // Custom transport (e.g., SharedTransport for multi-tab)
|
|
1341
|
+
* import { createSharedTransport } from '@agentick/client-multiplexer';
|
|
1342
|
+
* const sharedClient = createClient({
|
|
1343
|
+
* baseUrl: 'https://api.example.com',
|
|
1344
|
+
* transport: createSharedTransport({ baseUrl: 'https://api.example.com' }),
|
|
1345
|
+
* });
|
|
1346
|
+
*
|
|
1347
|
+
* // Subscribe to a session
|
|
1348
|
+
* const session = client.subscribe('conv-123');
|
|
1349
|
+
*
|
|
1350
|
+
* // Send a message
|
|
1351
|
+
* const handle = session.send({ messages: [{ role: 'user', content: [...] }] });
|
|
1352
|
+
* await handle.result;
|
|
1353
|
+
* ```
|
|
1354
|
+
*/
|
|
1355
|
+
export function createClient(config) {
|
|
1356
|
+
// If a custom transport object is provided, use it directly
|
|
1357
|
+
if (config.transport && typeof config.transport === "object") {
|
|
1358
|
+
return new AgentickClient(config);
|
|
1359
|
+
}
|
|
1360
|
+
const transport = config.transport === "auto" || !config.transport
|
|
1361
|
+
? detectTransport(config.baseUrl)
|
|
1362
|
+
: config.transport;
|
|
1363
|
+
if (transport === "websocket") {
|
|
1364
|
+
// Log warning - WebSocket transport requires using the WSTransport directly
|
|
1365
|
+
// or the gateway client for full functionality
|
|
1366
|
+
console.warn("[AgentickClient] WebSocket URL detected. For full WebSocket support, " +
|
|
1367
|
+
"use createWSTransport() directly or connect to a Gateway. " +
|
|
1368
|
+
"Falling back to SSE transport with URL conversion.");
|
|
1369
|
+
// Convert ws:// to http:// for SSE fallback
|
|
1370
|
+
let baseUrl = config.baseUrl;
|
|
1371
|
+
if (baseUrl.startsWith("ws://")) {
|
|
1372
|
+
baseUrl = baseUrl.replace("ws://", "http://");
|
|
1373
|
+
}
|
|
1374
|
+
else if (baseUrl.startsWith("wss://")) {
|
|
1375
|
+
baseUrl = baseUrl.replace("wss://", "https://");
|
|
1376
|
+
}
|
|
1377
|
+
return new AgentickClient({ ...config, baseUrl });
|
|
1378
|
+
}
|
|
1379
|
+
return new AgentickClient(config);
|
|
1380
|
+
}
|
|
1381
|
+
//# sourceMappingURL=client.js.map
|