@betterlu/agentlu 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +83 -3
- package/dist/index.js +206 -22
- package/package.json +1 -1
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
|
-
/**
|
|
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,7 +474,21 @@ 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;
|
|
436
493
|
private runJsonForApiKey;
|
|
437
494
|
private runJsonOnce;
|
|
@@ -506,13 +563,26 @@ export declare class UseCaseRunner {
|
|
|
506
563
|
private readonly operations;
|
|
507
564
|
private apiKey;
|
|
508
565
|
private ready;
|
|
566
|
+
/** In-flight/short-lived guard so a burst of runs does not stampede registration. */
|
|
567
|
+
private registerFailures;
|
|
568
|
+
private lastRegisterError;
|
|
569
|
+
private registerFailedAt;
|
|
570
|
+
/** Cooldown is short and only suppresses *repeated* failures in one burst. */
|
|
571
|
+
private static readonly REGISTER_COOLDOWN_MS;
|
|
509
572
|
constructor(parent: AgentLu, name: string, opts?: {
|
|
510
573
|
person?: string | null;
|
|
511
574
|
defaults?: ClientDefaults;
|
|
512
575
|
use_case_profile?: UseCaseProfile;
|
|
513
576
|
});
|
|
514
|
-
/**
|
|
515
|
-
|
|
577
|
+
/**
|
|
578
|
+
* Idempotent get-or-create; safe to call on boot or before every run.
|
|
579
|
+
* A failed registration is remembered for a short cooldown so an app that
|
|
580
|
+
* calls `ensure()` in a loop does not hammer the server; runs always retry
|
|
581
|
+
* registration transparently (pass `force`).
|
|
582
|
+
*/
|
|
583
|
+
ensure(opts?: {
|
|
584
|
+
force?: boolean;
|
|
585
|
+
}): Promise<void>;
|
|
516
586
|
private invalidate;
|
|
517
587
|
private withAuthRetry;
|
|
518
588
|
/**
|
|
@@ -531,7 +601,15 @@ export declare class UseCaseRunner {
|
|
|
531
601
|
text: string;
|
|
532
602
|
response: ResponseObject;
|
|
533
603
|
}>;
|
|
604
|
+
/** `runJson` that returns `null` instead of throwing on any failure. */
|
|
605
|
+
runJsonOrNull<T = unknown>(opts: RunOptions): Promise<T | null>;
|
|
606
|
+
/** `runText` that returns `null` instead of throwing on any failure. */
|
|
607
|
+
runTextOrNull(opts: RunOptions): Promise<string | null>;
|
|
534
608
|
stream(opts: RunOptions): AsyncGenerator<Record<string, unknown>>;
|
|
609
|
+
/** Typed stream for this use-case: events + `finalText()`. */
|
|
610
|
+
typedStream(opts: RunOptions): AsyncIterable<StreamEvent> & {
|
|
611
|
+
finalText: () => Promise<string>;
|
|
612
|
+
};
|
|
535
613
|
/**
|
|
536
614
|
* Upload with this use-case's key, so the file is owned by the same
|
|
537
615
|
* service client that will reference it via `run({ fileIds })`.
|
|
@@ -547,3 +625,5 @@ export declare class UseCaseRunner {
|
|
|
547
625
|
};
|
|
548
626
|
readonly decisions: DecisionApi;
|
|
549
627
|
}
|
|
628
|
+
/** True when an error should be retried by the shared policy. */
|
|
629
|
+
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)
|
|
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
|
-
|
|
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,14 +255,47 @@ 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
292
|
const response = await this.createRunResponse(opts, apiKey);
|
|
209
293
|
if (response.status === "failed" && response.error) {
|
|
210
294
|
throw new AgentLuError(200, response.error.code, response.error.message, response.error.retryable);
|
|
211
295
|
}
|
|
296
|
+
if (opts.throwOnIncomplete && response.status === "incomplete") {
|
|
297
|
+
throw new AgentLuResponseError(response);
|
|
298
|
+
}
|
|
212
299
|
return { data: response.output_parsed, response };
|
|
213
300
|
}
|
|
214
301
|
async runJsonForApiKey(apiKey, opts) {
|
|
@@ -247,7 +334,19 @@ export class AgentLu {
|
|
|
247
334
|
}
|
|
248
335
|
async createRunResponse(opts, apiKey) {
|
|
249
336
|
const body = await this.buildRunBody(opts, apiKey);
|
|
250
|
-
|
|
337
|
+
const transport = this.transportFromRun(opts, apiKey);
|
|
338
|
+
// A non-streaming run is retried on transient faults; an auto key makes
|
|
339
|
+
// those retries safe (the server replays the first outcome instead of
|
|
340
|
+
// starting a second run). `createRunResponse` is only used by the sync
|
|
341
|
+
// paths; streaming has its own method and never sends this header.
|
|
342
|
+
const headers = {};
|
|
343
|
+
if (opts.idempotencyKey) {
|
|
344
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
headers["Idempotency-Key"] = randomIdempotencyKey();
|
|
348
|
+
}
|
|
349
|
+
return this.request("POST", "/responses", body, { ...transport, headers });
|
|
251
350
|
}
|
|
252
351
|
async buildRunBody(opts, apiKey) {
|
|
253
352
|
const uploaded = await this.uploadRunFiles(opts, apiKey);
|
|
@@ -328,9 +427,12 @@ export class AgentLu {
|
|
|
328
427
|
return { fileIds, fileKinds };
|
|
329
428
|
}
|
|
330
429
|
transportFromRun(opts, apiKey) {
|
|
430
|
+
// The server budget and the client deadline must not race: give the client
|
|
431
|
+
// 5s of slack so the server's own timeout/error envelope arrives first.
|
|
432
|
+
const serverBudgetMs = opts.timeout_ms ?? opts.timeoutMs ?? this.defaultTimeoutMs;
|
|
331
433
|
return {
|
|
332
434
|
apiKey,
|
|
333
|
-
timeoutMs:
|
|
435
|
+
timeoutMs: serverBudgetMs !== undefined ? serverBudgetMs + 5_000 : this.defaultTimeoutMs,
|
|
334
436
|
maxAttempts: opts.maxAttempts,
|
|
335
437
|
signal: opts.signal,
|
|
336
438
|
};
|
|
@@ -353,6 +455,11 @@ export class AgentLu {
|
|
|
353
455
|
};
|
|
354
456
|
async waitForApiKey(id, apiKey, opts) {
|
|
355
457
|
const intervalMs = Math.max(opts?.intervalMs ?? 1_000, 50);
|
|
458
|
+
// A background run may sit behind a queue; without a default deadline a
|
|
459
|
+
// caller that forgets timeoutMs polls forever.
|
|
460
|
+
const deadlineMs = opts?.timeoutMs === 0
|
|
461
|
+
? undefined
|
|
462
|
+
: (opts?.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS);
|
|
356
463
|
const started = Date.now();
|
|
357
464
|
for (;;) {
|
|
358
465
|
if (opts?.signal?.aborted) {
|
|
@@ -360,7 +467,7 @@ export class AgentLu {
|
|
|
360
467
|
? opts.signal.reason
|
|
361
468
|
: new AgentLuError(0, "aborted", "wait aborted", false);
|
|
362
469
|
}
|
|
363
|
-
const remaining =
|
|
470
|
+
const remaining = deadlineMs !== undefined ? Math.max(deadlineMs - (Date.now() - started), 1) : undefined;
|
|
364
471
|
const row = await this.request("GET", `/responses/${encodeURIComponent(id)}`, undefined, {
|
|
365
472
|
apiKey,
|
|
366
473
|
signal: opts?.signal,
|
|
@@ -369,7 +476,7 @@ export class AgentLu {
|
|
|
369
476
|
});
|
|
370
477
|
if (isTerminalStatus(row.status))
|
|
371
478
|
return row;
|
|
372
|
-
if (
|
|
479
|
+
if (deadlineMs !== undefined && Date.now() - started + intervalMs >= deadlineMs) {
|
|
373
480
|
throw new AgentLuError(408, "timeout", `Timed out waiting for response ${id}`, true);
|
|
374
481
|
}
|
|
375
482
|
await sleep(intervalMs);
|
|
@@ -472,13 +579,13 @@ export class AgentLu {
|
|
|
472
579
|
return (await res.json());
|
|
473
580
|
}
|
|
474
581
|
async raw(method, path, body, opts) {
|
|
475
|
-
// `409 thread_busy
|
|
476
|
-
//
|
|
582
|
+
// `409 thread_busy`, `503 too_many_active_runs`, network faults, 502/504
|
|
583
|
+
// and any server-marked retryable error are correctness/transient bounds,
|
|
584
|
+
// not rate limits. Retrying with jittered backoff is almost always right,
|
|
477
585
|
// so the SDK does it - honoring Retry-After when the server sends one.
|
|
478
586
|
// A 503 can also signal a config boundary (no decision model key); those
|
|
479
587
|
// are not retryable and must surface immediately. Callers can cap attempts
|
|
480
588
|
// per request (`maxAttempts`); a timeout/caller abort is never retried.
|
|
481
|
-
const nonRetryable503 = new Set(["systemone_unconfigured"]);
|
|
482
589
|
const maxAttempts = opts?.maxAttempts !== undefined && Number.isFinite(opts.maxAttempts) && opts.maxAttempts >= 1
|
|
483
590
|
? Math.floor(opts.maxAttempts)
|
|
484
591
|
: 5;
|
|
@@ -488,11 +595,7 @@ export class AgentLu {
|
|
|
488
595
|
return await this.rawOnce(method, path, body, opts);
|
|
489
596
|
}
|
|
490
597
|
catch (e) {
|
|
491
|
-
if (e
|
|
492
|
-
(e.code === "thread_busy" ||
|
|
493
|
-
e.code === "too_many_active_runs" ||
|
|
494
|
-
(e.status === 503 && !nonRetryable503.has(e.code))) &&
|
|
495
|
-
attempt < maxAttempts - 1) {
|
|
598
|
+
if (isRetryableError(e) && attempt < maxAttempts - 1) {
|
|
496
599
|
last = e;
|
|
497
600
|
await sleep(retryDelayMs(e, attempt));
|
|
498
601
|
continue;
|
|
@@ -511,6 +614,8 @@ export class AgentLu {
|
|
|
511
614
|
const apiKey = opts?.apiKey ?? this.apiKey;
|
|
512
615
|
if (opts?.auth !== false && apiKey)
|
|
513
616
|
headers.Authorization = `Bearer ${apiKey}`;
|
|
617
|
+
if (opts?.headers)
|
|
618
|
+
Object.assign(headers, opts.headers);
|
|
514
619
|
const signal = requestSignal(opts?.signal, opts?.timeoutMs ?? this.defaultTimeoutMs);
|
|
515
620
|
let res;
|
|
516
621
|
try {
|
|
@@ -562,22 +667,45 @@ export class UseCaseRunner {
|
|
|
562
667
|
operations;
|
|
563
668
|
apiKey = null;
|
|
564
669
|
ready = null;
|
|
670
|
+
/** In-flight/short-lived guard so a burst of runs does not stampede registration. */
|
|
671
|
+
registerFailures = 0;
|
|
672
|
+
lastRegisterError = null;
|
|
673
|
+
registerFailedAt = null;
|
|
674
|
+
/** Cooldown is short and only suppresses *repeated* failures in one burst. */
|
|
675
|
+
static REGISTER_COOLDOWN_MS = 1_000;
|
|
565
676
|
constructor(parent, name, opts) {
|
|
566
677
|
this.name = name;
|
|
567
678
|
this.registerOpts = opts ?? {};
|
|
568
679
|
this.operations = scopedUseCaseOperations.get(parent);
|
|
569
680
|
}
|
|
570
|
-
/**
|
|
571
|
-
|
|
681
|
+
/**
|
|
682
|
+
* Idempotent get-or-create; safe to call on boot or before every run.
|
|
683
|
+
* A failed registration is remembered for a short cooldown so an app that
|
|
684
|
+
* calls `ensure()` in a loop does not hammer the server; runs always retry
|
|
685
|
+
* registration transparently (pass `force`).
|
|
686
|
+
*/
|
|
687
|
+
ensure(opts) {
|
|
572
688
|
if (this.apiKey)
|
|
573
689
|
return Promise.resolve();
|
|
574
690
|
if (!this.ready) {
|
|
691
|
+
if (!opts?.force &&
|
|
692
|
+
this.registerFailedAt !== null &&
|
|
693
|
+
Date.now() - this.registerFailedAt < UseCaseRunner.REGISTER_COOLDOWN_MS) {
|
|
694
|
+
return Promise.reject(this.lastRegisterError ??
|
|
695
|
+
new AgentLuError(503, "register_cooldown", "use-case registration is cooling down", true));
|
|
696
|
+
}
|
|
575
697
|
this.ready = this.operations.register(this.name, this.registerOpts)
|
|
576
698
|
.then((key) => {
|
|
577
699
|
this.apiKey = key;
|
|
700
|
+
this.registerFailedAt = null;
|
|
701
|
+
this.registerFailures = 0;
|
|
702
|
+
this.lastRegisterError = null;
|
|
578
703
|
})
|
|
579
704
|
.catch((e) => {
|
|
580
705
|
this.ready = null;
|
|
706
|
+
this.registerFailedAt = Date.now();
|
|
707
|
+
this.registerFailures += 1;
|
|
708
|
+
this.lastRegisterError = e;
|
|
581
709
|
throw e;
|
|
582
710
|
});
|
|
583
711
|
}
|
|
@@ -588,14 +716,14 @@ export class UseCaseRunner {
|
|
|
588
716
|
this.ready = null;
|
|
589
717
|
}
|
|
590
718
|
async withAuthRetry(fn) {
|
|
591
|
-
await this.ensure();
|
|
719
|
+
await this.ensure({ force: true });
|
|
592
720
|
try {
|
|
593
721
|
return await fn(this.apiKey);
|
|
594
722
|
}
|
|
595
723
|
catch (e) {
|
|
596
724
|
if (e instanceof AgentLuError && e.status === 401) {
|
|
597
725
|
this.invalidate();
|
|
598
|
-
await this.ensure();
|
|
726
|
+
await this.ensure({ force: true });
|
|
599
727
|
return await fn(this.apiKey);
|
|
600
728
|
}
|
|
601
729
|
throw e;
|
|
@@ -615,8 +743,28 @@ export class UseCaseRunner {
|
|
|
615
743
|
async runText(opts) {
|
|
616
744
|
return this.withAuthRetry((apiKey) => this.operations.runText(apiKey, opts));
|
|
617
745
|
}
|
|
746
|
+
/** `runJson` that returns `null` instead of throwing on any failure. */
|
|
747
|
+
async runJsonOrNull(opts) {
|
|
748
|
+
try {
|
|
749
|
+
const { data } = await this.runJson(opts);
|
|
750
|
+
return data;
|
|
751
|
+
}
|
|
752
|
+
catch {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
/** `runText` that returns `null` instead of throwing on any failure. */
|
|
757
|
+
async runTextOrNull(opts) {
|
|
758
|
+
try {
|
|
759
|
+
const { text } = await this.runText(opts);
|
|
760
|
+
return text;
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
618
766
|
async *stream(opts) {
|
|
619
|
-
await this.ensure();
|
|
767
|
+
await this.ensure({ force: true });
|
|
620
768
|
try {
|
|
621
769
|
yield* this.operations.stream(this.apiKey, opts);
|
|
622
770
|
}
|
|
@@ -624,10 +772,14 @@ export class UseCaseRunner {
|
|
|
624
772
|
if (!(e instanceof AgentLuError && e.status === 401))
|
|
625
773
|
throw e;
|
|
626
774
|
this.invalidate();
|
|
627
|
-
await this.ensure();
|
|
775
|
+
await this.ensure({ force: true });
|
|
628
776
|
yield* this.operations.stream(this.apiKey, opts);
|
|
629
777
|
}
|
|
630
778
|
}
|
|
779
|
+
/** Typed stream for this use-case: events + `finalText()`. */
|
|
780
|
+
typedStream(opts) {
|
|
781
|
+
return typedStream(this.stream(opts));
|
|
782
|
+
}
|
|
631
783
|
/**
|
|
632
784
|
* Upload with this use-case's key, so the file is owned by the same
|
|
633
785
|
* service client that will reference it via `run({ fileIds })`.
|
|
@@ -743,11 +895,43 @@ function requestSignal(signal, timeoutMs) {
|
|
|
743
895
|
return signals.length ? mergeSignals(signals) : undefined;
|
|
744
896
|
}
|
|
745
897
|
function retryDelayMs(err, attempt) {
|
|
898
|
+
// Jitter keeps a fleet of clients from retrying in lockstep after a blip.
|
|
899
|
+
const jitter = 0.75 + Math.random() * 0.5;
|
|
746
900
|
if (err.retryAfterMs !== undefined) {
|
|
747
|
-
return Math.min(10_000, Math.max(250, err.retryAfterMs));
|
|
901
|
+
return Math.min(10_000, Math.max(250, err.retryAfterMs * jitter));
|
|
748
902
|
}
|
|
749
|
-
return Math.min(10_000, 500 * 2 ** attempt);
|
|
903
|
+
return Math.min(10_000, 500 * 2 ** attempt * jitter);
|
|
904
|
+
}
|
|
905
|
+
/** Statuses worth a retry even without a server `retryable` flag. */
|
|
906
|
+
const RETRYABLE_STATUSES = new Set([502, 503, 504]);
|
|
907
|
+
/** True when an error should be retried by the shared policy. */
|
|
908
|
+
export function isRetryableError(err) {
|
|
909
|
+
if (!(err instanceof AgentLuError))
|
|
910
|
+
return false;
|
|
911
|
+
if (err.code === "systemone_unconfigured")
|
|
912
|
+
return false;
|
|
913
|
+
if (err.code === "thread_busy" || err.code === "too_many_active_runs")
|
|
914
|
+
return true;
|
|
915
|
+
// Network faults are retried; a client timeout is not (the deadline was hit
|
|
916
|
+
// and a retry would start a second run past the caller's budget).
|
|
917
|
+
if (err.code === "network_error")
|
|
918
|
+
return true;
|
|
919
|
+
if (RETRYABLE_STATUSES.has(err.status))
|
|
920
|
+
return true;
|
|
921
|
+
// The server marks quota exhaustion retryable:false; anything it marks
|
|
922
|
+
// retryable:true is safe to retry even on an otherwise-unremarkable status.
|
|
923
|
+
return err.retryable === true && err.status >= 500;
|
|
750
924
|
}
|
|
751
925
|
function sleep(ms) {
|
|
752
926
|
return new Promise((r) => setTimeout(r, ms));
|
|
753
927
|
}
|
|
928
|
+
/**
|
|
929
|
+
* Unique-enough idempotency key without assuming `crypto.randomUUID` exists
|
|
930
|
+
* (browser SDK consumers on older runtimes).
|
|
931
|
+
*/
|
|
932
|
+
function randomIdempotencyKey() {
|
|
933
|
+
const g = globalThis;
|
|
934
|
+
if (g.crypto?.randomUUID)
|
|
935
|
+
return `sdk_${g.crypto.randomUUID()}`;
|
|
936
|
+
return `sdk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
937
|
+
}
|
package/package.json
CHANGED