@betterlu/agentlu 1.3.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
@@ -10,7 +10,7 @@ const client = new OpenAI({ baseURL: "http://localhost:8912/v1", apiKey });
10
10
  const r = await client.responses.create({ model: "fast", input: "hi" });
11
11
  ```
12
12
 
13
- This client adds get-or-create registration, typed `run({ schema })`, and auto-retry on `409 thread_busy`.
13
+ This client adds get-or-create registration, typed `runJson`, and auto-retry on `409 thread_busy`.
14
14
 
15
15
  ## Install
16
16
 
@@ -33,4 +33,128 @@ const { data, response } = await alu.run({
33
33
  console.log(response.output_text);
34
34
  ```
35
35
 
36
+ ## JSON results
37
+
38
+ `runJson` returns only a completed response with the server-provided `output_parsed` value. It preserves JSON `null`, `false`, `0`, and empty arrays. A missing parsed value, failed response, incomplete response, cancellation, or queued response throws an SDK error instead of parsing `output_text`. `background: true` is rejected by this helper; use raw `run` when polling is intended.
39
+
40
+ ```ts
41
+ const { data, response } = await alu.runJson<{ answer: string }>({
42
+ input: "Answer briefly",
43
+ schema: { type: "object", properties: { answer: { type: "string" } } },
44
+ schemaName: "Answer",
45
+ strict: true,
46
+ });
47
+ ```
48
+
49
+ An explicit `text.format` is forwarded unchanged. In particular, `text: { format: { type: "text" } }` is not silently changed to JSON by `runJson`.
50
+
51
+ ## Use-case defaults
52
+
53
+ Defaults are resolved by the service for every request:
54
+
55
+ ```text
56
+ request field -> registered client default -> agent/profile default
57
+ ```
58
+
59
+ Register a client once per use-case. The runner sends only request-level overrides, so its registered schema is not resent with every run:
60
+
61
+ ```ts
62
+ const schema = { type: "object", properties: { meal: { type: "string" } } };
63
+ const mealEnricher = alu.useCase("mealu-enrich", {
64
+ defaults: {
65
+ agent: "api",
66
+ store: false,
67
+ tools: [],
68
+ reasoning: { effort: "medium" },
69
+ text: { format: { type: "json_schema", name: "Meal", schema } },
70
+ },
71
+ });
72
+
73
+ const { data } = await mealEnricher.runJson<{ meal: string }>({ input: "two eggs" });
74
+ ```
75
+
76
+ Set a field on `run` or `runJson` to override its registered default. `strict: false` still requires parsed JSON but does not require that value to match the schema.
77
+
36
78
  Env: `AGENTLU_URL` (default `http://localhost:8912/v1`), `AGENTLU_API_KEY`.
79
+
80
+ ## Decisions (System One / Jev)
81
+
82
+ `decisions` is a different primitive from `run`: Jev returns typed, calibrated
83
+ probabilities - never text - in ~100-500ms. Use it for routing, classification,
84
+ scoring and gating inside your own code.
85
+
86
+ ```ts
87
+ import { AgentLu, noul, choice, score } from "@betterlu/agentlu";
88
+
89
+ const alu = new AgentLu();
90
+ alu.apiKey = key;
91
+
92
+ const { answers } = await alu.decisions.create({
93
+ state: "I was charged twice and I'm losing sales. Please fix this ASAP.",
94
+ questions: {
95
+ urgent: noul("Does this need urgent attention?"),
96
+ team: choice("Which team should handle it?", {
97
+ billing: "charges and payments",
98
+ shipping: "delivery",
99
+ }),
100
+ frustration: score("How frustrated is the customer?", ["Calm", "Frustrated", "Very angry"]),
101
+ },
102
+ });
103
+
104
+ if (answers.urgent.type === "noul" && answers.urgent.noul > 0.8) routeToHuman();
105
+ if (answers.team.type === "choice") return lookupOrder(answers.team.choice);
106
+ ```
107
+
108
+ `answers.<name>` is a discriminated union on `type`:
109
+
110
+ | Question | Answer fields |
111
+ |---|---|
112
+ | `noul` | `noul` - probability that the answer is yes (0..1) |
113
+ | `choice` | `choice`, `confidence`, `probabilities` per option |
114
+ | `score` | `score`, `confidence`, `legend`, `probabilities` per step |
115
+
116
+ `decisions.ask()` is the same call returning only the answers map. Discover the
117
+ model in `GET /v1/models` (`harness: "systemone"`); it is only callable on
118
+ `POST /v1/decisions`, sync, no streaming. A missing key surfaces as HTTP 503
119
+ `systemone_unconfigured` (not retried by the SDK).
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
+
139
+ ## Browser handoff
140
+
141
+ The `browser` family reuses a persistent, headful BrowserLu session. Scope it
142
+ to the operations the caller needs and identify the linked household person:
143
+
144
+ ```ts
145
+ const { response } = await alu.run({
146
+ agent: "api",
147
+ person: "larry",
148
+ tools: [{ type: "browser", allowed_tools: ["browser_open", "browser_snapshot"] }],
149
+ input: "Read the current page and summarize it.",
150
+ });
151
+
152
+ if (response.agentlu?.browser?.status === "needs_human") {
153
+ // Let this request end. The human completes login/MFA in the AgentLu viewer,
154
+ // then send a new request with the same conversation/session context.
155
+ }
156
+ ```
157
+
158
+ Browser response metadata contains the session and page identity plus screenshot
159
+ ids. Screenshot pixels stay in the authenticated viewer and are not copied into
160
+ API response metadata.
package/dist/index.d.ts CHANGED
@@ -6,10 +6,165 @@
6
6
  * new OpenAI({ baseURL: "http://localhost:8912/v1", apiKey })
