@astralform/js 1.1.0 → 2.0.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.js CHANGED
@@ -20,7 +20,7 @@ var RateLimitError = class extends AstralformError {
20
20
  }
21
21
  };
22
22
  var LLMNotConfiguredError = class extends AstralformError {
23
- constructor(message = "LLM provider not configured for this project") {
23
+ constructor(message = "LLM provider not configured for this agent") {
24
24
  super(message, "llm_not_configured");
25
25
  this.name = "LLMNotConfiguredError";
26
26
  }
@@ -292,11 +292,11 @@ var AstralformClient = class {
292
292
  "accessToken is required and must be a non-empty string in user-token mode"
293
293
  );
294
294
  }
295
- const projectId = typeof config.projectId === "string" && config.projectId.length > 0 ? config.projectId : null;
295
+ const agentId = typeof config.agentId === "string" && config.agentId.length > 0 ? config.agentId : null;
296
296
  this.auth = {
297
297
  kind: "user_token",
298
298
  accessToken: config.accessToken,
299
- projectId,
299
+ agentId,
300
300
  endUserId: typeof config.endUserId === "string" && config.endUserId.length > 0 ? config.endUserId : null
301
301
  };
302
302
  }
@@ -318,17 +318,17 @@ var AstralformClient = class {
318
318
  this.auth = { ...this.auth, accessToken };
319
319
  }
320
320
  /**
321
- * Swap the active project for a user-token client. The backend verifies the
322
- * current developer has access to the new project; a 403 comes back if not.
321
+ * Swap the active agent for a user-token client. The backend verifies the
322
+ * current developer has access to the new agent; a 403 comes back if not.
323
323
  */
