@butlerbot/sdk 0.0.29 → 0.0.31

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";
@@ -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,68 @@ 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
+ if (!response)
56
+ return undefined;
57
+ // 404 is "nothing was running", which is the ordinary answer to stopping a moment late.
58
+ // Anything else is the server refusing or failing, and a stop button that quietly does
59
+ // nothing is the hardest kind of bug to report — so it is said out loud.
60
+ if (!response.ok) {
61
+ if (response.status !== 404)
62
+ console.warn(`[Stop failed: ${response.status}]`, await response.text().catch(() => ""));
63
+ return undefined;
64
+ }
65
+ const body = await response.json().catch(() => undefined);
66
+ return body?.stop;
67
+ }
68
+ async steer(request) {
69
+ const endpoint = this.config.steerEndpoint?.();
70
+ if (!endpoint)
71
+ return { ok: false, reason: "no_turn" };
72
+ const response = await this.post(endpoint, { chatId: request.chatId, message: request.message });
73
+ if (!response)
74
+ return { ok: false, reason: "no_turn" };
75
+ if (response.ok)
76
+ return { ok: true };
77
+ // 409: the turn is no longer accepting messages. The caller still has something the
78
+ // user typed and must send it as an ordinary turn.
79
+ if (response.status === 409)
80
+ return { ok: false, reason: "too_late" };
81
+ // Anything other than "no turn" means the message did not land for a reason the caller
82
+ // cannot see: it still falls back to sending, but not in silence.
83
+ if (response.status !== 404)
84
+ console.warn(`[Steer failed: ${response.status}]`, await response.text().catch(() => ""));
85
+ return { ok: false, reason: "no_turn" };
86
+ }
87
+ async post(endpoint, body) {
88
+ const url = (0, url_formatter_1.formatURL)(endpoint, undefined, { apiKey: this.config.apiKey, debug: this.config.debug });
89
+ try {
90
+ return await fetch(url, {
91
+ method: "POST",
92
+ headers: { "content-type": "application/json" },
93
+ body: JSON.stringify(body),
94
+ });
95
+ }
96
+ catch (error) {
97
+ // A stop that cannot reach the server is not worth throwing over: the turn is
98
+ // ending on its own soon enough, and the caller has no better move to make. It is
99
+ // still worth saying, and not only in debug — an interruption that vanishes without
100
+ // a trace is indistinguishable from one the UI forgot to wire up.
101
+ console.warn(`[Interrupt failed: ${endpoint}]`, error);
102
+ return undefined;
103
+ }
104
+ }
43
105
  }
44
106
  exports.SSEConversationTransport = SSEConversationTransport;
45
107
  function asQuery(request) {
@@ -197,6 +197,16 @@ export type ConvoStatusPayload = {
197
197
  };
198
198
  export type ResponseStatusPayload = {
199
199
  completed: boolean;
200
+ /** Present when the turn was stopped rather than finished on its own.
201
+ * `mode` is what was applied and `requestedMode` what was asked for: they differ when a
202
+ * hard stop was downgraded because the model's provider ignores a cancelled stream. */
203
+ stop?: {
204
+ mode: "soft" | "hard";
205
+ requestedMode: "soft" | "hard";
206
+ by: "user" | "system";
207
+ /** When the stop was requested. */
208
+ at: number;
209
+ };
200
210
  /** Rich response metadata — only present on the final `completed: true` event.
201
211
  * Populated by the caller (e.g. gateway) after the response finishes and usage is available. */
202
212
  metadata?: ResponseMetadata;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",