7
7
  *
8
8
  * This client exists for the ergonomic bits OpenAI's SDK does not know about:
9
- * get-or-create registration, typed `run({ schema })`, and auto-retry on
9
+ * get-or-create registration, typed `runJson`, and auto-retry on
10
10
  * `409 thread_busy`.
11
11
  */
12
12
  export declare const DEFAULT_BASE_URL = "http://localhost:8912/v1";
13
+ export type Backend = "pi";
14
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
15
+ export type ApiToolFamily = "web_search" | "fetch_content" | "hublu" | "memory" | "image" | "browser" | "android";
16
+ /** Closed tool-family request accepted by `/v1/responses`. */
17
+ export interface ApiToolSpec {
18
+ type: ApiToolFamily;
19
+ /** Optional family-specific operation allowlist (HubLu, BrowserLu, or AndroidLu). */
20
+ allowed_tools?: string[];
21
+ }
22
+ export type ApiToolInput = ApiToolFamily | ApiToolSpec;
23
+ export type BrowserStatus = "active" | "needs_human" | "human_control" | "idle" | "closed" | "interrupted";
24
+ export interface BrowserScreenshot {
25
+ id: string;
26
+ createdAt: string;
27
+ width: number;
28
+ height: number;
29
+ }
30
+ export interface BrowserActivity {
31
+ sessionId: string;
32
+ username: "larry" | "misa";
33
+ ownerId?: string;
34
+ tabId?: string;
35
+ url?: string;
36
+ title?: string;
37
+ status: BrowserStatus;
38
+ action?: string;
39
+ reason?: string;
40
+ observationId?: string;
41
+ screenshots?: BrowserScreenshot[];
42
+ }
43
+ export type AndroidStatus = "active" | "needs_human" | "human_control" | "idle" | "closed" | "interrupted" | "reserved";
44
+ export type AndroidOperationState = "running" | "succeeded" | "failed" | "unknown";
45
+ export interface AndroidOperation {
46
+ operationId: string;
47
+ kind: string;
48
+ state: AndroidOperationState;
49
+ dispatchedAt: string;
50
+ settledAt?: string;
51
+ outcome?: string;
52
+ }
53
+ export interface AndroidActivity {
54
+ sessionId: string;
55
+ username: "larry" | "misa";
56
+ ownerId?: string;
57
+ status: AndroidStatus;
58
+ action?: string;
59
+ reason?: string;
60
+ observationId?: string;
61
+ leaseId?: string;
62
+ readiness?: "stopped" | "booting" | "ready" | "failed";
63
+ control?: "idle" | "agent" | "needs_human" | "human" | "reserved" | "interrupted";
64
+ foregroundPackage?: string;
65
+ pendingOperation?: AndroidOperation;
66
+ lastOperation?: AndroidOperation;
67
+ viewerPath?: string;
68
+ reservationExpiresAt?: string;
69
+ }
70
+ export interface ReasoningOptions {
71
+ effort?: ThinkingLevel;
72
+ }
73
+ export interface ClientReasoningOptions {
74
+ effort: ThinkingLevel;
75
+ }
76
+ export interface TextFormatText {
77
+ type: "text";
78
+ }
79
+ export interface TextFormatJsonSchema {
80
+ type: "json_schema";
81
+ name: string;
82
+ schema: Record<string, unknown>;
83
+ strict?: boolean;
84
+ description?: string;
85
+ }
86
+ export type TextFormat = TextFormatText | TextFormatJsonSchema;
87
+ export interface TextConfig {
88
+ format?: TextFormat;
89
+ }
90
+ /** Yes/no, answered as the probability that the answer is yes. */
91
+ export interface NoulQuestion {
92
+ type: "noul";
93
+ instructions: string;
94
+ /** Optional descriptions of the true/false poles (calibrates the answer). */
95
+ criteria?: {
96
+ true: string;
97
+ false: string;
98
+ };
99
+ }
100
+ export interface ChoiceQuestion {
101
+ type: "choice";
102
+ instructions: string;
103
+ /** Option name -> description. At least two options. */
104
+ criteria: Record<string, string>;
105
+ }
106
+ export interface ScoreQuestion {
107
+ type: "score";
108
+ instructions: string;
109
+ /** Ordered levels, lowest first. At least two. */
110
+ criteria: string[];
111
+ }
112
+ export type DecisionQuestion = NoulQuestion | ChoiceQuestion | ScoreQuestion;
113
+ export interface NoulAnswer {
114
+ type: "noul";
115
+ noul: number;
116
+ }
117
+ export interface ChoiceAnswer {
118
+ type: "choice";
119
+ choice: string;
120
+ confidence: number;
121
+ probabilities: Record<string, number>;
122
+ }
123
+ export interface ScoreAnswer {
124
+ type: "score";
125
+ score: number;
126
+ confidence: number;
127
+ legend?: Record<string, string>;
128
+ probabilities: Record<string, number>;
129
+ }
130
+ export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
131
+ export interface DecisionOptions {
132
+ /** Unstructured input: a text state, or a JSON object (serialized upstream). */
133
+ state: string | Record<string, unknown>;
134
+ questions: Record<string, DecisionQuestion>;
135
+ /** Decision model id (default: the configured `typesafe/jev`). */
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;
147
+ }
148
+ export interface DecisionObject {
149
+ id: string;
150
+ object: "decision";
151
+ created_at: number;
152
+ model: string;
153
+ answers: Record<string, DecisionAnswer>;
154
+ usage: {
155
+ input_tokens: number;
156
+ output_tokens: number;
157
+ total_tokens: number;
158
+ } | null;
159
+ [k: string]: unknown;
160
+ }
161
+ /** Question builders; sugar over the wire shape. */
162
+ export declare const noul: (instructions: string, criteria?: {
163
+ true: string;
164
+ false: string;
165
+ }) => NoulQuestion;
166
+ export declare const choice: (instructions: string, criteria: Record<string, string>) => ChoiceQuestion;
167
+ export declare const score: (instructions: string, levels: string[]) => ScoreQuestion;
13
168
  export declare class AgentLuError extends Error {
14
169
  readonly status: number;
15
170
  readonly code: string;
@@ -20,24 +175,37 @@ export declare class AgentLuError extends Error {
20
175
  /** Parsed Retry-After header, in milliseconds, when present. */
21
176
  retryAfterMs?: number | undefined);
22
177
  }
