@betterlu/agentlu 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -10,6 +10,12 @@
10
10
  * background `wait`, and auto-retry on `409 thread_busy`.
11
11
  */
12
12
  export declare const DEFAULT_BASE_URL = "http://localhost:8912/v1";
13
+ /**
14
+ * Accept an origin (`http://host:8912`) or a `/v1` base and normalize to the
15
+ * `/v1` base the API actually serves, so `new AgentLu({ baseUrl: "http://host:8912" })`
16
+ * works the same as the documented default.
17
+ */
18
+ export declare function normalizeBaseUrl(raw: string): string;
13
19
  export type Backend = "pi";
14
20
  export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
15
21
  export type ApiToolFamily = "web_search" | "fetch_content" | "hublu" | "memory" | "image" | "browser" | "android";
@@ -306,19 +312,32 @@ export interface RunOptions {
306
312
  schemaName?: string;
307
313
  /** Queue a background run instead of waiting. Returns the queued response. */
308
314
  background?: boolean;
315
+ /**
316
+ * Explicit idempotency key. Non-streaming runs always send one (generated
317
+ * when omitted) so SDK-level retries cannot double-run; set it yourself to
318
+ * make two separate `run` calls idempotent.
319
+ */
320
+ idempotencyKey?: string;
309
321
  /**
310
322
  * `runJson` only: extra model attempts after `schema_validation_failed`,
311
323
  * `missing_output_parsed`, or `incomplete`. Defaults to 1 (no extra tries).
312
324
  */
313
325
  attempts?: number;
326
+ /** `run`/`runJson`/`runText` only: throw instead of returning when the run is `incomplete`. */
327
+ throwOnIncomplete?: boolean;
314
328
  }
315
329
  export interface WaitOptions {
316
330
  /** Delay between polls. Defaults to 1000ms. */
317
331
  intervalMs?: number;
318
- /** Overall wait deadline. The in-flight poll is aborted when it fires. */
332
+ /**
333
+ * Overall wait deadline (default 10 minutes). The in-flight poll is aborted
334
+ * when it fires. Pass `0` to wait indefinitely.
335
+ */
319
336
  timeoutMs?: number;
320
337
  signal?: AbortSignal;
321
338
  }
