@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.cjs CHANGED
@@ -1190,6 +1190,11 @@ function translateWireEvent(wire) {
1190
1190
  }
1191
1191
 
1192
1192
  // src/session.ts
1193
+ var SSE_MAX_RECONNECTS = 6;
1194
+ var TOOL_RESULT_MAX_RETRIES = 3;
1195
+ function sseReconnectDelayMs(attempt) {
1196
+ return Math.min(500 * 2 ** (attempt - 1), 5e3);
1197
+ }
1193
1198
  function pathEquals(a, b) {
1194
1199
  if (a.length !== b.length) return false;
1195
1200
  for (let i = 0; i < a.length; i++) {
@@ -1226,6 +1231,13 @@ var ChatSession = class {
1226
1231
  this.abortController = null;
1227
1232
  /** Last received sequence number for resumable reconnection */
1228
1233
  this.lastSeq = -1;
1234
+ /**
1235
+ * Client-tool call_ids whose result was already submitted this turn. On a
1236
+ * reconnect the resumed stream can replay a tool request we already handled;
1237
+ * this dedups so each is executed + submitted at most once (but a request we
1238
+ * never submitted still runs). Cleared at the start of each turn.
1239
+ */
1240
+ this.submittedToolCallIds = /* @__PURE__ */ new Set();
1229
1241
  /** Current job ID for cancellation */
1230
1242
  this.currentJobId = null;
1231
1243
  this.client = new AstralformClient(config);
@@ -1360,13 +1372,9 @@ var ChatSession = class {
1360
1372
  }
1361
1373
  const messageId = job.message_id;
1362
1374
  this.lastSeq = -1;
1363
- const stream = this.client.streamJobEvents(
1364
- job.job_id,
1365
- this.lastSeq,
1366
- this.abortController?.signal
1367
- );
1375
+ this.submittedToolCallIds.clear();
1368
1376
  await this.consumeEventStream(
1369
- stream,
1377
+ job.job_id,
1370
1378
  conversationId,
1371
1379
  messageId,
1372
1380
  true
@@ -1377,7 +1385,41 @@ var ChatSession = class {
1377
1385
  * Shared event consumption loop. Parses each wire event, updates
1378
1386
  * minimal session state, and emits typed ChatEvents to consumers.
1379
1387
  */
1380
- async consumeEventStream(stream, conversationId, messageId, executeClientTools) {
1388
+ async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1389
+ const signal = this.abortController?.signal;
1390
+ for (let attempt = 0; ; attempt++) {
1391
+ const stream = this.client.streamJobEvents(jobId, this.lastSeq, signal);
1392
+ let sawTerminal;
1393
+ try {
1394
+ sawTerminal = await this.pumpStream(
1395
+ stream,
1396
+ conversationId,
1397
+ messageId,
1398
+ executeClientTools
1399
+ );
1400
+ } catch (err) {
1401
+ if (signal?.aborted) return;
1402
+ if (err instanceof AuthenticationError || err instanceof RateLimitError) {
1403
+ throw err;
1404
+ }
1405
+ if (attempt >= SSE_MAX_RECONNECTS) throw err;
1406
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1407
+ continue;
1408
+ }
1409
+ if (sawTerminal || signal?.aborted) return;
1410
+ if (attempt >= SSE_MAX_RECONNECTS) {
1411
+ throw new ConnectionError("Lost connection to the response stream.");
1412
+ }
1413
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1414
+ }
1415
+ }
1416
+ /**
1417
+ * Consume a single SSE stream to exhaustion. Returns whether a terminal
1418
+ * event (``message_stop`` / ``error``) was seen, so the caller can decide
1419
+ * whether an ended stream means "turn done" vs "dropped, reconnect".
1420
+ */
1421
+ async pumpStream(stream, conversationId, messageId, executeClientTools) {
1422
+ let sawTerminal = false;
1381
1423
  for await (const raw of stream) {
1382
1424
  let parsed;
1383
1425
  try {
@@ -1395,6 +1437,9 @@ var ChatSession = class {
1395
1437
  } catch {
1396
1438
  continue;
1397
1439
  }
1440
+ if (parsed.type === "message_stop" || parsed.type === "error") {
1441
+ sawTerminal = true;
1442
+ }
1398
1443
  await this.dispatchWireEvent(
1399
1444
  parsed,
1400
1445
  conversationId,
@@ -1402,6 +1447,39 @@ var ChatSession = class {
1402
1447
  executeClientTools
1403
1448
  );
1404
1449
  }
1450
+ return sawTerminal;
1451
+ }
1452
+ /** Sleep for ``ms``, resolving early if the turn is aborted mid-backoff. */
1453
+ sleepUnlessAborted(ms, signal) {
1454
+ return new Promise((resolve) => {
1455
+ if (signal?.aborted) return resolve();
1456
+ const timer = setTimeout(() => {
1457
+ signal?.removeEventListener("abort", onAbort);
1458
+ resolve();
1459
+ }, ms);
1460
+ const onAbort = () => {
1461
+ clearTimeout(timer);
1462
+ resolve();
1463
+ };
1464
+ signal?.addEventListener("abort", onAbort, { once: true });
1465
+ });
1466
+ }
1467
+ /** POST a client-tool result, retrying transient failures a few times. */
1468
+ async submitToolResultWithRetry(payload) {
1469
+ const signal = this.abortController?.signal;
1470
+ for (let attempt = 0; ; attempt++) {
1471
+ try {
1472
+ await this.client.submitToolResult(payload);
1473
+ return;
1474
+ } catch (err) {
1475
+ if (signal?.aborted) throw err;
1476
+ if (err instanceof AuthenticationError || err instanceof RateLimitError) {
1477
+ throw err;
1478
+ }
1479
+ if (attempt >= TOOL_RESULT_MAX_RETRIES) throw err;
1480
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1481
+ }
1482
+ }
1405
1483
  }
1406
1484
  async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1407
1485
  this.applyWireSideEffects(wire, conversationId, messageId);
@@ -1411,18 +1489,22 @@ var ChatSession = class {
1411
1489
  }
1412
1490
  if (executeClientTools && wire.type === "block_stop" && wire.status === "awaiting_client_result" && wire.final?.call_id) {
1413
1491
  const f = wire.final;
1414
- const request = {
1415
- callId: f.call_id ?? "",
1416
- toolName: f.tool_name ?? "",
1417
- arguments: f.input ?? {},
1418
- isClientTool: true
1419
- };
1420
- const results = await this.executeClientTools([request]);
1421
- await this.client.submitToolResult({
1422
- conversation_id: conversationId,
1423
- message_id: messageId,
1424
- tool_results: results
1425
- });
1492
+ const callId = f.call_id ?? "";
1493
+ if (callId && !this.submittedToolCallIds.has(callId)) {
1494
+ const request = {
1495
+ callId,
1496
+ toolName: f.tool_name ?? "",
1497
+ arguments: f.input ?? {},
1498
+ isClientTool: true
1499
+ };
1500
+ const results = await this.executeClientTools([request]);
1501
+ await this.submitToolResultWithRetry({
1502
+ conversation_id: conversationId,
1503
+ message_id: messageId,
1504
+ tool_results: results
1505
+ });
1506
+ this.submittedToolCallIds.add(callId);
1507
+ }
1426
1508
  }
1427
1509
  }
1428
1510
  /**
@@ -1519,16 +1601,12 @@ var ChatSession = class {
1519
1601
  this.isStreaming = true;
1520
1602
  this.currentJobId = jobId;
1521
1603
  this.lastSeq = -1;
1604
+ this.submittedToolCallIds.clear();
1522
1605
  this.resetStreamingState();
1523
1606
  this.abortController = new AbortController();
1524
1607
  try {
1525
- const stream = this.client.streamJobEvents(
1526
- jobId,
1527
- this.lastSeq,
1528
- this.abortController?.signal
1529
- );
1530
1608
  await this.consumeEventStream(
1531
- stream,
1609
+ jobId,
1532
1610
  this.conversationId ?? "",
1533
1611
  "",
1534
1612
  false