@oberik/sdk 0.19.0 → 0.21.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/cjs/index.js CHANGED
@@ -66,7 +66,7 @@ function ingestFailureStatus(failure) {
66
66
  * the ChatDone, and `.done`/`.cancel`/`.disconnect`/`.runId` remain available. */
67
67
  function makeStreamHandle(done, extra) {
68
68
  // A plain thenable: `await handle` delegates to `done`, and the control methods
69
- // (.done/.cancel/.disconnect/.runId) remain directly accessible.
69
+ // (.done/.cancel/.disconnect/.runId/.sessionId) remain directly accessible.
70
70
  return {
71
71
  done,
72
72
  then: (onF, onR) => done.then(onF, onR),
@@ -206,63 +206,56 @@ function uiSchemaViolations(schema, args) {
206
206
  /** Node's default headers timeout — the ceiling `dispatcher` exists to raise. */
207
207
  const NODE_HEADERS_TIMEOUT_MS = 300_000;
208
208
  /**
209
- * Which handler protocol a dispatcher speaks, asked of the dispatcher itself.
209
+ * Whether a dispatcher speaks the RENAMED handler interface — the one Node's built-in
210
+ * `fetch` cannot drive.
210
211
  *
211
- * `dispatch(opts, handler)` validates the handler and throws naming the FIRST method it
212
- * wanted and did not find so handing it an empty object is a question rather than a
213
- * request. Measured, not assumed: three probes open **zero sockets** on undici 7.29 and
214
- * 8.10 alike (`net.Socket.prototype.connect` counted), and the process survives both. The
215
- * handler has to be EMPTY for that: `{ onError }` alone is accepted by undici 7, which then
216
- * queues a real request, and a handler that throws during validation with nowhere to put
217
- * the error takes the process down with an unhandled `error` event on the Client.
212
+ * Asked of the dispatcher, because it is the only thing that knows: `dispatch(opts, handler)`
213
+ * validates the handler and throws naming the first method it wanted and did not find, so an
214
+ * incomplete handler is a question rather than a request. Measured on Node 23.5.0
215
+ * (`process.versions.undici` 6.21.0):
218
216
  *
219
- * Observed verbatim, with a valid `origin` and `{}` as the handler:
217
+ * undici 7.29.0 Agent accepted — the interface Node's fetch speaks
218
+ * undici 8.10.0 Agent throws invalid onRequestStart method
220
219
  *
221
- * undici 7.29.0 invalid onConnect method
222
- * undici 8.10.0 invalid onRequestStart method
223
- * Node 23.5.0's embedded invalid onError method
220
+ * Three things here were each found by trying it, and every one of them matters:
224
221
  *
225
- * The two vocabularies are the whole point: undici renamed the handler interface between
226
- * majors, and a dispatcher speaking the wrong one makes EVERY request through Node's
227
- * built-in fetch fail with `TypeError: fetch failed` / `invalid onRequestStart method`.
222
+ * * **The handler carries `onError` and nothing else.** With `{}` undici 7 throws too
223
+ * and its throw path hands the error to a handler that cannot take it, which emits
224
+ * `error` on the Client and takes the whole process down. A check that can kill its
225
+ * caller is worse than the bug it reports.
226
+ * * **The origin is a `.invalid` host** (RFC 2606, never resolves). A dispatcher that
227
+ * ACCEPTS the handler queues the request, and this way that costs a failed DNS lookup
228
+ * rather than a TCP connection to somebody's port 1. Zero sockets, measured.
229
+ * * **The global dispatcher is never probed.** Comparing against "whatever serves fetch"
230
+ * would be the version-proof way to ask, and it cannot be done safely: importing npm
231
+ * undici 8 installs a `Dispatcher1Wrapper` there which accepts ANY handler, tells you
232
+ * nothing, and opened a socket. `process.versions.undici` answers the same question
233
+ * without touching anything.
228
234
  */
229
- function dispatcherProtocol(dispatcher) {
235
+ function speaksRenamedInterface(dispatcher) {
230
236
  const dispatch = dispatcher?.dispatch;
231
237
  if (typeof dispatch !== "function")
232
- return null;
233
- let message = "";
238
+ return false;
234
239
  try {
235
- dispatch.call(dispatcher, { origin: "http://127.0.0.1:1", path: "/", method: "GET" }, {});
236
- // It accepted a handler with no methods at all, so it is not undici and there is
237
- // nothing to compare. Callers of exotic dispatchers are on their own, as before.
238
- return null;
240
+ dispatch.call(dispatcher, { origin: "http://oberik-dispatcher-probe.invalid", path: "/", method: "GET" }, { onError() { } });
241
+ return false; // accepted it, so it is not the renamed interface
239
242
  }
240
243
  catch (e) {
241
- message = e?.message ?? "";
244
+ return /onRequestStart|onResponseStart|onResponseData|onResponseEnd/.test(e?.message ?? "");
242
245
  }
243
- if (/onRequestStart|onResponseStart|onResponseData|onResponseEnd/.test(message))
244
- return "new";
245
- if (/onConnect|onHeaders|onData|onComplete|onError/.test(message))
246
- return "old";
247
- return null;
248
246
  }
249
- /** The protocol whatever serves `fetch` by default speaks, if it can be asked.
250
- *
251
- * `globalThis[Symbol.for("undici.globalDispatcher.1")]` is the global agent — the one
252
- * `fetch` uses when no `dispatcher` is passed. Usually Node's embedded undici; sometimes
253
- * the caller's own npm undici, which installs itself there on import. Either is the right
254
- * thing to compare against, because either is what would otherwise be serving.
247
+ /** The undici major built into this Node, or null where it does not say.
255
248
  *
256
- * `null` when there is nothing there yet (it appears on first use) — and it is never
257
- * CREATED here: firing a request to find out would be a side effect in a function whose
258
- * whole job is to answer a question.
259
- *
260
- * Asked rather than assumed so the check keeps working on a Node that embeds a newer
261
- * undici: what matters is whether the two agree, not which major either one is. */
262
- function embeddedProtocol() {
263
- const embedded = globalThis[Symbol.for("undici.globalDispatcher.1")];
264
- return embedded ? dispatcherProtocol(embedded) : null;
249
+ * `process.versions.undici` is what makes this check outlive the current majors: the
250
+ * renamed interface is only a problem while Node embeds the older one, and when that
251
+ * changes this inverts by itself rather than confidently refusing the right dispatcher. */
252
+ function embeddedUndiciMajor() {
253
+ const version = globalThis.process?.versions?.undici;
254
+ const major = Number(String(version ?? "").split(".")[0]);
255
+ return Number.isFinite(major) && major > 0 ? major : null;
265
256
  }
257
+ /** The first undici major that renamed the dispatcher handler interface. */
258
+ const RENAMED_INTERFACE_FROM = 8;
266
259
  /**
267
260
  * Why a `dispatcher` will not work, or null if there is no reason to think it won't.
268
261
  *
@@ -275,24 +268,23 @@ function embeddedProtocol() {
275
268
  function dispatcherProblem(dispatcher) {
276
269
  if (dispatcher == null)
277
270
  return null;
278
- const theirs = dispatcherProtocol(dispatcher);
279
- if (theirs === null) {
280
- if (typeof dispatcher.dispatch !== "function") {
281
- return ("`dispatcher` is not a dispatcher: it has no `dispatch` method. Pass an instance " +
282
- "of undici's `Agent` (not the class, and not the options for one).");
283
- }
284
- return null; // a dispatcher we cannot interrogate — assume the caller knows
271
+ if (typeof dispatcher.dispatch !== "function") {
272
+ return ("`dispatcher` is not a dispatcher: it has no `dispatch` method. Pass an instance " +
273
+ "of undici's `Agent` (not the class, and not the options for one).");
285
274
  }
286
- // Compare like with like where possible; fall back to the only mismatch that exists in
287
- // any released Node, which is a caller's `undici@8` against an embedded 6/7.
288
- const mine = embeddedProtocol();
289
- const incompatible = mine !== null ? theirs !== mine : theirs === "new";
290
- if (!incompatible)
275
+ const embedded = embeddedUndiciMajor();
276
+ // Node already speaks the renamed interface, so a dispatcher that does is the correct
277
+ // thing to pass and one that does not is the suspect. Nothing to say either way: which
278
+ // of the two is wrong depends on a major nobody has shipped yet.
279
+ if (embedded !== null && embedded >= RENAMED_INTERFACE_FROM)
291
280
  return null;
292
- return ("the `dispatcher` comes from a different undici major than the one built into this " +
293
- "Node, so its handler interface does not match what `fetch` will pass it — every " +
294
- "request would fail with `invalid onRequestStart method`. Install `undici@^7`, or " +
295
- "drop the dispatcher and stream long turns instead.");
281
+ if (!speaksRenamedInterface(dispatcher))
282
+ return null;
283
+ return ("the `dispatcher` comes from a newer undici than the one built into this Node" +
284
+ (embedded ? ` (${embedded}.x)` : "") +
285
+ ", so its handler interface does not match what `fetch` will pass it — every request " +
286
+ "would fail with `invalid onRequestStart method`. Install `undici@^7`, or drop the " +
287
+ "dispatcher and stream long turns instead.");
296
288
  }
297
289
  /**
298
290
  * Whether a blocking budget of `ms` will actually be honoured.
@@ -379,9 +371,31 @@ class AgentApiError extends Error {
379
371
  }
380
372
  exports.AgentApiError = AgentApiError;
381
373
  class AgentStreamError extends Error {
382
- constructor(message) {
374
+ /** Why the stream ended, when the server said. See `StreamErrorCode`. */
375
+ code;
376
+ /** The run that was streaming, if one had been accepted. */
377
+ runId;
378
+ /** The conversation the turn belongs to.
379
+ *
380
+ * Present whenever the stream got as far as its `start` frame — including on the
381
+ * reconnect that failed, which is the case this exists for. A long turn used to die
382
+ * with a bare 404 and take the session id with it, so a client holding half an answer
383
+ * had nowhere to look for the rest; the answer had been written to the conversation
384
+ * all along. With this: `ai.chat.sessions.messages(err.sessionId)`. */
385
+ sessionId;
386
+ /** The text that had streamed before it broke. Render it rather than dropping it —
387
+ * the user watched it arrive. */
388
+ content;
389
+ /** The reasoning trace that had streamed, same reason. */
390
+ reasoning;
391
+ constructor(message, ctx) {
383
392
  super(message);
384
393
  this.name = "AgentStreamError";
394
+ this.code = ctx?.code;
395
+ this.runId = ctx?.runId;
396
+ this.sessionId = ctx?.sessionId;
397
+ this.content = ctx?.content;
398
+ this.reasoning = ctx?.reasoning;
385
399
  }
386
400
  }
387
401
  exports.AgentStreamError = AgentStreamError;
@@ -629,20 +643,30 @@ async function* parseSSE(body, signal) {
629
643
  buffer = buffer.slice(m.index + m[0].length);
630
644
  let id;
631
645
  let event = "message";
646
+ let hadField = false;
632
647
  const dataLines = [];
633
648
  for (const line of raw.split(/\r?\n/)) {
634
649
  if (line.startsWith("id:")) {
635
650
  const n = parseInt(line.slice(3).trim(), 10);
636
651
  if (!Number.isNaN(n))
637
652
  id = n;
653
+ hadField = true;
638
654
  }
639
655
  else if (line.startsWith("event:")) {
640
656
  event = line.slice(6).trim();
657
+ hadField = true;
641
658
  }
642
659
  else if (line.startsWith("data:")) {
643
660
  dataLines.push(line.slice(5).replace(/^ /, ""));
661
+ hadField = true;
644
662
  }
645
663
  }
664
+ // A frame of nothing but comments (`: keepalive`) is a keepalive, and the server
665
+ // sends one whenever the turn has been thinking rather than talking. It is not an
666
+ // event: passing it on would hand `onEvent` a frame with no name and empty data,
667
+ // which every consumer would have to learn to ignore.
668
+ if (!hadField)
669
+ continue;
646
670
  yield { id, event, data: dataLines.join("\n") };
647
671
  }
648
672
  }
@@ -1465,6 +1489,12 @@ class AgentFramework {
1465
1489
  * `chat.send({ session_id, tool_results })`. And a run lives in the memory of the API
1466
1490
  * process that started it, so behind several workers this finds it only on that
1467
1491
  * worker — the same constraint the underlying resume endpoint has always had.
1492
+ *
1493
+ * Attaching to a run whose frames the server no longer holds is not an error you have
1494
+ * to guess about: `done` rejects with an `AgentStreamError` whose `code` says whether
1495
+ * the turn finished (`stream_expired` — read the history) or died with its process
1496
+ * (`stream_lost` — send it again), and whose `sessionId` tells you which conversation
1497
+ * either way.
1468
1498
  */
1469
1499
  attach: (runId, handlers = {}, opts = {}) => this.startStream({}, handlers, { runId, lastEventId: opts.lastEventId ?? 0 }),
1470
1500
  /** Send a message into a turn that is still running (needs `steer`).
@@ -1544,6 +1574,13 @@ class AgentFramework {
1544
1574
  * only answer for the one that took your request: `authoritative: false` means "no
1545
1575
  * run HERE, possibly one elsewhere", which is a different thing from "the turn has
1546
1576
  * finished". True on a single-worker deployment, which is the common one.
1577
+ *
1578
+ * `status` is what a null `run_id` actually means, and it matters most in the case
1579
+ * that used to be invisible: `lost` says a turn WAS generating and the process
1580
+ * running it went away — a deploy, a crash — so nothing was persisted and sending it
1581
+ * again is the right move. A client reading only `run_id` was told "nothing is
1582
+ * running" and concluded the turn had finished, which is how a ten-minute answer
1583
+ * became a blank bubble nobody retried.
1547
1584
  */
1548
1585
  activeRun: (sessionId) => this.request("GET", `/chat/sessions/${sessionId}/run`),
1549
1586
  /** Name a conversation, or clear its name with `null`.
@@ -2037,6 +2074,9 @@ class AgentFramework {
2037
2074
  disconnect: () => { stopped = true; current?.disconnect(); },
2038
2075
  cancel: async () => { stopped = true; await current?.cancel(); },
2039
2076
  runId: () => current?.runId(),
2077
+ // The rounds of a tool loop are all one conversation, so the inner handle's answer
2078
+ // is this one's — and the fallback covers the moment before the first round opens.
2079
+ sessionId: () => current?.sessionId() ?? body.session_id ?? undefined,
2040
2080
  steer: (m) => current?.steer(m) ?? Promise.resolve(false),
2041
2081
  });
2042
2082
  }
@@ -2198,6 +2238,11 @@ class AgentFramework {
2198
2238
  let lastId = resume ? (resume.lastEventId ?? 0) - 1 : -1;
2199
2239
  let full = "";
2200
2240
  let reasoning = "";
2241
+ // Learned from the `start` frame, which is the FIRST thing a turn emits — and for a
2242
+ // brand-new conversation the only place this id has ever appeared before `done`. It
2243
+ // is tracked here rather than left to the caller so that a stream which breaks can
2244
+ // still say which conversation to read.
2245
+ let sessionId = body.session_id ?? undefined;
2201
2246
  // Merged by ref, because the wire carries one subagent per move and a UI wants the
2202
2247
  // whole set. Doing it here rather than in every client is the difference between a
2203
2248
  // panel that survives a dropped frame and one that quietly goes stale.
@@ -2254,6 +2299,9 @@ class AgentFramework {
2254
2299
  case "run":
2255
2300
  runId = ev.data.run_id;
2256
2301
  break;
2302
+ case "start":
2303
+ sessionId = ev.data.session_id;
2304
+ break;
2257
2305
  case "token":
2258
2306
  full += ev.data.delta;
2259
2307
  handlers.onToken?.(ev.data.delta, full);
@@ -2320,11 +2368,23 @@ class AgentFramework {
2320
2368
  handlers.onReasoning?.(ev.data.delta, reasoning);
2321
2369
  break;
2322
2370
  case "done":
2371
+ sessionId = ev.data.session_id ?? sessionId;
2323
2372
  return ev.data;
2324
2373
  case "cancelled":
2325
2374
  throw new AgentCancelledError(); // terminal
2326
2375
  case "error":
2327
- throw new AgentStreamError("server: " + ev.data.detail); // terminal
2376
+ // Everything the caller needs to not lose the turn: what broke, which
2377
+ // conversation it was in, and the text the user already watched arrive.
2378
+ // This used to be a bare message, so a stream that died at minute
2379
+ // eighteen threw away the answer AND the only handle on where the rest
2380
+ // of it had been written.
2381
+ throw new AgentStreamError("server: " + ev.data.detail, {
2382
+ code: ev.data.code,
2383
+ runId: ev.data.run_id ?? runId,
2384
+ sessionId: ev.data.session_id ?? sessionId,
2385
+ content: full,
2386
+ reasoning,
2387
+ }); // terminal
2328
2388
  }
2329
2389
  }
2330
2390
  // Stream ended with no terminal event => dropped connection.
@@ -2345,7 +2405,12 @@ class AgentFramework {
2345
2405
  if (shouldRetry) {
2346
2406
  attempt += 1;
2347
2407
  if (attempt > maxRetries || runId === undefined)
2348
- throw new AgentStreamError("stream ended before completion (retries exhausted)");
2408
+ throw new AgentStreamError("stream ended before completion (retries exhausted)", {
2409
+ runId,
2410
+ sessionId,
2411
+ content: full,
2412
+ reasoning,
2413
+ });
2349
2414
  handlers.onReconnect?.(attempt);
2350
2415
  await sleep(backoff(attempt));
2351
2416
  }
@@ -2384,7 +2449,8 @@ class AgentFramework {
2384
2449
  }
2385
2450
  };
2386
2451
  return makeStreamHandle(done, {
2387
- disconnect: () => ac.abort(), cancel, runId: () => runId, steer,
2452
+ disconnect: () => ac.abort(), cancel, runId: () => runId,
2453
+ sessionId: () => sessionId, steer,
2388
2454
  });
2389
2455
  }
2390
2456
  // ==========================================================================