@astralform/js 1.1.0 → 1.2.0

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/index.d.cts CHANGED
@@ -917,6 +917,13 @@ declare class ChatSession {
917
917
  private processStream;
918
918
  /** Last received sequence number for resumable reconnection */
919
919
  private lastSeq;
920
+ /**
921
+ * Client-tool call_ids whose result was already submitted this turn. On a
922
+ * reconnect the resumed stream can replay a tool request we already handled;
923
+ * this dedups so each is executed + submitted at most once (but a request we
924
+ * never submitted still runs). Cleared at the start of each turn.
925
+ */
926
+ private submittedToolCallIds;
920
927
  /** Current job ID for cancellation */
921
928
  currentJobId: string | null;
922
929
  private consumeJobStream;
@@ -925,6 +932,16 @@ declare class ChatSession {
925
932
  * minimal session state, and emits typed ChatEvents to consumers.
926
933
  */
927
934
  private consumeEventStream;
935
+ /**
936
+ * Consume a single SSE stream to exhaustion. Returns whether a terminal
937
+ * event (``message_stop`` / ``error``) was seen, so the caller can decide
938
+ * whether an ended stream means "turn done" vs "dropped, reconnect".
939
+ */
940
+ private pumpStream;
941
+ /** Sleep for ``ms``, resolving early if the turn is aborted mid-backoff. */
942
+ private sleepUnlessAborted;
943
+ /** POST a client-tool result, retrying transient failures a few times. */
944
+ private submitToolResultWithRetry;
928
945
  private dispatchWireEvent;
929
946
  /**
930
947
  * State mutations driven by wire events. Kept separate from translation so
package/dist/index.d.ts CHANGED
@@ -917,6 +917,13 @@ declare class ChatSession {
917
917
  private processStream;
918
918
  /** Last received sequence number for resumable reconnection */
919
919
  private lastSeq;
920
+ /**
921
+ * Client-tool call_ids whose result was already submitted this turn. On a
922
+ * reconnect the resumed stream can replay a tool request we already handled;
923
+ * this dedups so each is executed + submitted at most once (but a request we
924
+ * never submitted still runs). Cleared at the start of each turn.
925
+ */
926
+ private submittedToolCallIds;
920
927
  /** Current job ID for cancellation */
921
928
  currentJobId: string | null;
922
929
  private consumeJobStream;
@@ -925,6 +932,16 @@ declare class ChatSession {
925
932
  * minimal session state, and emits typed ChatEvents to consumers.
926
933
  */
927
934
  private consumeEventStream;
935
+ /**
936
+ * Consume a single SSE stream to exhaustion. Returns whether a terminal
937
+ * event (``message_stop`` / ``error``) was seen, so the caller can decide
938
+ * whether an ended stream means "turn done" vs "dropped, reconnect".
939
+ */
940
+ private pumpStream;
941
+ /** Sleep for ``ms``, resolving early if the turn is aborted mid-backoff. */
942
+ private sleepUnlessAborted;
943
+ /** POST a client-tool result, retrying transient failures a few times. */
944
+ private submitToolResultWithRetry;
928
945
  private dispatchWireEvent;
929
946
  /**
930
947
  * State mutations driven by wire events. Kept separate from translation so
package/dist/index.js CHANGED
@@ -1144,6 +1144,11 @@ function translateWireEvent(wire) {
1144
1144
  }
1145
1145
 
1146
1146
  // src/session.ts
1147
+ var SSE_MAX_RECONNECTS = 6;
1148
+ var TOOL_RESULT_MAX_RETRIES = 3;
1149
+ function sseReconnectDelayMs(attempt) {
1150
+ return Math.min(500 * 2 ** (attempt - 1), 5e3);
1151
+ }
1147
1152
  function pathEquals(a, b) {
1148
1153
  if (a.length !== b.length) return false;
1149
1154
  for (let i = 0; i < a.length; i++) {
@@ -1180,6 +1185,13 @@ var ChatSession = class {
1180
1185
  this.abortController = null;
1181
1186
  /** Last received sequence number for resumable reconnection */
1182
1187
  this.lastSeq = -1;
1188
+ /**
1189
+ * Client-tool call_ids whose result was already submitted this turn. On a
1190
+ * reconnect the resumed stream can replay a tool request we already handled;
1191
+ * this dedups so each is executed + submitted at most once (but a request we
1192
+ * never submitted still runs). Cleared at the start of each turn.
1193
+ */
1194
+ this.submittedToolCallIds = /* @__PURE__ */ new Set();
1183
1195
  /** Current job ID for cancellation */
1184
1196
  this.currentJobId = null;
1185
1197
  this.client = new AstralformClient(config);
@@ -1314,13 +1326,9 @@ var ChatSession = class {
1314
1326
  }
1315
1327
  const messageId = job.message_id;
1316
1328
  this.lastSeq = -1;
1317
- const stream = this.client.streamJobEvents(
1318
- job.job_id,
1319
- this.lastSeq,
1320
- this.abortController?.signal
1321
- );
1329
+ this.submittedToolCallIds.clear();
1322
1330
  await this.consumeEventStream(
1323
- stream,
1331
+ job.job_id,
1324
1332
  conversationId,
1325
1333
  messageId,
1326
1334
  true
@@ -1331,7 +1339,41 @@ var ChatSession = class {
1331
1339
  * Shared event consumption loop. Parses each wire event, updates
1332
1340
  * minimal session state, and emits typed ChatEvents to consumers.
1333
1341
  */
1334
- async consumeEventStream(stream, conversationId, messageId, executeClientTools) {
1342
+ async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1343
+ const signal = this.abortController?.signal;
1344
+ for (let attempt = 0; ; attempt++) {
1345
+ const stream = this.client.streamJobEvents(jobId, this.lastSeq, signal);
1346
+ let sawTerminal;
1347
+ try {
1348
+ sawTerminal = await this.pumpStream(
1349
+ stream,
1350
+ conversationId,
1351
+ messageId,
1352
+ executeClientTools
1353
+ );
1354
+ } catch (err) {
1355
+ if (signal?.aborted) return;
1356
+ if (err instanceof AuthenticationError || err instanceof RateLimitError) {
1357
+ throw err;
1358
+ }
1359
+ if (attempt >= SSE_MAX_RECONNECTS) throw err;
1360
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1361
+ continue;
1362
+ }
1363
+ if (sawTerminal || signal?.aborted) return;
1364
+ if (attempt >= SSE_MAX_RECONNECTS) {
1365
+ throw new ConnectionError("Lost connection to the response stream.");
1366
+ }
1367
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1368
+ }
1369
+ }
1370
+ /**
1371
+ * Consume a single SSE stream to exhaustion. Returns whether a terminal
1372
+ * event (``message_stop`` / ``error``) was seen, so the caller can decide
1373
+ * whether an ended stream means "turn done" vs "dropped, reconnect".
1374
+ */
1375
+ async pumpStream(stream, conversationId, messageId, executeClientTools) {
1376
+ let sawTerminal = false;
1335
1377
  for await (const raw of stream) {
1336
1378
  let parsed;
1337
1379
  try {
@@ -1349,6 +1391,9 @@ var ChatSession = class {
1349
1391
  } catch {
1350
1392
  continue;
1351
1393
  }
1394
+ if (parsed.type === "message_stop" || parsed.type === "error") {
1395
+ sawTerminal = true;
1396
+ }
1352
1397
  await this.dispatchWireEvent(
1353
1398
  parsed,
1354
1399
  conversationId,
@@ -1356,6 +1401,39 @@ var ChatSession = class {
1356
1401
  executeClientTools
1357
1402
  );
1358
1403
  }
1404
+ return sawTerminal;
1405
+ }
1406
+ /** Sleep for ``ms``, resolving early if the turn is aborted mid-backoff. */
1407
+ sleepUnlessAborted(ms, signal) {
1408
+ return new Promise((resolve) => {
1409
+ if (signal?.aborted) return resolve();
1410
+ const timer = setTimeout(() => {
1411
+ signal?.removeEventListener("abort", onAbort);
1412
+ resolve();
1413
+ }, ms);
1414
+ const onAbort = () => {
1415
+ clearTimeout(timer);
1416
+ resolve();
1417
+ };
1418
+ signal?.addEventListener("abort", onAbort, { once: true });
1419
+ });
1420
+ }
1421
+ /** POST a client-tool result, retrying transient failures a few times. */
1422
+ async submitToolResultWithRetry(payload) {
1423
+ const signal = this.abortController?.signal;
1424
+ for (let attempt = 0; ; attempt++) {
1425
+ try {
1426
+ await this.client.submitToolResult(payload);
1427
+ return;
1428
+ } catch (err) {
1429
+ if (signal?.aborted) throw err;
1430
+ if (err instanceof AuthenticationError || err instanceof RateLimitError) {
1431
+ throw err;
1432
+ }
1433
+ if (attempt >= TOOL_RESULT_MAX_RETRIES) throw err;
1434
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1435
+ }
1436
+ }
1359
1437
  }
1360
1438
  async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1361
1439
  this.applyWireSideEffects(wire, conversationId, messageId);
@@ -1365,18 +1443,22 @@ var ChatSession = class {
1365
1443
  }
1366
1444
  if (executeClientTools && wire.type === "block_stop" && wire.status === "awaiting_client_result" && wire.final?.call_id) {
1367
1445
  const f = wire.final;
1368
- const request = {
1369
- callId: f.call_id ?? "",
1370
- toolName: f.tool_name ?? "",
1371
- arguments: f.input ?? {},
1372
- isClientTool: true
1373
- };
1374
- const results = await this.executeClientTools([request]);
1375
- await this.client.submitToolResult({
1376
- conversation_id: conversationId,
1377
- message_id: messageId,
1378
- tool_results: results
1379
- });
1446
+ const callId = f.call_id ?? "";
1447
+ if (callId && !this.submittedToolCallIds.has(callId)) {
1448
+ const request = {
1449
+ callId,
1450
+ toolName: f.tool_name ?? "",
1451
+ arguments: f.input ?? {},
1452
+ isClientTool: true
1453
+ };
1454
+ const results = await this.executeClientTools([request]);
1455
+ await this.submitToolResultWithRetry({
1456
+ conversation_id: conversationId,
1457
+ message_id: messageId,
1458
+ tool_results: results
1459
+ });
1460
+ this.submittedToolCallIds.add(callId);
1461
+ }
1380
1462
  }
1381
1463
  }
1382
1464
  /**
@@ -1473,16 +1555,12 @@ var ChatSession = class {
1473
1555
  this.isStreaming = true;
1474
1556
  this.currentJobId = jobId;
1475
1557
  this.lastSeq = -1;
1558
+ this.submittedToolCallIds.clear();
1476
1559
  this.resetStreamingState();
1477
1560
  this.abortController = new AbortController();
1478
1561
  try {
1479
- const stream = this.client.streamJobEvents(
1480
- jobId,
1481
- this.lastSeq,
1482
- this.abortController?.signal
1483
- );
1484
1562
  await this.consumeEventStream(
1485
- stream,
1563
+ jobId,
1486
1564
  this.conversationId ?? "",
1487
1565
  "",
1488
1566
  false