178
+ export declare class AgentLuResponseError extends AgentLuError {
179
+ readonly response: ResponseObject;
180
+ readonly responseStatus: string;
181
+ constructor(response: ResponseObject);
182
+ }
23
183
  export interface AgentLuOptions {
24
184
  baseUrl?: string;
25
185
  apiKey?: string;
26
186
  fetch?: typeof fetch;
27
187
  }
28
188
  export interface ClientDefaults {
189
+ backend?: Backend;
29
190
  agent?: string;
30
191
  model?: string;
31
192
  instructions?: string;
32
193
  temperature?: number;
33
- maxOutputTokens?: number;
34
- tools?: Array<string | {
35
- type: string;
36
- allowed_tools?: string[];
37
- }>;
194
+ tools?: ApiToolInput[];
38
195
  timeoutMs?: number;
196
+ store?: boolean;
197
+ reasoning?: ClientReasoningOptions;
198
+ text?: TextConfig;
199
+ }
200
+ /** High-level use-case declaration for model routing and priority tier. */
201
+ export interface UseCaseProfile {
202
+ inputModalities?: Array<"text" | "image" | "video">;
203
+ outputKind?: "text" | "json_schema";
204
+ /** MiniMax priority admission consent (priority costs 1.5x standard). */
205
+ priorityTier?: "never" | "retry" | "first" | "always";
39
206
  }
