@polpo-ai/sdk 0.15.76 → 0.15.78

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.
@@ -9,6 +9,8 @@ export class ChatCompletionStream {
9
9
  sessionId = null;
10
10
  /** Active Run id used by steering APIs. Available after start() or first next(). */
11
11
  runId = null;
12
+ /** Last fully processed durable SSE cursor. */
13
+ lastEventId = null;
12
14
  /** If the stream ended with finish_reason "ask_user", this contains the questions. */
13
15
  askUser = null;
14
16
  /** Suggested next messages emitted before the stream closes. */
@@ -27,6 +29,7 @@ export class ChatCompletionStream {
27
29
  aborted = false;
28
30
  fetchFn;
29
31
  url;
32
+ runsUrl;
30
33
  clientHeaders;
31
34
  req;
32
35
  reader = null;
@@ -34,18 +37,70 @@ export class ChatCompletionStream {
34
37
  buffer = "";
35
38
  started = false;
36
39
  abortController = new AbortController();
37
- constructor(fetchFn, url, clientHeaders, req) {
40
+ resumeMode = false;
41
+ resumeAfter;
42
+ detached = false;
43
+ terminal = false;
44
+ connectionStateListeners = new Set();
45
+ constructor(fetchFn, url, runsUrl, clientHeaders, req) {
38
46
  this.fetchFn = fetchFn;
39
47
  this.url = url;
48
+ this.runsUrl = runsUrl;
40
49
  this.clientHeaders = clientHeaders;
41
50
  this.req = req;
42
51
  }
43
- /**
44
- * Abort the in-flight stream. Cancels the fetch request and closes the reader.
45
- * The server will detect the disconnect and stop generating.
46
- */
52
+ /** Backward-compatible cancel command. Use detach() to keep a durable run alive. */
47
53
  abort() {
48
54
  this.aborted = true;
55
+ void this.cancel().catch(() => { });
56
+ }
57
+ /** Observe transport state without implementing a second SSE parser. */
58
+ subscribeConnectionState(listener) {
59
+ this.connectionStateListeners.add(listener);
60
+ return () => { this.connectionStateListeners.delete(listener); };
61
+ }
62
+ /** Close only this subscriber. A durable run continues server-side. */
63
+ detach() {
64
+ this.detached = true;
65
+ this.closeLocalStream();
66
+ }
67
+ /** Explicitly cancel the underlying run, then close this subscriber. */
68
+ async cancel(reason) {
69
+ this.aborted = true;
70
+ const runId = this.runId;
71
+ this.closeLocalStream();
72
+ if (!runId)
73
+ return;
74
+ const res = await this.fetchFn(`${this.runsUrl}/${encodeURIComponent(runId)}/cancel`, {
75
+ method: "POST",
76
+ headers: {
77
+ "Content-Type": "application/json",
78
+ ...authorizationHeader(this.clientHeaders),
79
+ },
80
+ body: JSON.stringify(reason ? { reason } : {}),
81
+ });
82
+ if (!res.ok)
83
+ throw await responseError(res, "Run cancellation failed");
84
+ }
85
+ /** Reattach this stream to its existing run after the last processed cursor. */
86
+ resume(options = {}) {
87
+ if (!this.runId) {
88
+ throw new PolpoApiError("Cannot resume before the server returns a run id", "VALIDATION_ERROR", 400);
89
+ }
90
+ this.closeLocalStream();
91
+ this.abortController = new AbortController();
92
+ this.decoder = new TextDecoder();
93
+ this.buffer = "";
94
+ this.reader = null;
95
+ this.started = false;
96
+ this.aborted = false;
97
+ this.detached = false;
98
+ this.terminal = false;
99
+ this.resumeMode = true;
100
+ this.resumeAfter = options.after ?? this.lastEventId ?? undefined;
101
+ return this;
102
+ }
103
+ closeLocalStream() {
49
104
  this.abortController.abort();
50
105
  this.reader?.cancel().catch(() => { });
51
106
  }
@@ -60,96 +115,231 @@ export class ChatCompletionStream {
60
115
  this.started = true;
61
116
  const headers = {
62
117
  "Content-Type": "application/json",
118
+ ...authorizationHeader(this.clientHeaders),
63
119
  };
64
- if (this.clientHeaders["Authorization"]) {
65
- headers["Authorization"] = this.clientHeaders["Authorization"];
120
+ if (this.resumeMode && this.resumeAfter !== undefined) {
121
+ headers["Last-Event-ID"] = this.resumeAfter;
66
122
  }
67
123
  if (this.req.sessionId) {
68
124
  headers["x-session-id"] = this.req.sessionId;
69
125
  }
70
126
  const { sessionId: _, ...body } = this.req;
71
- const res = await this.fetchFn(this.url, {
72
- method: "POST",
73
- headers,
74
- body: JSON.stringify({ ...body, stream: true }),
75
- signal: this.abortController.signal,
76
- });
127
+ const res = this.resumeMode
128
+ ? await this.fetchFn(`${this.runsUrl}/${encodeURIComponent(this.runId)}/events${this.resumeAfter === undefined ? "" : `?cursor=${encodeURIComponent(this.resumeAfter)}`}`, { method: "GET", headers, signal: this.abortController.signal })
129
+ : await this.fetchFn(this.url, {
130
+ method: "POST",
131
+ headers,
132
+ body: JSON.stringify({ ...body, stream: true }),
133
+ signal: this.abortController.signal,
134
+ });
77
135
  if (!res.ok) {
78
- const err = await res.json().catch(() => ({ error: { message: res.statusText } }));
79
- throw new PolpoApiError(err.error?.message ?? "Chat completions failed", res.status === 401 ? "AUTH_REQUIRED" : "INTERNAL_ERROR", res.status);
136
+ throw await responseError(res, this.resumeMode ? "Run resume failed" : "Chat completions failed");
80
137
  }
81
138
  // Capture session ID from response header
82
- this.sessionId = res.headers.get("x-session-id");
83
- this.runId = res.headers.get("x-polpo-run-id");
139
+ if (!this.resumeMode) {
140
+ this.sessionId = res.headers.get("x-session-id");
141
+ this.runId = res.headers.get("x-polpo-run-id");
142
+ }
143
+ this.terminal = res.headers.get("x-polpo-run-terminal") === "true";
84
144
  this.reader = res.body?.getReader() ?? null;
85
145
  if (!this.reader)
86
146
  throw new PolpoApiError("No response body", "INTERNAL_ERROR", 500);
147
+ this.emitConnectionState("streaming");
87
148
  }
88
149
  async *[Symbol.asyncIterator]() {
89
150
  await this.ensureStarted();
90
- const reader = this.reader;
151
+ if (this.terminal)
152
+ return;
153
+ let reconnectAttempts = 0;
91
154
  try {
92
- while (true) {
93
- const { done, value } = await reader.read();
94
- if (done)
95
- break;
96
- this.buffer += this.decoder.decode(value, { stream: true });
97
- const lines = this.buffer.split("\n");
98
- this.buffer = lines.pop() ?? "";
99
- for (const line of lines) {
100
- const trimmed = line.trim();
101
- if (!trimmed || !trimmed.startsWith("data: "))
102
- continue;
103
- const data = trimmed.slice(6);
104
- if (data === "[DONE]")
105
- return;
106
- try {
107
- const chunk = JSON.parse(data);
108
- if (chunk.polpo?.suggestions) {
109
- this.suggestions = chunk.polpo.suggestions;
155
+ while (!this.aborted && !this.detached) {
156
+ const reader = this.reader;
157
+ let terminal = false;
158
+ try {
159
+ while (true) {
160
+ const { done, value } = await reader.read();
161
+ if (done)
162
+ break;
163
+ this.buffer += this.decoder.decode(value, { stream: true });
164
+ const parsed = extractSseEvents(this.buffer);
165
+ this.buffer = parsed.remainder;
166
+ for (const event of parsed.events) {
167
+ if (event.id !== undefined)
168
+ this.lastEventId = event.id;
169
+ const projected = projectChatSseData(event.data);
170
+ if (projected.kind === "ignore")
171
+ continue;
172
+ if (projected.kind === "error") {
173
+ throw new PolpoApiError(projected.message, "INTERNAL_ERROR", 500);
174
+ }
175
+ if (projected.kind === "done") {
176
+ terminal = true;
177
+ return;
178
+ }
179
+ let chunk;
180
+ try {
181
+ chunk = JSON.parse(projected.data);
182
+ }
183
+ catch {
184
+ if (this.isDurable()) {
185
+ throw new PolpoApiError("Durable run returned a malformed response chunk", "INTERNAL_ERROR", 502);
186
+ }
187
+ continue;
188
+ }
189
+ if (chunk.polpo?.suggestions) {
190
+ this.suggestions = chunk.polpo.suggestions;
191
+ }
192
+ // Capture ask_user payload from the chunk
193
+ const choice = chunk.choices[0];
194
+ if (choice?.finish_reason === "ask_user" && choice.ask_user) {
195
+ this.askUser = choice.ask_user;
196
+ }
197
+ // Capture mission_preview payload from the chunk
198
+ if (choice?.finish_reason === "mission_preview" && choice.mission_preview) {
199
+ this.missionPreview = choice.mission_preview;
200
+ }
201
+ // Capture vault_preview payload from the chunk
202
+ if (choice?.finish_reason === "vault_preview" && choice.vault_preview) {
203
+ this.vaultPreview = choice.vault_preview;
204
+ }
205
+ // Capture open_file payload from the chunk
206
+ if (choice?.finish_reason === "open_file" && choice.open_file) {
207
+ this.openFile = choice.open_file;
208
+ }
209
+ // Capture navigate_to payload from the chunk
210
+ if (choice?.finish_reason === "navigate_to" && choice.navigate_to) {
211
+ this.navigateTo = choice.navigate_to;
212
+ }
213
+ // Capture open_tab payload from the chunk
214
+ if (choice?.finish_reason === "open_tab" && choice.open_tab) {
215
+ this.openTab = choice.open_tab;
216
+ }
217
+ yield chunk;
218
+ reconnectAttempts = 0;
110
219
  }
111
- // Capture ask_user payload from the chunk
112
- const choice = chunk.choices[0];
113
- if (choice?.finish_reason === "ask_user" && choice.ask_user) {
114
- this.askUser = choice.ask_user;
115
- }
116
- // Capture mission_preview payload from the chunk
117
- if (choice?.finish_reason === "mission_preview" && choice.mission_preview) {
118
- this.missionPreview = choice.mission_preview;
119
- }
120
- // Capture vault_preview payload from the chunk
121
- if (choice?.finish_reason === "vault_preview" && choice.vault_preview) {
122
- this.vaultPreview = choice.vault_preview;
123
- }
124
- // Capture open_file payload from the chunk
125
- if (choice?.finish_reason === "open_file" && choice.open_file) {
126
- this.openFile = choice.open_file;
127
- }
128
- // Capture navigate_to payload from the chunk
129
- if (choice?.finish_reason === "navigate_to" && choice.navigate_to) {
130
- this.navigateTo = choice.navigate_to;
131
- }
132
- // Capture open_tab payload from the chunk
133
- if (choice?.finish_reason === "open_tab" && choice.open_tab) {
134
- this.openTab = choice.open_tab;
135
- }
136
- yield chunk;
137
220
  }
138
- catch {
139
- // skip malformed chunks
221
+ }
222
+ catch (err) {
223
+ if (this.aborted || this.detached)
224
+ return;
225
+ if (err instanceof PolpoApiError)
226
+ throw err;
227
+ if (!(err instanceof DOMException && err.name === "AbortError") && !this.isDurable()) {
228
+ throw err;
140
229
  }
141
230
  }
231
+ if (terminal || this.aborted || this.detached || !this.isDurable() || !this.runId)
232
+ return;
233
+ if (reconnectAttempts >= 5) {
234
+ throw new PolpoApiError("Run stream reconnect limit exceeded", "INTERNAL_ERROR", 503);
235
+ }
236
+ reconnectAttempts += 1;
237
+ this.emitConnectionState("reconnecting");
238
+ await reconnectDelay(reconnectAttempts, this.abortController.signal);
239
+ if (this.aborted || this.detached)
240
+ return;
241
+ this.resume({ after: this.lastEventId ?? undefined });
242
+ await this.ensureStarted();
142
243
  }
143
244
  }
144
- catch (err) {
145
- // Suppress AbortError — this is expected when the user stops the stream
146
- if (err instanceof DOMException && err.name === "AbortError")
147
- return;
148
- if (this.aborted)
149
- return;
150
- throw err;
245
+ finally {
246
+ this.emitConnectionState("closed");
151
247
  }
152
248
  }
249
+ isDurable() {
250
+ return this.req.polpo?.delivery?.onDisconnect === "continue";
251
+ }
252
+ emitConnectionState(state) {
253
+ for (const listener of this.connectionStateListeners)
254
+ listener(state);
255
+ }
256
+ }
257
+ function extractSseEvents(buffer) {
258
+ const separator = /(?:\r\n|\r|\n){2}/g;
259
+ const blocks = [];
260
+ let offset = 0;
261
+ for (let match = separator.exec(buffer); match; match = separator.exec(buffer)) {
262
+ blocks.push(buffer.slice(offset, match.index));
263
+ offset = match.index + match[0].length;
264
+ }
265
+ const remainder = buffer.slice(offset);
266
+ const events = [];
267
+ for (const block of blocks) {
268
+ let id;
269
+ let event;
270
+ const data = [];
271
+ for (const line of block.split(/\r\n|\r|\n/)) {
272
+ if (line.startsWith(":"))
273
+ continue;
274
+ if (line.startsWith("id:"))
275
+ id = line.slice(3).trimStart();
276
+ else if (line.startsWith("event:"))
277
+ event = line.slice(6).trimStart();
278
+ else if (line.startsWith("data:"))
279
+ data.push(line.slice(5).trimStart());
280
+ }
281
+ if (data.length > 0)
282
+ events.push({ data: data.join("\n"), ...(id === undefined ? {} : { id }), ...(event === undefined ? {} : { event }) });
283
+ }
284
+ return { events, remainder };
285
+ }
286
+ function projectChatSseData(data) {
287
+ if (data === "[DONE]")
288
+ return { kind: "done" };
289
+ try {
290
+ const parsed = JSON.parse(data);
291
+ if ("error" in parsed && parsed.error) {
292
+ return {
293
+ kind: "error",
294
+ message: typeof parsed.error.message === "string"
295
+ ? parsed.error.message
296
+ : "Run failed",
297
+ };
298
+ }
299
+ const event = parsed;
300
+ if (event.schemaVersion !== 1 || typeof event.type !== "string") {
301
+ return { kind: "chunk", data };
302
+ }
303
+ if (event.type === "response.done")
304
+ return { kind: "done" };
305
+ if (event.type === "response.chunk" && typeof event.data?.data === "string") {
306
+ return { kind: "chunk", data: event.data.data };
307
+ }
308
+ if (event.type === "run.failed") {
309
+ return {
310
+ kind: "error",
311
+ message: typeof event.data?.message === "string" ? event.data.message : "Run failed",
312
+ };
313
+ }
314
+ if (event.type === "run.cancelled")
315
+ return { kind: "done" };
316
+ return { kind: "ignore" };
317
+ }
318
+ catch {
319
+ return { kind: "chunk", data };
320
+ }
321
+ }
322
+ function authorizationHeader(headers) {
323
+ return headers.Authorization ? { Authorization: headers.Authorization } : {};
324
+ }
325
+ async function responseError(response, fallback) {
326
+ const body = await response.json().catch(() => ({ error: { message: response.statusText } }));
327
+ const message = body.error?.message ?? body.error ?? fallback;
328
+ return new PolpoApiError(typeof message === "string" ? message : fallback, response.status === 401 ? "AUTH_REQUIRED" : "INTERNAL_ERROR", response.status);
329
+ }
330
+ function reconnectDelay(attempt, signal) {
331
+ if (signal.aborted)
332
+ return Promise.resolve();
333
+ const delay = Math.min(2_000, 100 * 2 ** (attempt - 1));
334
+ return new Promise((resolve) => {
335
+ const timer = setTimeout(finish, delay);
336
+ function finish() {
337
+ clearTimeout(timer);
338
+ signal.removeEventListener("abort", finish);
339
+ resolve();
340
+ }
341
+ signal.addEventListener("abort", finish, { once: true });
342
+ });
153
343
  }
154
344
  function scheduleIdentifier(value, label = "Schedule id") {
155
345
  if (typeof value !== "string" || !value.trim()) {
@@ -869,6 +1059,65 @@ export class PolpoClient {
869
1059
  abortRun(runId, reason) {
870
1060
  return this.post(`/runs/${encodeURIComponent(runId)}/abort`, reason === undefined ? {} : { reason });
871
1061
  }
1062
+ /** Follow the canonical durable event log for an existing run. */
1063
+ async *streamRunEvents(runId, options = {}) {
1064
+ const query = options.after === undefined
1065
+ ? ""
1066
+ : `?cursor=${encodeURIComponent(options.after)}`;
1067
+ const response = await this.fetchFn(`${this.baseUrl}/v1/runs/${encodeURIComponent(runId)}/events${query}`, {
1068
+ method: "GET",
1069
+ headers: {
1070
+ Accept: "text/event-stream",
1071
+ ...authorizationHeader(this.headers),
1072
+ ...(options.after === undefined ? {} : { "Last-Event-ID": options.after }),
1073
+ },
1074
+ signal: options.signal,
1075
+ });
1076
+ if (!response.ok)
1077
+ throw await responseError(response, "Run event stream failed");
1078
+ const reader = response.body?.getReader();
1079
+ if (!reader)
1080
+ throw new PolpoApiError("No response body", "INTERNAL_ERROR", 500);
1081
+ const decoder = new TextDecoder();
1082
+ let buffer = "";
1083
+ try {
1084
+ while (true) {
1085
+ const { done, value } = await reader.read();
1086
+ if (done)
1087
+ break;
1088
+ buffer += decoder.decode(value, { stream: true });
1089
+ const parsed = extractSseEvents(buffer);
1090
+ buffer = parsed.remainder;
1091
+ for (const item of parsed.events) {
1092
+ try {
1093
+ const event = JSON.parse(item.data);
1094
+ if (event.schemaVersion === 1 && event.runId === runId)
1095
+ yield event;
1096
+ }
1097
+ catch {
1098
+ // Canonical streams ignore malformed transport frames.
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+ finally {
1104
+ reader.releaseLock();
1105
+ }
1106
+ }
1107
+ /** Request idempotent cancellation for a durable run. */
1108
+ async cancelRun(runId, reason) {
1109
+ const response = await this.fetchFn(`${this.baseUrl}/v1/runs/${encodeURIComponent(runId)}/cancel`, {
1110
+ method: "POST",
1111
+ headers: {
1112
+ "Content-Type": "application/json",
1113
+ ...authorizationHeader(this.headers),
1114
+ },
1115
+ body: JSON.stringify(reason ? { reason } : {}),
1116
+ });
1117
+ if (!response.ok)
1118
+ throw await responseError(response, "Run cancellation failed");
1119
+ return await response.json();
1120
+ }
872
1121
  // ── Chat Completions (OpenAI-compatible) ─────────────────
873
1122
  /**
874
1123
  * Talk to Polpo via the OpenAI-compatible chat completions endpoint.
@@ -912,7 +1161,7 @@ export class PolpoClient {
912
1161
  const reqWithUser = req.user === undefined && this.defaultUser !== undefined
913
1162
  ? { ...req, user: this.defaultUser }
914
1163
  : req;
915
- return new ChatCompletionStream(this.fetchFn, url, this.headers, reqWithUser);
1164
+ return new ChatCompletionStream(this.fetchFn, url, `${this.baseUrl}/v1/runs`, this.headers, reqWithUser);
916
1165
  }
917
1166
  // ── Sessions ────────────────────────────────────────────
918
1167
  getSessions() {