339
+ /** Default overall deadline for `responses.wait`. */
340
+ export declare const DEFAULT_WAIT_TIMEOUT_MS: number;
322
341
  export interface UploadOptions {
323
342
  timeoutMs?: number;
324
343
  signal?: AbortSignal;
@@ -355,6 +374,30 @@ export interface ResponseObject {
355
374
  [k: string]: unknown;
356
375
  }
357
376
  export declare function isTerminalStatus(status: string): boolean;
377
+ /**
378
+ * One SSE event from `/v1/responses` with `stream: true`. The event names and
379
+ * shapes mirror the OpenAI Responses stream; `delta` is the incremental text
380
+ * for `response.output_text.delta`.
381
+ */
382
+ export interface StreamEvent {
383
+ type: string;
384
+ sequence_number?: number;
385
+ /** Incremental text on `response.output_text.delta`. */
386
+ delta?: string;
387
+ [k: string]: unknown;
388
+ }
389
+ /**
390
+ * Accumulate a raw stream into typed events and the final text. `events()`
391
+ * yields every event; `finalText()` resolves after the stream ends with the
392
+ * concatenated `response.output_text.delta` payloads.
393
+ *
394
+ * const stream = alu.typedStream({ input: "hi" });
395
+ * for await (const ev of stream) { ... }
396
+ * const text = stream.finalText();
397
+ */
398
+ export declare function typedStream(source: AsyncIterable<Record<string, unknown>>): AsyncIterable<StreamEvent> & {
399
+ finalText: () => Promise<string>;
400
+ };
358
401
  export declare class AgentLu {
359
402
  readonly baseUrl: string;
360
403
  apiKey: string | undefined;
@@ -431,8 +474,24 @@ export declare class AgentLu {
431
474
  text: string;
432
475
  response: ResponseObject;
433
476
  }>;
477
+ /**
478
+ * `runJson` that returns `null` instead of throwing on any failure. For
479
+ * best-effort enrichment where a missing result is a normal outcome.
480
+ */
481
+ runJsonOrNull<T = unknown>(opts: RunOptions): Promise<T | null>;
482
+ /** `runText` that returns `null` instead of throwing on any failure. */
483
+ runTextOrNull(opts: RunOptions): Promise<string | null>;
434
484
  stream(opts: RunOptions): AsyncGenerator<Record<string, unknown>>;
485
+ /**
486
+ * Typed stream: yields `StreamEvent`s and exposes `finalText()` with the
487
+ * concatenated output text after the stream ends.
488
+ */
489
+ typedStream(opts: RunOptions): AsyncIterable<StreamEvent> & {
490
+ finalText: () => Promise<string>;
491
+ };
435
492
  private runForApiKey;
493
+ /** Attempts for a run: the caller's `maxAttempts` or the shared default. */
494
+ private runAttempts;
436
495
  private runJsonForApiKey;
437
496
  private runJsonOnce;
438
497
  private runTextForApiKey;
@@ -506,13 +565,26 @@ export declare class UseCaseRunner {
506
565
  private readonly operations;
507
566
  private apiKey;
508
567
  private ready;
568
+ /** In-flight/short-lived guard so a burst of runs does not stampede registration. */
569
+ private registerFailures;
570
+ private lastRegisterError;
571
+ private registerFailedAt;
572
+ /** Cooldown is short and only suppresses *repeated* failures in one burst. */
573
+ private static readonly REGISTER_COOLDOWN_MS;
509
574
  constructor(parent: AgentLu, name: string, opts?: {
510
575
  person?: string | null;
511
576
  defaults?: ClientDefaults;
512
577
  use_case_profile?: UseCaseProfile;
513
578
  });
514
- /** Idempotent get-or-create; safe to call on boot or before every run. */
515
- ensure(): Promise<void>;
579
+ /**
580
+ * Idempotent get-or-create; safe to call on boot or before every run.
581
+ * A failed registration is remembered for a short cooldown so an app that
582
+ * calls `ensure()` in a loop does not hammer the server; runs always retry
583
+ * registration transparently (pass `force`).
584
+ */
585
+ ensure(opts?: {
586
+ force?: boolean;
587
+ }): Promise<void>;
516
588
  private invalidate;
517
589
  private withAuthRetry;
518
590
  /**
@@ -531,7 +603,15 @@ export declare class UseCaseRunner {
531
603
  text: string;
532
604
  response: ResponseObject;
533
605
  }>;
606
+ /** `runJson` that returns `null` instead of throwing on any failure. */
607
+ runJsonOrNull<T = unknown>(opts: RunOptions): Promise<T | null>;
608
+ /** `runText` that returns `null` instead of throwing on any failure. */
609
+ runTextOrNull(opts: RunOptions): Promise<string | null>;
534
610
  stream(opts: RunOptions): AsyncGenerator<Record<string, unknown>>;
611
+ /** Typed stream for this use-case: events + `finalText()`. */
612
+ typedStream(opts: RunOptions): AsyncIterable<StreamEvent> & {
613
+ finalText: () => Promise<string>;
614
+ };
535
615
  /**
536
616
  * Upload with this use-case's key, so the file is owned by the same
537
617
  * service client that will reference it via `run({ fileIds })`.
@@ -547,3 +627,5 @@ export declare class UseCaseRunner {
547
627
  };
548
628
  readonly decisions: DecisionApi;
549
629
  }
630
+ /** True when an error should be retried by the shared policy. */
631
+ export declare function isRetryableError(err: unknown): boolean;
package/dist/index.js CHANGED
@@ -10,6 +10,17 @@
10
10
  * background `wait`, and auto-retry on `409 thread_busy`.
11
11
  */
12
12
  export const DEFAULT_BASE_URL = "http://localhost:8912/v1";
13
+ /**
14
+ * Accept an origin (`http://host:8912`) or a `/v1` base and normalize to the
15
+ * `/v1` base the API actually serves, so `new AgentLu({ baseUrl: "http://host:8912" })`
16
+ * works the same as the documented default.
17
+ */
18
+ export function normalizeBaseUrl(raw) {
19
+ const trimmed = raw.replace(/\/+$/, "");
20
+ if (/\/v1$/.test(trimmed))
21
+ return trimmed;
22
+ return `${trimmed}/v1`;
23
+ }
13
24
  /** Strip client-only decision options before the body reaches `/v1/decisions`. */
14
25
  function decisionWire(body) {
15
26
  const wire = { state: body.state, questions: body.questions };
@@ -69,9 +80,49 @@ export class AgentLuResponseError extends AgentLuError {
69
80
  this.responseStatus = responseStatus;
70
81
  }
71
82
  }
83
+ /** Default overall deadline for `responses.wait`. */
84
+ export const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60_000;
72
85
  export function isTerminalStatus(status) {
73
86
  return status === "completed" || status === "failed" || status === "incomplete" || status === "cancelled";
74
87
  }
88
+ /**
89
+ * Accumulate a raw stream into typed events and the final text. `events()`
90
+ * yields every event; `finalText()` resolves after the stream ends with the
91
+ * concatenated `response.output_text.delta` payloads.
92
+ *
93
+ * const stream = alu.typedStream({ input: "hi" });
94
+ * for await (const ev of stream) { ... }
95
+ * const text = stream.finalText();
96
+ */
97
+ export function typedStream(source) {
98
+ let text = "";
99
+ let done = false;
100
+ const it = (async function* () {
101
+ try {
102
+ for await (const raw of source) {
103
+ const ev = raw;
104
+ if (ev.type === "response.output_text.delta" && typeof ev.delta === "string") {
105
+ text += ev.delta;
106
+ }
107
+ yield ev;
108
+ }
109
+ }
110
+ finally {
111
+ done = true;
112
+ }
113
+ })();
114
+ return Object.assign(it, {
115
+ finalText: async () => {
116
+ // Drain if the caller never iterated to the end.
117
+ if (!done) {
118
+ for await (const _ of it) {
119
+ void _;
120
+ }
121
+ }
122
+ return text;
123
+ },
124
+ });
125
+ }
75
126
  function env(name) {
76
127
  return typeof process !== "undefined" ? process.env[name] : undefined;
77
128
  }
@@ -82,7 +133,7 @@ export class AgentLu {
82
133
  fetchImpl;
83
134
  defaultTimeoutMs;
84
135
  constructor(opts = {}) {
85
- this.baseUrl = (opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL).replace(/\/$/, "");
136
+ this.baseUrl = normalizeBaseUrl(opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL);
86
137
  this.apiKey = opts.apiKey ?? env("AGENTLU_API_KEY");
87
138
  this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
88
139
  this.defaultTimeoutMs = opts.timeoutMs;
@@ -107,7 +158,10 @@ export class AgentLu {
107
158
  });
108
159
  }
109
160
  postClient(name, opts) {
110
- return this.request("POST", "/clients", { name, ...opts }, { auth: false });
161
+ // Registration is get-or-create and callers gate it behind `ensure()`'s
162
+ // cooldown; two attempts smooth a blip without stacking long backoffs
163
+ // before the cooldown can do its job.
164
+ return this.request("POST", "/clients", { name, ...opts }, { auth: false, maxAttempts: 2 });
111
165
  }
112
166
  /** Get-or-create a client by name. Caches the returned key on this instance. */
113
167
  async register(name, opts) {
@@ -201,15 +255,71 @@ export class AgentLu {
201
255
  async runText(opts) {
202
256
  return this.runTextForApiKey(this.apiKey, opts);
203
257
  }
258
+ /**
259
+ * `runJson` that returns `null` instead of throwing on any failure. For
260
+ * best-effort enrichment where a missing result is a normal outcome.
261
+ */
262
+ async runJsonOrNull(opts) {
263
+ try {
264
+ const { data } = await this.runJson(opts);
265
+ return data;
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ }
271
+ /** `runText` that returns `null` instead of throwing on any failure. */
272
+ async runTextOrNull(opts) {
273
+ try {
274
+ const { text } = await this.runText(opts);
275
+ return text;
276
+ }
277
+ catch {
278
+ return null;
279
+ }
280
+ }
204
281
  async *stream(opts) {
205
282
  yield* this.streamRunForApiKey(opts, this.apiKey);
206
283
  }
284
+ /**
285
+ * Typed stream: yields `StreamEvent`s and exposes `finalText()` with the
286
+ * concatenated output text after the stream ends.
287
+ */
288
+ typedStream(opts) {
289
+ return typedStream(this.stream(opts));
290
+ }
207
291
  async runForApiKey(apiKey, opts) {
208
- const response = await this.createRunResponse(opts, apiKey);
209
- if (response.status === "failed" && response.error) {
210
- throw new AgentLuError(200, response.error.code, response.error.message, response.error.retryable);
292
+ // A failed run is an HTTP 200 with `error.retryable` in the body, which the
293
+ // transport retry never sees. Retry those here (idempotency keys make it
294
+ // safe), honoring `maxAttempts` like the transport does. The key is minted
295
+ // once per logical run so every attempt replays the same server-side run.
296
+ const maxAttempts = this.runAttempts(opts);
297
+ const idempotencyKey = opts.idempotencyKey ?? randomIdempotencyKey();
298
+ let last;
299
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
300
+ const response = await this.createRunResponse({ ...opts, idempotencyKey }, apiKey);
301
+ if (response.status === "failed" && response.error) {
302
+ const err = new AgentLuError(200, response.error.code, response.error.message, response.error.retryable);
303
+ // A retryable failed run is a transient provider fault; retry it.
304
+ if (response.error.retryable && attempt < maxAttempts - 1) {
305
+ last = err;
306
+ await sleep(retryDelayMs(err, attempt));
307
+ continue;
308
+ }
309
+ throw err;
310
+ }
311
+ if (opts.throwOnIncomplete && response.status === "incomplete") {
312
+ throw new AgentLuResponseError(response);
313
+ }
314
+ return { data: response.output_parsed, response };
211
315
  }
212
- return { data: response.output_parsed, response };
316
+ throw last ?? new AgentLuError(200, "upstream_error", "run failed after retries", false);
317
+ }
318
+ /** Attempts for a run: the caller's `maxAttempts` or the shared default. */
319
+ runAttempts(opts) {
320
+ return opts.maxAttempts !== undefined && Number.isFinite(opts.maxAttempts) && opts.maxAttempts >= 1
321
+ ? Math.floor(opts.maxAttempts)
322
+ : 5;
213
323
  }
214
324
  async runJsonForApiKey(apiKey, opts) {
215
325
  const attempts = jsonAttempts(opts);
@@ -247,7 +357,19 @@ export class AgentLu {
247
357
  }
248
358
  async createRunResponse(opts, apiKey) {
249
359
  const body = await this.buildRunBody(opts, apiKey);
250
- return this.request("POST", "/responses", body, this.transportFromRun(opts, apiKey));
360
+ const transport = this.transportFromRun(opts, apiKey);
361
+ // A non-streaming run is retried on transient faults; an auto key makes
362
+ // those retries safe (the server replays the first outcome instead of
363
+ // starting a second run). `createRunResponse` is only used by the sync
364
+ // paths; streaming has its own method and never sends this header.
365
+ const headers = {};
366
+ if (opts.idempotencyKey) {
367
+ headers["Idempotency-Key"] = opts.idempotencyKey;
368
+ }
369
+ else {
370
+ headers["Idempotency-Key"] = randomIdempotencyKey();
371
+ }
372
+ return this.request("POST", "/responses", body, { ...transport, headers });
251
373
  }
252
374
  async buildRunBody(opts, apiKey) {
253
375
  const uploaded = await this.uploadRunFiles(opts, apiKey);
@@ -328,9 +450,12 @@ export class AgentLu {
328
450
  return { fileIds, fileKinds };
329
451
  }
330
452
  transportFromRun(opts, apiKey) {
453
+ // The server budget and the client deadline must not race: give the client
454
+ // 5s of slack so the server's own timeout/error envelope arrives first.
455
+ const serverBudgetMs = opts.timeout_ms ?? opts.timeoutMs ?? this.defaultTimeoutMs;
331
456
  return {
332
457
  apiKey,
333
- timeoutMs: opts.timeoutMs ?? this.defaultTimeoutMs,
458
+ timeoutMs: serverBudgetMs !== undefined ? serverBudgetMs + 5_000 : this.defaultTimeoutMs,
334
459
  maxAttempts: opts.maxAttempts,
335
460
  signal: opts.signal,
336
461
  };
@@ -353,6 +478,11 @@ export class AgentLu {
353
478
  };
354
479
  async waitForApiKey(id, apiKey, opts) {
355
480
  const intervalMs = Math.max(opts?.intervalMs ?? 1_000, 50);
481
+ // A background run may sit behind a queue; without a default deadline a
482
+ // caller that forgets timeoutMs polls forever.
483
+ const deadlineMs = opts?.timeoutMs === 0
484
+ ? undefined
485
+ : (opts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS);
356
486
  const started = Date.now();
357
487
  for (;;) {
358
488
  if (opts?.signal?.aborted) {
@@ -360,7 +490,7 @@ export class AgentLu {
360
490
  ? opts.signal.reason
361
491
  : new AgentLuError(0, "aborted", "wait aborted", false);
362
492
  }
363
- const remaining = opts?.timeoutMs !== undefined ? Math.max(opts.timeoutMs - (Date.now() - started), 1) : undefined;
493
+ const remaining = deadlineMs !== undefined ? Math.max(deadlineMs - (Date.now() - started), 1) : undefined;
364
494
  const row = await this.request("GET", `/responses/${encodeURIComponent(id)}`, undefined, {
365
495
  apiKey,
366
496
  signal: opts?.signal,
@@ -369,7 +499,7 @@ export class AgentLu {
369
499
  });
370
500
  if (isTerminalStatus(row.status))
371
501
  return row;
372
- if (opts?.timeoutMs !== undefined && Date.now() - started + intervalMs >= opts.timeoutMs) {
502
+ if (deadlineMs !== undefined && Date.now() - started + intervalMs >= deadlineMs) {
373
503
  throw new AgentLuError(408, "timeout", `Timed out waiting for response ${id}`, true);
374
504
  }
375
505
  await sleep(intervalMs);
@@ -472,13 +602,13 @@ export class AgentLu {
472
602
  return (await res.json());
473
603
  }
474
604
  async raw(method, path, body, opts) {
475
- // `409 thread_busy` and `503 too_many_active_runs` are correctness bounds,
476
- // not rate limits. Retrying with backoff is almost always the right thing,
605
+ // `409 thread_busy`, `503 too_many_active_runs`, network faults, 502/504
606
+ // and any server-marked retryable error are correctness/transient bounds,
607
+ // not rate limits. Retrying with jittered backoff is almost always right,
477
608
  // so the SDK does it - honoring Retry-After when the server sends one.
478
609
  // A 503 can also signal a config boundary (no decision model key); those
479
610
  // are not retryable and must surface immediately. Callers can cap attempts
480
611
  // per request (`maxAttempts`); a timeout/caller abort is never retried.
481
- const nonRetryable503 = new Set(["systemone_unconfigured"]);
482
612
  const maxAttempts = opts?.maxAttempts !== undefined && Number.isFinite(opts.maxAttempts) && opts.maxAttempts >= 1
483
613
  ? Math.floor(opts.maxAttempts)
484
614
  : 5;
@@ -488,11 +618,7 @@ export class AgentLu {
488
618
  return await this.rawOnce(method, path, body, opts);
489
619
  }
490
620
  catch (e) {
491
- if (e instanceof AgentLuError &&
492
- (e.code === "thread_busy" ||
493
- e.code === "too_many_active_runs" ||
494
- (e.status === 503 && !nonRetryable503.has(e.code))) &&
495
- attempt < maxAttempts - 1) {
621
+ if (isRetryableError(e) && attempt < maxAttempts - 1) {
496
622
  last = e;
497
623
  await sleep(retryDelayMs(e, attempt));
498
624
  continue;
@@ -511,6 +637,8 @@ export class AgentLu {
511
637
  const apiKey = opts?.apiKey ?? this.apiKey;
512
638
  if (opts?.auth !== false && apiKey)
513
639
  headers.Authorization = `Bearer ${apiKey}`;
640
+ if (opts?.headers)
641
+ Object.assign(headers, opts.headers);
514
642
  const signal = requestSignal(opts?.signal, opts?.timeoutMs ?? this.defaultTimeoutMs);
515
643
  let res;
516
644
  try {
@@ -562,22 +690,45 @@ export class UseCaseRunner {
562
690
  operations;
563
691
  apiKey = null;
564
692
  ready = null;
693
+ /** In-flight/short-lived guard so a burst of runs does not stampede registration. */
694
+ registerFailures = 0;
695
+ lastRegisterError = null;
696
+ registerFailedAt = null;
697
+ /** Cooldown is short and only suppresses *repeated* failures in one burst. */
698
+ static REGISTER_COOLDOWN_MS = 1_000;
565
699
  constructor(parent, name, opts) {
566
700
  this.name = name;
567
701
  this.registerOpts = opts ?? {};
568
702
  this.operations = scopedUseCaseOperations.get(parent);
569
703
  }
570
- /** Idempotent get-or-create; safe to call on boot or before every run. */
571
- ensure() {
704
+ /**
705
+ * Idempotent get-or-create; safe to call on boot or before every run.
706
+ * A failed registration is remembered for a short cooldown so an app that
707
+ * calls `ensure()` in a loop does not hammer the server; runs always retry
708
+ * registration transparently (pass `force`).
709
+ */
710
+ ensure(opts) {
572
711
  if (this.apiKey)
573
712
  return Promise.resolve();
574
713
  if (!this.ready) {
714
+ if (!opts?.force &&
715
+ this.registerFailedAt !== null &&
716
+ Date.now() - this.registerFailedAt < UseCaseRunner.REGISTER_COOLDOWN_MS) {
717
+ return Promise.reject(this.lastRegisterError ??
718
+ new AgentLuError(503, "register_cooldown", "use-case registration is cooling down", true));
719
+ }
575
720
  this.ready = this.operations.register(this.name, this.registerOpts)
576
721
  .then((key) => {
577
722
  this.apiKey = key;
723
+ this.registerFailedAt = null;
724
+ this.registerFailures = 0;
725
+ this.lastRegisterError = null;
578
726
  })
579
727
  .catch((e) => {
580
728
  this.ready = null;
729
+ this.registerFailedAt = Date.now();
730
+ this.registerFailures += 1;
731
+ this.lastRegisterError = e;
581
732
  throw e;
582
733
  });
583
734
  }
@@ -588,14 +739,14 @@ export class UseCaseRunner {
588
739
  this.ready = null;
589
740
  }
590
741
  async withAuthRetry(fn) {
591
- await this.ensure();
742
+ await this.ensure({ force: true });
592
743
  try {
593
744
  return await fn(this.apiKey);
594
745
  }
595
746
  catch (e) {
596
747
  if (e instanceof AgentLuError && e.status === 401) {
597
748
  this.invalidate();
598
- await this.ensure();
749
+ await this.ensure({ force: true });
599
750
  return await fn(this.apiKey);
600
751
  }
601
752
  throw e;
@@ -615,8 +766,28 @@ export class UseCaseRunner {
615
766
  async runText(opts) {
616
767
  return this.withAuthRetry((apiKey) => this.operations.runText(apiKey, opts));
617
768
  }
769
+ /** `runJson` that returns `null` instead of throwing on any failure. */
770
+ async runJsonOrNull(opts) {
771
+ try {
772
+ const { data } = await this.runJson(opts);
773
+ return data;
774
+ }
775
+ catch {
776
+ return null;
777
+ }
778
+ }
779
+ /** `runText` that returns `null` instead of throwing on any failure. */
780
+ async runTextOrNull(opts) {
781
+ try {
782
+ const { text } = await this.runText(opts);
783
+ return text;
784
+ }
785
+ catch {
786
+ return null;
787
+ }
788
+ }
618
789
  async *stream(opts) {
619
- await this.ensure();
790
+ await this.ensure({ force: true });
620
791
  try {
621
792
  yield* this.operations.stream(this.apiKey, opts);
622
793
  }
@@ -624,10 +795,14 @@ export class UseCaseRunner {
624
795
  if (!(e instanceof AgentLuError && e.status === 401))
625
796
  throw e;
626
797
  this.invalidate();
627
- await this.ensure();
798
+ await this.ensure({ force: true });
628
799
  yield* this.operations.stream(this.apiKey, opts);
629
800
  }
630
801
  }
802
+ /** Typed stream for this use-case: events + `finalText()`. */
803
+ typedStream(opts) {
804
+ return typedStream(this.stream(opts));
805
+ }
631
806
  /**
632
807
  * Upload with this use-case's key, so the file is owned by the same
633
808
  * service client that will reference it via `run({ fileIds })`.
@@ -743,11 +918,43 @@ function requestSignal(signal, timeoutMs) {
743
918
  return signals.length ? mergeSignals(signals) : undefined;
744
919
  }
745
920
  function retryDelayMs(err, attempt) {
921
+ // Jitter keeps a fleet of clients from retrying in lockstep after a blip.
922
+ const jitter = 0.75 + Math.random() * 0.5;
746
923
  if (err.retryAfterMs !== undefined) {
747
- return Math.min(10_000, Math.max(250, err.retryAfterMs));
924
+ return Math.min(10_000, Math.max(250, err.retryAfterMs * jitter));
748
925
  }
749
- return Math.min(10_000, 500 * 2 ** attempt);
926
+ return Math.min(10_000, 500 * 2 ** attempt * jitter);
927
+ }
928
+ /** Statuses worth a retry even without a server `retryable` flag. */
929
+ const RETRYABLE_STATUSES = new Set([502, 503, 504]);
930
+ /** True when an error should be retried by the shared policy. */
931
+ export function isRetryableError(err) {
932
+ if (!(err instanceof AgentLuError))
933
+ return false;
934
+ if (err.code === "systemone_unconfigured")
935
+ return false;
936
+ if (err.code === "thread_busy" || err.code === "too_many_active_runs")
937
+ return true;
938
+ // Network faults are retried; a client timeout is not (the deadline was hit
939
+ // and a retry would start a second run past the caller's budget).
940
+ if (err.code === "network_error")
941
+ return true;
942
+ if (RETRYABLE_STATUSES.has(err.status))
943
+ return true;
944
+ // The server marks quota exhaustion retryable:false; anything it marks
945
+ // retryable:true is safe to retry even on an otherwise-unremarkable status.
946
+ return err.retryable === true && err.status >= 500;
750
947
  }
751
948
  function sleep(ms) {
752
949
  return new Promise((r) => setTimeout(r, ms));
753
950
  }
951
+ /**
952
+ * Unique-enough idempotency key without assuming `crypto.randomUUID` exists
953
+ * (browser SDK consumers on older runtimes).
954
+ */
955
+ function randomIdempotencyKey() {
956
+ const g = globalThis;
957
+ if (g.crypto?.randomUUID)
958
+ return `sdk_${g.crypto.randomUUID()}`;
959
+ return `sdk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
960
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betterlu/agentlu",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
4
4
  "description": "Client for the agentlu service API (/v1). Or just use the openai SDK pointed at http://localhost:8912/v1.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",