40
207
  export interface RunOptions {
208
+ backend?: Backend;
41
209
  agent?: string;
42
210
  model?: string;
43
211
  input?: string;
@@ -54,10 +222,10 @@ export interface RunOptions {
54
222
  content: string | Array<Record<string, unknown>>;
55
223
  }>;
56
224
  instructions?: string;
57
- tools?: Array<string | {
58
- type: string;
59
- allowed_tools?: string[];
60
- }>;
225
+ tools?: ApiToolInput[];
226
+ reasoning?: ReasoningOptions;
227
+ text?: TextConfig;
228
+ strict?: boolean;
61
229
  conversation?: string;
62
230
  store?: boolean;
63
231
  person?: string | null;
@@ -78,6 +246,7 @@ export interface RunOptions {
78
246
  export interface ResponseObject {
79
247
  id: string;
80
248
  status: string;
249
+ backend: Backend;
81
250
  output_text: string;
82
251
  output_parsed?: unknown;
83
252
  conversation?: {
@@ -88,8 +257,19 @@ export interface ResponseObject {
88
257
  message: string;
89
258
  retryable: boolean;
90
259
  } | null;
260
+ incomplete_details?: {
261
+ reason: string;
262
+ } | null;
91
263
  agentlu?: {
92
264
  session_url?: string | null;
265
+ /** Model the runtime actually served (requested `model` may have fallen back). */
266
+ effective_model?: string | null;
267
+ /** MiniMax admission tier requested for the winning attempt, when any. */
268
+ service_tier?: "standard" | "priority";
269
+ /** Latest persistent BrowserLu activity; screenshot bytes are never in this object. */
270
+ browser?: BrowserActivity;
271
+ /** Latest persistent AndroidLu activity; screenshot bytes are never in this object. */
272
+ android?: AndroidActivity;
93
273
  };
94
274
  [k: string]: unknown;
95
275
  }
@@ -98,11 +278,13 @@ export declare class AgentLu {
98
278
  apiKey: string | undefined;
99
279
  private readonly fetchImpl;
100
280
  constructor(opts?: AgentLuOptions);
281
+ private postClient;
101
282
  /** Get-or-create a client by name. Caches the returned key on this instance. */
102
283
  register(name: string, opts?: {
103
284
  person?: string | null;
104
285
  instructions?: string | null;
105
286
  defaults?: ClientDefaults;
287
+ use_case_profile?: UseCaseProfile;
106
288
  }): Promise<ResponseObject & {
107
289
  api_key?: string;
108
290
  name: string;
@@ -113,10 +295,11 @@ export declare class AgentLu {
113
295
  * The use-case's defaults (system prompt, tools, temperature, ...) live
114
296
  * server-side on the client, so `run()` only needs the input. Registering a
115
297
  * use-case is registering a client - `alu.useCase("mealu-enrich", {...})`.
116
- */
298
+ */
117
299
  useCase(name: string, opts?: {
118
300
  person?: string | null;
119
301
  defaults?: ClientDefaults;
302
+ use_case_profile?: UseCaseProfile;
120
303
  }): UseCaseRunner;
121
304
  /**
122
305
  * Register a use-case client and resolve to its api key. The key is cached
@@ -126,7 +309,9 @@ export declare class AgentLu {
126
309
  useCaseClient(name: string, opts?: {
127
310
  person?: string | null;
128
311
  defaults?: ClientDefaults;
312
+ use_case_profile?: UseCaseProfile;
129
313
  }): Promise<string>;
314
+ private registerForUseCase;
130
315
  /**
131
316
  * Upload an image, video, or document for use with `run({ fileIds })`.
132
317
  * Returns the `file_id` and stored `kind`.
@@ -135,6 +320,7 @@ export declare class AgentLu {
135
320
  id: string;
136
321
  kind?: string;
137
322
  }>;
323
+ private uploadForApiKey;
138
324
  me(): Promise<unknown>;
139
325
  /**
140
326
  * One-shot helper. When `schema` is given, returns the validated object as
@@ -145,6 +331,13 @@ export declare class AgentLu {
145
331
  data?: T;
146
332
  response: ResponseObject;
147
333
  }>;
334
+ runJson<T = unknown>(opts: RunOptions): Promise<{
335
+ data: T;
336
+ response: ResponseObject;
337
+ }>;
338
+ private runForApiKey;
339
+ private runJsonForApiKey;
340
+ private createRunResponse;
148
341
  readonly responses: {
149
342
  create: (body: Record<string, unknown>) => Promise<ResponseObject>;
150
343
  retrieve: (id: string) => Promise<ResponseObject>;
@@ -156,8 +349,9 @@ export declare class AgentLu {
156
349
  object: string;
157
350
  data: ResponseObject[];
158
351
  }>;
159
- stream: (body: Record<string, unknown>) => AsyncGenerator<Record<string, unknown>>;
352
+ stream: (body: Record<string, unknown>) => AsyncGenerator<Record<string, unknown>, any, any>;
160
353
  };
354
+ private streamForApiKey;
161
355
  readonly threads: {
162
356
  create: (body?: Record<string, unknown>) => Promise<unknown>;
163
357
  retrieve: (id: string) => Promise<unknown>;
@@ -165,6 +359,27 @@ export declare class AgentLu {
165
359
  delete: (id: string) => Promise<unknown>;
166
360
  messages: (id: string) => Promise<unknown>;
167
361
  };
362
+ /**
363
+ * Decision model (System One / Jev): typed questions in, calibrated
364
+ * probabilities out, ~100-500ms. Sync only - there is no stream or poll.
365
+ *
366
+ * const { answers } = await alu.decisions.create({
367
+ * state: "I was charged twice and I'm losing sales. Please fix this ASAP.",
368
+ * questions: {
369
+ * urgent: noul("Does this need urgent attention?"),
370
+ * team: choice("Which team should handle it?", { billing: "charges", shipping: "delivery" }),
371
+ * },
372
+ * });
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.
377
+ */
378
+ readonly decisions: {
379
+ create: (body: DecisionOptions) => Promise<DecisionObject>;
380
+ /** Same call, returning only the answers map. */
381
+ ask: (body: DecisionOptions) => Promise<Record<string, DecisionAnswer>>;
382
+ };
168
383
  agents(): Promise<unknown>;
169
384
  models(): Promise<unknown>;
170
385
  tools(): Promise<unknown>;
@@ -184,14 +399,15 @@ export declare class AgentLu {
184
399
  * const { data } = await enrich.run({ input: "two eggs and toast" });
185
400
  */
186
401
  export declare class UseCaseRunner {
187
- private readonly parent;
188
402
  readonly name: string;
189
403
  private readonly registerOpts;
404
+ private readonly operations;
190
405
  private apiKey;
191
406
  private ready;
192
407
  constructor(parent: AgentLu, name: string, opts?: {
193
408
  person?: string | null;
194
409
  defaults?: ClientDefaults;
410
+ use_case_profile?: UseCaseProfile;
195
411
  });
196
412
  /** Idempotent get-or-create; safe to call before every run. */
197
413
  private ensure;
@@ -203,6 +419,10 @@ export declare class UseCaseRunner {
203
419
  data?: T;
204
420
  response: ResponseObject;
205
421
  }>;
422
+ runJson<T = unknown>(opts: RunOptions): Promise<{
423
+ data: T;
424
+ response: ResponseObject;
425
+ }>;
206
426
  stream(body: Record<string, unknown>): Promise<AsyncGenerator<Record<string, unknown>, any, any>>;
207
427
  /**
208
428
  * Upload with this use-case's key, so the file is owned by the same
package/dist/index.js CHANGED
@@ -6,10 +6,36 @@
6
6
  * new OpenAI({ baseURL: "http://localhost:8912/v1", apiKey })
7
7
  *
8
8
  * This client exists for the ergonomic bits OpenAI's SDK does not know about:
9
- * get-or-create registration, typed `run({ schema })`, and auto-retry on
9
+ * get-or-create registration, typed `runJson`, and auto-retry on
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
+ }
23
+ /** Question builders; sugar over the wire shape. */
24
+ export const noul = (instructions, criteria) => ({
25
+ type: "noul",
26
+ instructions,
27
+ ...(criteria ? { criteria } : {}),
28
+ });
29
+ export const choice = (instructions, criteria) => ({
30
+ type: "choice",
31
+ instructions,
32
+ criteria,
33
+ });
34
+ export const score = (instructions, levels) => ({
35
+ type: "score",
36
+ instructions,
37
+ criteria: levels,
38
+ });
13
39
  export class AgentLuError extends Error {
14
40
  status;
15
41
  code;
@@ -26,9 +52,23 @@ export class AgentLuError extends Error {
26
52
  this.name = "AgentLuError";
27
53
  }
28
54
  }
55
+ export class AgentLuResponseError extends AgentLuError {
56
+ response;
57
+ responseStatus;
58
+ constructor(response) {
59
+ const responseStatus = response.status;
60
+ const code = response.error?.code ?? responseStatus;
61
+ const message = response.error?.message ?? `Response status "${responseStatus}" is not completed.`;
62
+ super(200, code, message, response.error?.retryable ?? false);
63
+ this.name = "AgentLuResponseError";
64
+ this.response = response;
65
+ this.responseStatus = responseStatus;
66
+ }
67
+ }
29
68
  function env(name) {
30
69
  return typeof process !== "undefined" ? process.env[name] : undefined;
31
70
  }
71
+ const scopedUseCaseOperations = new WeakMap();
32
72
  export class AgentLu {
33
73
  baseUrl;
34
74
  apiKey;
@@ -37,10 +77,20 @@ export class AgentLu {
37
77
  this.baseUrl = (opts.baseUrl ?? env("AGENTLU_URL") ?? DEFAULT_BASE_URL).replace(/\/$/, "");
38
78
  this.apiKey = opts.apiKey ?? env("AGENTLU_API_KEY");
39
79
  this.fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
80
+ scopedUseCaseOperations.set(this, {
81
+ register: (name, registerOpts) => this.registerForUseCase(name, registerOpts),
82
+ run: (apiKey, runOpts) => this.runForApiKey(apiKey, runOpts),
83
+ runJson: (apiKey, runOpts) => this.runJsonForApiKey(apiKey, runOpts),
84
+ stream: (apiKey, body) => this.streamForApiKey(body, apiKey),
85
+ upload: (apiKey, data, filename, mime) => this.uploadForApiKey(data, filename, mime, apiKey),
86
+ });
87
+ }
88
+ postClient(name, opts) {
89
+ return this.request("POST", "/clients", { name, ...opts }, { auth: false });
40
90
  }
41
91
  /** Get-or-create a client by name. Caches the returned key on this instance. */
42
92
  async register(name, opts) {
43
- const res = await this.request("POST", "/clients", { name, ...opts }, { auth: false });
93
+ const res = await this.postClient(name, opts);
44
94
  if (res.api_key)
45
95
  this.apiKey = res.api_key;
46
96
  return res;
@@ -51,7 +101,7 @@ export class AgentLu {
51
101
  * The use-case's defaults (system prompt, tools, temperature, ...) live
52
102
  * server-side on the client, so `run()` only needs the input. Registering a
53
103
  * use-case is registering a client - `alu.useCase("mealu-enrich", {...})`.
54
- */
104
+ */
55
105
  useCase(name, opts) {
56
106
  return new UseCaseRunner(this, name, opts);
57
107
  }
@@ -61,21 +111,30 @@ export class AgentLu {
61
111
  * `responses.create` is authorized.
62
112
  */
63
113
  async useCaseClient(name, opts) {
64
- const res = await this.request("POST", "/clients", { name, ...opts }, { auth: false });
114
+ const res = await this.postClient(name, opts);
65
115
  if (!res.api_key)
66
116
  throw new AgentLuError(500, "internal_error", "register returned no key", true);
67
117
  this.apiKey = res.api_key;
68
118
  return res.api_key;
69
119
  }
120
+ async registerForUseCase(name, opts) {
121
+ const res = await this.postClient(name, opts);
122
+ if (!res.api_key)
123
+ throw new AgentLuError(500, "internal_error", "register returned no key", true);
124
+ return res.api_key;
125
+ }
70
126
  /**
71
127
  * Upload an image, video, or document for use with `run({ fileIds })`.
72
128
  * Returns the `file_id` and stored `kind`.
73
129
  */
74
130
  async upload(data, filename, mime) {
131
+ return this.uploadForApiKey(data, filename, mime, this.apiKey);
132
+ }
133
+ async uploadForApiKey(data, filename, mime, apiKey) {
75
134
  const form = new FormData();
76
135
  const blob = data instanceof Blob ? data : new Blob([new Uint8Array(data)], { type: mime ?? "application/octet-stream" });
77
136
  form.append("file", blob, filename);
78
- const res = await this.raw("POST", "/files", form);
137
+ const res = await this.raw("POST", "/files", form, { apiKey });
79
138
  return (await res.json());
80
139
  }
81
140
  async me() {
@@ -87,6 +146,29 @@ export class AgentLu {
87
146
  * could not produce matching JSON.
88
147
  */
89
148
  async run(opts) {
149
+ return this.runForApiKey(this.apiKey, opts);
150
+ }
151
+ async runJson(opts) {
152
+ assertJsonRunOptions(opts);
153
+ return this.runJsonForApiKey(this.apiKey, opts);
154
+ }
155
+ async runForApiKey(apiKey, opts) {
156
+ const response = await this.createRunResponse(opts, apiKey);
157
+ if (response.status === "failed" && response.error) {
158
+ throw new AgentLuError(200, response.error.code, response.error.message, response.error.retryable);
159
+ }
160
+ return { data: response.output_parsed, response };
161
+ }
162
+ async runJsonForApiKey(apiKey, opts) {
163
+ const response = await this.createRunResponse(opts, apiKey);
164
+ if (response.status !== "completed")
165
+ throw new AgentLuResponseError(response);
166
+ if (!Object.prototype.hasOwnProperty.call(response, "output_parsed")) {
167
+ throw new AgentLuError(200, "missing_output_parsed", "Completed response did not include output_parsed.", false);
168
+ }
169
+ return { data: response.output_parsed, response };
170
+ }
171
+ async createRunResponse(opts, apiKey) {
90
172
  let input = opts.inputMessages ?? opts.input;
91
173
  if (opts.fileIds?.length) {
92
174
  input = [
@@ -108,10 +190,12 @@ export class AgentLu {
108
190
  }
109
191
  const body = {
110
192
  input,
193
+ backend: opts.backend,
111
194
  agent: opts.agent,
112
195
  model: opts.model,
113
196
  instructions: opts.instructions,
114
197
  tools: opts.tools,
198
+ reasoning: opts.reasoning,
115
199
  conversation: opts.conversation,
116
200
  store: opts.store,
117
201
  person: opts.person,
@@ -124,21 +208,20 @@ export class AgentLu {
124
208
  background: opts.background,
125
209
  metadata: opts.metadata,
126
210
  };
127
- if (opts.schema) {
211
+ if (opts.text !== undefined) {
212
+ body.text = opts.text;
213
+ }
214
+ else if (opts.schema !== undefined) {
128
215
  body.text = {
129
216
  format: {
130
217
  type: "json_schema",
131
218
  name: opts.schemaName ?? "Result",
132
219
  schema: opts.schema,
133
- strict: true,
220
+ strict: opts.strict ?? true,
134
221
  },
135
222
  };
136
223
  }
137
- const response = await this.responses.create(body);
138
- if (response.status === "failed" && response.error) {
139
- throw new AgentLuError(200, response.error.code, response.error.message, response.error.retryable);
140
- }
141
- return { data: response.output_parsed, response };
224
+ return this.request("POST", "/responses", body, { apiKey });
142
225
  }
143
226
  responses = {
144
227
  create: (body) => this.request("POST", "/responses", body),
@@ -153,42 +236,43 @@ export class AgentLu {
153
236
  const qs = q.toString();
154
237
  return this.request("GET", `/responses${qs ? `?${qs}` : ""}`);
155
238
  },
156
- stream: async function* stream(body) {
157
- const res = await this.raw("POST", "/responses", { ...body, stream: true });
158
- if (!res.body)
159
- throw new AgentLuError(0, "internal_error", "empty stream body", true);
160
- const reader = res.body.getReader();
161
- const decoder = new TextDecoder();
162
- let buf = "";
163
- try {
164
- for (;;) {
165
- const { done, value } = await reader.read();
166
- if (done)
167
- break;
168
- buf += decoder.decode(value, { stream: true });
169
- const lines = buf.split("\n");
170
- buf = lines.pop() ?? "";
171
- for (const line of lines) {
172
- const trimmed = line.trim();
173
- if (!trimmed.startsWith("data: "))
174
- continue;
175
- const payload = trimmed.slice(6);
176
- if (payload === "[DONE]")
177
- return;
178
- try {
179
- yield JSON.parse(payload);
180
- }
181
- catch {
182
- /* ignore malformed chunks */
183
- }
239
+ stream: (body) => this.streamForApiKey(body),
240
+ };
241
+ async *streamForApiKey(body, apiKey) {
242
+ const res = await this.raw("POST", "/responses", { ...body, stream: true }, { apiKey });
243
+ if (!res.body)
244
+ throw new AgentLuError(0, "internal_error", "empty stream body", true);
245
+ const reader = res.body.getReader();
246
+ const decoder = new TextDecoder();
247
+ let buf = "";
248
+ try {
249
+ for (;;) {
250
+ const { done, value } = await reader.read();
251
+ if (done)
252
+ break;
253
+ buf += decoder.decode(value, { stream: true });
254
+ const lines = buf.split("\n");
255
+ buf = lines.pop() ?? "";
256
+ for (const line of lines) {
257
+ const trimmed = line.trim();
258
+ if (!trimmed.startsWith("data: "))
259
+ continue;
260
+ const payload = trimmed.slice(6);
261
+ if (payload === "[DONE]")
262
+ return;
263
+ try {
264
+ yield JSON.parse(payload);
265
+ }
266
+ catch {
267
+ /* ignore malformed chunks */
184
268
  }
185
269
  }
186
270
  }
187
- finally {
188
- reader.releaseLock();
189
- }
190
- }.bind(this),
191
- };
271
+ }
272
+ finally {
273
+ reader.releaseLock();
274
+ }
275
+ }
192
276
  threads = {
193
277
  create: (body = {}) => this.request("POST", "/threads", body),
194
278
  retrieve: (id) => this.request("GET", `/threads/${encodeURIComponent(id)}`),
@@ -196,6 +280,27 @@ export class AgentLu {
196
280
  delete: (id) => this.request("DELETE", `/threads/${encodeURIComponent(id)}`),
197
281
  messages: (id) => this.request("GET", `/threads/${encodeURIComponent(id)}/messages`),
198
282
  };
283
+ /**
284
+ * Decision model (System One / Jev): typed questions in, calibrated
285
+ * probabilities out, ~100-500ms. Sync only - there is no stream or poll.
286
+ *
287
+ * const { answers } = await alu.decisions.create({
288
+ * state: "I was charged twice and I'm losing sales. Please fix this ASAP.",
289
+ * questions: {
290
+ * urgent: noul("Does this need urgent attention?"),
291
+ * team: choice("Which team should handle it?", { billing: "charges", shipping: "delivery" }),
292
+ * },
293
+ * });
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.
298
+ */
299
+ decisions = {
300
+ create: (body) => this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body)),
301
+ /** Same call, returning only the answers map. */
302
+ ask: async (body) => (await this.request("POST", "/decisions", decisionWire(body), decisionRequestOptions(body))).answers,
303
+ };
199
304
  async agents() {
200
305
  return this.request("GET", "/agents", undefined, { auth: false });
201
306
  }
@@ -212,8 +317,15 @@ export class AgentLu {
212
317
  // `409 thread_busy` and `503 too_many_active_runs` are correctness bounds,
213
318
  // not rate limits. Retrying with backoff is almost always the right thing,
214
319
  // so the SDK does it - honoring Retry-After when the server sends one.
320
+ // A 503 can also signal a config boundary (no decision model key); those
321
+ // are not retryable and must surface immediately. Callers can cap attempts
322
+ // per request (`maxAttempts`); a timeout/caller abort is never retried.
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;
215
327
  let last;
216
- for (let attempt = 0; attempt < 5; attempt++) {
328
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
217
329
  try {
218
330
  const res = await this.raw(method, path, body, opts);
219
331
  if (res.status === 204)
@@ -223,8 +335,10 @@ export class AgentLu {
223
335
  }
224
336
  catch (e) {
225
337
  if (e instanceof AgentLuError &&
226
- (e.code === "thread_busy" || e.code === "too_many_active_runs" || e.status === 503) &&
227
- attempt < 4) {
338
+ (e.code === "thread_busy" ||
339
+ e.code === "too_many_active_runs" ||
340
+ (e.status === 503 && !nonRetryable503.has(e.code))) &&
341
+ attempt < maxAttempts - 1) {
228
342
  last = e;
229
343
  await sleep(retryDelayMs(e, attempt));
230
344
  continue;
@@ -240,11 +354,14 @@ export class AgentLu {
240
354
  if (body !== undefined && !isForm) {
241
355
  headers["Content-Type"] = "application/json";
242
356
  }
243
- if (opts?.auth !== false && this.apiKey)
244
- headers.Authorization = `Bearer ${this.apiKey}`;
357
+ const apiKey = opts?.apiKey ?? this.apiKey;
358
+ if (opts?.auth !== false && apiKey)
359
+ headers.Authorization = `Bearer ${apiKey}`;
360
+ const signal = requestSignal(opts?.signal, opts?.timeoutMs);
245
361
  const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
246
362
  method,
247
363
  headers,
364
+ ...(signal ? { signal } : {}),
248
365
  body: body === undefined ? undefined : isForm ? body : JSON.stringify(body),
249
366
  });
250
367
  if (!res.ok) {
@@ -280,23 +397,22 @@ export class AgentLu {
280
397
  * const { data } = await enrich.run({ input: "two eggs and toast" });
281
398
  */
282
399
  export class UseCaseRunner {
283
- parent;
284
400
  name;
285
401
  registerOpts;
402
+ operations;
286
403
  apiKey = null;
287
404
  ready = null;
288
405
  constructor(parent, name, opts) {
289
- this.parent = parent;
290
406
  this.name = name;
291
407
  this.registerOpts = opts ?? {};
408
+ this.operations = scopedUseCaseOperations.get(parent);
292
409
  }
293
410
  /** Idempotent get-or-create; safe to call before every run. */
294
411
  ensure() {
295
412
  if (this.apiKey)
296
413
  return Promise.resolve();
297
414
  if (!this.ready) {
298
- this.ready = this.parent
299
- .useCaseClient(this.name, this.registerOpts)
415
+ this.ready = this.operations.register(this.name, this.registerOpts)
300
416
  .then((key) => {
301
417
  this.apiKey = key;
302
418
  })
@@ -313,25 +429,16 @@ export class UseCaseRunner {
313
429
  */
314
430
  async run(opts) {
315
431
  await this.ensure();
316
- const parentKey = this.parent.apiKey;
317
- this.parent.apiKey = this.apiKey ?? undefined;
318
- try {
319
- return await this.parent.run(opts);
320
- }
321
- finally {
322
- this.parent.apiKey = parentKey;
323
- }
432
+ return (await this.operations.run(this.apiKey, opts));
433
+ }
434
+ async runJson(opts) {
435
+ assertJsonRunOptions(opts);
436
+ await this.ensure();
437
+ return (await this.operations.runJson(this.apiKey, opts));
324
438
  }
325
439
  async stream(body) {
326
440
  await this.ensure();
327
- const parentKey = this.parent.apiKey;
328
- this.parent.apiKey = this.apiKey ?? undefined;
329
- try {
330
- return await this.parent.responses.stream(body);
331
- }
332
- finally {
333
- this.parent.apiKey = parentKey;
334
- }
441
+ return this.operations.stream(this.apiKey, body);
335
442
  }
336
443
  /**
337
444
  * Upload with this use-case's key, so the file is owned by the same
@@ -339,14 +446,12 @@ export class UseCaseRunner {
339
446
  */
340
447
  async upload(data, filename, mime) {
341
448
  await this.ensure();
342
- const parentKey = this.parent.apiKey;
343
- this.parent.apiKey = this.apiKey ?? undefined;
344
- try {
345
- return await this.parent.upload(data, filename, mime);
346
- }
347
- finally {
348
- this.parent.apiKey = parentKey;
349
- }
449
+ return this.operations.upload(this.apiKey, data, filename, mime);
450
+ }
451
+ }
452
+ function assertJsonRunOptions(opts) {
453
+ if (opts.background === true) {
454
+ throw new AgentLuError(400, "background_not_supported", "runJson requires a completed response and cannot use background.", false);
350
455
  }
351
456
  }
352
457
  function parseRetryAfter(res) {
@@ -356,6 +461,41 @@ function parseRetryAfter(res) {
356
461
  const seconds = Number(header);
357
462
  return Number.isFinite(seconds) ? seconds * 1000 : undefined;
358
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
+ }
359
499
  function retryDelayMs(err, attempt) {
360
500
  if (err.retryAfterMs !== undefined) {
361
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.3.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",