@betterlu/agentlu 1.4.0 → 1.5.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/README.md CHANGED
@@ -118,6 +118,24 @@ model in `GET /v1/models` (`harness: "systemone"`); it is only callable on
118
118
  `POST /v1/decisions`, sync, no streaming. A missing key surfaces as HTTP 503
119
119
  `systemone_unconfigured` (not retried by the SDK).
120
120
 
121
+ Decision calls accept three client-side options beyond `state` / `questions` /
122
+ `model`:
123
+
124
+ | Option | Effect |
125
+ |---|---|
126
+ | `timeoutMs` | Per-attempt deadline. A timeout aborts the request and is never retried. |
127
+ | `maxAttempts` | Total attempts including the first; defaults to the shared 5-attempt policy. |
128
+ | `signal` | Caller cancellation, combined with `timeoutMs` when both are set. |
129
+
130
+ ```ts
131
+ const answers = await alu.decisions.ask({
132
+ state: latestMessage,
133
+ questions: { rename: noul("Is this a name change request?") },
134
+ timeoutMs: 5_000,
135
+ maxAttempts: 2,
136
+ });
137
+ ```
138
+
121
139
  ## Browser handoff
122
140
 
123
141
  The `browser` family reuses a persistent, headful BrowserLu session. Scope it
package/dist/index.d.ts CHANGED
@@ -134,6 +134,16 @@ export interface DecisionOptions {
134
134
  questions: Record<string, DecisionQuestion>;
135
135
  /** Decision model id (default: the configured `typesafe/jev`). */
136
136
  model?: string;
137
+ /**
138
+ * Per-attempt client deadline in milliseconds. A timeout aborts the request
139
+ * and is never retried; without it the request is bounded only by the
140
+ * caller's fetch implementation.
141
+ */
142
+ timeoutMs?: number;
143
+ /** Total attempts including the first. Defaults to the shared 5-attempt policy. */
144
+ maxAttempts?: number;
145
+ /** Caller cancellation, combined with `timeoutMs` when both are set. */
146
+ signal?: AbortSignal;
137
147
  }
138
148
  export interface DecisionObject {
139
149
  id: string;
@@ -361,6 +371,9 @@ export declare class AgentLu {
361
371
  * },
362
372
  * });
363
373
  * if (answers.urgent.noul > 0.8) routeToHuman();
374
+ *
375
+ * `timeoutMs` bounds each attempt (timeouts are never retried), `maxAttempts`
376
+ * overrides the shared 5-attempt policy, and `signal` cancels the call.
364
377
  */
365
378
  readonly decisions: {
366
379
  create: (body: DecisionOptions) => Promise<DecisionObject>;
package/dist/index.js CHANGED
@@ -10,6 +10,16 @@
10
10
  * `409 thread_busy`.
11
11
  */
12
12
  export const DEFAULT_BASE_URL = "http://localhost:8912/v1";
13
+ /** Strip client-only decision options before the body reaches `/v1/decisions`. */
14
+ function decisionWire(body) {
15
+ const wire = { state: body.state, questions: body.questions };
16
+ if (body.model !== undefined)
17
+ wire.model = body.model;
18
+ return wire;
19
+ }
20
+ function decisionRequestOptions(body) {
21
+ return { timeoutMs: body.timeoutMs, maxAttempts: body.maxAttempts, signal: body.signal };
22
+ }
13
23
  /** Question builders; sugar over the wire shape. */
14
24
  export const noul = (instructions, criteria) => ({
15
25
  type: "noul",
@@ -282,11 +292,14 @@ export class AgentLu {
282
292
  * },
283
293
  * });
284
294
  * if (answers.urgent.noul > 0.8) routeToHuman();
295
+ *
296
+ * `timeoutMs` bounds each attempt (timeouts are never retried), `maxAttempts`
297
+ * overrides the shared 5-attempt policy, and `signal` cancels the call.
285
298
  */
286
299
  decisions = {
287
- create: (body) => this.request("POST", "/decisions", body),
300
+ create: (body) => this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body)),
288
301
  /** Same call, returning only the answers map. */
