@butlerbot/sdk 0.0.27 → 0.0.29
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/dist/link/link.d.ts +18 -0
- package/dist/link/link.js +34 -1
- package/dist/modules/stream_accumulator.d.ts +10 -5
- package/dist/modules/stream_accumulator.js +20 -7
- package/package.json +1 -1
- package/readme.md +12 -8
package/dist/link/link.d.ts
CHANGED
|
@@ -123,6 +123,8 @@ export declare class Link {
|
|
|
123
123
|
* never needs to ask.
|
|
124
124
|
*/
|
|
125
125
|
private lastInboundAt;
|
|
126
|
+
/** Ids of keepalive pulses, so their pongs can be dropped instead of shown as logs. */
|
|
127
|
+
private pulses;
|
|
126
128
|
private closedByUs;
|
|
127
129
|
constructor(options: LinkOptions);
|
|
128
130
|
/** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
|
|
@@ -211,12 +213,28 @@ export declare class Link {
|
|
|
211
213
|
* big enough to take longer than the timeout to drain got its own connection torn
|
|
212
214
|
* down with `4000 heartbeat timeout` — always mid-response, always on the longest
|
|
213
215
|
* answers, which are the ones a user least wants to lose.
|
|
216
|
+
*
|
|
217
|
+
* Not asking is not the same as saying nothing, though. The server reaps connections
|
|
218
|
+
* that have sent it no frames for 100s, because a socket the client walked away from
|
|
219
|
+
* still answers websocket pings at the network layer and only the client's own frames
|
|
220
|
+
* prove someone is still there. A link busy receiving a long turn used to go completely
|
|
221
|
+
* silent for as long as the turn ran and got closed as idle — `1001 idle: no frames
|
|
222
|
+
* received`, mid-response again. So a busy interval still sends a pulse; it just does
|
|
223
|
+
* not wait for the reply, which is the half that could not survive a full send queue.
|
|
214
224
|
*/
|
|
215
225
|
private startHeartbeat;
|
|
216
226
|
/** Wakes when the connection will have been silent for a full interval, not before. */
|
|
217
227
|
private scheduleHeartbeat;
|
|
218
228
|
/** One liveness round trip. Only ever sent to a connection that has gone quiet. */
|
|
219
229
|
private ping;
|
|
230
|
+
/**
|
|
231
|
+
* A ping sent with no deadline and no interest in the answer.
|
|
232
|
+
*
|
|
233
|
+
* Its only job is to land on the server so the connection does not look abandoned. A
|
|
234
|
+
* failure here is not evidence of anything — inbound frames already proved the socket
|
|
235
|
+
* works — so it stays quiet and lets the real heartbeat make that call.
|
|
236
|
+
*/
|
|
237
|
+
private pulse;
|
|
220
238
|
/** Never longer than the interval itself: a second ping in flight tells us nothing new. */
|
|
221
239
|
private heartbeatTimeoutMs;
|
|
222
240
|
/** Any frame from the server, of any kind, is proof the connection still works. */
|
package/dist/link/link.js
CHANGED
|
@@ -56,6 +56,8 @@ class Link {
|
|
|
56
56
|
* never needs to ask.
|
|
57
57
|
*/
|
|
58
58
|
this.lastInboundAt = 0;
|
|
59
|
+
/** Ids of keepalive pulses, so their pongs can be dropped instead of shown as logs. */
|
|
60
|
+
this.pulses = new Set();
|
|
59
61
|
this.closedByUs = false;
|
|
60
62
|
this.options = {
|
|
61
63
|
...DEFAULTS,
|
|
@@ -390,6 +392,14 @@ class Link {
|
|
|
390
392
|
* big enough to take longer than the timeout to drain got its own connection torn
|
|
391
393
|
* down with `4000 heartbeat timeout` — always mid-response, always on the longest
|
|
392
394
|
* answers, which are the ones a user least wants to lose.
|
|
395
|
+
*
|
|
396
|
+
* Not asking is not the same as saying nothing, though. The server reaps connections
|
|
397
|
+
* that have sent it no frames for 100s, because a socket the client walked away from
|
|
398
|
+
* still answers websocket pings at the network layer and only the client's own frames
|
|
399
|
+
* prove someone is still there. A link busy receiving a long turn used to go completely
|
|
400
|
+
* silent for as long as the turn ran and got closed as idle — `1001 idle: no frames
|
|
401
|
+
* received`, mid-response again. So a busy interval still sends a pulse; it just does
|
|
402
|
+
* not wait for the reply, which is the half that could not survive a full send queue.
|
|
393
403
|
*/
|
|
394
404
|
startHeartbeat(generation) {
|
|
395
405
|
if (!this.options.heartbeatMs)
|
|
@@ -408,8 +418,10 @@ class Link {
|
|
|
408
418
|
if (generation !== this.generation)
|
|
409
419
|
return;
|
|
410
420
|
// Something arrived while this was pending: the connection is demonstrably
|
|
411
|
-
// alive and there is nothing to ask.
|
|
421
|
+
// alive and there is nothing to ask. Tell the server we are still here and
|
|
422
|
+
// wait out the rest of its silence instead.
|
|
412
423
|
if (Date.now() - this.lastInboundAt < this.options.heartbeatMs) {
|
|
424
|
+
this.pulse();
|
|
413
425
|
return this.scheduleHeartbeat(generation);
|
|
414
426
|
}
|
|
415
427
|
this.ping(generation);
|
|
@@ -440,6 +452,21 @@ class Link {
|
|
|
440
452
|
this.dropSocket(generation, 4000, "heartbeat timeout");
|
|
441
453
|
});
|
|
442
454
|
}
|
|
455
|
+
/**
|
|
456
|
+
* A ping sent with no deadline and no interest in the answer.
|
|
457
|
+
*
|
|
458
|
+
* Its only job is to land on the server so the connection does not look abandoned. A
|
|
459
|
+
* failure here is not evidence of anything — inbound frames already proved the socket
|
|
460
|
+
* works — so it stays quiet and lets the real heartbeat make that call.
|
|
461
|
+
*/
|
|
462
|
+
pulse() {
|
|
463
|
+
try {
|
|
464
|
+
this.pulses.add(this.send("ping", {}));
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
this.debug("could not send the keepalive pulse, leaving it to the next heartbeat");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
443
470
|
/** Never longer than the interval itself: a second ping in flight tells us nothing new. */
|
|
444
471
|
heartbeatTimeoutMs() {
|
|
445
472
|
return Math.max(250, Math.min(this.options.requestTimeoutMs, this.options.heartbeatMs));
|
|
@@ -452,6 +479,8 @@ class Link {
|
|
|
452
479
|
if (this.heartbeat)
|
|
453
480
|
clearTimeout(this.heartbeat);
|
|
454
481
|
this.heartbeat = undefined;
|
|
482
|
+
// Pongs owed by a socket that is going away will never arrive.
|
|
483
|
+
this.pulses.clear();
|
|
455
484
|
}
|
|
456
485
|
// =============================================
|
|
457
486
|
// WAITING
|
|
@@ -652,6 +681,10 @@ class Link {
|
|
|
652
681
|
waiting.onFrame?.(frame);
|
|
653
682
|
return;
|
|
654
683
|
}
|
|
684
|
+
// The pong to a keepalive pulse. Nothing is waiting for it, and it is not a log
|
|
685
|
+
// anyone asked to see.
|
|
686
|
+
if (frame.replyTo && this.pulses.delete(frame.replyTo))
|
|
687
|
+
return;
|
|
655
688
|
switch (frame.type) {
|
|
656
689
|
case "tool.call":
|
|
657
690
|
this.handleToolCall(frame);
|
|
@@ -12,9 +12,14 @@
|
|
|
12
12
|
* { messageId, message: "Good day to you", completed } // the whole value: replace
|
|
13
13
|
* { messageId, delta: " to you", completed: false } // what was added: append
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* Deltas are also sent bare: no metadata block, since it is the same on every frame of a
|
|
16
|
+
* message and several times the size of the few characters a delta carries. The frame that
|
|
17
|
+
* opens a message brings it, and the one that finishes it brings it again.
|
|
18
|
+
*
|
|
19
|
+
* Callers should not have to care about any of that. This puts the message back together,
|
|
20
|
+
* so `payload.message` is the whole message so far exactly as it always was, restores the
|
|
21
|
+
* metadata onto every event, and keeps `payload.delta` for anyone who would rather append
|
|
22
|
+
* than re-render.
|
|
18
23
|
*
|
|
19
24
|
* Whole values arrive for the last event of a message and for anything replaying after a
|
|
20
25
|
* reconnect, and they replace rather than extend. That is what makes a reconnect cheap and
|
|
@@ -23,7 +28,7 @@
|
|
|
23
28
|
/**
|
|
24
29
|
* Rebuilds whole values from a stream of pieces.
|
|
25
30
|
*
|
|
26
|
-
* Stateful, and one per stream: it holds
|
|
27
|
-
*
|
|
31
|
+
* Stateful, and one per stream: it holds what every message the stream is still writing
|
|
32
|
+
* has said so far, so a turn and a progress stream never see each other's.
|
|
28
33
|
*/
|
|
29
34
|
export declare function createStreamAccumulator(): (payload: unknown) => unknown;
|
|
@@ -13,9 +13,14 @@
|
|
|
13
13
|
* { messageId, message: "Good day to you", completed } // the whole value: replace
|
|
14
14
|
* { messageId, delta: " to you", completed: false } // what was added: append
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Deltas are also sent bare: no metadata block, since it is the same on every frame of a
|
|
17
|
+
* message and several times the size of the few characters a delta carries. The frame that
|
|
18
|
+
* opens a message brings it, and the one that finishes it brings it again.
|
|
19
|
+
*
|
|
20
|
+
* Callers should not have to care about any of that. This puts the message back together,
|
|
21
|
+
* so `payload.message` is the whole message so far exactly as it always was, restores the
|
|
22
|
+
* metadata onto every event, and keeps `payload.delta` for anyone who would rather append
|
|
23
|
+
* than re-render.
|
|
19
24
|
*
|
|
20
25
|
* Whole values arrive for the last event of a message and for anything replaying after a
|
|
21
26
|
* reconnect, and they replace rather than extend. That is what makes a reconnect cheap and
|
|
@@ -31,10 +36,11 @@ const STREAMED_FIELDS = {
|
|
|
31
36
|
/**
|
|
32
37
|
* Rebuilds whole values from a stream of pieces.
|
|
33
38
|
*
|
|
34
|
-
* Stateful, and one per stream: it holds
|
|
35
|
-
*
|
|
39
|
+
* Stateful, and one per stream: it holds what every message the stream is still writing
|
|
40
|
+
* has said so far, so a turn and a progress stream never see each other's.
|
|
36
41
|
*/
|
|
37
42
|
function createStreamAccumulator() {
|
|
43
|
+
/** Text so far and the metadata it was opened with, per streamed id. */
|
|
38
44
|
const values = new Map();
|
|
39
45
|
return (payload) => {
|
|
40
46
|
const response = payload;
|
|
@@ -52,19 +58,26 @@ function createStreamAccumulator() {
|
|
|
52
58
|
const whole = event.payload[fields.text];
|
|
53
59
|
if (typeof delta !== "string" && typeof whole !== "string")
|
|
54
60
|
return payload;
|
|
55
|
-
const
|
|
61
|
+
const held = values.get(id);
|
|
62
|
+
const text = typeof delta === "string" ? (held?.text ?? "") + delta : whole;
|
|
63
|
+
// Deltas are sent without metadata, because it is identical on every frame of a
|
|
64
|
+
// message and many times the size of the text. The frame that opened the message
|
|
65
|
+
// carried it, so it is remembered here and handed back on every event — a caller
|
|
66
|
+
// sees it throughout, exactly as when the server repeated it a thousand times.
|
|
67
|
+
const metadata = event.metadata ?? held?.metadata;
|
|
56
68
|
// A finished value is the last anyone will hear of that id. Holding it would only
|
|
57
69
|
// leak, and ids are reused across the steps of a turn.
|
|
58
70
|
if (event.payload.completed)
|
|
59
71
|
values.delete(id);
|
|
60
72
|
else
|
|
61
|
-
values.set(id, text);
|
|
73
|
+
values.set(id, { text, metadata });
|
|
62
74
|
return {
|
|
63
75
|
...response,
|
|
64
76
|
data: {
|
|
65
77
|
...response.data,
|
|
66
78
|
response: {
|
|
67
79
|
...event,
|
|
80
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
68
81
|
payload: {
|
|
69
82
|
...event.payload,
|
|
70
83
|
[fields.text]: text,
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -41,15 +41,19 @@ const { text } = await convo.ask("Hey there Alfred!");
|
|
|
41
41
|
|
|
42
42
|
### Streaming
|
|
43
43
|
|
|
44
|
-
Alfred streams a reply as it writes it.
|
|
45
|
-
|
|
44
|
+
Alfred streams a reply as it writes it. A message opens with a whole value, is extended a
|
|
45
|
+
piece at a time, and finishes whole again. No event carries both:
|
|
46
46
|
|
|
47
47
|
```jsonc
|
|
48
|
-
{ "messageId": "m1", "message": "Good day
|
|
49
|
-
{ "messageId": "m1", "delta": " to you", "completed": false }
|
|
48
|
+
{ "messageId": "m1", "message": "Good day", "completed": false, "metadata": {...} }
|
|
49
|
+
{ "messageId": "m1", "delta": " to you", "completed": false }
|
|
50
|
+
{ "messageId": "m1", "message": "Good day to you", "completed": true, "metadata": {...} }
|
|
50
51
|
```
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
Deltas travel bare — no metadata block, since it is identical on every frame of a message
|
|
54
|
+
and many times the size of the few characters a delta carries. The SDK remembers it from
|
|
55
|
+
the frame that opened the message and puts it back, so **every event you receive has both
|
|
56
|
+
the whole message and its metadata**, exactly as it always did:
|
|
53
57
|
|
|
54
58
|
```typescript
|
|
55
59
|
convo.send("Tell me a story", (res) => {
|
|
@@ -68,9 +72,9 @@ stuttering one — append it when it is there, and replace with `message` when i
|
|
|
68
72
|
after a reconnect. Do not read `completed` to tell the two apart — a whole message arrives
|
|
69
73
|
with `completed: false` whenever you are being caught up mid-answer.
|
|
70
74
|
|
|
71
|
-
`accumulateStream: false` hands you the wire payloads untouched
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
`accumulateStream: false` hands you the wire payloads untouched: deltas with no `message`
|
|
76
|
+
beside them and no metadata. Only worth it if you are appending anyway and want nothing
|
|
77
|
+
between you and the socket.
|
|
74
78
|
|
|
75
79
|
## Link
|
|
76
80
|
|