@astralform/js 1.0.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
@@ -554,6 +554,49 @@ var AstralformClient = class {
554
554
  async submitToolApproval(request) {
555
555
  await this.post("/v1/tool-approval", request);
556
556
  }
557
+ // --- End-user tool-permission self-service ---
558
+ /**
559
+ * List the current end user's own remembered tool-permission grants.
560
+ * Only `conversation`/`always` grants exist (`once` is never persisted).
561
+ * Paginated via `limit` (default 100, max 200) / `offset`; `total` lets you
562
+ * page through all of them.
563
+ */
564
+ async getMyToolPermissions(options) {
565
+ const params = new URLSearchParams();
566
+ if (options?.limit != null) {
567
+ const safeLimit = Math.max(
568
+ 1,
569
+ Math.min(200, Math.floor(Number(options.limit)))
570
+ );
571
+ params.set("limit", String(safeLimit));
572
+ }
573
+ if (options?.offset != null) {
574
+ const safeOffset = Math.max(0, Math.floor(Number(options.offset)));
575
+ params.set("offset", String(safeOffset));
576
+ }
577
+ const qs = params.toString();
578
+ const raw = await this.get(`/v1/me/tool-permissions${qs ? `?${qs}` : ""}`);
579
+ return {
580
+ grants: raw.grants.map((g) => ({
581
+ id: g.id,
582
+ toolName: g.tool_name,
583
+ decision: g.decision,
584
+ scope: g.scope,
585
+ conversationId: g.conversation_id,
586
+ createdAt: g.created_at
587
+ })),
588
+ total: raw.total,
589
+ limit: raw.limit,
590
+ offset: raw.offset
591
+ };
592
+ }
593
+ /**
594
+ * Revoke one of the current end user's remembered grants by id. The agent
595
+ * will ask again the next time that tool is used.
596
+ */
597
+ async revokeToolPermission(id) {
598
+ await this.del(`/v1/me/tool-permissions/${encodeURIComponent(id)}`);
599
+ }
557
600
  // --- Conversation Assets ---
558
601
  mapAsset(raw) {
559
602
  return {
@@ -1147,6 +1190,11 @@ function translateWireEvent(wire) {
1147
1190
  }
1148
1191
 
1149
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
+ }
1150
1198
  function pathEquals(a, b) {
1151
1199
  if (a.length !== b.length) return false;
1152
1200
  for (let i = 0; i < a.length; i++) {
@@ -1183,6 +1231,13 @@ var ChatSession = class {
1183
1231
  this.abortController = null;
1184
1232
  /** Last received sequence number for resumable reconnection */
1185
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();
1186
1241
  /** Current job ID for cancellation */
1187
1242
  this.currentJobId = null;
1188
1243
  this.client = new AstralformClient(config);
@@ -1317,13 +1372,9 @@ var ChatSession = class {
1317
1372
  }
1318
1373
  const messageId = job.message_id;
1319
1374
  this.lastSeq = -1;
1320
- const stream = this.client.streamJobEvents(
1321
- job.job_id,
1322
- this.lastSeq,
1323
- this.abortController?.signal
1324
- );
1375
+ this.submittedToolCallIds.clear();
1325
1376
  await this.consumeEventStream(
1326
- stream,
1377
+ job.job_id,
1327
1378
  conversationId,
1328
1379
  messageId,
1329
1380
  true
@@ -1334,7 +1385,41 @@ var ChatSession = class {
1334
1385
  * Shared event consumption loop. Parses each wire event, updates
1335
1386
  * minimal session state, and emits typed ChatEvents to consumers.
1336
1387
  */
1337
- 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;
1338
1423
  for await (const raw of stream) {
1339
1424
  let parsed;
1340
1425
  try {
@@ -1352,6 +1437,9 @@ var ChatSession = class {
1352
1437
  } catch {
1353
1438
  continue;
1354
1439
  }
1440
+ if (parsed.type === "message_stop" || parsed.type === "error") {
1441
+ sawTerminal = true;
1442
+ }
1355
1443
  await this.dispatchWireEvent(
1356
1444
  parsed,
1357
1445
  conversationId,
@@ -1359,6 +1447,39 @@ var ChatSession = class {
1359
1447
  executeClientTools
1360
1448
  );
1361
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
+ }
1362
1483
  }
1363
1484
  async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1364
1485
  this.applyWireSideEffects(wire, conversationId, messageId);
@@ -1368,18 +1489,22 @@ var ChatSession = class {
1368
1489
  }
1369
1490
  if (executeClientTools && wire.type === "block_stop" && wire.status === "awaiting_client_result" && wire.final?.call_id) {
1370
1491
  const f = wire.final;
1371
- const request = {
1372
- callId: f.call_id ?? "",
1373
- toolName: f.tool_name ?? "",
1374
- arguments: f.input ?? {},
1375
- isClientTool: true
1376
- };
1377
- const results = await this.executeClientTools([request]);
1378
- await this.client.submitToolResult({
1379
- conversation_id: conversationId,
1380
- message_id: messageId,
1381
- tool_results: results
1382
- });
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
+ }
1383
1508
  }
1384
1509
  }
1385
1510
  /**
@@ -1476,16 +1601,12 @@ var ChatSession = class {
1476
1601
  this.isStreaming = true;
1477
1602
  this.currentJobId = jobId;
1478
1603
  this.lastSeq = -1;
1604
+ this.submittedToolCallIds.clear();
1479
1605
  this.resetStreamingState();
1480
1606
  this.abortController = new AbortController();
1481
1607
  try {
1482
- const stream = this.client.streamJobEvents(
1483
- jobId,
1484
- this.lastSeq,
1485
- this.abortController?.signal
1486
- );
1487
1608
  await this.consumeEventStream(
1488
- stream,
1609
+ jobId,
1489
1610
  this.conversationId ?? "",
1490
1611
  "",
1491
1612
  false