@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 +85 -3
- package/dist/index.js +233 -26
- 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,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
|
-
/**
|
|
515
|
-
|
|
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)
|
|
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,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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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 (
|
|
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
|
|
476
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
571
|
-
|
|
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