@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 +130 -64
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.ts +70 -2
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +130 -64
- package/dist/esm/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
*
|
|
209
|
+
* Whether a dispatcher speaks the RENAMED handler interface — the one Node's built-in
|
|
210
|
+
* `fetch` cannot drive.
|
|
210
211
|
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
226
|
-
*
|
|
227
|
-
*
|
|
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
|
|
235
|
+
function speaksRenamedInterface(dispatcher) {
|
|
230
236
|
const dispatch = dispatcher?.dispatch;
|
|
231
237
|
if (typeof dispatch !== "function")
|
|
232
|
-
return
|
|
233
|
-
let message = "";
|
|
238
|
+
return false;
|
|
234
239
|
try {
|
|
235
|
-
dispatch.call(dispatcher, { origin: "http://
|
|
236
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
* `
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2452
|
+
disconnect: () => ac.abort(), cancel, runId: () => runId,
|
|
2453
|
+
sessionId: () => sessionId, steer,
|
|
2388
2454
|
});
|
|
2389
2455
|
}
|
|
2390
2456
|
// ==========================================================================
|