@cubos/agent-sdk 0.0.1142885 → 0.0.1143127

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/README.md CHANGED
@@ -439,13 +439,28 @@ Transient stream failures do not end a subscription: the SDK reconnects with
439
439
  exponential backoff and reports them through `onError`, for logging or a
440
440
  "reconnecting" hint.
441
441
 
442
- Two failures that look like silence get handled rather than reported. A **401**
443
- buys one immediate reconnect with a forced token refresh — a short-lived token
444
- expiring under a stream that outlives it is the expected case, not a
445
- misconfiguration — and only a second rejection is final. A connection that stops
446
- sending **anything**, keep-alive comments included, is dropped and reopened after
447
- `idleTimeoutMs` (30s, twice the server's keep-alive interval): a half-open socket
448
- otherwise leaves the reader waiting forever with no error to react to.
442
+ Three failures that look like silence get handled rather than reported. A
443
+ **401** buys one immediate reconnect with a forced token refresh — a short-lived
444
+ token expiring under a stream that outlives it is the expected case, not a
445
+ misconfiguration — and only a second rejection is final. A **429** the per-user
446
+ cap on open streams, which one tab too many reaches — waits out the server's
447
+ `Retry-After` and reconnects, since the cap clears the moment any tab closes. A
448
+ connection that stops sending **anything**, keep-alive comments included, is
449
+ dropped and reopened after `idleTimeoutMs` (30s, twice the server's keep-alive
450
+ interval): a half-open socket otherwise leaves the reader waiting forever with
451
+ no error to react to.
452
+
453
+ And a stream has to **prove itself before it is trusted**. The server sends a
454
+ frame on every connect, so until a connection has delivered one — and again
455
+ whenever one ends — `subscribe` polls the same log every 3s through the `after`
456
+ cursor, and the conversation row with it. On a healthy network the frame lands
457
+ inside the first poll's 2s grace and no poll is ever made. On a network whose
458
+ proxy holds a `text/event-stream` response until it ends (corporate TLS
459
+ inspection, some antivirus — Chrome negotiating HTTP/1.1 with a host that speaks
460
+ h2 is the tell), every connection sits silent until something cuts it, and the
461
+ reply used to show up only after a page reload; now it shows up within 3s. The
462
+ same rule covers the gap between a drop and the reconnect that follows. One
463
+ cursor serves both paths, so nothing is delivered twice.
449
464
 
450
465
  What remains final — a 4xx that survived the refresh, a deleted conversation —
451
466
  closes the subscription for good and fires **`onFatal`** (`onError` sees it too).
package/dist/client.d.ts CHANGED
@@ -249,9 +249,10 @@ export declare class AgentClient {
249
249
  * the page before this one, and `latestChangeSeq` to start a subscription
250
250
  * from here instead of replaying everything.
251
251
  *
252
- * `hasOlder` is false only when the server returned fewer events than asked
253
- * for the one honest signal that the log is exhausted, since a page can
254
- * hold events that are not messages.
252
+ * `hasOlder` says whether any event precedes this page, which is not the
253
+ * same as whether the page was full: a page can hold events that are not
254
+ * messages, and a page shorter than asked for is what the server's own cap
255
+ * on `limit` returns for a large request.
255
256
  */
256
257
  /**
257
258
  * One page of the conversation's **event log**, oldest-first — the same rows
@@ -423,9 +424,23 @@ export declare class AgentClient {
423
424
  * to skip that backfill and receive only what is new; that is what makes
424
425
  * paging backwards meaningful, since otherwise the stream re-delivers the
425
426
  * history you just paged through.
427
+ *
428
+ * **A stream has to prove itself before it is trusted.** Until a connection
429
+ * delivers its first frame — and again whenever one ends — the same log is
430
+ * polled every `STREAM_POLL_INTERVAL_MS` with the `after` cursor, and the
431
+ * conversation row with it. On a healthy network the server's first frame
432
+ * arrives well inside the first poll's grace and no poll is ever made. On a
433
+ * network whose proxy holds a `text/event-stream` response until it ends —
434
+ * which it never does — every connection sits silent until something cuts
435
+ * it, and without this the reply only ever showed up after a page reload.
436
+ * The same rule covers the gap between a drop and the reconnect that follows.
426
437
  */
427
438
  subscribe(id: string, handlers: ConversationHandlers, opts?: {
428
439
  since?: number;
440
+ /** Overrides `STREAM_POLL_GRACE_MS` for this subscription. */
441
+ pollGraceMs?: number;
442
+ /** Overrides `STREAM_POLL_INTERVAL_MS` for this subscription. */
443
+ pollIntervalMs?: number;
429
444
  }): ConversationSubscription;
430
445
  /** Live chat list. Fires per conversation whose activity advances; upsert by
431
446
  * id and re-sort by `lastActivityAt` locally. */
package/dist/errors.d.ts CHANGED
@@ -50,3 +50,10 @@ export declare class AgentNetworkError extends AgentError {
50
50
  constructor(message: string, cause: unknown, timedOut?: boolean);
51
51
  }
52
52
  export declare function raiseForStatus(res: Response, fallback: string): Promise<void>;
53
+ /**
54
+ * Milliseconds from a `Retry-After`, which is either a delta in seconds or an
55
+ * HTTP date. Returns null when absent or unusable, so the caller can decide not
56
+ * to retry rather than guess an interval. Shared by the request retry and the
57
+ * stream reconnect, which honour the same header from the same limiter.
58
+ */
59
+ export declare function parseRetryAfter(header: string | null): number | null;
@@ -4995,10 +4995,14 @@ export interface paths {
4995
4995
  /**
4996
4996
  * List conversation events
4997
4997
  * @description Returns one page of the conversation's append-only event log in `seq` ascending order. Discarded (rolled-back) events are filtered out; use `events/stream` for the live delta view that surfaces them.
4998
+ *
4999
+ * With `after`, the page is instead the delta the stream would have sent: every row whose `change_seq` is past the cursor, in `change_seq` order, discarded and tentative rows included so a client can apply them exactly as it applies stream frames. This is how a client behind a proxy that buffers `text/event-stream` keeps up — poll it with the newest `change_seq` held and merge, the same loop the stream runs on its own reconnect. `before` and `after` cannot be combined (400).
4998
5000
  */
4999
5001
  get: {
5000
5002
  parameters: {
5001
5003
  query?: {
5004
+ /** @description Return events with `change_seq > after`, oldest change first — the stream's delta, as a page. Excludes `before`. */
5005
+ after?: number;
5002
5006
  /** @description Return events with `seq < before`; omit for the most recent page. */
5003
5007
  before?: number;
5004
5008
  /** @description Page size; clamped to 1..=200, default 50. */
@@ -5023,6 +5027,12 @@ export interface paths {
5023
5027
  "application/json": components["schemas"]["ConversationEvent"][];
5024
5028
  };
5025
5029
  };
5030
+ 400: {
5031
+ headers: {
5032
+ [name: string]: unknown;
5033
+ };
5034
+ content?: never;
5035
+ };
5026
5036
  401: {
5027
5037
  headers: {
5028
5038
  [name: string]: unknown;
package/dist/index.js CHANGED
@@ -76,6 +76,17 @@ async function raiseForStatus(res, fallback) {
76
76
  const base = DEFAULT_MESSAGES[res.status] ?? fallback;
77
77
  throw new AgentApiError(detail ? `${base} (${detail.slice(0, 500)})` : base, res.status, res.headers.get("x-request-id"), detail);
78
78
  }
79
+ function parseRetryAfter(header) {
80
+ if (!header)
81
+ return null;
82
+ const seconds = Number(header.trim());
83
+ if (Number.isFinite(seconds))
84
+ return Math.max(0, seconds * 1000);
85
+ const date = Date.parse(header);
86
+ if (Number.isNaN(date))
87
+ return null;
88
+ return Math.max(0, date - Date.now());
89
+ }
79
90
 
80
91
  // src/sse.ts
81
92
  var MAX_BACKOFF_MS = 30000;
@@ -98,11 +109,12 @@ class SseIdleTimeout extends Error {
98
109
  async function readSse(opts) {
99
110
  const doFetch = opts.fetchImpl ?? globalThis.fetch;
100
111
  const idleMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
101
- let lastEventId = opts.lastEventId;
112
+ const cursor = opts.cursor ?? { lastEventId: opts.lastEventId };
102
113
  let attempt = 0;
103
114
  let refreshing = false;
104
115
  while (!opts.signal.aborted) {
105
116
  let madeProgress = false;
117
+ let retryAfterMs = null;
106
118
  const connection = new AbortController;
107
119
  const unlink = forward(opts.signal, connection);
108
120
  let idleTimer;
@@ -121,8 +133,8 @@ async function readSse(opts) {
121
133
  ...await opts.headers?.({ forceRefresh: refreshing }),
122
134
  Accept: "text/event-stream"
123
135
  };
124
- if (lastEventId !== undefined)
125
- headers["Last-Event-ID"] = lastEventId;
136
+ if (cursor.lastEventId !== undefined)
137
+ headers["Last-Event-ID"] = cursor.lastEventId;
126
138
  armIdle();
127
139
  const res = await doFetch(opts.url, { headers, signal: connection.signal });
128
140
  if (opts.signal.aborted)
@@ -136,6 +148,10 @@ async function readSse(opts) {
136
148
  refreshing = true;
137
149
  continue;
138
150
  }
151
+ if (res.status === 429) {
152
+ retryAfterMs = Math.min(MAX_BACKOFF_MS, parseRetryAfter(res.headers.get("retry-after")) ?? MAX_BACKOFF_MS);
153
+ await raiseForStatus(res, `Could not open ${opts.url}.`);
154
+ }
139
155
  if (res.status >= 400 && res.status < 500) {
140
156
  await raiseForStatus(res, `Could not open ${opts.url}.`).catch((err) => {
141
157
  throw new Fatal(err);
@@ -149,7 +165,7 @@ async function readSse(opts) {
149
165
  if (parsed === null)
150
166
  continue;
151
167
  if (parsed.id !== null)
152
- lastEventId = parsed.id;
168
+ cursor.lastEventId = parsed.id;
153
169
  opts.onEvent(parsed.data, parsed.event);
154
170
  madeProgress = true;
155
171
  }
@@ -165,12 +181,13 @@ async function readSse(opts) {
165
181
  clearTimeout(idleTimer);
166
182
  unlink();
167
183
  connection.abort();
184
+ opts.onClose?.();
168
185
  }
169
186
  if (opts.signal.aborted)
170
187
  return;
171
188
  if (madeProgress)
172
189
  attempt = 0;
173
- const delayMs = Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt);
190
+ const delayMs = retryAfterMs ?? Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt);
174
191
  attempt += 1;
175
192
  await sleep(delayMs, opts.signal);
176
193
  }
@@ -250,17 +267,6 @@ function forward(outer, child) {
250
267
  var DEFAULT_TIMEOUT_MS = 30000;
251
268
  var DEFAULT_MAX_RETRIES = 2;
252
269
  var MAX_RETRY_WAIT_MS = 20000;
253
- function parseRetryAfter(header) {
254
- if (!header)
255
- return null;
256
- const seconds = Number(header.trim());
257
- if (Number.isFinite(seconds))
258
- return Math.max(0, seconds * 1000);
259
- const date = Date.parse(header);
260
- if (Number.isNaN(date))
261
- return null;
262
- return Math.max(0, date - Date.now());
263
- }
264
270
  function sleep2(ms, signal) {
265
271
  return new Promise((resolve, reject) => {
266
272
  if (signal?.aborted) {
@@ -1406,6 +1412,12 @@ function fold(a, b) {
1406
1412
 
1407
1413
  // src/client.ts
1408
1414
  var MAX_IMAGES_PER_MESSAGE = 10;
1415
+ function hasEventsBefore(oldestSeq) {
1416
+ return oldestSeq !== null && oldestSeq > 1;
1417
+ }
1418
+ var EVENTS_MAX_LIMIT = 200;
1419
+ var STREAM_POLL_GRACE_MS = 2000;
1420
+ var STREAM_POLL_INTERVAL_MS = 3000;
1409
1421
 
1410
1422
  class AgentClient {
1411
1423
  #transport;
@@ -1483,7 +1495,7 @@ class AgentClient {
1483
1495
  const page = await this.#messagePage(id, { limit, before, signal: opts.signal });
1484
1496
  for (let i = page.messages.length - 1;i >= 0; i--)
1485
1497
  yield page.messages[i];
1486
- if (page.eventCount < limit || page.oldestSeq === null)
1498
+ if (page.oldestSeq === null || !hasEventsBefore(page.oldestSeq))
1487
1499
  return;
1488
1500
  before = page.oldestSeq;
1489
1501
  }
@@ -1641,7 +1653,7 @@ class AgentClient {
1641
1653
  events: page.events,
1642
1654
  oldestSeq: page.oldestSeq,
1643
1655
  latestChangeSeq: page.latestChangeSeq,
1644
- hasOlder: page.eventCount === limit
1656
+ hasOlder: hasEventsBefore(page.oldestSeq)
1645
1657
  };
1646
1658
  }
1647
1659
  async listMessagesPage(id, opts = {}) {
@@ -1654,7 +1666,7 @@ class AgentClient {
1654
1666
  lastTurnDoneSeq: page.lastTurnDoneSeq,
1655
1667
  oldestSeq: page.oldestSeq,
1656
1668
  latestChangeSeq: page.latestChangeSeq,
1657
- hasOlder: page.eventCount >= limit
1669
+ hasOlder: hasEventsBefore(page.oldestSeq)
1658
1670
  };
1659
1671
  }
1660
1672
  async sendMessage(id, content, signal) {
@@ -1772,66 +1784,154 @@ class AgentClient {
1772
1784
  }
1773
1785
  subscribe(id, handlers, opts = {}) {
1774
1786
  const controller = new AbortController;
1787
+ const signal = controller.signal;
1788
+ const graceMs = opts.pollGraceMs ?? STREAM_POLL_GRACE_MS;
1789
+ const intervalMs = opts.pollIntervalMs ?? STREAM_POLL_INTERVAL_MS;
1775
1790
  const streamedArgs = new Map;
1791
+ const cursor = { lastEventId: opts.since === undefined ? undefined : String(opts.since) };
1792
+ let delivered = opts.since ?? 0;
1793
+ const deliverEvent = (raw) => {
1794
+ if (raw.change_seq <= delivered)
1795
+ return;
1796
+ delivered = raw.change_seq;
1797
+ handlers.onEvent?.(raw);
1798
+ const message = toMessage(raw);
1799
+ if (message)
1800
+ handlers.onMessage?.(message);
1801
+ const todos = toTodos(raw);
1802
+ if (todos)
1803
+ handlers.onTodos?.(todos, raw.seq);
1804
+ const turnDone = toTurnDone(raw);
1805
+ if (turnDone !== null)
1806
+ handlers.onTurnDone?.(turnDone);
1807
+ const call = toClientToolCall(raw);
1808
+ if (call)
1809
+ handlers.onClientToolCall?.(call);
1810
+ for (const call2 of toToolCallArguments(raw)) {
1811
+ streamedArgs.set(call2.toolCallId, call2.arguments);
1812
+ }
1813
+ const activity = toToolActivity(raw);
1814
+ if (activity)
1815
+ handlers.onToolActivity?.(withToolArguments(activity, streamedArgs));
1816
+ handlers.onCursor?.(raw.change_seq);
1817
+ };
1818
+ const wantsEvents = Boolean(handlers.onMessage || handlers.onEvent || handlers.onTodos || handlers.onClientToolCall || handlers.onToolActivity || handlers.onTurnDone || handlers.onActivity || handlers.onOpen);
1819
+ const wantsMeta = Boolean(handlers.onConversation);
1776
1820
  this.#base().then((base) => {
1777
- if (controller.signal.aborted)
1821
+ if (signal.aborted)
1778
1822
  return;
1779
- if (handlers.onMessage || handlers.onEvent || handlers.onTodos || handlers.onClientToolCall || handlers.onToolActivity || handlers.onTurnDone || handlers.onActivity || handlers.onOpen) {
1823
+ let eventsProven = !wantsEvents;
1824
+ let metaProven = !wantsMeta;
1825
+ let ended = false;
1826
+ let pollTimer;
1827
+ let polling = false;
1828
+ let polledStatus;
1829
+ let polledRow;
1830
+ const poll = async () => {
1831
+ pollTimer = undefined;
1832
+ if (signal.aborted || ended || polling)
1833
+ return;
1834
+ if (eventsProven && metaProven)
1835
+ return;
1836
+ polling = true;
1837
+ try {
1838
+ const row = await this.#transport.request("GET", `${base}/${id}`, {
1839
+ signal
1840
+ });
1841
+ if (!eventsProven) {
1842
+ for (;; ) {
1843
+ const rows = await this.#transport.request("GET", `${base}/${id}/events`, { query: { after: delivered, limit: EVENTS_MAX_LIMIT }, signal });
1844
+ if (signal.aborted)
1845
+ return;
1846
+ for (const raw of rows) {
1847
+ deliverEvent(raw);
1848
+ cursor.lastEventId = String(delivered);
1849
+ }
1850
+ if (rows.length < EVENTS_MAX_LIMIT)
1851
+ break;
1852
+ }
1853
+ const status = `${row.is_processing}:${row.has_pending_turn}`;
1854
+ if (status !== polledStatus) {
1855
+ polledStatus = status;
1856
+ handlers.onActivity?.(toTurnStatus(row));
1857
+ }
1858
+ }
1859
+ if (!metaProven) {
1860
+ const serialized = JSON.stringify(row);
1861
+ if (serialized !== polledRow) {
1862
+ polledRow = serialized;
1863
+ handlers.onConversation?.(toConversation(row));
1864
+ }
1865
+ }
1866
+ } catch (err) {
1867
+ if (!signal.aborted)
1868
+ handlers.onError?.(err);
1869
+ } finally {
1870
+ polling = false;
1871
+ schedulePoll(intervalMs);
1872
+ }
1873
+ };
1874
+ const schedulePoll = (delayMs) => {
1875
+ if (signal.aborted || ended || pollTimer !== undefined)
1876
+ return;
1877
+ if (eventsProven && metaProven)
1878
+ return;
1879
+ pollTimer = setTimeout(() => void poll(), delayMs);
1880
+ };
1881
+ const stopPolling = () => {
1882
+ clearTimeout(pollTimer);
1883
+ pollTimer = undefined;
1884
+ };
1885
+ signal.addEventListener("abort", stopPolling, { once: true });
1886
+ const fatal = (err) => {
1887
+ ended = true;
1888
+ stopPolling();
1889
+ handlers.onError?.(err);
1890
+ handlers.onFatal?.(err);
1891
+ };
1892
+ if (wantsEvents) {
1780
1893
  readSse({
1781
1894
  url: this.#transport.url(`${base}/${id}/events/stream`),
1782
1895
  event: ["conversation_event", "conversation_status"],
1783
- lastEventId: opts.since === undefined ? undefined : String(opts.since),
1896
+ cursor,
1784
1897
  headers: ({ forceRefresh }) => this.#transport.streamHeaders(forceRefresh),
1785
1898
  fetchImpl: this.#transport.fetchImpl,
1786
- signal: controller.signal,
1899
+ signal,
1787
1900
  onError: handlers.onError,
1788
1901
  onOpen: handlers.onOpen,
1902
+ onClose: () => {
1903
+ eventsProven = false;
1904
+ schedulePoll(graceMs);
1905
+ },
1789
1906
  onEvent: (frame, name) => {
1907
+ eventsProven = true;
1790
1908
  if (name === "conversation_status") {
1791
1909
  handlers.onActivity?.(toTurnStatus(frame));
1792
1910
  return;
1793
1911
  }
1794
- const raw = frame;
1795
- handlers.onEvent?.(raw);
1796
- const message = toMessage(raw);
1797
- if (message)
1798
- handlers.onMessage?.(message);
1799
- const todos = toTodos(raw);
1800
- if (todos)
1801
- handlers.onTodos?.(todos, raw.seq);
1802
- const turnDone = toTurnDone(raw);
1803
- if (turnDone !== null)
1804
- handlers.onTurnDone?.(turnDone);
1805
- const call = toClientToolCall(raw);
1806
- if (call)
1807
- handlers.onClientToolCall?.(call);
1808
- for (const call2 of toToolCallArguments(raw)) {
1809
- streamedArgs.set(call2.toolCallId, call2.arguments);
1810
- }
1811
- const activity = toToolActivity(raw);
1812
- if (activity)
1813
- handlers.onToolActivity?.(withToolArguments(activity, streamedArgs));
1814
- handlers.onCursor?.(raw.change_seq);
1912
+ deliverEvent(frame);
1815
1913
  }
1816
- }).catch((err) => {
1817
- handlers.onError?.(err);
1818
- handlers.onFatal?.(err);
1819
- });
1914
+ }).catch(fatal);
1820
1915
  }
1821
- if (handlers.onConversation) {
1916
+ if (wantsMeta) {
1822
1917
  readSse({
1823
1918
  url: this.#transport.url(`${base}/${id}/meta/stream`),
1824
1919
  event: "conversation_meta",
1825
1920
  headers: ({ forceRefresh }) => this.#transport.streamHeaders(forceRefresh),
1826
1921
  fetchImpl: this.#transport.fetchImpl,
1827
- signal: controller.signal,
1922
+ signal,
1828
1923
  onError: handlers.onError,
1829
- onEvent: (raw) => handlers.onConversation?.(toConversation(raw))
1830
- }).catch((err) => {
1831
- handlers.onError?.(err);
1832
- handlers.onFatal?.(err);
1833
- });
1924
+ onClose: () => {
1925
+ metaProven = false;
1926
+ schedulePoll(graceMs);
1927
+ },
1928
+ onEvent: (raw) => {
1929
+ metaProven = true;
1930
+ handlers.onConversation?.(toConversation(raw));
1931
+ }
1932
+ }).catch(fatal);
1834
1933
  }
1934
+ schedulePoll(graceMs);
1835
1935
  });
1836
1936
  return { close: () => controller.abort() };
1837
1937
  }
@@ -1878,5 +1978,5 @@ export {
1878
1978
  AgentApiError
1879
1979
  };
1880
1980
 
1881
- //# debugId=641C44370747442A64756E2164756E21
1981
+ //# debugId=F147527891A76CE064756E2164756E21
1882
1982
  //# sourceMappingURL=index.js.map