@butlerbot/sdk 0.0.28 → 0.0.30

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/config.d.ts CHANGED
@@ -25,6 +25,16 @@ export declare const CONFIG: {
25
25
  };
26
26
  };
27
27
  };
28
+ interrupt: {
29
+ v4: {
30
+ stop: string;
31
+ steer: string;
32
+ };
33
+ v5: {
34
+ stop: string;
35
+ steer: string;
36
+ };
37
+ };
28
38
  progress: {
29
39
  v4: {
30
40
  base: string;
package/dist/config.js CHANGED
@@ -18,6 +18,16 @@ exports.CONFIG = {
18
18
  v1: { base: "/api/convo/get/history" }
19
19
  }
20
20
  },
21
+ interrupt: {
22
+ v4: {
23
+ stop: "/api/alfred/v4/chat/stop",
24
+ steer: "/api/alfred/v4/chat/steer",
25
+ },
26
+ v5: {
27
+ stop: "/api/alfred/v5/chat/stop",
28
+ steer: "/api/alfred/v5/chat/steer",
29
+ },
30
+ },
21
31
  progress: {
22
32
  v4: {
23
33
  base: "/api/alfred/v4/chat/progress",
package/dist/index.d.ts CHANGED
@@ -38,3 +38,4 @@ export type { APIPath };
38
38
  export type { ConversationStream, ConversationTransport, TransportTurnRequest, TransportHandlers, } from "./modules/transport";
39
39
  export { LinkConversationTransport } from "./modules/transport_link";
40
40
  export { SSEConversationTransport } from "./modules/transport_sse";
41
+ export type { SteerResult, TurnStopMode, TurnStopped } from "./modules/transport";
@@ -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. Wait out the rest of its silence instead.
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);
@@ -120,6 +120,25 @@ export type LinkClientPayloads = {
120
120
  "conversation.detach": {
121
121
  chatId: string;
122
122
  };
123
+ /**
124
+ * Stops a running turn.
125
+ *
126
+ * `soft` lets the model call in flight finish and takes no further step; `hard` aborts the
127
+ * stream where it stands. A hard stop against a provider that cannot be cancelled is
128
+ * applied as a soft one — the answer says which was applied.
129
+ */
130
+ "conversation.stop": {
131
+ chatId: string;
132
+ mode: "soft" | "hard";
133
+ };
134
+ /**
135
+ * Says something to a turn that is still running. It reaches the model at its next step
136
+ * boundary; the turn is not interrupted.
137
+ */
138
+ "conversation.steer": {
139
+ chatId: string;
140
+ message: string;
141
+ };
123
142
  };
124
143
  export type LinkClientFrameType = keyof LinkClientPayloads;
125
144
  /**
@@ -196,6 +215,17 @@ export type LinkServerPayloads = {
196
215
  error?: string;
197
216
  message?: string;
198
217
  };
218
+ /** A turn was stopped. `mode` is what was applied, `requestedMode` what was asked for. */
219
+ "conversation.stopped": {
220
+ chatId: string;
221
+ mode: "soft" | "hard";
222
+ requestedMode: "soft" | "hard";
223
+ by: "user" | "system";
224
+ };
225
+ /** A steered message was accepted and will reach the model at its next step boundary. */
226
+ "conversation.steered": {
227
+ chatId: string;
228
+ };
199
229
  "goodbye": {
200
230
  reason: string;
201
231
  reconnectAfterMs: number;
@@ -1,7 +1,7 @@
1
1
  import { EventSource } from "eventsource";
2
2
  import { APIPath } from "../config";
3
3
  import type { Link } from "../link/link";
4
- import { ConversationStream } from "./transport";
4
+ import { ConversationStream, SteerResult, TurnStopMode, TurnStopped } from "./transport";
5
5
  import { RequestResponseV3, RequestResponseV4 } from "../types/type_registry";
6
6
  import { RequestResponseV5 } from "../types/response/v5/dialogue_response_v5";
7
7
  import { ConversationStateResponse } from "../types/state/convo_state_response";
@@ -181,6 +181,32 @@ export declare class Conversation<V extends APIPath = "v4"> {
181
181
  private receiver;
182
182
  /** Sends a message into the conversation */
183
183
  send(message: string, cb: (chunk: RequestResponseByVersion[V]) => any, options?: DialogueRequestOptions): ConversationStream;
184
+ /**
185
+ * Stops the turn running in this conversation.
186
+ *
187
+ * `soft` (the default) lets the model finish the call it is in the middle of and takes no
188
+ * further step: the answer so far is kept, in history and in what the model remembers
189
+ * saying, and it works on every model. `hard` cuts the stream off mid-sentence and aborts
190
+ * the tools with it — but only actually stops the bill where the provider supports
191
+ * cancellation, so against one that does not the server applies a soft stop instead. The
192
+ * returned `mode` says which you got; `undefined` means there was nothing left to stop.
193
+ *
194
+ * Either way the turn's stream ends the way it always does, carrying what was produced
195
+ * before the stop. There is nothing else to clean up.
196
+ */
197
+ stop(mode?: TurnStopMode): Promise<TurnStopped | undefined>;
198
+ /**
199
+ * Says something to the turn that is already running.
200
+ *
201
+ * The message reaches the model at its next step boundary, so nothing in flight is thrown
202
+ * away and the model reads it as the user talking mid-task — which is usually what someone
203
+ * typing while Alfred works actually means.
204
+ *
205
+ * Check the result. `too_late` means the message was NOT delivered, because the turn was
206
+ * already stopping or over, and it should be sent with `send()` instead. Dropping it is
207
+ * the one thing that is never right.
208
+ */
209
+ steer(message: string): Promise<SteerResult>;
184
210
  /**
185
211
  * Sends a message and resolves with the finished reply.
186
212
  *
@@ -15,11 +15,14 @@ class Conversation {
15
15
  this.chatApiV = (config.chatApiV || DEFAULT_CONVO_API_V);
16
16
  const serverUrl = config.serverUrl || config_1.CONFIG.server;
17
17
  const progressConfig = config_1.CONFIG.paths.progress[this.chatApiV];
18
+ const interruptConfig = config_1.CONFIG.paths.interrupt[this.chatApiV];
18
19
  this.endpoints = {
19
20
  conversation: serverUrl + (config.convoPath || config.path || config_1.CONFIG.paths.conversation[this.chatApiV].base),
20
21
  history: serverUrl + (config.historyPath || config_1.CONFIG.paths.history.chat.v1.base),
21
22
  progressStream: serverUrl + (config.progressStreamPath || (progressConfig?.stream ?? config_1.CONFIG.paths.progress.v4.stream)),
22
23
  progress: serverUrl + (config.progressPath || (progressConfig?.base ?? config_1.CONFIG.paths.progress.v4.base)),
24
+ stop: serverUrl + (interruptConfig?.stop ?? config_1.CONFIG.paths.interrupt.v4.stop),
25
+ steer: serverUrl + (interruptConfig?.steer ?? config_1.CONFIG.paths.interrupt.v4.steer),
23
26
  };
24
27
  this.apiKey = config.apiKey;
25
28
  this.debug = config.debug || false;
@@ -36,6 +39,8 @@ class Conversation {
36
39
  else {
37
40
  this.transport = new transport_sse_1.SSEConversationTransport({
38
41
  endpoint: () => this.endpoints.conversation,
42
+ stopEndpoint: () => this.endpoints.stop,
43
+ steerEndpoint: () => this.endpoints.steer,
39
44
  apiKey: this.apiKey,
40
45
  debug: this.debug,
41
46
  });
@@ -261,6 +266,44 @@ class Conversation {
261
266
  },
262
267
  });
263
268
  }
269
+ /**
270
+ * Stops the turn running in this conversation.
271
+ *
272
+ * `soft` (the default) lets the model finish the call it is in the middle of and takes no
273
+ * further step: the answer so far is kept, in history and in what the model remembers
274
+ * saying, and it works on every model. `hard` cuts the stream off mid-sentence and aborts
275
+ * the tools with it — but only actually stops the bill where the provider supports
276
+ * cancellation, so against one that does not the server applies a soft stop instead. The
277
+ * returned `mode` says which you got; `undefined` means there was nothing left to stop.
278
+ *
279
+ * Either way the turn's stream ends the way it always does, carrying what was produced
280
+ * before the stop. There is nothing else to clean up.
281
+ */
282
+ async stop(mode = "soft") {
283
+ if (!this.convoId)
284
+ return undefined;
285
+ if (!this.transport.stop)
286
+ return undefined;
287
+ return this.transport.stop({ chatId: this.convoId, mode });
288
+ }
289
+ /**
290
+ * Says something to the turn that is already running.
291
+ *
292
+ * The message reaches the model at its next step boundary, so nothing in flight is thrown
293
+ * away and the model reads it as the user talking mid-task — which is usually what someone
294
+ * typing while Alfred works actually means.
295
+ *
296
+ * Check the result. `too_late` means the message was NOT delivered, because the turn was
297
+ * already stopping or over, and it should be sent with `send()` instead. Dropping it is
298
+ * the one thing that is never right.
299
+ */
300
+ async steer(message) {
301
+ if (!this.convoId)
302
+ return { ok: false, reason: "no_turn" };
303
+ if (!this.transport.steer)
304
+ return { ok: false, reason: "no_turn" };
305
+ return this.transport.steer({ chatId: this.convoId, message });
306
+ }
264
307
  /**
265
308
  * Sends a message and resolves with the finished reply.
266
309
  *
@@ -6,16 +6,48 @@
6
6
  * only decides how a turn is sent and how its stream comes back — everything a
7
7
  * caller sees, including the payload shape, is identical either way.
8
8
  */
9
- /** A turn in progress. `close()` stops delivery locally. */
9
+ /** A turn in progress. */
10
10
  export type ConversationStream = {
11
11
  /**
12
12
  * Stops listening. The turn itself continues server-side and its reply is still
13
- * persisted, on both transports — there is no cancel.
13
+ * persisted, on both transports — this is not a cancel. To actually stop the turn,
14
+ * use `Conversation.stop()`.
14
15
  */
15
16
  close(): void;
16
17
  /** The underlying EventSource, when the turn is being carried over SSE. */
17
18
  readonly source?: unknown;
18
19
  };
20
+ /**
21
+ * How hard to stop a turn.
22
+ *
23
+ * - `soft`: let the model call in flight finish, then stop. Keeps the text, costs what that
24
+ * step cost, and works everywhere. The right default for a stop button.
25
+ * - `hard`: abort the stream mid-sentence and every tool with it. Only stops the bill on
26
+ * providers that support cancellation; against one that does not the server applies a soft
27
+ * stop instead, rather than throwing away output that is billed either way. Check the
28
+ * `mode` you get back to see which happened.
29
+ */
30
+ export type TurnStopMode = "soft" | "hard";
31
+ /** A stop, as the server applied it. */
32
+ export type TurnStopped = {
33
+ /** What was applied. */
34
+ mode: TurnStopMode;
35
+ /** What was asked for. Differs from `mode` when a hard stop was downgraded. */
36
+ requestedMode: TurnStopMode;
37
+ by: "user" | "system";
38
+ };
39
+ /**
40
+ * What became of a message steered into a running turn.
41
+ *
42
+ * `too_late` is the one that matters: the turn was already stopping or finished, the message
43
+ * was NOT delivered, and it should be sent as an ordinary turn instead. Never drop it.
44
+ */
45
+ export type SteerResult = {
46
+ ok: true;
47
+ } | {
48
+ ok: false;
49
+ reason: "no_turn" | "too_late";
50
+ };
19
51
  export type TransportTurnRequest = {
20
52
  chatId?: string;
21
53
  message: string;
@@ -36,8 +68,31 @@ export type TransportAttachRequest = {
36
68
  /** Resume point: only what came after this event is replayed. */
37
69
  afterEventId?: string;
38
70
  };
71
+ /** A request to interrupt a turn that is already running. */
72
+ export type TransportStopRequest = {
73
+ chatId: string;
74
+ mode: TurnStopMode;
75
+ };
76
+ export type TransportSteerRequest = {
77
+ chatId: string;
78
+ message: string;
79
+ };
39
80
  export interface ConversationTransport {
40
81
  send(request: TransportTurnRequest, handlers: TransportHandlers): ConversationStream;
82
+ /**
83
+ * Stops the turn running in a conversation.
84
+ *
85
+ * Resolves `undefined` when there was nothing to stop — the turn finished on its own, or
86
+ * never started. That is not an error: it is the answer to pressing stop a moment too late.
87
+ */
88
+ stop?(request: TransportStopRequest): Promise<TurnStopped | undefined>;
89
+ /**
90
+ * Says something to a turn that is still running, to be read at its next step boundary.
91
+ *
92
+ * The turn is not interrupted: work in flight finishes and the model reads the message as
93
+ * the user talking mid-task.
94
+ */
95
+ steer?(request: TransportSteerRequest): Promise<SteerResult>;
41
96
  /**
42
97
  * Watches a turn that is already running, without starting one.
43
98
  *
@@ -1,5 +1,5 @@
1
1
  import type { Link } from "../link/link";
2
- import { ConversationStream, ConversationTransport, TransportAttachRequest, TransportHandlers, TransportTurnRequest } from "./transport";
2
+ import { ConversationStream, ConversationTransport, SteerResult, TransportAttachRequest, TransportHandlers, TransportSteerRequest, TransportStopRequest, TransportTurnRequest, TurnStopped } from "./transport";
3
3
  export type LinkSessionConfig = {
4
4
  model?: string;
5
5
  personality?: string;
@@ -20,6 +20,21 @@ export declare class LinkConversationTransport implements ConversationTransport
20
20
  private sessionChatId?;
21
21
  constructor(link: Link, config: () => LinkSessionConfig);
22
22
  send(request: TransportTurnRequest, handlers: TransportHandlers): ConversationStream;
23
+ /**
24
+ * Stops a running turn.
25
+ *
26
+ * Addressed by conversation rather than by session, so a client that reconnected — losing
27
+ * its session but not the turn — can still stop what it is watching. The turn's own stream
28
+ * ends normally afterwards, carrying whatever the model produced before the stop.
29
+ */
30
+ stop(request: TransportStopRequest): Promise<TurnStopped | undefined>;
31
+ /**
32
+ * Says something to a turn that is still running.
33
+ *
34
+ * A refusal is the interesting case: the caller is holding a message the user typed and
35
+ * has to send it as an ordinary turn instead of dropping it.
36
+ */
37
+ steer(request: TransportSteerRequest): Promise<SteerResult>;
23
38
  /** Ends the session, if one is open. The conversation can still be resumed later. */
24
39
  end(): Promise<void>;
25
40
  /**
@@ -33,6 +33,49 @@ class LinkConversationTransport {
33
33
  });
34
34
  return { close: () => { closed = true; } };
35
35
  }
36
+ /**
37
+ * Stops a running turn.
38
+ *
39
+ * Addressed by conversation rather than by session, so a client that reconnected — losing
40
+ * its session but not the turn — can still stop what it is watching. The turn's own stream
41
+ * ends normally afterwards, carrying whatever the model produced before the stop.
42
+ */
43
+ async stop(request) {
44
+ try {
45
+ const frame = await this.link.exchange("conversation.stop", request, {
46
+ isDone: (reply) => reply.type === "conversation.stopped" || reply.type === "ack" || reply.type === "error",
47
+ });
48
+ if (frame.type !== "conversation.stopped")
49
+ return undefined;
50
+ const { mode, requestedMode, by } = frame.payload;
51
+ return { mode, requestedMode, by };
52
+ }
53
+ catch {
54
+ // Nothing was running, or the link is gone. Either way there is no turn to stop and
55
+ // nothing useful for the caller to do about it.
56
+ return undefined;
57
+ }
58
+ }
59
+ /**
60
+ * Says something to a turn that is still running.
61
+ *
62
+ * A refusal is the interesting case: the caller is holding a message the user typed and
63
+ * has to send it as an ordinary turn instead of dropping it.
64
+ */
65
+ async steer(request) {
66
+ try {
67
+ const frame = await this.link.exchange("conversation.steer", request, {
68
+ isDone: (reply) => reply.type === "conversation.steered" || reply.type === "ack" || reply.type === "error",
69
+ });
70
+ if (frame.type === "conversation.steered")
71
+ return { ok: true };
72
+ return { ok: false, reason: "no_turn" };
73
+ }
74
+ catch (error) {
75
+ const code = error instanceof protocol_1.LinkError ? error.code : undefined;
76
+ return { ok: false, reason: code === "too_late" ? "too_late" : "no_turn" };
77
+ }
78
+ }
36
79
  /** Ends the session, if one is open. The conversation can still be resumed later. */
37
80
  async end() {
38
81
  const sessionId = this.sessionId;
@@ -1,5 +1,5 @@
1
1
  import { EventSource } from "eventsource";
2
- import { ConversationStream, ConversationTransport, TransportHandlers, TransportTurnRequest } from "./transport";
2
+ import { ConversationStream, ConversationTransport, SteerResult, TransportHandlers, TransportSteerRequest, TransportStopRequest, TransportTurnRequest, TurnStopped } from "./transport";
3
3
  type StreamOptions = {
4
4
  debug?: boolean;
5
5
  onPayload(payload: {
@@ -20,9 +20,21 @@ export declare class SSEConversationTransport implements ConversationTransport {
20
20
  private readonly config;
21
21
  constructor(config: {
22
22
  endpoint(): string;
23
+ stopEndpoint?(): string;
24
+ steerEndpoint?(): string;
23
25
  apiKey: string;
24
26
  debug?: boolean;
25
27
  });
26
28
  send(request: TransportTurnRequest, handlers: TransportHandlers): ConversationStream;
29
+ /**
30
+ * The turn is a GET that streams until it is done, so an interruption cannot travel on the
31
+ * same request and gets one of its own.
32
+ *
33
+ * Closing the stream is deliberately not a stop: a reload, a backgrounded tab or a
34
+ * reconnect all drop the connection and all expect to pick the answer back up.
35
+ */
36
+ stop(request: TransportStopRequest): Promise<TurnStopped | undefined>;
37
+ steer(request: TransportSteerRequest): Promise<SteerResult>;
38
+ private post;
27
39
  }
28
40
  export {};
@@ -40,6 +40,54 @@ class SSEConversationTransport {
40
40
  });
41
41
  return { close: () => sse.close(), source: sse };
42
42
  }
43
+ /**
44
+ * The turn is a GET that streams until it is done, so an interruption cannot travel on the
45
+ * same request and gets one of its own.
46
+ *
47
+ * Closing the stream is deliberately not a stop: a reload, a backgrounded tab or a
48
+ * reconnect all drop the connection and all expect to pick the answer back up.
49
+ */
50
+ async stop(request) {
51
+ const endpoint = this.config.stopEndpoint?.();
52
+ if (!endpoint)
53
+ return undefined;
54
+ const response = await this.post(endpoint, { chatId: request.chatId, mode: request.mode });
55
+ // 404 is "nothing was running", which is the ordinary answer to stopping a moment late.
56
+ if (!response || response.status === 404)
57
+ return undefined;
58
+ const body = await response.json().catch(() => undefined);
59
+ return body?.stop;
60
+ }
61
+ async steer(request) {
62
+ const endpoint = this.config.steerEndpoint?.();
63
+ if (!endpoint)
64
+ return { ok: false, reason: "no_turn" };
65
+ const response = await this.post(endpoint, { chatId: request.chatId, message: request.message });
66
+ if (!response)
67
+ return { ok: false, reason: "no_turn" };
68
+ if (response.ok)
69
+ return { ok: true };
70
+ // 409: the turn is no longer accepting messages. The caller still has something the
71
+ // user typed and must send it as an ordinary turn.
72
+ return { ok: false, reason: response.status === 409 ? "too_late" : "no_turn" };
73
+ }
74
+ async post(endpoint, body) {
75
+ const url = (0, url_formatter_1.formatURL)(endpoint, undefined, { apiKey: this.config.apiKey, debug: this.config.debug });
76
+ try {
77
+ return await fetch(url, {
78
+ method: "POST",
79
+ headers: { "content-type": "application/json" },
80
+ body: JSON.stringify(body),
81
+ });
82
+ }
83
+ catch (error) {
84
+ // A stop that cannot reach the server is not worth throwing over: the turn is
85
+ // ending on its own soon enough, and the caller has no better move to make.
86
+ if (this.config.debug)
87
+ console.warn(`[Interrupt failed: ${endpoint}]`, error);
88
+ return undefined;
89
+ }
90
+ }
43
91
  }
44
92
  exports.SSEConversationTransport = SSEConversationTransport;
45
93
  function asQuery(request) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.28",
3
+ "version": "0.0.30",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",