289
- ask: async (body) => (await this.request("POST", "/decisions", body)).answers,
302
+ ask: async (body) => (await this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body))).answers,
290
303
  };
291
304
  async agents() {
292
305
  return this.request("GET", "/agents", undefined, { auth: false });
@@ -305,10 +318,14 @@ export class AgentLu {
305
318
  // not rate limits. Retrying with backoff is almost always the right thing,
306
319
  // so the SDK does it - honoring Retry-After when the server sends one.
307
320
  // A 503 can also signal a config boundary (no decision model key); those
308
- // are not retryable and must surface immediately.
321
+ // are not retryable and must surface immediately. Callers can cap attempts
322
+ // per request (`maxAttempts`); a timeout/caller abort is never retried.
309
323
  const nonRetryable503 = new Set(["systemone_unconfigured"]);
324
+ const maxAttempts = opts?.maxAttempts !== undefined && Number.isFinite(opts.maxAttempts) && opts.maxAttempts >= 1
325
+ ? Math.floor(opts.maxAttempts)
326
+ : 5;
310
327
  let last;
311
- for (let attempt = 0; attempt < 5; attempt++) {
328
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
312
329
  try {
313
330
  const res = await this.raw(method, path, body, opts);
314
331
  if (res.status === 204)
@@ -321,7 +338,7 @@ export class AgentLu {
321
338
  (e.code === "thread_busy" ||
322
339
  e.code === "too_many_active_runs" ||
323
340
  (e.status === 503 && !nonRetryable503.has(e.code))) &&
324
- attempt < 4) {
341
+ attempt < maxAttempts - 1) {
325
342
  last = e;
326
343
  await sleep(retryDelayMs(e, attempt));
327
344
  continue;
@@ -340,9 +357,11 @@ export class AgentLu {
340
357
  const apiKey = opts?.apiKey ?? this.apiKey;
341
358
  if (opts?.auth !== false && apiKey)
342
359
  headers.Authorization = `Bearer ${apiKey}`;
360
+ const signal = requestSignal(opts?.signal, opts?.timeoutMs);
343
361
  const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
344
362
  method,
345
363
  headers,
364
+ ...(signal ? { signal } : {}),
346
365
  body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
347
366
  });
348
367
  if (!res.ok) {
@@ -442,6 +461,41 @@ function parseRetryAfter(res) {
442
461
  const seconds = Number(header);
443
462
  return Number.isFinite(seconds) ? seconds * 1000 : undefined;
444
463
  }
464
+ /** AbortSignal.timeout with a bare fallback (Node 18, older browsers). */
465
+ function timeoutSignal(timeoutMs) {
466
+ if (typeof AbortSignal.timeout === "function")
467
+ return AbortSignal.timeout(timeoutMs);
468
+ const controller = new AbortController();
469
+ const timer = setTimeout(() => controller.abort(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);
470
+ timer.unref?.();
471
+ return controller.signal;
472
+ }
473
+ /** AbortSignal.any with a bare fallback (Node 18, older browsers). */
474
+ function mergeSignals(signals) {
475
+ if (signals.length === 1)
476
+ return signals[0];
477
+ if (typeof AbortSignal.any === "function")
478
+ return AbortSignal.any(signals);
479
+ const controller = new AbortController();
480
+ for (const signal of signals) {
481
+ if (signal.aborted) {
482
+ controller.abort(signal.reason);
483
+ break;
484
+ }
485
+ signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true });
486
+ }
487
+ return controller.signal;
488
+ }
489
+ /** Combined deadline/cancellation signal; undefined when neither applies. */
490
+ function requestSignal(signal, timeoutMs) {
491
+ const signals = [];
492
+ if (signal)
493
+ signals.push(signal);
494
+ if (timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0) {
495
+ signals.push(timeoutSignal(timeoutMs));
496
+ }
497
+ return signals.length ? mergeSignals(signals) : undefined;
498
+ }
445
499
  function retryDelayMs(err, attempt) {
446
500
  if (err.retryAfterMs !== undefined) {
447
501
  return Math.min(10_000, Math.max(250, err.retryAfterMs));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@betterlu/agentlu",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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",