@oberik/sdk 0.1.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.
@@ -0,0 +1,1743 @@
1
+ /**
2
+ * Agent Framework — single-file TypeScript client.
3
+ *
4
+ * Isomorphic: works server-side (Node 18+) and in the browser. The only runtime
5
+ * requirements are the standard `fetch`, `Blob`, `TextDecoder`, and web
6
+ * `ReadableStream` (all present in Node 18+ and modern browsers). No deps.
7
+ *
8
+ * Handles the tedious parts for you:
9
+ * - Auth via JWT (Bearer) or legacy API key, with optional async token refresh.
10
+ * - Chat streaming over SSE with automatic reconnection — if the connection
11
+ * drops mid-generation it resumes from the last event id (server keeps going).
12
+ * - Client-side tools: register a handler and the client auto-executes tool
13
+ * calls the agent makes and submits the results, looping until the agent ends.
14
+ * - Resumable uploads: presigned S3 multipart with per-part retry + concurrency.
15
+ * - Resumable downloads: presigned URL fetched with ranged GETs that retry.
16
+ *
17
+ * Quick start:
18
+ * const af = createClient({ token: jwt }); // hosted API
19
+ * const af = createClient({ token: jwt, baseUrl: "http://localhost:8000" }); // local
20
+ * const res = await af.chat.send({ message: "hello" });
21
+ *
22
+ * // Client-side tools — the client runs your handler and resumes automatically:
23
+ * af.registerTool({
24
+ * name: "get_weather",
25
+ * description: "Current weather for a city",
26
+ * parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
27
+ * handler: async ({ city }) => ({ tempC: 21, city }),
28
+ * });
29
+ * const done = await af.chat.run({ message: "what's the weather in Paris?" });
30
+ * // ...or streaming, tokens + auto tool dispatch in one call:
31
+ * const handle = af.chat.stream({ message: "weather in Paris?" }, { onToken: t => process.stdout.write(t) });
32
+ * await handle.done;
33
+ */
34
+ // ============================================================================
35
+ // Types
36
+ // ============================================================================
37
+ /**
38
+ * Where the agent API lives. Override for a self-hosted deployment or local
39
+ * development (`http://localhost:8000`); the hosted API needs no `baseUrl` at all.
40
+ */
41
+ export const DEFAULT_BASE_URL = "https://api.oberik.com";
42
+ /** Wrap the stream's `done` promise into a thenable handle: `await handle` yields
43
+ * the ChatDone, and `.done`/`.cancel`/`.disconnect`/`.runId` remain available. */
44
+ function makeStreamHandle(done, extra) {
45
+ // A plain thenable: `await handle` delegates to `done`, and the control methods
46
+ // (.done/.cancel/.disconnect/.runId) remain directly accessible.
47
+ return {
48
+ done,
49
+ then: (onF, onR) => done.then(onF, onR),
50
+ catch: (onR) => done.catch(onR),
51
+ finally: (onF) => done.finally(onF),
52
+ ...extra,
53
+ };
54
+ }
55
+ export class AgentCancelledError extends Error {
56
+ constructor() {
57
+ super("run cancelled");
58
+ this.name = "AgentCancelledError";
59
+ }
60
+ }
61
+ // ============================================================================
62
+ // Errors
63
+ // ============================================================================
64
+ export class AgentApiError extends Error {
65
+ status;
66
+ detail;
67
+ constructor(status, detail) {
68
+ super(typeof detail === "string" ? detail : `HTTP ${status}`);
69
+ this.name = "AgentApiError";
70
+ this.status = status;
71
+ this.detail = detail;
72
+ }
73
+ }
74
+ export class AgentStreamError extends Error {
75
+ constructor(message) {
76
+ super(message);
77
+ this.name = "AgentStreamError";
78
+ }
79
+ }
80
+ // ============================================================================
81
+ // Helpers
82
+ // ============================================================================
83
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+ const backoff = (attempt) => Math.min(1000 * 2 ** attempt, 15000) + Math.floor(Math.random() * 250);
85
+ /** Convert client tool defs to the OpenAI-format schemas the API expects. */
86
+ function toolSchemas(tools) {
87
+ return [...tools.values()].map((t) => ({
88
+ type: "function",
89
+ function: { name: t.name, description: t.description, parameters: t.parameters ?? { type: "object", properties: {} } },
90
+ }));
91
+ }
92
+ /** Run each pending client tool via its handler; failures become error results
93
+ * (not thrown) so one bad tool doesn't abort the whole turn. */
94
+ async function executeToolCalls(calls, tools) {
95
+ return Promise.all(calls.map(async (call) => {
96
+ const tool = tools.get(call.name);
97
+ let content;
98
+ if (!tool) {
99
+ content = JSON.stringify({ error: `no client tool registered named "${call.name}"` });
100
+ }
101
+ else {
102
+ try {
103
+ const out = await tool.handler(call.args ?? {}, call);
104
+ content = typeof out === "string" ? out : JSON.stringify(out ?? null);
105
+ }
106
+ catch (e) {
107
+ content = JSON.stringify({ error: String(e?.message ?? e) });
108
+ }
109
+ }
110
+ return { tool_call_id: call.id, content };
111
+ }));
112
+ }
113
+ /** Put each paused question batch to the handler and shape the result for the API.
114
+ *
115
+ * A handler that throws is treated as the user declining rather than as a failure:
116
+ * the alternative is leaving the turn paused forever on a question nobody can now
117
+ * answer, and "they'd rather talk about it" is both true and recoverable. */
118
+ async function collectAnswers(pending, handler) {
119
+ const out = [];
120
+ for (const batch of pending) {
121
+ let answer;
122
+ try {
123
+ const result = await handler(batch);
124
+ answer = Array.isArray(result) ? { answers: result } : result;
125
+ }
126
+ catch (e) {
127
+ answer = { chat_instead: true, message: String(e?.message ?? e) };
128
+ }
129
+ out.push({ ...answer, tool_call_id: answer.tool_call_id ?? batch.tool_call_id });
130
+ }
131
+ return out;
132
+ }
133
+ function toUint8(input) {
134
+ if (typeof Blob !== "undefined" && input instanceof Blob) {
135
+ return { size: input.size, slice: (a, b) => input.slice(a, b) };
136
+ }
137
+ const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
138
+ return { size: u8.byteLength, slice: (a, b) => u8.subarray(a, b) };
139
+ }
140
+ async function* parseSSE(body, signal) {
141
+ const reader = body.getReader();
142
+ const decoder = new TextDecoder();
143
+ // Handle both LF (\n\n) and CRLF (\r\n\r\n) frame separators and line endings.
144
+ const FRAME_SEP = /\r?\n\r?\n/;
145
+ let buffer = "";
146
+ try {
147
+ for (;;) {
148
+ if (signal?.aborted)
149
+ throw new DOMException("aborted", "AbortError");
150
+ const { value, done } = await reader.read();
151
+ if (done)
152
+ break;
153
+ buffer += decoder.decode(value, { stream: true });
154
+ let m;
155
+ while ((m = FRAME_SEP.exec(buffer)) !== null) {
156
+ const raw = buffer.slice(0, m.index);
157
+ buffer = buffer.slice(m.index + m[0].length);
158
+ let id;
159
+ let event = "message";
160
+ const dataLines = [];
161
+ for (const line of raw.split(/\r?\n/)) {
162
+ if (line.startsWith("id:")) {
163
+ const n = parseInt(line.slice(3).trim(), 10);
164
+ if (!Number.isNaN(n))
165
+ id = n;
166
+ }
167
+ else if (line.startsWith("event:")) {
168
+ event = line.slice(6).trim();
169
+ }
170
+ else if (line.startsWith("data:")) {
171
+ dataLines.push(line.slice(5).replace(/^ /, ""));
172
+ }
173
+ }
174
+ yield { id, event, data: dataLines.join("\n") };
175
+ }
176
+ }
177
+ }
178
+ finally {
179
+ try {
180
+ reader.releaseLock();
181
+ }
182
+ catch {
183
+ /* noop */
184
+ }
185
+ }
186
+ }
187
+ // ============================================================================
188
+ // Client
189
+ // ============================================================================
190
+ export class AgentFramework {
191
+ baseUrl;
192
+ opts;
193
+ _fetch;
194
+ toolRegistry = new Map();
195
+ /** Active session watchers, so a streamed turn can mark its own messages seen. */
196
+ watchers = new Set();
197
+ /** The bearer in use: `opts.token` initially, replaced on refresh. */
198
+ currentToken;
199
+ /** The last token the server rejected, so we don't keep re-sending it. */
200
+ rejectedToken;
201
+ /** In-flight refresh, shared so N concurrent 401s mint one token, not N. */
202
+ refreshing;
203
+ constructor(opts) {
204
+ this.opts = opts;
205
+ this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
206
+ // Bind to globalThis: native fetch throws "Illegal invocation" in the browser
207
+ // when called as a method (this !== window). (Node's fetch tolerates it, so this
208
+ // only bit browser usage.)
209
+ const rawFetch = opts.fetch ?? globalThis.fetch;
210
+ if (!rawFetch)
211
+ throw new Error("No fetch available; pass options.fetch");
212
+ this._fetch = rawFetch.bind(globalThis);
213
+ this.currentToken = opts.token;
214
+ for (const t of opts.tools ?? [])
215
+ this.toolRegistry.set(t.name, t);
216
+ }
217
+ /** Seconds before a token's own expiry at which we stop using it.
218
+ *
219
+ * Reacting to a 401 covers most requests, because the failed one is replayed. It
220
+ * cannot cover an upload: a FormData body is a stream that may already have been
221
+ * consumed, so a multipart request that 401s cannot be replayed and the expiry
222
+ * surfaces as a failed upload with nothing the caller can do. Nor does it help a
223
+ * long-running turn that starts with a token about to lapse.
224
+ *
225
+ * So a token is retired slightly before it expires, from the `exp` it carries. The
226
+ * margin covers clock skew between the browser and the server and the round trip
227
+ * itself. The 401 path stays exactly as it was — this only avoids reaching it. */
228
+ /** Everything behind `chat.sessions.handoff()`.
229
+ *
230
+ * A closure rather than a class because all of it is state that dies with the
231
+ * hand-off, and because the whole point is that the caller holds one object and
232
+ * nothing else. See the notes on `HandoffController` for what it takes off them. */
233
+ buildHandoff(sessionId, initial, options) {
234
+ const api = this.chat.sessions;
235
+ const settleMs = options.settleMs ?? 700;
236
+ const relayMs = options.relayMs ?? 16;
237
+ const fallbackMs = options.fallbackMs ?? 1500;
238
+ const fail = (e) => options.onError?.(String(e?.message ?? e));
239
+ let handoff = initial;
240
+ let live = null;
241
+ let clip = null;
242
+ let polled = null;
243
+ let active = true;
244
+ let checking = false;
245
+ let stream = null;
246
+ /** The selector the stream was opened for — what actually identifies the view. */
247
+ let watching = null;
248
+ let frames = 0;
249
+ let fallbackTimer = null;
250
+ let checkTimer = null;
251
+ let element = null;
252
+ let dragging = false;
253
+ let lastMove = 0;
254
+ const compute = () => {
255
+ const streaming = !!live;
256
+ const from = streaming ? clip : polled;
257
+ const origin = from?.origin ?? { x: 0, y: 0 };
258
+ const width = from?.width ?? 0;
259
+ const height = from?.height ?? 0;
260
+ const clipped = !!from?.clipped;
261
+ // The screencast is scaled to Chrome's own maxWidth, so image pixels and page
262
+ // pixels are NOT the same unit. Cropping without the page width puts the region
263
+ // in the wrong place and every click with it.
264
+ const pageWidth = live?.device_width ?? 0;
265
+ const cropping = streaming && clipped && width > 0 && height > 0 && pageWidth > 0;
266
+ // Every key the SDK owns is always present, "" where it does not apply — so a view
267
+ // that stops cropping clears the offsets instead of leaving the last ones behind.
268
+ const style = cropping
269
+ ? {
270
+ image: {
271
+ // Blow the full-viewport frame up until the region fills the window, then
272
+ // shift it so the region is what shows through. Percentages are of the
273
+ // WINDOW's width — including the vertical one, which is how CSS margins
274
+ // work and what makes this survive a resize.
275
+ width: `${(pageWidth / width) * 100}%`,
276
+ marginLeft: `${(-origin.x / width) * 100}%`,
277
+ marginTop: `${(-origin.y / width) * 100}%`,
278
+ maxWidth: "none",
279
+ },
280
+ window: {
281
+ width: "100%",
282
+ maxWidth: `${width}px`,
283
+ aspectRatio: `${width} / ${height}`,
284
+ },
285
+ }
286
+ : {
287
+ image: { width: "auto", marginLeft: "", marginTop: "", maxWidth: "100%" },
288
+ window: { width: "", maxWidth: "100%", aspectRatio: "" },
289
+ };
290
+ return {
291
+ active,
292
+ url: (streaming ? clip?.url : polled?.url) || polled?.url || handoff.url || "",
293
+ image: (streaming ? live?.image : polled?.image) || "",
294
+ live: streaming,
295
+ origin,
296
+ width,
297
+ height,
298
+ clipped,
299
+ cropping,
300
+ style,
301
+ interactive: handoff.interactive,
302
+ checking,
303
+ handoff,
304
+ };
305
+ };
306
+ let view = compute();
307
+ const paint = () => {
308
+ const el = element;
309
+ if (!el)
310
+ return;
311
+ if (view.image && el.src !== view.image)
312
+ el.src = view.image;
313
+ if (el.style)
314
+ Object.assign(el.style, view.style.image);
315
+ const parent = el.parentElement;
316
+ if (parent?.style)
317
+ Object.assign(parent.style, view.style.window);
318
+ };
319
+ const publish = () => {
320
+ view = compute();
321
+ paint();
322
+ options.onView?.(view);
323
+ };
324
+ const stopWatching = () => {
325
+ stream?.close();
326
+ stream = null;
327
+ watching = null;
328
+ if (fallbackTimer)
329
+ clearTimeout(fallbackTimer);
330
+ fallbackTimer = null;
331
+ };
332
+ /** Open the live view, or leave it alone if it is already showing this region.
333
+ *
334
+ * Keyed on the selector, NOT on the hand-off object: the agent re-announces a
335
+ * hand-off as it works and each announcement is a new object, so re-opening on
336
+ * every one tore the picture down and put "waiting for the page" back up
337
+ * mid-gesture. */
338
+ const watch = () => {
339
+ const selector = handoff.selector ?? "";
340
+ if (!active || (stream && watching === selector))
341
+ return;
342
+ stopWatching();
343
+ watching = selector;
344
+ frames = 0;
345
+ stream = api.browserStream(sessionId, {
346
+ onFrame: (f) => {
347
+ if (!active)
348
+ return;
349
+ frames++;
350
+ live = f;
351
+ publish();
352
+ },
353
+ onClip: (c) => {
354
+ if (!active)
355
+ return;
356
+ clip = c;
357
+ publish();
358
+ },
359
+ // Reported, not swallowed: a hand-off whose frames never arrive shows an
360
+ // empty box and tells nobody why.
361
+ onError: (m) => options.onError?.(m),
362
+ }, selector);
363
+ // Nothing painted? Fetch one frame the slow way, so the user is not left looking
364
+ // at a spinner over a stream that may simply be quiet.
365
+ fallbackTimer = setTimeout(() => {
366
+ if (!active || frames)
367
+ return;
368
+ api
369
+ .browserFrame(sessionId, selector)
370
+ .then((f) => {
371
+ if (!active || frames)
372
+ return;
373
+ polled = f;
374
+ publish();
375
+ })
376
+ .catch(fail);
377
+ }, fallbackMs);
378
+ };
379
+ const finish = async () => {
380
+ if (!active)
381
+ return;
382
+ const resume = !!handoff.blocking;
383
+ active = false;
384
+ stopWatching();
385
+ if (checkTimer)
386
+ clearTimeout(checkTimer);
387
+ checkTimer = null;
388
+ publish();
389
+ options.onEnded?.({ resume });
390
+ };
391
+ const send = async (input) => {
392
+ if (!active)
393
+ return;
394
+ const body = {
395
+ ...input,
396
+ selector: handoff.selector,
397
+ // Echoed, never added to here: the server adds it, so a crop that moved between
398
+ // paint and click cannot displace the click.
399
+ origin: input.origin ?? view.origin,
400
+ // A drag is dozens of events a second; rendering a JPEG for each would cost more
401
+ // than the gesture and arrive after the stream had already shown it. With no
402
+ // stream the reply IS the feedback.
403
+ want_frame: input.want_frame ?? !live,
404
+ };
405
+ try {
406
+ const frame = await api.browserInput(sessionId, body);
407
+ if (body.want_frame && frame?.image) {
408
+ polled = frame;
409
+ publish();
410
+ }
411
+ }
412
+ catch (e) {
413
+ fail(e);
414
+ }
415
+ };
416
+ const settled = () => {
417
+ // Only a blocking hand-off has something to finish, and only when the agent gave
418
+ // a goal to judge against.
419
+ if (!active || !handoff.blocking || !handoff.auto_done || checkTimer)
420
+ return;
421
+ checkTimer = setTimeout(async () => {
422
+ checkTimer = null;
423
+ checking = true;
424
+ publish();
425
+ try {
426
+ const result = await api.handoffCheck(sessionId);
427
+ if (result.checked && result.done) {
428
+ checking = false;
429
+ await finish();
430
+ return;
431
+ }
432
+ }
433
+ catch (e) {
434
+ // Never fatal: the Done button is the whole fallback.
435
+ fail(e);
436
+ }
437
+ checking = false;
438
+ if (active)
439
+ publish();
440
+ }, settleMs);
441
+ };
442
+ /** Where a pointer landed, in the image space the server expects.
443
+ *
444
+ * The element is CSS-scaled to fit its box, so displayed pixels are converted back
445
+ * to page pixels here. When cropping, the visible window is the parent box rather
446
+ * than the (deliberately oversized) image. */
447
+ const point = (el, event) => {
448
+ const box = el.getBoundingClientRect();
449
+ if (!box.width || !box.height)
450
+ return null;
451
+ const shown = view.cropping
452
+ ? { w: view.width, h: view.height }
453
+ : {
454
+ w: view.width || el.naturalWidth || box.width,
455
+ h: view.height || el.naturalHeight || box.height,
456
+ };
457
+ const win = view.cropping ? (el.parentElement?.getBoundingClientRect() ?? box) : box;
458
+ if (!win.width || !win.height)
459
+ return null;
460
+ return {
461
+ x: Math.round((event.clientX - win.left) * (shown.w / win.width)),
462
+ y: Math.round((event.clientY - win.top) * (shown.h / win.height)),
463
+ };
464
+ };
465
+ const held = (event) => {
466
+ const out = [];
467
+ if (event.shiftKey)
468
+ out.push("Shift");
469
+ if (event.ctrlKey)
470
+ out.push("Control");
471
+ if (event.altKey)
472
+ out.push("Alt");
473
+ if (event.metaKey)
474
+ out.push("Meta");
475
+ return out;
476
+ };
477
+ const button = (event) => event.button === 2 ? "right" : event.button === 1 ? "middle" : "left";
478
+ const attach = (el) => {
479
+ element = el;
480
+ const onDown = (event) => {
481
+ // Read-only is enforced server-side too; this just avoids a pointless refusal.
482
+ if (!active || !view.interactive)
483
+ return;
484
+ const p = point(el, event);
485
+ if (!p)
486
+ return;
487
+ // Capture, so a drag that wanders off the image still ends properly. Without it
488
+ // the pointer stays down on the page and every later click compounds the mess.
489
+ el.setPointerCapture?.(event.pointerId);
490
+ dragging = true;
491
+ lastMove = 0;
492
+ void send({ type: "pointer_down", ...p, button: button(event), modifiers: held(event) });
493
+ };
494
+ const onMove = (event) => {
495
+ if (!active || !view.interactive || !dragging)
496
+ return;
497
+ const now = Date.now();
498
+ if (now - lastMove < relayMs)
499
+ return;
500
+ lastMove = now;
501
+ const p = point(el, event);
502
+ if (p)
503
+ void send({ type: "pointer_move", ...p });
504
+ };
505
+ const onUp = (event) => {
506
+ if (!active || !view.interactive || !dragging)
507
+ return;
508
+ dragging = false;
509
+ el.releasePointerCapture?.(event.pointerId);
510
+ const p = point(el, event);
511
+ if (p) {
512
+ void send({ type: "pointer_up", ...p, button: button(event), modifiers: held(event) });
513
+ }
514
+ settled();
515
+ };
516
+ const onWheel = (event) => {
517
+ if (!active || !view.interactive)
518
+ return;
519
+ // Over the element, not the window: a challenge in its own scrollable pane does
520
+ // not move when the document scrolls.
521
+ void send({
522
+ type: "wheel",
523
+ x: Math.round(view.width / 2),
524
+ y: Math.round(view.height / 2),
525
+ delta_x: Math.round(event.deltaX ?? 0),
526
+ delta_y: Math.round(event.deltaY ?? 0),
527
+ });
528
+ };
529
+ const onContextMenu = (event) => event.preventDefault?.();
530
+ el.addEventListener("pointerdown", onDown);
531
+ el.addEventListener("pointermove", onMove);
532
+ el.addEventListener("pointerup", onUp);
533
+ el.addEventListener("pointercancel", onUp);
534
+ el.addEventListener("wheel", onWheel);
535
+ el.addEventListener("contextmenu", onContextMenu);
536
+ paint();
537
+ return () => {
538
+ el.removeEventListener("pointerdown", onDown);
539
+ el.removeEventListener("pointermove", onMove);
540
+ el.removeEventListener("pointerup", onUp);
541
+ el.removeEventListener("pointercancel", onUp);
542
+ el.removeEventListener("wheel", onWheel);
543
+ el.removeEventListener("contextmenu", onContextMenu);
544
+ dragging = false;
545
+ if (element === el)
546
+ element = null;
547
+ };
548
+ };
549
+ watch();
550
+ return {
551
+ get view() {
552
+ return view;
553
+ },
554
+ attach,
555
+ update: (next) => {
556
+ handoff = next;
557
+ if (!next.active) {
558
+ void finish();
559
+ return;
560
+ }
561
+ watch();
562
+ publish();
563
+ },
564
+ send,
565
+ type: async (text, opts = {}) => {
566
+ if (text)
567
+ await send({ type: "type", text });
568
+ if (opts.submit !== false)
569
+ await send({ type: "key", key: "Enter" });
570
+ },
571
+ key: (key, modifiers) => send({ type: "key", key, modifiers }),
572
+ scroll: (pixels) => send({ type: "scroll", pixels }),
573
+ settled,
574
+ done: finish,
575
+ close: () => {
576
+ active = false;
577
+ stopWatching();
578
+ if (checkTimer)
579
+ clearTimeout(checkTimer);
580
+ checkTimer = null;
581
+ },
582
+ };
583
+ }
584
+ static EXPIRY_MARGIN_S = 60;
585
+ /** Seconds until this JWT expires, or null if it does not say.
586
+ *
587
+ * Reads `exp` without verifying anything: the signature is the server's business and
588
+ * a token we cannot parse is simply used as-is, which is the behaviour that existed
589
+ * before. Never throws — a malformed token must not break a request that might have
590
+ * worked. */
591
+ tokenLifeLeft(token) {
592
+ if (!token)
593
+ return null;
594
+ try {
595
+ const [, payload] = token.split(".");
596
+ if (!payload)
597
+ return null;
598
+ const json = payload.replace(/-/g, "+").replace(/_/g, "/");
599
+ // Whichever base64 the runtime has. Neither is guaranteed — this SDK runs in a
600
+ // browser, in Node and in a worker — and having neither just means no proactive
601
+ // refresh, which leaves the 401 path doing what it always did.
602
+ const g = globalThis;
603
+ const decoded = typeof g.atob === "function"
604
+ ? g.atob(json)
605
+ : g.Buffer
606
+ ? g.Buffer.from(json, "base64").toString("utf8")
607
+ : null;
608
+ if (!decoded)
609
+ return null;
610
+ const claims = JSON.parse(decoded);
611
+ const exp = Number(claims?.exp);
612
+ if (!Number.isFinite(exp))
613
+ return null;
614
+ return exp - Math.floor(Date.now() / 1000);
615
+ }
616
+ catch {
617
+ return null;
618
+ }
619
+ }
620
+ /** The bearer for the next request. */
621
+ async resolveToken() {
622
+ if (!this.opts.getToken)
623
+ return this.currentToken;
624
+ const token = await this.opts.getToken({ expired: false });
625
+ // About to lapse: ask for a replacement now rather than after the server refuses it.
626
+ const left = this.tokenLifeLeft(token || this.currentToken);
627
+ if (this.proactive && left !== null && left < AgentFramework.EXPIRY_MARGIN_S) {
628
+ const fresh = await this.refreshToken();
629
+ // If refreshing didn't buy any life — a project whose whole TTL is shorter than
630
+ // the margin, or a token endpoint that is down — stop doing this. Minting once per
631
+ // request is a far worse failure than the expiry it was avoiding, and every
632
+ // request is already replayed on a real 401, so this path is only ever an
633
+ // optimisation to give up on.
634
+ if (fresh === undefined || (this.tokenLifeLeft(fresh) ?? 0) < AgentFramework.EXPIRY_MARGIN_S) {
635
+ this.proactive = false;
636
+ }
637
+ if (fresh)
638
+ return fresh;
639
+ }
640
+ if (!token)
641
+ return this.currentToken;
642
+ // If the app handed back the exact token the server just rejected — the common
643
+ // shape, `getToken: () => myCache.token`, where the cache didn't learn about our
644
+ // refresh — use the fresher one we already hold instead of 401-ing every request.
645
+ if (token === this.rejectedToken && this.currentToken && this.currentToken !== token) {
646
+ return this.currentToken;
647
+ }
648
+ this.currentToken = token;
649
+ return token;
650
+ }
651
+ /** Whether pre-expiry refreshing is still worth attempting; see `resolveToken`. */
652
+ proactive = true;
653
+ /** Ask `getToken` for a replacement after a 401, at most one call in flight so N
654
+ * concurrent rejections mint one token rather than N. Returns undefined when there
655
+ * is no callback (or it failed) — the signal to let the 401 through untouched. */
656
+ async refreshToken() {
657
+ const get = this.opts.getToken;
658
+ if (!get)
659
+ return undefined;
660
+ this.rejectedToken = this.currentToken;
661
+ if (!this.refreshing) {
662
+ this.refreshing = (async () => {
663
+ try {
664
+ const fresh = await get({ expired: true });
665
+ if (!fresh)
666
+ return undefined;
667
+ this.currentToken = fresh;
668
+ return fresh;
669
+ }
670
+ catch {
671
+ // A failing token endpoint must not mask the auth error: surface the 401.
672
+ return undefined;
673
+ }
674
+ finally {
675
+ this.refreshing = undefined;
676
+ }
677
+ })();
678
+ }
679
+ return this.refreshing;
680
+ }
681
+ /** Register a client-side tool (handler run when the agent calls it). */
682
+ registerTool(tool) {
683
+ this.toolRegistry.set(tool.name, tool);
684
+ return this;
685
+ }
686
+ registerTools(tools) {
687
+ for (const t of tools)
688
+ this.toolRegistry.set(t.name, t);
689
+ return this;
690
+ }
691
+ /** Merge the client-level registry with any per-call tools (per-call wins). */
692
+ resolveTools(extra) {
693
+ const m = new Map(this.toolRegistry);
694
+ for (const t of extra ?? [])
695
+ m.set(t.name, t);
696
+ return m;
697
+ }
698
+ // -- auth headers --------------------------------------------------------
699
+ /** `bearer` overrides token resolution — used to replay a request with the token a
700
+ * refresh just produced, instead of asking for one again. */
701
+ async authHeaders(bearer) {
702
+ const h = { ...(this.opts.headers ?? {}) };
703
+ const token = bearer ?? (await this.resolveToken());
704
+ if (token)
705
+ h["Authorization"] = `Bearer ${token}`;
706
+ else if (this.opts.apiKey)
707
+ h["X-API-Key"] = this.opts.apiKey;
708
+ return h;
709
+ }
710
+ // -- low-level request ---------------------------------------------------
711
+ /** Like `request`, but hands back the raw Response (for binary payloads). */
712
+ async raw(method, path, init = {}) {
713
+ const url = new URL(this.baseUrl + path);
714
+ for (const [k, v] of Object.entries(init.query ?? {})) {
715
+ if (v !== undefined && v !== null)
716
+ url.searchParams.set(k, String(v));
717
+ }
718
+ const send = async (bearer) => {
719
+ const headers = { ...(await this.authHeaders(bearer)), ...(init.headers ?? {}) };
720
+ let body;
721
+ if (init.form !== undefined) {
722
+ // Let fetch set Content-Type so the multipart boundary is correct.
723
+ body = init.form;
724
+ }
725
+ else if (init.body !== undefined) {
726
+ headers["Content-Type"] = "application/json";
727
+ body = JSON.stringify(init.body);
728
+ }
729
+ return this._fetch(url.toString(), { method, headers, body, signal: init.signal });
730
+ };
731
+ let res = await send();
732
+ // Token expired mid-session: get a fresh one and replay the request once, so the
733
+ // expiry is invisible to the caller.
734
+ //
735
+ // Uploads are replayed too. They used to be excluded on the theory that a body
736
+ // might already be consumed, but a `FormData` is not a consumed stream — fetch
737
+ // serialises it into a fresh multipart body per call, in the browser and in Node
738
+ // alike. Excluding it meant an upload was the one request an expiry could kill
739
+ // outright, and an upload is the request a user is least willing to repeat.
740
+ if (res.status === 401) {
741
+ const fresh = await this.refreshToken();
742
+ if (fresh)
743
+ res = await send(fresh);
744
+ }
745
+ if (!res.ok) {
746
+ let detail = await res.text();
747
+ try {
748
+ detail = JSON.parse(detail);
749
+ if (detail && typeof detail === "object" && "detail" in detail)
750
+ detail = detail.detail;
751
+ }
752
+ catch {
753
+ /* leave as text */
754
+ }
755
+ throw new AgentApiError(res.status, detail);
756
+ }
757
+ return res;
758
+ }
759
+ async request(method, path, init = {}) {
760
+ const res = await this.raw(method, path, init);
761
+ if (res.status === 204)
762
+ return undefined;
763
+ const ct = res.headers.get("content-type") ?? "";
764
+ return (ct.includes("application/json") ? await res.json() : await res.text());
765
+ }
766
+ // -- auth ----------------------------------------------------------------
767
+ /**
768
+ * Mint a narrower token from the current credential — for a backend holding a
769
+ * project key that hands short-lived, per-end-user tokens to its frontend. Every
770
+ * field is intersected/clamped with what the caller already holds, so this can only
771
+ * ever narrow: a restricted token cannot mint a broader one.
772
+ */
773
+ auth = {
774
+ token: (body = {}) => this.request("POST", "/auth/token", { body }),
775
+ };
776
+ // -- tenants (operator admin; require adminKey / X-Admin-Key) -------------
777
+ adminHeaders() {
778
+ if (!this.opts.adminKey)
779
+ throw new Error("adminKey is required for tenant-management calls");
780
+ return { "X-Admin-Key": this.opts.adminKey };
781
+ }
782
+ tenants = {
783
+ // api_key is returned ONLY here, once.
784
+ create: (body) => this.request("POST", "/tenants", { body, headers: this.adminHeaders() }),
785
+ list: () => this.request("GET", "/tenants", { headers: this.adminHeaders() }),
786
+ /** Merge keys into a tenant's settings (model routing, retrieval models, allowed
787
+ * browser origins, guardrail policy...). Merged, not replaced — omitted keys are
788
+ * left alone. */
789
+ patchSettings: (id, settings) => this.request("PATCH", `/tenants/${id}`, { body: { settings }, headers: this.adminHeaders() }),
790
+ };
791
+ /** Liveness (`health`) and dependency readiness (`ready`). No auth required — use
792
+ * them from a probe or a status page. `ready` reports "degraded" with a per-check
793
+ * reason rather than failing, so you can tell "up but Postgres is unreachable"
794
+ * apart from "down". */
795
+ health = {
796
+ live: () => this.request("GET", "/health"),
797
+ ready: () => this.request("GET", "/ready"),
798
+ };
799
+ // -- tools ---------------------------------------------------------------
800
+ tools = {
801
+ list: () => this.request("GET", "/tools"),
802
+ };
803
+ // -- chat ----------------------------------------------------------------
804
+ chat = {
805
+ /** One round-trip. Returns `requires_action` + `tool_calls` for you to handle
806
+ * manually — use `chat.run` to auto-dispatch client tools instead. */
807
+ send: (body) => this.request("POST", "/chat", { body }),
808
+ /** Blocking chat that AUTO-EXECUTES registered client tools: it sends the
809
+ * message, and whenever the agent asks for client tools it runs their
810
+ * handlers, submits the results, and repeats until the agent is done. */
811
+ run: (opts = {}) => this.runWithTools(opts),
812
+ /** Streaming chat. When client tools are registered/passed, tool calls are
813
+ * auto-executed and the stream resumes, so `done` only resolves when the
814
+ * agent finishes (never with `requires_action`). */
815
+ stream: (body, handlers = {}) => {
816
+ const handle = this.startStreamWithTools(body, handlers);
817
+ // A watcher on this session must not re-deliver what we just streamed
818
+ // ourselves, so mark this turn's messages seen before the next poll.
819
+ handle.done
820
+ .then((d) => this.resyncWatchers(d.session_id))
821
+ .catch(() => { }); // stream errors surface through the handle, not here
822
+ return handle;
823
+ },
824
+ /** Send a message into a turn that is still running (needs `steer`).
825
+ *
826
+ * Prefer `handle.steer(...)` when you have the stream handle. This is for a
827
+ * caller that only kept the run id. Resolves false if the turn already ended. */
828
+ steer: async (runId, message) => {
829
+ try {
830
+ await this.request("POST", `/chat/stream/${runId}/steer`, { body: { message } });
831
+ return true;
832
+ }
833
+ catch (e) {
834
+ if (e instanceof AgentApiError && (e.status === 409 || e.status === 404))
835
+ return false;
836
+ throw e;
837
+ }
838
+ },
839
+ /** Resume a turn the agent paused on a question.
840
+ *
841
+ * Use it when you drive the picker yourself (`chat.send` returned `questions`);
842
+ * with `onQuestion` on `chat.run`/`chat.stream` this happens for you.
843
+ *
844
+ * const r = await ai.chat.send({ message: "migrate the fetchers" });
845
+ * if (r.questions.length) {
846
+ * const picked = await showPicker(r.questions[0]); // your UI
847
+ * await ai.chat.answer(r.session_id, {
848
+ * tool_call_id: r.questions[0].tool_call_id,
849
+ * answers: [{ question_id: "Scope", selected: [picked] }],
850
+ * });
851
+ * }
852
+ *
853
+ * Pass `{ chat_instead: true, message }` when the user would rather keep
854
+ * talking — the agent drops the question instead of re-asking it. */
855
+ /** Tell the agent the user is finished with a page it handed over, resuming the
856
+ * turn that stopped on it. */
857
+ handoffDone: (sessionId) => this.request("POST", "/chat", { body: { session_id: sessionId, handoff_done: true } }),
858
+ answer: (sessionId, ...answers) => this.request("POST", "/chat", {
859
+ body: { session_id: sessionId, question_answers: answers },
860
+ }),
861
+ sessions: {
862
+ list: (userRef) => this.request("GET", "/chat/sessions", { query: { user_ref: userRef } }),
863
+ /** The agent's plan for this conversation — what a UI renders on a page load,
864
+ * or between turns. A turn that touched the list also returns it directly. */
865
+ todos: (sessionId) => this.request("GET", `/chat/sessions/${sessionId}/todos`),
866
+ /** What the handed-over page looks like right now. Needs `browser_handoff`.
867
+ *
868
+ * Poll this while the user has control, and pass the same `selector` the agent
869
+ * handed over so the view doesn't jump. Re-read rather than sent once because
870
+ * the thing a person has to act on — a CAPTCHA challenge grid — often only
871
+ * appears after their first click. */
872
+ browserFrame: (sessionId, selector = "") => this.request("POST", `/chat/sessions/${sessionId}/browser/frame`, { body: { selector } }),
873
+ /** Watch the handed-over page live, instead of asking for pictures of it.
874
+ *
875
+ * Frames are pushed as Chrome repaints. Polling `browserFrame` gives roughly one
876
+ * frame a second, which is fine for watching a page settle and not enough to act
877
+ * on one — a slider puzzle at that rate can't be completed.
878
+ *
879
+ * `onClip` fires only when the region worth showing MOVES (an overlay appears,
880
+ * the page scrolls). Crop client-side against the last clip: the frames are the
881
+ * whole viewport, so `origin` is both where to crop and what to echo back with a
882
+ * pointer event. */
883
+ browserStream: (sessionId, handlers, selector = "") => {
884
+ const controller = new AbortController();
885
+ void (async () => {
886
+ try {
887
+ const url = new URL(`${this.baseUrl}/chat/sessions/${sessionId}/browser/stream`);
888
+ if (selector)
889
+ url.searchParams.set("selector", selector);
890
+ const open = async (bearer) => {
891
+ const headers = await this.authHeaders(bearer);
892
+ headers["Accept"] = "text/event-stream";
893
+ return this._fetch(url.toString(), {
894
+ method: "GET",
895
+ headers,
896
+ signal: controller.signal,
897
+ });
898
+ };
899
+ let res = await open();
900
+ if (res.status === 401) {
901
+ // A hand-off can sit open longer than a token lives — the whole point is
902
+ // that it waits for a person. Expiring mid-gesture must not end the view.
903
+ const fresh = await this.refreshToken();
904
+ if (fresh)
905
+ res = await open(fresh);
906
+ }
907
+ if (!res.ok)
908
+ throw new AgentApiError(res.status, await res.text());
909
+ if (!res.body)
910
+ throw new AgentStreamError("browser stream returned no body");
911
+ for await (const ev of parseSSE(res.body, controller.signal)) {
912
+ if (ev.event === "frame")
913
+ handlers.onFrame(JSON.parse(ev.data));
914
+ else if (ev.event === "clip")
915
+ handlers.onClip?.(JSON.parse(ev.data));
916
+ else if (ev.event === "error") {
917
+ handlers.onError?.(JSON.parse(ev.data)?.detail ?? "browser stream failed");
918
+ }
919
+ }
920
+ }
921
+ catch (e) {
922
+ // An abort is the caller closing the viewer, not a failure to report.
923
+ if (e?.name === "AbortError")
924
+ return;
925
+ handlers.onError?.(String(e?.message ?? e));
926
+ }
927
+ })();
928
+ return { close: () => controller.abort() };
929
+ },
930
+ /** Has the person finished with the page they were handed?
931
+ *
932
+ * Call it after they let go of the mouse. A separate small model looks at the
933
+ * region and reports one of four words; when `done`, press Done for them with
934
+ * `chat.handoffDone`. Rate-limited server-side, so calling it on every mouse-up
935
+ * is fine — the extra calls come back `checked: false`. */
936
+ handoffCheck: (sessionId) => this.request("POST", `/chat/sessions/${sessionId}/browser/check`),
937
+ /** Relay one thing the user did into the page, and get the resulting frame.
938
+ *
939
+ * Send `x`/`y` in IMAGE space along with the `origin` of the frame they clicked
940
+ * — echo it back rather than adding it yourself, so a frame that moved between
941
+ * render and click can't displace the click.
942
+ *
943
+ * Watching `browserStream`? Set `want_frame: false` — otherwise every pointer
944
+ * move renders a JPEG that arrives after the stream already showed it. */
945
+ browserInput: (sessionId, input) => this.request("POST", `/chat/sessions/${sessionId}/browser/input`, { body: input }),
946
+ /** The whole hand-off, driven for you.
947
+ *
948
+ * Call it when a `browser_handoff` event arrives with `active: true`, give it an
949
+ * `<img>`, and you are done: it opens the live stream, crops each frame to the
950
+ * region the agent pointed at, maps clicks and drags back into page space, relays
951
+ * them, and asks the server whether the gesture finished the job — pressing Done
952
+ * for the user when it did.
953
+ *
954
+ * const view = ai.chat.sessions.handoff(sessionId, handoff, {
955
+ * onView: () => render(),
956
+ * onEnded: ({ resume }) => resume && ai.chat.handoffDone(sessionId),
957
+ * });
958
+ * const detach = view.attach(imgElement); // in your effect
959
+ * // …later: detach(); view.close();
960
+ *
961
+ * Nothing here needs a browser until `attach()`, and `attach()` needs only an
962
+ * object shaped like an image — so importing the SDK on a server is unaffected. */
963
+ handoff: (sessionId, handoff, options = {}) => this.buildHandoff(sessionId, handoff, options),
964
+ /** A few things the user might say next, in their voice — render as buttons and
965
+ * send the clicked one as an ordinary message. Needs the `followups` capability.
966
+ *
967
+ * Call it AFTER the turn's `done`, not before: it is a separate request on
968
+ * purpose, so the answer is never held up by a suggestion nobody asked for.
969
+ * The agent is not told a message was suggested rather than typed, and nothing
970
+ * is stored. Returns [] when nothing sensible follows or generation failed —
971
+ * a missing affordance is not worth an error. */
972
+ followups: (sessionId, count = 3) => this.request("POST", `/chat/sessions/${sessionId}/followups`, { body: { count } }),
973
+ /** One or two sentences on where this conversation got to, for a user coming
974
+ * back after a while. Needs the `recap` capability.
975
+ *
976
+ * Your client decides when to ask — only it knows the tab has been idle. This
977
+ * is not context compaction: that summarizes FOR the model and is replayed to
978
+ * it, whereas the agent never sees this. Returns "" on failure. */
979
+ recap: (sessionId) => this.request("POST", `/chat/sessions/${sessionId}/recap`),
980
+ messages: (sessionId) => this.request("GET", `/chat/sessions/${sessionId}/messages`),
981
+ delete: (sessionId) => this.request("DELETE", `/chat/sessions/${sessionId}`),
982
+ /**
983
+ * Deliver messages that appear in a session which this client did not stream.
984
+ *
985
+ * A scheduled reminder (`schedule_reminder`, or any task with an `agent`
986
+ * action) runs server-side and appends its answer to the session — there is no
987
+ * stream to listen to, so without this a reminder fires and the UI never hears
988
+ * about it. History is snapshotted on start and this client's own turns are
989
+ * marked seen automatically, so `onMessage` only fires for genuinely new
990
+ * messages. For server-to-server delivery, give the task a `callback_url`
991
+ * instead of polling.
992
+ */
993
+ watch: (sessionId, handlers) => this.watchSession(sessionId, handlers),
994
+ /** Branch a conversation: create a new chat by copying `sessionId` up to and
995
+ * including `upToMessageId` (or the whole chat if omitted). */
996
+ fork: (sessionId, opts = {}) => this.request("POST", `/chat/sessions/${sessionId}/fork`, {
997
+ body: { up_to_message_id: opts.upToMessageId ?? null, title: opts.title },
998
+ }),
999
+ },
1000
+ };
1001
+ // -- documents -----------------------------------------------------------
1002
+ documents = {
1003
+ list: (query = {}) => this.request("GET", "/documents", { query }),
1004
+ get: (id) => this.request("GET", `/documents/${id}`),
1005
+ delete: (id) => this.request("DELETE", `/documents/${id}`),
1006
+ retrieve: (body) => this.request("POST", "/documents/retrieve", { body }),
1007
+ /** Small-file convenience upload via multipart form (server proxies to S3). */
1008
+ uploadSimple: async (file, opts) => {
1009
+ const form = new FormData();
1010
+ const blob = typeof Blob !== "undefined" && file instanceof Blob
1011
+ ? file
1012
+ : new Blob([file], { type: opts.contentType ?? "application/octet-stream" });
1013
+ form.append("file", blob, opts.filename);
1014
+ form.append("tags", (opts.tags ?? []).join(","));
1015
+ // Set visibility AT UPLOAD, not after: a document is retrievable as soon as
1016
+ // ingestion finishes, so patching later leaves a window where it's readable by
1017
+ // more people than intended.
1018
+ if (opts.visibility)
1019
+ form.append("visibility", opts.visibility);
1020
+ if (opts.visibility_scope)
1021
+ form.append("visibility_scope", opts.visibility_scope);
1022
+ if (opts.acl_roles?.length)
1023
+ form.append("acl_roles", opts.acl_roles.join(","));
1024
+ if (opts.acl_groups?.length)
1025
+ form.append("acl_groups", opts.acl_groups.join(","));
1026
+ const send = async (bearer) => this._fetch(this.baseUrl + "/documents", {
1027
+ method: "POST",
1028
+ headers: await this.authHeaders(bearer),
1029
+ body: form,
1030
+ signal: opts.signal,
1031
+ });
1032
+ let res = await send();
1033
+ // Same replay as `request`: an upload is often the longest-lived request a page
1034
+ // makes, and losing a whole file to a token that lapsed while it was in flight is
1035
+ // the worst possible time to surface an expiry.
1036
+ if (res.status === 401) {
1037
+ const fresh = await this.refreshToken();
1038
+ if (fresh)
1039
+ res = await send(fresh);
1040
+ }
1041
+ if (!res.ok)
1042
+ throw new AgentApiError(res.status, await res.text());
1043
+ return (await res.json());
1044
+ },
1045
+ /** Resumable presigned multipart upload (direct to S3). */
1046
+ upload: (file, opts) => this.multipartUpload(file, opts),
1047
+ /** Poll a document until ingestion finishes (status "ready" or "failed").
1048
+ * Ingestion is async, so querying a just-uploaded doc may return nothing until
1049
+ * this resolves. Throws on "failed" or timeout. */
1050
+ waitReady: async (id, opts = {}) => {
1051
+ const timeout = opts.timeoutMs ?? 120_000;
1052
+ const interval = opts.intervalMs ?? 1500;
1053
+ const deadline = Date.now() + timeout;
1054
+ for (;;) {
1055
+ const doc = await this.request("GET", `/documents/${id}`, { signal: opts.signal });
1056
+ if (doc.status === "ready")
1057
+ return doc;
1058
+ if (doc.status === "failed")
1059
+ throw new AgentApiError(422, `ingestion failed: ${doc.error ?? "unknown"}`);
1060
+ if (Date.now() > deadline)
1061
+ throw new AgentApiError(408, `ingestion timed out after ${timeout}ms (status=${doc.status})`);
1062
+ await sleep(interval);
1063
+ }
1064
+ },
1065
+ /** Upload a small file AND wait for it to finish ingesting — the common case. */
1066
+ uploadAndWait: async (file,
1067
+ // Takes the ACL fields too: this is the upload most callers use, so omitting them
1068
+ // here forced a second `patch` and left a window where the document was already
1069
+ // retrievable at its default visibility.
1070
+ opts) => {
1071
+ const doc = await this.documents.uploadSimple(file, opts);
1072
+ return this.documents.waitReady(doc.id, { timeoutMs: opts.timeoutMs, signal: opts.signal });
1073
+ },
1074
+ /** Get a presigned GET URL for the raw file. */
1075
+ downloadUrl: (id) => this.request("GET", `/documents/${id}/download-url`),
1076
+ /** Resumable ranged download; returns a Blob. */
1077
+ download: (id, opts = {}) => this.rangedDownload(id, opts),
1078
+ /** Update a document's tags / visibility / ACL. */
1079
+ patch: (id, body) => this.request("PATCH", `/documents/${id}`, { body }),
1080
+ /** Reprocess a document (retry a failed ingest, or re-embed with a new model). */
1081
+ reingest: (id) => this.request("POST", `/documents/${id}/reingest`),
1082
+ /** Inspect the stored chunks of a document (text + page + index). */
1083
+ chunks: (id) => this.request("GET", `/documents/${id}/chunks`),
1084
+ };
1085
+ // -- tasks ---------------------------------------------------------------
1086
+ tasks = {
1087
+ create: (body) => this.request("POST", "/tasks", { body }),
1088
+ list: (status) => this.request("GET", "/tasks", { query: { status } }),
1089
+ get: (id) => this.request("GET", `/tasks/${id}`),
1090
+ cancel: (id) => this.request("POST", `/tasks/${id}/cancel`),
1091
+ };
1092
+ // -- knowledge sources (ai.sync data connectors) -------------------------
1093
+ sources = {
1094
+ list: () => this.request("GET", "/sources"),
1095
+ get: (id) => this.request("GET", `/sources/${id}`),
1096
+ types: () => this.request("GET", "/sources/types"),
1097
+ /** Rotate credentials, retag, change the refresh cadence or the ACL. A new
1098
+ * `config` is tested before it replaces the old one, so a bad DSN fails here
1099
+ * (400) instead of at the next scheduled sync. Changing the interval
1100
+ * reschedules; 0 turns auto-refresh off. */
1101
+ patch: (id, body) => this.request("PATCH", `/sources/${id}`, { body }),
1102
+ /** Trigger a sync now; pass `fullRefresh` to re-pull everything. */
1103
+ sync: (id, fullRefresh = false) => this.request("POST", `/sources/${id}/sync`, { query: { full_refresh: fullRefresh } }),
1104
+ remove: (id) => this.request("DELETE", `/sources/${id}`),
1105
+ };
1106
+ // -- governance: audit trail + right-to-be-forgotten (admin) -------------
1107
+ audit = {
1108
+ /** Read the tenant's audit trail (admin). Filter by action/subject. */
1109
+ list: (opts = {}) => this.request("GET", "/audit", { query: opts }),
1110
+ /** Purge a subject's documents, sessions, and vectors (GDPR erasure; admin). */
1111
+ forget: (subject) => this.request("POST", "/audit/forget", { body: { subject } }),
1112
+ };
1113
+ /**
1114
+ * Sandboxed compute — the isolated machines the agent works in. Each session has a
1115
+ * stable id, survives sleep/wake, and can be reattached to a later turn via
1116
+ * `chat({ computer_session_id })`.
1117
+ *
1118
+ * Nothing to configure: the deployment runs one sandbox host, and a project with the
1119
+ * `computer` capability gets a machine. `host()` says what that machine is.
1120
+ *
1121
+ * const s = await ai.computers.create();
1122
+ * await ai.computers.upload(s.id, file); // any file type, straight in
1123
+ * await ai.chat({ message: "summarise inbox/data.csv", computer_session_id: s.id });
1124
+ * await ai.computers.pause(s.id); // sleep now instead of on idle
1125
+ */
1126
+ computers = {
1127
+ /** What this deployment's sandboxes are and can do — use it to hide a feature
1128
+ * (port exposure, say) rather than offering a button that fails. */
1129
+ host: () => this.request("GET", "/computers/host"),
1130
+ /** Start a sandbox. Bind it to a chat session to give that conversation a
1131
+ * persistent workspace. */
1132
+ create: (body = {}) => this.request("POST", "/computers/sessions", { body }),
1133
+ list: (opts = {}) => this.request("GET", "/computers/sessions", { query: opts }),
1134
+ get: (id) => this.request("GET", `/computers/sessions/${id}`),
1135
+ /** Put it to sleep now rather than waiting for it to go idle; the workspace is
1136
+ * preserved either way. */
1137
+ pause: (id) => this.request("POST", `/computers/sessions/${id}/pause`),
1138
+ /** Wake a sleeping sandbox and reattach to its workspace. */
1139
+ resume: (id) => this.request("POST", `/computers/sessions/${id}/resume`),
1140
+ destroy: (id) => this.request("DELETE", `/computers/sessions/${id}`),
1141
+ /** Run a command yourself (same guardrails as the agent's tool). `timeout_s` is
1142
+ * clamped to the host's ceiling — 30 minutes — and then the command is killed. */
1143
+ exec: (id, body) => this.request("POST", `/computers/sessions/${id}/exec`, { body }),
1144
+ /** Push a file (any type) into the sandbox. */
1145
+ upload: async (id, file, dest) => {
1146
+ const form = new FormData();
1147
+ form.append("file", file, file.name ?? "upload.bin");
1148
+ if (dest)
1149
+ form.append("dest", dest);
1150
+ return this.request("POST", `/computers/sessions/${id}/files`, { form });
1151
+ },
1152
+ /** Pull a file out of the sandbox as bytes. */
1153
+ download: async (id, path) => {
1154
+ const res = await this.raw("GET", `/computers/sessions/${id}/files`, { query: { path } });
1155
+ return res.blob();
1156
+ },
1157
+ };
1158
+ /**
1159
+ * Connect an external data source and keep it live in the agent's knowledge.
1160
+ * Validates the connector, runs an initial sync, and (when an interval is set)
1161
+ * schedules recurring auto-refresh so the data never goes stale.
1162
+ *
1163
+ * await ai.sync({ name: "orders", connector_type: "postgres",
1164
+ * config: { dsn, query: "select id, status, total from orders",
1165
+ * cursor_column: "updated_at" }, sync_interval_seconds: 900 });
1166
+ */
1167
+ sync = (config) => this.request("POST", "/sources", { body: config });
1168
+ // ==========================================================================
1169
+ // Automatic client-tool dispatch
1170
+ // ==========================================================================
1171
+ async runWithTools(opts) {
1172
+ const { tools: extra, maxToolRounds, onToolCalls, onQuestion, ...body } = opts;
1173
+ const tools = this.resolveTools(extra);
1174
+ const schemas = tools.size ? toolSchemas(tools) : undefined;
1175
+ const maxRounds = maxToolRounds ?? 10;
1176
+ let resp = await this.request("POST", "/chat", {
1177
+ body: { ...body, client_tools: schemas },
1178
+ signal: opts.signal,
1179
+ });
1180
+ let rounds = 0;
1181
+ // Two ways a turn pauses: work for the client to run, or a question for the user
1182
+ // to answer. Both resume the same way, and the round budget covers both together
1183
+ // so a pathological alternation can't loop forever.
1184
+ for (;;) {
1185
+ const hasTools = resp.requires_action && resp.tool_calls.length > 0;
1186
+ const hasQuestions = onQuestion != null && (resp.questions?.length ?? 0) > 0;
1187
+ if (!hasTools && !hasQuestions)
1188
+ return resp;
1189
+ if (rounds++ >= maxRounds)
1190
+ throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
1191
+ const next = { session_id: resp.session_id, client_tools: schemas };
1192
+ if (hasTools) {
1193
+ onToolCalls?.(resp.tool_calls);
1194
+ next.tool_results = await executeToolCalls(resp.tool_calls, tools);
1195
+ }
1196
+ if (hasQuestions)
1197
+ next.question_answers = await collectAnswers(resp.questions, onQuestion);
1198
+ resp = await this.request("POST", "/chat", { body: next, signal: opts.signal });
1199
+ }
1200
+ }
1201
+ /** Wrap startStream so a `done` carrying `requires_action` auto-executes the
1202
+ * client tools and continues the stream (same session) until the agent ends. */
1203
+ startStreamWithTools(body, handlers) {
1204
+ const tools = this.resolveTools(handlers.tools);
1205
+ const onQuestion = handlers.onQuestion;
1206
+ if (tools.size === 0 && onQuestion == null)
1207
+ return this.startStream(body, handlers);
1208
+ const schemas = tools.size ? toolSchemas(tools) : undefined;
1209
+ const maxRounds = handlers.maxToolRounds ?? 10;
1210
+ let current;
1211
+ let stopped = false;
1212
+ const done = (async () => {
1213
+ let reqBody = { ...body, client_tools: schemas };
1214
+ let rounds = 0;
1215
+ for (;;) {
1216
+ current = this.startStream(reqBody, handlers);
1217
+ const d = await current.done;
1218
+ const hasTools = d.requires_action && d.tool_calls.length > 0;
1219
+ const hasQuestions = onQuestion != null && (d.questions?.length ?? 0) > 0;
1220
+ if (!hasTools && !hasQuestions)
1221
+ return d;
1222
+ if (stopped)
1223
+ return d;
1224
+ if (rounds++ >= maxRounds)
1225
+ throw new AgentStreamError(`agent pause loop exceeded maxToolRounds (${maxRounds})`);
1226
+ reqBody = { session_id: d.session_id, client_tools: schemas };
1227
+ if (hasTools) {
1228
+ handlers.onToolCalls?.(d.tool_calls);
1229
+ reqBody.tool_results = await executeToolCalls(d.tool_calls, tools);
1230
+ }
1231
+ if (hasQuestions)
1232
+ reqBody.question_answers = await collectAnswers(d.questions, onQuestion);
1233
+ }
1234
+ })();
1235
+ return makeStreamHandle(done, {
1236
+ disconnect: () => { stopped = true; current?.disconnect(); },
1237
+ cancel: async () => { stopped = true; await current?.cancel(); },
1238
+ runId: () => current?.runId(),
1239
+ steer: (m) => current?.steer(m) ?? Promise.resolve(false),
1240
+ });
1241
+ }
1242
+ async resyncWatchers(sessionId) {
1243
+ await Promise.all([...this.watchers].filter((w) => w.sessionId === sessionId).map((w) => w.resync()));
1244
+ }
1245
+ watchSession(sessionId, handlers) {
1246
+ const seen = new Set();
1247
+ let primed = false;
1248
+ let stopped = false;
1249
+ /** Read the session and emit whatever has not been delivered yet. */
1250
+ const collect = async (emit) => {
1251
+ const messages = await this.chat.sessions.messages(sessionId);
1252
+ const fresh = messages.filter((m) => !seen.has(m.id));
1253
+ for (const m of fresh)
1254
+ seen.add(m.id);
1255
+ if (emit)
1256
+ for (const m of fresh)
1257
+ handlers.onMessage(m);
1258
+ };
1259
+ const entry = { sessionId, resync: () => collect(false) };
1260
+ this.watchers.add(entry);
1261
+ const controller = new AbortController();
1262
+ let poller;
1263
+ const stop = () => {
1264
+ if (stopped)
1265
+ return;
1266
+ stopped = true;
1267
+ if (poller)
1268
+ clearInterval(poller);
1269
+ controller.abort();
1270
+ this.watchers.delete(entry);
1271
+ };
1272
+ handlers.signal?.addEventListener("abort", stop, { once: true });
1273
+ /** Fallback: ask periodically. Works against any deployment. */
1274
+ const startPolling = () => {
1275
+ if (stopped || poller)
1276
+ return;
1277
+ handlers.onTransport?.("poll");
1278
+ const tick = async () => {
1279
+ if (stopped)
1280
+ return;
1281
+ try {
1282
+ await collect(primed);
1283
+ primed = true;
1284
+ }
1285
+ catch (e) {
1286
+ handlers.onError?.(e);
1287
+ }
1288
+ };
1289
+ poller = setInterval(tick, handlers.intervalMs ?? 4000);
1290
+ void tick();
1291
+ };
1292
+ /**
1293
+ * Preferred: subscribe to the session's event stream, so a message appears the
1294
+ * moment it is committed. Deployments without the endpoint fall back to polling,
1295
+ * which is why this stays an implementation detail of `watch`.
1296
+ */
1297
+ const startStreaming = async () => {
1298
+ let lastId;
1299
+ try {
1300
+ // Snapshot first, so history is never replayed as new.
1301
+ await collect(false);
1302
+ primed = true;
1303
+ }
1304
+ catch {
1305
+ /* the stream itself will surface a real failure */
1306
+ }
1307
+ for (let attempt = 0; !stopped; attempt++) {
1308
+ try {
1309
+ const url = new URL(`${this.baseUrl}/chat/sessions/${sessionId}/events`);
1310
+ if (lastId)
1311
+ url.searchParams.set("after", lastId);
1312
+ const headers = await this.authHeaders();
1313
+ headers["Accept"] = "text/event-stream";
1314
+ const res = await this._fetch(url.toString(), {
1315
+ method: "GET",
1316
+ headers,
1317
+ signal: controller.signal,
1318
+ });
1319
+ if (res.status === 404 || res.status === 405)
1320
+ return startPolling(); // older deployment
1321
+ if (!res.ok || !res.body)
1322
+ throw new AgentStreamError(`session events: HTTP ${res.status}`);
1323
+ handlers.onTransport?.("stream");
1324
+ attempt = 0;
1325
+ for await (const frame of parseSSE(res.body, controller.signal)) {
1326
+ if (stopped)
1327
+ return;
1328
+ if (frame.event !== "message" || !frame.data)
1329
+ continue;
1330
+ let message;
1331
+ try {
1332
+ message = JSON.parse(frame.data);
1333
+ }
1334
+ catch {
1335
+ continue; // a keepalive or a frame we don't understand
1336
+ }
1337
+ if (seen.has(message.id))
1338
+ continue;
1339
+ seen.add(message.id);
1340
+ lastId = message.id;
1341
+ handlers.onMessage(message);
1342
+ }
1343
+ if (stopped)
1344
+ return;
1345
+ }
1346
+ catch (e) {
1347
+ if (stopped || controller.signal.aborted)
1348
+ return;
1349
+ handlers.onError?.(e);
1350
+ // A dropped connection is normal; back off, then resume after `lastId`.
1351
+ await sleep(Math.min(1000 * 2 ** Math.min(attempt, 4), 15_000));
1352
+ }
1353
+ }
1354
+ };
1355
+ if (handlers.poll)
1356
+ startPolling();
1357
+ else
1358
+ void startStreaming();
1359
+ return { stop, resync: entry.resync };
1360
+ }
1361
+ // ==========================================================================
1362
+ // Streaming with reconnect
1363
+ // ==========================================================================
1364
+ startStream(body, handlers) {
1365
+ const ac = new AbortController();
1366
+ const onOuterAbort = () => ac.abort();
1367
+ handlers.signal?.addEventListener("abort", onOuterAbort);
1368
+ const maxRetries = handlers.maxRetries ?? 10;
1369
+ let runId;
1370
+ let lastId = -1;
1371
+ let full = "";
1372
+ let reasoning = "";
1373
+ // Merged by ref, because the wire carries one subagent per move and a UI wants the
1374
+ // whole set. Doing it here rather than in every client is the difference between a
1375
+ // panel that survives a dropped frame and one that quietly goes stale.
1376
+ const subagents = new Map();
1377
+ const openConnection = async (bearer) => {
1378
+ const headers = await this.authHeaders(bearer);
1379
+ headers["Accept"] = "text/event-stream";
1380
+ if (runId === undefined) {
1381
+ headers["Content-Type"] = "application/json";
1382
+ return this._fetch(this.baseUrl + "/chat/stream", {
1383
+ method: "POST",
1384
+ headers,
1385
+ body: JSON.stringify(body),
1386
+ signal: ac.signal,
1387
+ });
1388
+ }
1389
+ const url = new URL(`${this.baseUrl}/chat/stream/${runId}`);
1390
+ url.searchParams.set("last_event_id", String(lastId));
1391
+ return this._fetch(url.toString(), { method: "GET", headers, signal: ac.signal });
1392
+ };
1393
+ const done = (async () => {
1394
+ let attempt = 0; // reset to 0 whenever a frame is received (see below)
1395
+ let refreshed = false; // one token refresh per connection, reset on progress
1396
+ for (;;) {
1397
+ if (ac.signal.aborted)
1398
+ throw new DOMException("aborted", "AbortError");
1399
+ let shouldRetry = false;
1400
+ try {
1401
+ let res = await openConnection();
1402
+ if (res.status === 401 && !refreshed) {
1403
+ // The token expired — on a resume this re-attaches to the SAME run, so
1404
+ // generation already in flight is picked up rather than restarted.
1405
+ const fresh = await this.refreshToken();
1406
+ if (fresh) {
1407
+ refreshed = true;
1408
+ res = await openConnection(fresh);
1409
+ }
1410
+ }
1411
+ if (!res.ok) {
1412
+ // POST failed, or 404 on resume (run expired) — terminal, no retry.
1413
+ throw new AgentApiError(res.status, await res.text());
1414
+ }
1415
+ runId = res.headers.get("X-Run-Id") ?? runId;
1416
+ if (!res.body)
1417
+ throw new Error("response has no body"); // retryable
1418
+ for await (const frame of parseSSE(res.body, ac.signal)) {
1419
+ attempt = 0; // progress: a successful (re)connection resets the budget
1420
+ refreshed = false; // ...and re-arms the refresh, for streams outliving a TTL
1421
+ if (frame.id !== undefined)
1422
+ lastId = frame.id;
1423
+ const ev = { event: frame.event, data: safeJson(frame.data) };
1424
+ handlers.onEvent?.(ev);
1425
+ switch (ev.event) {
1426
+ case "run":
1427
+ runId = ev.data.run_id;
1428
+ break;
1429
+ case "token":
1430
+ full += ev.data.delta;
1431
+ handlers.onToken?.(ev.data.delta, full);
1432
+ break;
1433
+ case "tool_start":
1434
+ handlers.onToolStart?.(ev.data.name, ev.data.input);
1435
+ break;
1436
+ case "tool_end":
1437
+ handlers.onToolEnd?.(ev.data.name, ev.data.output);
1438
+ break;
1439
+ case "citations":
1440
+ handlers.onCitations?.(ev.data.citations);
1441
+ break;
1442
+ case "sources":
1443
+ handlers.onSources?.(ev.data.sources);
1444
+ break;
1445
+ case "guardrail":
1446
+ handlers.onGuardrail?.(ev.data.stage, ev.data.flags, ev.data.content);
1447
+ break;
1448
+ case "attachments":
1449
+ handlers.onAttachments?.(ev.data.attachments);
1450
+ break;
1451
+ case "todos":
1452
+ handlers.onTodos?.(ev.data.todos);
1453
+ break;
1454
+ case "subagent":
1455
+ subagents.set(ev.data.subagent.ref, ev.data.subagent);
1456
+ handlers.onSubagents?.([...subagents.values()]);
1457
+ break;
1458
+ case "subagents":
1459
+ // The authoritative list. It replaces rather than merges: a subagent
1460
+ // the server no longer reports is one this conversation does not have.
1461
+ subagents.clear();
1462
+ for (const a of ev.data.subagents)
1463
+ subagents.set(a.ref, a);
1464
+ handlers.onSubagents?.([...subagents.values()]);
1465
+ break;
1466
+ case "command_output":
1467
+ handlers.onCommandOutput?.(ev.data);
1468
+ break;
1469
+ case "command_finished":
1470
+ handlers.onCommandFinished?.(ev.data);
1471
+ break;
1472
+ case "questions":
1473
+ handlers.onQuestions?.(ev.data.questions);
1474
+ break;
1475
+ case "browser_handoff":
1476
+ handlers.onBrowserHandoff?.(ev.data);
1477
+ break;
1478
+ case "context":
1479
+ handlers.onContext?.(ev.data);
1480
+ break;
1481
+ case "reasoning":
1482
+ reasoning += ev.data.delta;
1483
+ handlers.onReasoning?.(ev.data.delta, reasoning);
1484
+ break;
1485
+ case "done":
1486
+ return ev.data;
1487
+ case "cancelled":
1488
+ throw new AgentCancelledError(); // terminal
1489
+ case "error":
1490
+ throw new AgentStreamError("server: " + ev.data.detail); // terminal
1491
+ }
1492
+ }
1493
+ // Stream ended with no terminal event => dropped connection.
1494
+ shouldRetry = true;
1495
+ }
1496
+ catch (err) {
1497
+ if (ac.signal.aborted)
1498
+ throw new DOMException("aborted", "AbortError");
1499
+ // Terminal errors: server 'error'/'cancelled' event, or an HTTP failure.
1500
+ if (err instanceof AgentApiError ||
1501
+ err instanceof AgentCancelledError ||
1502
+ (err instanceof AgentStreamError && err.message.startsWith("server:"))) {
1503
+ throw err;
1504
+ }
1505
+ shouldRetry = true; // network/read error
1506
+ }
1507
+ // Single retry site — increments once per failed attempt, resumes if possible.
1508
+ if (shouldRetry) {
1509
+ attempt += 1;
1510
+ if (attempt > maxRetries || runId === undefined)
1511
+ throw new AgentStreamError("stream ended before completion (retries exhausted)");
1512
+ handlers.onReconnect?.(attempt);
1513
+ await sleep(backoff(attempt));
1514
+ }
1515
+ }
1516
+ })().finally(() => handlers.signal?.removeEventListener("abort", onOuterAbort));
1517
+ const cancel = async () => {
1518
+ const id = runId;
1519
+ if (id) {
1520
+ try {
1521
+ await this.request("POST", `/chat/stream/${id}/cancel`);
1522
+ }
1523
+ catch {
1524
+ /* best-effort */
1525
+ }
1526
+ }
1527
+ ac.abort();
1528
+ };
1529
+ const steer = async (message) => {
1530
+ if (!runId)
1531
+ return false; // nothing to steer yet — the run has no id
1532
+ try {
1533
+ await this.request("POST", `/chat/stream/${runId}/steer`, { body: { message } });
1534
+ return true;
1535
+ }
1536
+ catch (e) {
1537
+ // 409 means the turn finished between the user typing and this landing. That
1538
+ // is the caller's cue to send it as a normal message, so report it rather
1539
+ // than throwing — a lost message is worse than a false.
1540
+ if (e instanceof AgentApiError && (e.status === 409 || e.status === 404))
1541
+ return false;
1542
+ throw e;
1543
+ }
1544
+ };
1545
+ return makeStreamHandle(done, {
1546
+ disconnect: () => ac.abort(), cancel, runId: () => runId, steer,
1547
+ });
1548
+ }
1549
+ // ==========================================================================
1550
+ // Resumable multipart upload
1551
+ // ==========================================================================
1552
+ async multipartUpload(file, opts) {
1553
+ const { size, slice } = toUint8(file);
1554
+ const concurrency = opts.concurrency ?? 4;
1555
+ const maxRetries = opts.maxRetries ?? 5;
1556
+ const presign = await this.request("POST", "/documents/presign-upload", {
1557
+ body: {
1558
+ filename: opts.filename,
1559
+ content_type: opts.contentType,
1560
+ tags: opts.tags ?? [],
1561
+ ...(opts.visibility ? { visibility: opts.visibility } : {}),
1562
+ ...(opts.visibility_scope ? { visibility_scope: opts.visibility_scope } : {}),
1563
+ ...(opts.acl_roles ? { acl_roles: opts.acl_roles } : {}),
1564
+ ...(opts.acl_groups ? { acl_groups: opts.acl_groups } : {}),
1565
+ },
1566
+ signal: opts.signal,
1567
+ });
1568
+ const partSize = presign.part_size;
1569
+ const partCount = Math.max(1, Math.ceil(size / partSize));
1570
+ const partNumbers = Array.from({ length: partCount }, (_, i) => i + 1);
1571
+ try {
1572
+ const { urls } = await this.request("POST", `/documents/${presign.document_id}/presign-parts`, { body: { upload_id: presign.upload_id, part_numbers: partNumbers }, signal: opts.signal });
1573
+ const urlByPart = new Map(urls.map((u) => [u.part_number, u.url]));
1574
+ const etags = new Array(partCount);
1575
+ let sent = 0;
1576
+ let cursor = 0;
1577
+ const worker = async () => {
1578
+ for (;;) {
1579
+ if (opts.signal?.aborted)
1580
+ throw new DOMException("aborted", "AbortError");
1581
+ const idx = cursor++;
1582
+ if (idx >= partCount)
1583
+ return;
1584
+ const partNumber = partNumbers[idx];
1585
+ const start = idx * partSize;
1586
+ const end = Math.min(start + partSize, size);
1587
+ const bodyPart = slice(start, end);
1588
+ const url = urlByPart.get(partNumber);
1589
+ let lastErr;
1590
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1591
+ try {
1592
+ const res = await this._fetch(url, {
1593
+ method: "PUT",
1594
+ body: bodyPart,
1595
+ signal: opts.signal,
1596
+ });
1597
+ if (!res.ok)
1598
+ throw new AgentStreamError(`part ${partNumber} failed: HTTP ${res.status}`);
1599
+ const etag = res.headers.get("ETag") ?? res.headers.get("etag");
1600
+ if (!etag)
1601
+ throw new AgentStreamError(`part ${partNumber}: missing ETag (expose it via S3 CORS ExposeHeaders)`);
1602
+ etags[idx] = { part_number: partNumber, etag };
1603
+ sent += end - start;
1604
+ opts.onProgress?.(sent, size);
1605
+ lastErr = undefined;
1606
+ break;
1607
+ }
1608
+ catch (e) {
1609
+ if (opts.signal?.aborted)
1610
+ throw e;
1611
+ lastErr = e;
1612
+ if (attempt < maxRetries)
1613
+ await sleep(backoff(attempt));
1614
+ }
1615
+ }
1616
+ if (lastErr)
1617
+ throw lastErr;
1618
+ }
1619
+ };
1620
+ await Promise.all(Array.from({ length: Math.min(concurrency, partCount) }, () => worker()));
1621
+ return await this.request("POST", `/documents/${presign.document_id}/complete-upload`, {
1622
+ body: {
1623
+ upload_id: presign.upload_id,
1624
+ parts: etags.map((e) => ({ part_number: e.part_number, etag: e.etag })),
1625
+ },
1626
+ signal: opts.signal,
1627
+ });
1628
+ }
1629
+ catch (err) {
1630
+ // Best-effort abort so we don't leak an incomplete multipart upload.
1631
+ try {
1632
+ await this.request("POST", `/documents/${presign.document_id}/abort-upload`, {
1633
+ body: { upload_id: presign.upload_id },
1634
+ });
1635
+ }
1636
+ catch {
1637
+ /* ignore */
1638
+ }
1639
+ throw err;
1640
+ }
1641
+ }
1642
+ // ==========================================================================
1643
+ // Resumable ranged download
1644
+ // ==========================================================================
1645
+ async rangedDownload(id, opts) {
1646
+ const info = await this.documents.downloadUrl(id);
1647
+ const chunkSize = opts.chunkSize ?? 8 * 1024 * 1024;
1648
+ const maxRetries = opts.maxRetries ?? 5;
1649
+ const type = info.content_type ?? "application/octet-stream";
1650
+ let url = info.url; // refreshed if the presigned URL expires mid-download
1651
+ const refreshUrl = async () => {
1652
+ url = (await this.documents.downloadUrl(id)).url;
1653
+ };
1654
+ // Unknown size: single GET, retry whole on failure (no resume possible).
1655
+ if (info.size == null) {
1656
+ for (let attempt = 0;; attempt++) {
1657
+ try {
1658
+ const res = await this._fetch(url, { signal: opts.signal });
1659
+ if (res.status === 401 || res.status === 403) {
1660
+ await refreshUrl();
1661
+ throw new AgentStreamError("presigned url expired; refreshed");
1662
+ }
1663
+ if (!res.ok)
1664
+ throw new AgentStreamError(`download failed: HTTP ${res.status}`);
1665
+ const buf = await res.arrayBuffer();
1666
+ opts.onProgress?.(buf.byteLength, null);
1667
+ return new Blob([buf], { type });
1668
+ }
1669
+ catch (e) {
1670
+ if (opts.signal?.aborted || attempt >= maxRetries)
1671
+ throw e;
1672
+ await sleep(backoff(attempt));
1673
+ }
1674
+ }
1675
+ }
1676
+ const total = info.size;
1677
+ const parts = [];
1678
+ let received = 0;
1679
+ outer: while (received < total) {
1680
+ const wantEnd = Math.min(received + chunkSize, total) - 1;
1681
+ let lastErr;
1682
+ let ok = false;
1683
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1684
+ try {
1685
+ const res = await this._fetch(url, {
1686
+ headers: { Range: `bytes=${received}-${wantEnd}` },
1687
+ signal: opts.signal,
1688
+ });
1689
+ if (res.status === 401 || res.status === 403) {
1690
+ await refreshUrl(); // presigned URL expired mid-download
1691
+ continue;
1692
+ }
1693
+ if (res.status === 200) {
1694
+ // Server ignored Range and sent the whole object. Only valid at the
1695
+ // very start; otherwise appending it would corrupt the output.
1696
+ if (received !== 0)
1697
+ throw new AgentStreamError("server ignored Range on a resumed download");
1698
+ const buf = new Uint8Array(await res.arrayBuffer());
1699
+ parts.push(buf);
1700
+ received = buf.byteLength;
1701
+ opts.onProgress?.(received, total);
1702
+ break outer;
1703
+ }
1704
+ if (res.status !== 206)
1705
+ throw new AgentStreamError(`range download failed: HTTP ${res.status}`);
1706
+ const buf = new Uint8Array(await res.arrayBuffer());
1707
+ if (buf.byteLength === 0)
1708
+ throw new AgentStreamError("empty range response");
1709
+ const take = Math.min(buf.byteLength, total - received); // never overshoot
1710
+ parts.push(take === buf.byteLength ? buf : buf.subarray(0, take));
1711
+ received += take;
1712
+ opts.onProgress?.(received, total);
1713
+ ok = true;
1714
+ break;
1715
+ }
1716
+ catch (e) {
1717
+ if (opts.signal?.aborted)
1718
+ throw e;
1719
+ lastErr = e;
1720
+ if (attempt < maxRetries)
1721
+ await sleep(backoff(attempt));
1722
+ }
1723
+ }
1724
+ if (!ok)
1725
+ throw lastErr;
1726
+ }
1727
+ return new Blob(parts, { type });
1728
+ }
1729
+ }
1730
+ function safeJson(s) {
1731
+ try {
1732
+ return JSON.parse(s);
1733
+ }
1734
+ catch {
1735
+ return s;
1736
+ }
1737
+ }
1738
+ /** Factory helper. */
1739
+ export function createClient(opts) {
1740
+ return new AgentFramework(opts);
1741
+ }
1742
+ export default AgentFramework;
1743
+ //# sourceMappingURL=index.js.map