324
- updateProjectId(projectId) {
324
+ updateAgentId(agentId) {
325
325
  if (this.auth.kind !== "user_token") {
326
- throw new Error("updateProjectId is only valid in user-token mode");
326
+ throw new Error("updateAgentId is only valid in user-token mode");
327
327
  }
328
- if (!projectId || typeof projectId !== "string") {
329
- throw new Error("projectId must be a non-empty string");
328
+ if (!agentId || typeof agentId !== "string") {
329
+ throw new Error("agentId must be a non-empty string");
330
330
  }
331
- this.auth = { ...this.auth, projectId };
331
+ this.auth = { ...this.auth, agentId };
332
332
  }
333
333
  /**
334
334
  * Set (or clear) the end-user override for user-token mode.
@@ -350,13 +350,13 @@ var AstralformClient = class {
350
350
  return this.auth.kind === "user_token" ? this.auth.endUserId : null;
351
351
  }
352
352
  /**
353
- * Active project for user-token mode, or `null` if pre-pick (client
354
- * was constructed without one). For API-key mode the project is baked
353
+ * Active agent for user-token mode, or `null` if pre-pick (client
354
+ * was constructed without one). For API-key mode the agent is baked
355
355
  * into the key, so this getter returns `null` there too — use
356
356
  * `authMode` to disambiguate.
357
357
  */
358
- get projectId() {
359
- return this.auth.kind === "user_token" ? this.auth.projectId : null;
358
+ get agentId() {
359
+ return this.auth.kind === "user_token" ? this.auth.agentId : null;
360
360
  }
361
361
  /** Which auth mode this client was constructed with. */
362
362
  get authMode() {
@@ -378,8 +378,8 @@ var AstralformClient = class {
378
378
  const headers = {
379
379
  Authorization: `Bearer ${this.auth.accessToken}`
380
380
  };
381
- if (this.auth.projectId) {
382
- headers["X-Project-ID"] = this.auth.projectId;
381
+ if (this.auth.agentId) {
382
+ headers["X-Project-ID"] = this.auth.agentId;
383
383
  }
384
384
  if (this.auth.endUserId) {
385
385
  headers["X-End-User-ID"] = this.auth.endUserId;
@@ -434,7 +434,9 @@ var AstralformClient = class {
434
434
  async getHealth() {
435
435
  return this.get("/v1/health");
436
436
  }
437
- async getProjectStatus() {
437
+ // Agent readiness check. The path is a legacy wire name (shared with the
438
+ // iOS SDK) — it scopes to the client's active agent via X-Project-ID.
439
+ async getAgentStatus() {
438
440
  const raw = await this.get("/v1/project/status");
439
441
  const ui = raw.ui_components ?? {};
440
442
  return {
@@ -477,6 +479,12 @@ var AstralformClient = class {
477
479
  async deleteConversation(id) {
478
480
  await this.del(`/v1/conversations/${encodeURIComponent(id)}`);
479
481
  }
482
+ /**
483
+ * List the AI personas (sub-agents) available INSIDE the client's active
484
+ * agent workspace — orchestrator + specialists, addressed per message via
485
+ * `ChatStreamRequest.agent_name`. Not to be confused with `listAgents()`,
486
+ * which enumerates the team-level agents a signed-in user can open.
487
+ */
480
488
  async getAgents() {
481
489
  const raw = await this.get("/v1/agents");
482
490
  return raw.map((a) => ({
@@ -598,7 +606,7 @@ var AstralformClient = class {
598
606
  }
599
607
  // --- Account-scoped discovery (user-token mode) ---
600
608
  //
601
- // Lets a signed-in user pick which team/project they want to act on.
609
+ // Lets a signed-in user pick which team/agent they want to act on.
602
610
  // Backend gates these on OIDC user context (no X-Project-ID required) —
603
611
  // sending them in API-key mode yields 401.
604
612
  async listTeams() {
@@ -611,14 +619,19 @@ var AstralformClient = class {
611
619
  role: t.role
612
620
  }));
613
621
  }
614
- async listProjects(teamId) {
615
- const raw = await this.get(`/v1/teams/${encodeURIComponent(teamId)}/projects`);
616
- return raw.map((p) => ({
617
- id: p.id,
618
- name: p.name,
619
- teamId: p.team_id,
620
- createdAt: p.created_at,
621
- updatedAt: p.updated_at
622
+ /**
623
+ * List the team-level agents (formerly "projects") the signed-in user can
624
+ * open — the pickable workspaces under a team. Not to be confused with
625
+ * `getAgents()`, which lists the AI personas inside the active agent.
626
+ */
627
+ async listAgents(teamId) {
628
+ const raw = await this.get(`/v1/teams/${encodeURIComponent(teamId)}/agents`);
629
+ return raw.map((a) => ({
630
+ id: a.id,
631
+ name: a.name,
632
+ teamId: a.team_id,
633
+ createdAt: a.created_at,
634
+ updatedAt: a.updated_at
622
635
  }));
623
636
  }
624
637
  // --- Jobs API ---
@@ -1144,6 +1157,11 @@ function translateWireEvent(wire) {
1144
1157
  }
1145
1158
 
1146
1159
  // src/session.ts
1160
+ var SSE_MAX_RECONNECTS = 6;
1161
+ var TOOL_RESULT_MAX_RETRIES = 3;
1162
+ function sseReconnectDelayMs(attempt) {
1163
+ return Math.min(500 * 2 ** (attempt - 1), 5e3);
1164
+ }
1147
1165
  function pathEquals(a, b) {
1148
1166
  if (a.length !== b.length) return false;
1149
1167
  for (let i = 0; i < a.length; i++) {
@@ -1156,7 +1174,7 @@ var ChatSession = class {
1156
1174
  /**
1157
1175
  * Pluggable UI protocol adapters. Consumers register a framework-
1158
1176
  * specific adapter (e.g. React) for each MIME type they can render,
1159
- * typically gated on ``session.projectStatus.uiComponents.protocol``.
1177
+ * typically gated on ``session.agentStatus.uiComponents.protocol``.
1160
1178
  * ``ToolBlock``-style consumers look up the adapter for an incoming
1161
1179
  * embedded resource and hand off rendering.
1162
1180
  */
@@ -1166,7 +1184,7 @@ var ChatSession = class {
1166
1184
  this.conversations = [];
1167
1185
  this.messages = [];
1168
1186
  this.isStreaming = false;
1169
- this.projectStatus = null;
1187
+ this.agentStatus = null;
1170
1188
  this.agents = [];
1171
1189
  this.skills = [];
1172
1190
  this.enabledClientTools = /* @__PURE__ */ new Set();
@@ -1180,6 +1198,13 @@ var ChatSession = class {
1180
1198
  this.abortController = null;
1181
1199
  /** Last received sequence number for resumable reconnection */
1182
1200
  this.lastSeq = -1;
1201
+ /**
1202
+ * Client-tool call_ids whose result was already submitted this turn. On a
1203
+ * reconnect the resumed stream can replay a tool request we already handled;
1204
+ * this dedups so each is executed + submitted at most once (but a request we
1205
+ * never submitted still runs). Cleared at the start of each turn.
1206
+ */
1207
+ this.submittedToolCallIds = /* @__PURE__ */ new Set();
1183
1208
  /** Current job ID for cancellation */
1184
1209
  this.currentJobId = null;
1185
1210
  this.client = new AstralformClient(config);
@@ -1202,13 +1227,13 @@ var ChatSession = class {
1202
1227
  }
1203
1228
  async connect() {
1204
1229
  const [status, conversations, agents, skills] = await Promise.allSettled([
1205
- this.client.getProjectStatus(),
1230
+ this.client.getAgentStatus(),
1206
1231
  this.client.getConversations(),
1207
1232
  this.client.getAgents().catch(() => []),
1208
1233
  this.client.getSkills().catch(() => [])
1209
1234
  ]);
1210
1235
  if (status.status === "fulfilled") {
1211
- this.projectStatus = status.value;
1236
+ this.agentStatus = status.value;
1212
1237
  }
1213
1238
  if (conversations.status === "fulfilled") {
1214
1239
  this.conversations = conversations.value;
@@ -1314,13 +1339,9 @@ var ChatSession = class {
1314
1339
  }
1315
1340
  const messageId = job.message_id;
1316
1341
  this.lastSeq = -1;
1317
- const stream = this.client.streamJobEvents(
1318
- job.job_id,
1319
- this.lastSeq,
1320
- this.abortController?.signal
1321
- );
1342
+ this.submittedToolCallIds.clear();
1322
1343
  await this.consumeEventStream(
1323
- stream,
1344
+ job.job_id,
1324
1345
  conversationId,
1325
1346
  messageId,
1326
1347
  true
@@ -1331,7 +1352,41 @@ var ChatSession = class {
1331
1352
  * Shared event consumption loop. Parses each wire event, updates
1332
1353
  * minimal session state, and emits typed ChatEvents to consumers.
1333
1354
  */
1334
- async consumeEventStream(stream, conversationId, messageId, executeClientTools) {
1355
+ async consumeEventStream(jobId, conversationId, messageId, executeClientTools) {
1356
+ const signal = this.abortController?.signal;
1357
+ for (let attempt = 0; ; attempt++) {
1358
+ const stream = this.client.streamJobEvents(jobId, this.lastSeq, signal);
1359
+ let sawTerminal;
1360
+ try {
1361
+ sawTerminal = await this.pumpStream(
1362
+ stream,
1363
+ conversationId,
1364
+ messageId,
1365
+ executeClientTools
1366
+ );
1367
+ } catch (err) {
1368
+ if (signal?.aborted) return;
1369
+ if (err instanceof AuthenticationError || err instanceof RateLimitError) {
1370
+ throw err;
1371
+ }
1372
+ if (attempt >= SSE_MAX_RECONNECTS) throw err;
1373
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1374
+ continue;
1375
+ }
1376
+ if (sawTerminal || signal?.aborted) return;
1377
+ if (attempt >= SSE_MAX_RECONNECTS) {
1378
+ throw new ConnectionError("Lost connection to the response stream.");
1379
+ }
1380
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1381
+ }
1382
+ }
1383
+ /**
1384
+ * Consume a single SSE stream to exhaustion. Returns whether a terminal
1385
+ * event (``message_stop`` / ``error``) was seen, so the caller can decide
1386
+ * whether an ended stream means "turn done" vs "dropped, reconnect".
1387
+ */
1388
+ async pumpStream(stream, conversationId, messageId, executeClientTools) {
1389
+ let sawTerminal = false;
1335
1390
  for await (const raw of stream) {
1336
1391
  let parsed;
1337
1392
  try {
@@ -1349,6 +1404,9 @@ var ChatSession = class {
1349
1404
  } catch {
1350
1405
  continue;
1351
1406
  }
1407
+ if (parsed.type === "message_stop" || parsed.type === "error") {
1408
+ sawTerminal = true;
1409
+ }
1352
1410
  await this.dispatchWireEvent(
1353
1411
  parsed,
1354
1412
  conversationId,
@@ -1356,6 +1414,39 @@ var ChatSession = class {
1356
1414
  executeClientTools
1357
1415
  );
1358
1416
  }
1417
+ return sawTerminal;
1418
+ }
1419
+ /** Sleep for ``ms``, resolving early if the turn is aborted mid-backoff. */
1420
+ sleepUnlessAborted(ms, signal) {
1421
+ return new Promise((resolve) => {
1422
+ if (signal?.aborted) return resolve();
1423
+ const timer = setTimeout(() => {
1424
+ signal?.removeEventListener("abort", onAbort);
1425
+ resolve();
1426
+ }, ms);
1427
+ const onAbort = () => {
1428
+ clearTimeout(timer);
1429
+ resolve();
1430
+ };
1431
+ signal?.addEventListener("abort", onAbort, { once: true });
1432
+ });
1433
+ }
1434
+ /** POST a client-tool result, retrying transient failures a few times. */
1435
+ async submitToolResultWithRetry(payload) {
1436
+ const signal = this.abortController?.signal;
1437
+ for (let attempt = 0; ; attempt++) {
1438
+ try {
1439
+ await this.client.submitToolResult(payload);
1440
+ return;
1441
+ } catch (err) {
1442
+ if (signal?.aborted) throw err;
1443
+ if (err instanceof AuthenticationError || err instanceof RateLimitError) {
1444
+ throw err;
1445
+ }
1446
+ if (attempt >= TOOL_RESULT_MAX_RETRIES) throw err;
1447
+ await this.sleepUnlessAborted(sseReconnectDelayMs(attempt + 1), signal);
1448
+ }
1449
+ }
1359
1450
  }
1360
1451
  async dispatchWireEvent(wire, conversationId, messageId, executeClientTools) {
1361
1452
  this.applyWireSideEffects(wire, conversationId, messageId);
@@ -1365,18 +1456,22 @@ var ChatSession = class {
1365
1456
  }
1366
1457
  if (executeClientTools && wire.type === "block_stop" && wire.status === "awaiting_client_result" && wire.final?.call_id) {
1367
1458
  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
- });
1459
+ const callId = f.call_id ?? "";
1460
+ if (callId && !this.submittedToolCallIds.has(callId)) {
1461
+ const request = {
1462
+ callId,
1463
+ toolName: f.tool_name ?? "",
1464
+ arguments: f.input ?? {},
1465
+ isClientTool: true
1466
+ };
1467
+ const results = await this.executeClientTools([request]);
1468
+ await this.submitToolResultWithRetry({
1469
+ conversation_id: conversationId,
1470
+ message_id: messageId,
1471
+ tool_results: results
1472
+ });
1473
+ this.submittedToolCallIds.add(callId);
1474
+ }
1380
1475
  }
1381
1476
  }
1382
1477
  /**
@@ -1473,16 +1568,12 @@ var ChatSession = class {
1473
1568
  this.isStreaming = true;
1474
1569
  this.currentJobId = jobId;
1475
1570
  this.lastSeq = -1;
1571
+ this.submittedToolCallIds.clear();
1476
1572
  this.resetStreamingState();
1477
1573
  this.abortController = new AbortController();
1478
1574
  try {
1479
- const stream = this.client.streamJobEvents(
1480
- jobId,
1481
- this.lastSeq,
1482
- this.abortController?.signal
1483
- );
1484
1575
  await this.consumeEventStream(
1485
- stream,
1576
+ jobId,
1486
1577
  this.conversationId ?? "",
1487
1578
  "",
1488
1579
  false