@loomcycle/client 1.106.0 → 1.107.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
@@ -323,6 +323,77 @@ const { entries } = await client.listVolumes();
323
323
 
324
324
  Refusals throw `SubstrateToolRefusedError` (collision with a static volume name, no `dynamic_root` configured, cross-tenant); transport failures throw the usual typed errors.
325
325
 
326
+ ### Decision models
327
+
328
+ Ask a **decision model** typed questions about a piece of text and get each answer with its probabilities. A decision model does not write: it reads the `state` you give it and answers a choice, a yes/no or a score, so there is no reply to parse and a call costs a few output tokens. Use it to route, gate, rank or grade text you already have. No run is involved; the tokens are charged to the caller and count against its token budget.
329
+
330
+ | Method | Returns | Notes |
331
+ |---|---|---|
332
+ | `decide(req: DecideRequest)` | `Promise<DecideResponse>` | `{model?, state, questions}` → `{model, provider, served_model, answers, usage}`. Mirrors `POST /v1/_decide`. |
333
+ | `listDecisionModels()` | `Promise<DecisionModelsResponse>` | The models a call may name, each with its limits, and the default. Mirrors `GET /v1/_decide/models`. |
334
+
335
+ `questions` is keyed by names you choose, and each answer comes back under its question's name. `criteria` is typed per question type:
336
+
337
+ | `type` | `criteria` | Answer |
338
+ |---|---|---|
339
+ | `choice` | required — `Record<string, string \| null>`: each key is an option, its value describes it (`null` when the key explains itself) | `{type, choice, probabilities, confidence}` |
340
+ | `noul` | optional — `{true?: string; false?: string}` | `{type, noul}` — the probability of YES, 0 to 1 |
341
+ | `score` | required — `string[]`, level descriptions **lowest first** | `{type, score, legend, probabilities, confidence}` — `score` is the expected position, counted from 0 |
342
+
343
+ ```ts
344
+ const res = await client.decide({
345
+ state: { ticket: "My invoice for March was charged twice and I want my money back." },
346
+ questions: {
347
+ route: {
348
+ type: "choice",
349
+ instructions: "Which team should handle this ticket?",
350
+ criteria: { billing: "invoices, refunds, charges", support: "bugs, outages", sales: null },
351
+ },
352
+ urgent: { type: "noul", instructions: "Does this ticket need a reply within the hour?" },
353
+ detail: {
354
+ type: "score",
355
+ instructions: "How complete is the problem report?",
356
+ criteria: ["no detail", "some detail", "everything needed"],
357
+ },
358
+ },
359
+ });
360
+
361
+ const route = res.answers.route;
362
+ if (route?.type === "choice") console.log(route.choice, route.probabilities);
363
+ const urgent = res.answers.urgent;
364
+ if (urgent?.type === "noul" && urgent.noul > 0.5) escalate();
365
+ ```
366
+
367
+ - **`noul: 0` is an answer** (a definite no), not a missing one — never test it for truthiness.
368
+ - **The probabilities are not calibrated.** Compare options within one answer; do not treat a fixed threshold as a guarantee.
369
+ - **An answer is the model's own JSON, passed through unchanged.** The answer types name the documented fields and stay open to others, so a field a later model adds is readable without an adapter release.
370
+ - Omit `model` for the deployment's default; name one only from `listDecisionModels()`.
371
+
372
+ A refusal throws with the decision's own code on `e.code` (see [Errors](#errors)):
373
+
374
+ | Status | `e.code` | What to do |
375
+ |---|---|---|
376
+ | 400 | `invalid_input` / `bad_question` / `bad_options` / `too_many_questions` | Fix the request; the message names the fault. |
377
+ | 400 | `model_not_allowed` | Omit `model`, or use a name from `listDecisionModels()`. |
378
+ | 403 | `operator_key_restricted` | The caller may not use the operator's provider key and has none of its own. Do not retry. |
379
+ | 413 | `prompt_too_large` | The request does not fit the model's context and is never shortened for you: shorten `state` or ask fewer questions. |
380
+ | 429 | `token_limit_exceeded` | The caller is at a hard token budget. Retrying does not help until the budget is raised or the month rolls over. |
381
+ | 502 | `model_not_found` / `call_failed` | The provider does not serve that model (the same call fails again) / the call did not complete (try once more). |
382
+ | 503 | `decision_not_configured` | This deployment declares no decision models. Do not retry. |
383
+ | 504 | `timeout` | Send the same call again; if it repeats, make it smaller. |
384
+
385
+ ```ts
386
+ try {
387
+ await client.decide(req);
388
+ } catch (e) {
389
+ if (e instanceof LoomcycleError && e.code === "prompt_too_large") {
390
+ // shorten state and retry
391
+ } else throw e;
392
+ }
393
+ ```
394
+
395
+ Two of these have a class of their own: a `prompt_too_large` throws `PromptTooLargeError` and a `token_limit_exceeded` throws `TokenLimitExceededError` (with the budget on `e.scope` / `e.used` / `e.limit`). For the rest, branch on `e.code`, not on the class: the class follows the HTTP status alone.
396
+
326
397
  ### Channels + run-state stream (v0.9.x n8n Phase 0)
327
398
 
328
399
  Two substrate-side surfaces added in the n8n integration's Phase 0 wire-API work. Useful for any orchestrator (not just n8n) that needs to see channel state or subscribe to run-state transitions.
@@ -458,6 +529,8 @@ These events are part of the persisted transcript (not the live `runStreaming` e
458
529
 
459
530
  Non-2xx responses throw typed subclasses of `LoomcycleError`. The original HTTP status is on `e.status`; the truncated response body is on `e.bodyText` (≤1 KiB).
460
531
 
532
+ When the error body is JSON with a string `code` (`token_limit_exceeded`, `per_user_quota_exhausted`, `model_not_allowed`, …), that code is on `e.code`, whichever class the status maps to. A status is shared between unrelated conditions — a 429 is backpressure, a per-user quota or a token budget — so branch on `e.code` when you need to tell them apart. It is `undefined` for a plain-text body.
533
+
461
534
  | HTTP status / body | Exception class |
462
535
  |---|---|
463
536
  | 400 | `InvalidArgumentError` |
@@ -472,13 +545,42 @@ Non-2xx responses throw typed subclasses of `LoomcycleError`. The original HTTP
472
545
  | 409 + "session" | `SessionBusyError` |
473
546
  | 409 + "agent_id" | `AgentIDInUseError` |
474
547
  | 409 (other) | `LoomcycleError` (base) |
475
- | 413 | `SnapshotTooLargeError` |
548
+ | 413 + code `snapshot_too_large` | `SnapshotTooLargeError` ⎫ |
549
+ | 413 + code `prompt_too_large` | `PromptTooLargeError` ⎬ all are `instanceof SnapshotTooLargeError` (back-compat) |
550
+ | 413 (other) | `RequestTooLargeError` ⎭ |
476
551
  | 422 | `SnapshotVersionError` |
477
- | 429 | `BackpressureError` |
552
+ | 429 + code `per_user_quota_exhausted` | `PerUserQuotaExhaustedError` |
553
+ | 429 + code `token_limit_exceeded` | `TokenLimitExceededError` (subclass of `BackpressureError` — back-compat) |
554
+ | 429 (other) | `BackpressureError` |
478
555
  | 503 + "pause manager not configured" | `PauseNotConfiguredError` (subclass of `UnavailableError` — back-compat) |
479
556
  | 503 (other) | `UnavailableError` |
480
557
  | 500 / other | `LoomcycleError` (base) |
481
558
 
559
+ **429: a token budget is not backpressure.** A plain `BackpressureError` is transient: the server is busy, back off and retry. `TokenLimitExceededError` is not: the caller is at a hard token budget (run creation and `decide()` both refuse with it), and the same call is refused until the budget is raised or the calendar month rolls over. It extends `BackpressureError` only so that an existing `instanceof BackpressureError` keeps catching it, so test for it first. The client never retries on its own; this matters to your retry loop.
560
+
561
+ ```ts
562
+ import { BackpressureError, TokenLimitExceededError } from "@loomcycle/client";
563
+
564
+ try {
565
+ for await (const ev of client.runStreaming({ /* ... */ })) {}
566
+ } catch (e) {
567
+ if (e instanceof TokenLimitExceededError) {
568
+ // Not retryable now. e.scope is "operator" | "tenant" | "user"; e.scopeId is
569
+ // the tenant or user (null for the operator-wide budget); e.used and e.limit
570
+ // are tokens; e.window is "month". Each is null if the server did not send it.
571
+ console.error(`token budget reached for ${e.scope}: ${e.used}/${e.limit} this ${e.window}`);
572
+ } else if (e instanceof BackpressureError) {
573
+ // Transient: back off and retry.
574
+ } else {
575
+ throw e;
576
+ }
577
+ }
578
+ ```
579
+
580
+ **413: three classes, one legacy base.** Every 413 used to throw `SnapshotTooLargeError`, whatever was too large. A 413 is now named for its cause: `PromptTooLargeError` for a `decide()` request that does not fit the model's context (shorten `state` or ask fewer questions), `RequestTooLargeError` for any other non-snapshot 413 (a request body over the server's limit, a memory value or quota, a channel payload — `e.code` says which), and `SnapshotTooLargeError` itself for a snapshot over its cap. `PromptTooLargeError` extends `RequestTooLargeError`, which extends `SnapshotTooLargeError`, so an existing `instanceof SnapshotTooLargeError` still matches every 413; that relation is for compatibility and says nothing about snapshots. To single out a snapshot that is too large, test `e.code === "snapshot_too_large"`.
581
+
582
+ `e.name` (and the `meta_reason` of a `stream_close` frame) is the most specific class name, so it reads `TokenLimitExceededError`, `PromptTooLargeError` or `RequestTooLargeError` where it used to read `BackpressureError` or `SnapshotTooLargeError`. Match with `instanceof` or `e.code`, not on the name.
583
+
482
584
  Priority within `404`: most-specific keyword wins (`snapshot` → `session` → `hook` → `agent` → base). The dispatch is keyword-matched on the response body lowercase; a hook with id `hook_agent_scan` still routes to `HookNotFoundError`, not `AgentNotFoundError`.
483
585
 
484
586
  ```ts
@@ -772,6 +772,36 @@ class LoomcycleClient {
772
772
  params.set("tenant", opts.tenant);
773
773
  return (0, fetch_helpers_js_1.deleteRequest)(this.ctx, `/v1/_limits?${params.toString()}`, opts);
774
774
  }
775
+ /** Ask a decision model typed questions (`choice` / `noul` / `score`) about
776
+ * `state` and get each answer with its probabilities. A decision model does
777
+ * not write: there is no reply to parse and the call costs a few output
778
+ * tokens. No run is involved; the tokens are charged to the caller and count
779
+ * against its token budget. Mirrors `POST /v1/_decide`.
780
+ *
781
+ * A refusal throws with the decision's own code on `e.code` and the HTTP
782
+ * status on `e.status`: 400 `invalid_input` / `bad_question` / `bad_options`
783
+ * / `too_many_questions` / `model_not_allowed`, 403
784
+ * `operator_key_restricted`, 413 `prompt_too_large`, 429
785
+ * `token_limit_exceeded`, 502 `model_not_found` / `call_failed`, 503
786
+ * `decision_not_configured`, 504 `timeout`. Branch on the code: apart from
787
+ * `PromptTooLargeError` (413) and `TokenLimitExceededError` (429), the
788
+ * error class follows the status alone, and a status is shared with
789
+ * unrelated conditions. */
790
+ async decide(req, opts) {
791
+ const body = {
792
+ state: req.state,
793
+ questions: req.questions,
794
+ };
795
+ if (req.model !== undefined)
796
+ body.model = req.model;
797
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_decide", body, opts);
798
+ }
799
+ /** The decision models a {@link decide} call may name, with each one's
800
+ * limits, and the default. Throws with `code: "decision_not_configured"`
801
+ * (503) on a deployment that declares none. Mirrors `GET /v1/_decide/models`. */
802
+ async listDecisionModels(opts) {
803
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_decide/models", opts);
804
+ }
775
805
  /** Read the full event log for a session. Each entry has seq,
776
806
  * run_id, ts_ns, type, event (the providers.Event payload). */
777
807
  async getTranscript(sessionId, opts) {
@@ -19,15 +19,22 @@
19
19
  * to raise typed errors via `raiseFromResponse`.
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.SubstrateToolRefusedError = exports.ChannelCursorRegressionError = exports.SnapshotVersionError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.NotPausedError = exports.AlreadyPausingError = exports.PauseNotConfiguredError = exports.InvalidArgumentError = exports.UnavailableError = exports.AuthError = exports.PerUserQuotaExhaustedError = exports.BackpressureError = exports.AgentIDInUseError = exports.SessionBusyError = exports.SessionNotFoundError = exports.AgentNotFoundError = exports.NotFoundError = exports.LoomcycleError = void 0;
22
+ exports.SubstrateToolRefusedError = exports.ChannelCursorRegressionError = exports.SnapshotVersionError = exports.PromptTooLargeError = exports.RequestTooLargeError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.NotPausedError = exports.AlreadyPausingError = exports.PauseNotConfiguredError = exports.InvalidArgumentError = exports.UnavailableError = exports.AuthError = exports.PerUserQuotaExhaustedError = exports.TokenLimitExceededError = exports.BackpressureError = exports.AgentIDInUseError = exports.SessionBusyError = exports.SessionNotFoundError = exports.AgentNotFoundError = exports.NotFoundError = exports.LoomcycleError = void 0;
23
23
  class LoomcycleError extends Error {
24
24
  status;
25
25
  bodyText;
26
+ /** The machine-readable `code` of a JSON error body (`model_not_allowed`,
27
+ * `token_limit_exceeded`, `per_user_quota_exhausted`, …), on whichever
28
+ * class the status maps to. Undefined when the body is not JSON or carries
29
+ * no string `code`. Read from the whole body, so it survives where
30
+ * `bodyText` is cut at 1 KiB. Branch on this, not on the message. */
31
+ code;
26
32
  constructor(message, opts) {
27
33
  super(message);
28
34
  this.name = "LoomcycleError";
29
35
  this.status = opts?.status;
30
36
  this.bodyText = opts?.bodyText;
37
+ this.code = opts?.code;
31
38
  }
32
39
  }
33
40
  exports.LoomcycleError = LoomcycleError;
@@ -71,6 +78,10 @@ class AgentIDInUseError extends LoomcycleError {
71
78
  }
72
79
  }
73
80
  exports.AgentIDInUseError = AgentIDInUseError;
81
+ /** Every HTTP 429 that is not a per-user quota. A plain BackpressureError is
82
+ * the transient case (the server is busy; back off and retry). It has one
83
+ * subclass that is NOT transient, TokenLimitExceededError — check for that
84
+ * first when deciding whether to retry. */
74
85
  class BackpressureError extends LoomcycleError {
75
86
  constructor(message, opts) {
76
87
  super(message, opts);
@@ -78,6 +89,51 @@ class BackpressureError extends LoomcycleError {
78
89
  }
79
90
  }
80
91
  exports.BackpressureError = BackpressureError;
92
+ /**
93
+ * TokenLimitExceededError signals that the caller is at a hard token budget:
94
+ * HTTP 429 with body code `token_limit_exceeded`, from run creation and from
95
+ * `decide()`.
96
+ *
97
+ * Do not retry it the way you retry backpressure. The budget is a calendar
98
+ * month's tokens for an operator, tenant or user; the same call is refused
99
+ * until an operator raises the budget or the window rolls over.
100
+ *
101
+ * It extends BackpressureError only so that code written before this class
102
+ * existed, which caught this refusal with `instanceof BackpressureError`,
103
+ * keeps catching it. Test for this class BEFORE BackpressureError:
104
+ *
105
+ * if (e instanceof TokenLimitExceededError) {
106
+ * // not retryable now: report e.scope / e.used / e.limit
107
+ * } else if (e instanceof BackpressureError) {
108
+ * // transient: back off and retry
109
+ * }
110
+ *
111
+ * The fields come from the response body and are null when the server did
112
+ * not send them.
113
+ */
114
+ class TokenLimitExceededError extends BackpressureError {
115
+ /** Whose budget was hit: "operator", "tenant" or "user". */
116
+ scope;
117
+ /** The id of that scope: the tenant id or the user subject. Null for the
118
+ * operator-wide budget, and where the server withholds it. */
119
+ scopeId;
120
+ /** Tokens the scope has used in the current window. */
121
+ used;
122
+ /** The hard ceiling that was reached, in tokens. */
123
+ limit;
124
+ /** The budget window, e.g. "month" (a calendar month, UTC). */
125
+ window;
126
+ constructor(message, opts) {
127
+ super(message, opts);
128
+ this.name = "TokenLimitExceededError";
129
+ this.scope = opts?.scope ?? null;
130
+ this.scopeId = opts?.scopeId ?? null;
131
+ this.used = opts?.used ?? null;
132
+ this.limit = opts?.limit ?? null;
133
+ this.window = opts?.window ?? null;
134
+ }
135
+ }
136
+ exports.TokenLimitExceededError = TokenLimitExceededError;
81
137
  /**
82
138
  * PerUserQuotaExhaustedError signals that the caller has hit their
83
139
  * per-user cap on in-flight (active+queued) runs. Distinct from
@@ -182,6 +238,14 @@ class SnapshotNotFoundError extends NotFoundError {
182
238
  }
183
239
  }
184
240
  exports.SnapshotNotFoundError = SnapshotNotFoundError;
241
+ /** A snapshot envelope over the size cap (HTTP 413, body code
242
+ * `snapshot_too_large`).
243
+ *
244
+ * For compatibility it is ALSO the base class of every other 413: until
245
+ * RequestTooLargeError and PromptTooLargeError existed, the client raised
246
+ * this class for any 413, so `instanceof SnapshotTooLargeError` is true for
247
+ * all of them and stays true. To single out a snapshot that is too large,
248
+ * test `e.code === "snapshot_too_large"`. */
185
249
  class SnapshotTooLargeError extends LoomcycleError {
186
250
  constructor(message, opts) {
187
251
  super(message, opts);
@@ -189,6 +253,32 @@ class SnapshotTooLargeError extends LoomcycleError {
189
253
  }
190
254
  }
191
255
  exports.SnapshotTooLargeError = SnapshotTooLargeError;
256
+ /** An HTTP 413 that is not about a snapshot: a request body over the server's
257
+ * size limit (`request_too_large`, or a plain-text 413 with no code), a
258
+ * memory value or quota (`memory_value_too_large`, `memory_quota_exceeded`),
259
+ * a channel payload (`payload_too_large`). `e.code` says which.
260
+ *
261
+ * It extends SnapshotTooLargeError only because that is the class these
262
+ * responses were raised as before this one existed; the relation keeps an
263
+ * existing `instanceof SnapshotTooLargeError` matching and says nothing about
264
+ * snapshots. */
265
+ class RequestTooLargeError extends SnapshotTooLargeError {
266
+ constructor(message, opts) {
267
+ super(message, opts);
268
+ this.name = "RequestTooLargeError";
269
+ }
270
+ }
271
+ exports.RequestTooLargeError = RequestTooLargeError;
272
+ /** A `decide()` request that does not fit the decision model's context (HTTP
273
+ * 413, body code `prompt_too_large`). The server never shortens it for you:
274
+ * shorten `state` or ask fewer questions, then send it again. */
275
+ class PromptTooLargeError extends RequestTooLargeError {
276
+ constructor(message, opts) {
277
+ super(message, opts);
278
+ this.name = "PromptTooLargeError";
279
+ }
280
+ }
281
+ exports.PromptTooLargeError = PromptTooLargeError;
192
282
  class SnapshotVersionError extends LoomcycleError {
193
283
  constructor(message, opts) {
194
284
  super(message, opts);
@@ -178,15 +178,23 @@ async function deleteJSON(ctx, path, opts) {
178
178
  * 409 + "session" → SessionBusyError
179
179
  * 409 + "agent_id" → AgentIDInUseError
180
180
  * 409 + (other) → LoomcycleError (base)
181
- * 413 → SnapshotTooLargeError
181
+ * 413 + code "snapshot_too_large" → SnapshotTooLargeError ──┐ all are
182
+ * 413 + code "prompt_too_large" → PromptTooLargeError │ instanceof
183
+ * 413 (other) → RequestTooLargeError ───┘ SnapshotTooLargeError
182
184
  * 422 → SnapshotVersionError (snapshot-version-too-new/unknown)
183
- * 429 → BackpressureError
185
+ * 429 + code "per_user_quota_exhausted" → PerUserQuotaExhaustedError
186
+ * 429 + code "token_limit_exceeded" → TokenLimitExceededError
187
+ * (subclass of BackpressureError)
188
+ * 429 (other) → BackpressureError
184
189
  * 503 + "pause manager not configured" → PauseNotConfiguredError
185
190
  * (subclass of UnavailableError)
186
191
  * 503 + (other) → UnavailableError
187
192
  * 500-599 (other) → LoomcycleError (base)
188
193
  * default → LoomcycleError (base)
189
194
  *
195
+ * Whatever class the status maps to, a JSON body's string `code` is on the
196
+ * error as `code` (see bodyCode).
197
+ *
190
198
  * Priority within a status group is most-specific-first; an unknown
191
199
  * 409 falls through to base LoomcycleError so callers see a
192
200
  * meaningful message + status. For 404, the catch-all is NotFoundError
@@ -212,7 +220,7 @@ async function raiseFromResponse(resp) {
212
220
  // ("401 Unauthorized" not "401 " with a trailing space).
213
221
  const statusPhrase = resp.statusText || stockStatusPhrase(status);
214
222
  const msg = bodyText.trim() ? bodyText.slice(0, 1024) : `${status} ${statusPhrase}`;
215
- const opts = { status, bodyText: bodyText.slice(0, 1024) };
223
+ const opts = { status, bodyText: bodyText.slice(0, 1024), code: bodyCode(bodyText) };
216
224
  switch (status) {
217
225
  case 400:
218
226
  throw new errors_js_1.InvalidArgumentError(msg, opts);
@@ -248,7 +256,14 @@ async function raiseFromResponse(resp) {
248
256
  throw new errors_js_1.AgentIDInUseError(msg, opts);
249
257
  throw new errors_js_1.LoomcycleError(msg, opts);
250
258
  case 413:
251
- throw new errors_js_1.SnapshotTooLargeError(msg, opts);
259
+ // Every 413 used to be a SnapshotTooLargeError, so the two classes
260
+ // below extend it: an existing `instanceof SnapshotTooLargeError`
261
+ // still matches all three.
262
+ if (opts.code === "snapshot_too_large")
263
+ throw new errors_js_1.SnapshotTooLargeError(msg, opts);
264
+ if (opts.code === "prompt_too_large")
265
+ throw new errors_js_1.PromptTooLargeError(msg, opts);
266
+ throw new errors_js_1.RequestTooLargeError(msg, opts);
252
267
  case 422: {
253
268
  // 422 is shared between snapshot version errors (existing)
254
269
  // and v0.8.22 substrate tool refusals. Discriminate by body:
@@ -257,7 +272,7 @@ async function raiseFromResponse(resp) {
257
272
  try {
258
273
  const parsed = JSON.parse(bodyText);
259
274
  if (parsed.code === "tool_refused") {
260
- throw new errors_js_1.SubstrateToolRefusedError(parsed.error ?? msg, { status, bodyText, tool: parsed.tool });
275
+ throw new errors_js_1.SubstrateToolRefusedError(parsed.error ?? msg, { status, bodyText, code: parsed.code, tool: parsed.tool });
261
276
  }
262
277
  }
263
278
  catch (e) {
@@ -269,6 +284,20 @@ async function raiseFromResponse(resp) {
269
284
  throw new errors_js_1.SnapshotVersionError(msg, opts);
270
285
  }
271
286
  case 429: {
287
+ // A hard token budget is not transient the way backpressure is, so
288
+ // it gets its own class (a BackpressureError subclass, for callers
289
+ // that already catch that).
290
+ if (opts.code === "token_limit_exceeded") {
291
+ const body = bodyObject(bodyText) ?? {};
292
+ throw new errors_js_1.TokenLimitExceededError(msg, {
293
+ ...opts,
294
+ scope: stringField(body.scope),
295
+ scopeId: stringField(body.scope_id),
296
+ used: numberField(body.used),
297
+ limit: numberField(body.limit),
298
+ window: stringField(body.window),
299
+ });
300
+ }
272
301
  // v0.10.1: distinguish per-user quota exhaustion from
273
302
  // operator-wide backpressure. The shapes share the 429 status
274
303
  // but the JSON body's `code` field discriminates. Consumers
@@ -284,6 +313,7 @@ async function raiseFromResponse(resp) {
284
313
  throw new errors_js_1.PerUserQuotaExhaustedError(parsed.error ?? msg, {
285
314
  status,
286
315
  bodyText,
316
+ code: parsed.code,
287
317
  userId: parsed.user_id,
288
318
  cap: parsed.cap,
289
319
  retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : undefined,
@@ -305,6 +335,36 @@ async function raiseFromResponse(resp) {
305
335
  throw new errors_js_1.LoomcycleError(msg, opts);
306
336
  }
307
337
  }
338
+ /** bodyCode returns the string `code` of a JSON error body, or undefined when
339
+ * the body is not a JSON object or has none. Statuses are shared between
340
+ * unrelated conditions (a 400 is any bad request; a 429 is backpressure, a
341
+ * user quota or a token budget), and the code is what tells them apart. */
342
+ function bodyCode(bodyText) {
343
+ return stringField(bodyObject(bodyText)?.code);
344
+ }
345
+ /** bodyObject returns a JSON error body as an object, or undefined when the
346
+ * body is not JSON or is a JSON array or scalar. */
347
+ function bodyObject(bodyText) {
348
+ try {
349
+ const parsed = JSON.parse(bodyText);
350
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
351
+ return parsed;
352
+ }
353
+ }
354
+ catch {
355
+ // not JSON: a plain-text error body has no fields
356
+ }
357
+ return undefined;
358
+ }
359
+ /** stringField / numberField read one body field, dropping a value of the
360
+ * wrong type: an error's typed fields must not carry whatever a proxy or an
361
+ * older server put under that name. An empty string counts as absent. */
362
+ function stringField(v) {
363
+ return typeof v === "string" && v !== "" ? v : undefined;
364
+ }
365
+ function numberField(v) {
366
+ return typeof v === "number" && Number.isFinite(v) ? v : undefined;
367
+ }
308
368
  /** stockStatusPhrase returns a stock reason phrase for the common
309
369
  * HTTP statuses raiseFromResponse handles. Used as a fallback when
310
370
  * Response.statusText is empty (HTTP/2 strips reason phrases). */
package/dist/cjs/index.js CHANGED
@@ -116,7 +116,9 @@
116
116
  * AuthError, UnavailableError, InvalidArgumentError,
117
117
  * PauseNotConfiguredError (subclass of UnavailableError),
118
118
  * AlreadyPausingError, NotPausedError, SnapshotNotFoundError,
119
- * SnapshotTooLargeError, SnapshotVersionError,
119
+ * SnapshotTooLargeError, RequestTooLargeError + PromptTooLargeError
120
+ * (both subclasses of SnapshotTooLargeError), TokenLimitExceededError
121
+ * (subclass of BackpressureError), SnapshotVersionError,
120
122
  * SubstrateToolRefusedError (v0.8.22)
121
123
  *
122
124
  * Transport: HTTP+SSE. Auth: Bearer token via the Authorization
@@ -127,7 +129,7 @@
127
129
  * See `adapters/ts/README.md` for usage examples.
128
130
  */
129
131
  Object.defineProperty(exports, "__esModule", { value: true });
130
- exports.UnavailableError = exports.SubstrateToolRefusedError = exports.SnapshotVersionError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.SessionNotFoundError = exports.SessionBusyError = exports.PerUserQuotaExhaustedError = exports.PauseNotConfiguredError = exports.NotPausedError = exports.LoomcycleError = exports.ChannelCursorRegressionError = exports.InvalidArgumentError = exports.NotFoundError = exports.BackpressureError = exports.AuthError = exports.AlreadyPausingError = exports.AgentNotFoundError = exports.AgentIDInUseError = exports.CLIENT_TOOL_SUBPROTOCOL = exports.clientToolsURL = exports.ClientToolHost = exports.InteractiveSession = exports.LoomcycleClient = void 0;
132
+ exports.UnavailableError = exports.TokenLimitExceededError = exports.SubstrateToolRefusedError = exports.SnapshotVersionError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.SessionNotFoundError = exports.SessionBusyError = exports.RequestTooLargeError = exports.PromptTooLargeError = exports.PerUserQuotaExhaustedError = exports.PauseNotConfiguredError = exports.NotPausedError = exports.LoomcycleError = exports.ChannelCursorRegressionError = exports.InvalidArgumentError = exports.NotFoundError = exports.BackpressureError = exports.AuthError = exports.AlreadyPausingError = exports.AgentNotFoundError = exports.AgentIDInUseError = exports.CLIENT_TOOL_SUBPROTOCOL = exports.clientToolsURL = exports.ClientToolHost = exports.InteractiveSession = exports.LoomcycleClient = void 0;
131
133
  var client_js_1 = require("./client.js");
132
134
  Object.defineProperty(exports, "LoomcycleClient", { enumerable: true, get: function () { return client_js_1.LoomcycleClient; } });
133
135
  var interactive_js_1 = require("./interactive.js");
@@ -149,10 +151,13 @@ Object.defineProperty(exports, "LoomcycleError", { enumerable: true, get: functi
149
151
  Object.defineProperty(exports, "NotPausedError", { enumerable: true, get: function () { return errors_js_1.NotPausedError; } });
150
152
  Object.defineProperty(exports, "PauseNotConfiguredError", { enumerable: true, get: function () { return errors_js_1.PauseNotConfiguredError; } });
151
153
  Object.defineProperty(exports, "PerUserQuotaExhaustedError", { enumerable: true, get: function () { return errors_js_1.PerUserQuotaExhaustedError; } });
154
+ Object.defineProperty(exports, "PromptTooLargeError", { enumerable: true, get: function () { return errors_js_1.PromptTooLargeError; } });
155
+ Object.defineProperty(exports, "RequestTooLargeError", { enumerable: true, get: function () { return errors_js_1.RequestTooLargeError; } });
152
156
  Object.defineProperty(exports, "SessionBusyError", { enumerable: true, get: function () { return errors_js_1.SessionBusyError; } });
153
157
  Object.defineProperty(exports, "SessionNotFoundError", { enumerable: true, get: function () { return errors_js_1.SessionNotFoundError; } });
154
158
  Object.defineProperty(exports, "SnapshotNotFoundError", { enumerable: true, get: function () { return errors_js_1.SnapshotNotFoundError; } });
155
159
  Object.defineProperty(exports, "SnapshotTooLargeError", { enumerable: true, get: function () { return errors_js_1.SnapshotTooLargeError; } });
156
160
  Object.defineProperty(exports, "SnapshotVersionError", { enumerable: true, get: function () { return errors_js_1.SnapshotVersionError; } });
157
161
  Object.defineProperty(exports, "SubstrateToolRefusedError", { enumerable: true, get: function () { return errors_js_1.SubstrateToolRefusedError; } });
162
+ Object.defineProperty(exports, "TokenLimitExceededError", { enumerable: true, get: function () { return errors_js_1.TokenLimitExceededError; } });
158
163
  Object.defineProperty(exports, "UnavailableError", { enumerable: true, get: function () { return errors_js_1.UnavailableError; } });
package/dist/client.d.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { InteractiveSession } from "./interactive.js";
27
27
  import { ClientToolHost, type ConnectClientToolsOptions } from "./client-tools.js";
28
- import type { CredentialListResponse, CredentialMeta, CredentialScope, DirectoryInspection, DirectoryTenant, DirectoryUser, ErasureReport, ErasureResult, Agent, AgentEvent, AgentStatus, CancelAgentResult, ClientOptions, ContinueOptions, RunOverrideOptions, EffectiveConfigResponse, RetuneRunResponse, RunConfigResponse, RunPromptResponse, CreateSnapshotOptions, EnsureCodeAgentOptions, EnsureCodeAgentResult, EnsureMcpServerOptions, EnsureMcpServerResult, HealthResponse, InterruptListResponse, InterruptStatus, WalkRunsPage, AckChannelOptions, AwaitChannelsOptions, BroadcastChannelsOptions, ChannelAckResult, ChannelAwaitResult, ChannelBroadcastResult, ChannelDescriptor, ChannelPeekResult, ChannelPublishResult, ChannelPurgeResult, ChannelReleaseResult, ChannelSubscribeResult, CreateChannelOptions, ListChannelsResponse, PeekChannelOptions, PublishChannelOptions, ReleaseChannelOptions, SetMemoryEntryOptions, SetMemoryEntryResponse, SubscribeChannelOptions, UpdateChannelOptions, LibraryAgentDefinition, LibraryListResponse, LibraryMcpServerDefinition, LibrarySkillDefinition, ListUsersResponse, UserRecord, CreateUserBody, UpdateUserBody, MintedUserToken, ListUserTokensResponse, RunnableAgentsResponse, LLMChatOptions, LLMChatResponse, LLMChatStreamItem, LLMEmbeddingsOptions, LLMEmbeddingsResponse, MCPServerDefVerifyResult, MemoryEmbedStatsResponse, MemoryEntriesResponse, MemoryEntryResponse, MemoryBackfillResponse, MemoryPurgeResponse, MemoryReembedResponse, MemoryScopeIDsResponse, MemoryScopesResponse, MemorySearchInput, MemorySearchResponse, PauseResult, PersistentVolumesResponse, EphemeralVolumesResponse, ResolveInterruptOptions, ResumeResult, ResolverMatrix, CompactRunResult, ReplaySessionResult, CancelTurnResult, ReviewRunResult, RunBatchOptions, RunBatchResult, RunOptions, ConfiguredRun, ConfiguredRunPatch, RunStateStreamItem, RuntimeStateResponse, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotRestoreResponse, StreamUserRunStatesOptions, SubstrateToolInput, SubstrateToolResponse, CreatedTeam, ListTeamsResponse, PromotedTeam, RetiredTeam, TeamBreakpoints, TeamDefDetail, TeamRunDetached, TeamRunTarget, TeamVerification, TeamDraft, TeamDraftVerification, TeamVersionList, TeamDiagram, TeamRunResult, PathToolInput, PathToolResponse, DocumentToolInput, DocumentToolResponse, HistoryToolInput, HistoryToolResponse, TranscriptResponse, WhoamiResponse, UsageDimension, UsageReportResponse, TokenLimit, TokenLimitsResponse, ConfigResponse, SetTokenLimitRequest } from "./types.js";
28
+ import type { CredentialListResponse, CredentialMeta, DecideRequest, DecideResponse, DecisionModelsResponse, CredentialScope, DirectoryInspection, DirectoryTenant, DirectoryUser, ErasureReport, ErasureResult, Agent, AgentEvent, AgentStatus, CancelAgentResult, ClientOptions, ContinueOptions, RunOverrideOptions, EffectiveConfigResponse, RetuneRunResponse, RunConfigResponse, RunPromptResponse, CreateSnapshotOptions, EnsureCodeAgentOptions, EnsureCodeAgentResult, EnsureMcpServerOptions, EnsureMcpServerResult, HealthResponse, InterruptListResponse, InterruptStatus, WalkRunsPage, AckChannelOptions, AwaitChannelsOptions, BroadcastChannelsOptions, ChannelAckResult, ChannelAwaitResult, ChannelBroadcastResult, ChannelDescriptor, ChannelPeekResult, ChannelPublishResult, ChannelPurgeResult, ChannelReleaseResult, ChannelSubscribeResult, CreateChannelOptions, ListChannelsResponse, PeekChannelOptions, PublishChannelOptions, ReleaseChannelOptions, SetMemoryEntryOptions, SetMemoryEntryResponse, SubscribeChannelOptions, UpdateChannelOptions, LibraryAgentDefinition, LibraryListResponse, LibraryMcpServerDefinition, LibrarySkillDefinition, ListUsersResponse, UserRecord, CreateUserBody, UpdateUserBody, MintedUserToken, ListUserTokensResponse, RunnableAgentsResponse, LLMChatOptions, LLMChatResponse, LLMChatStreamItem, LLMEmbeddingsOptions, LLMEmbeddingsResponse, MCPServerDefVerifyResult, MemoryEmbedStatsResponse, MemoryEntriesResponse, MemoryEntryResponse, MemoryBackfillResponse, MemoryPurgeResponse, MemoryReembedResponse, MemoryScopeIDsResponse, MemoryScopesResponse, MemorySearchInput, MemorySearchResponse, PauseResult, PersistentVolumesResponse, EphemeralVolumesResponse, ResolveInterruptOptions, ResumeResult, ResolverMatrix, CompactRunResult, ReplaySessionResult, CancelTurnResult, ReviewRunResult, RunBatchOptions, RunBatchResult, RunOptions, ConfiguredRun, ConfiguredRunPatch, RunStateStreamItem, RuntimeStateResponse, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotRestoreResponse, StreamUserRunStatesOptions, SubstrateToolInput, SubstrateToolResponse, CreatedTeam, ListTeamsResponse, PromotedTeam, RetiredTeam, TeamBreakpoints, TeamDefDetail, TeamRunDetached, TeamRunTarget, TeamVerification, TeamDraft, TeamDraftVerification, TeamVersionList, TeamDiagram, TeamRunResult, PathToolInput, PathToolResponse, DocumentToolInput, DocumentToolResponse, HistoryToolInput, HistoryToolResponse, TranscriptResponse, WhoamiResponse, UsageDimension, UsageReportResponse, TokenLimit, TokenLimitsResponse, ConfigResponse, SetTokenLimitRequest } from "./types.js";
29
29
  export declare class LoomcycleClient {
30
30
  private ctx;
31
31
  constructor(opts?: ClientOptions);
@@ -464,6 +464,30 @@ export declare class LoomcycleClient {
464
464
  tenant?: string;
465
465
  signal?: AbortSignal;
466
466
  }): Promise<void>;
467
+ /** Ask a decision model typed questions (`choice` / `noul` / `score`) about
468
+ * `state` and get each answer with its probabilities. A decision model does
469
+ * not write: there is no reply to parse and the call costs a few output
470
+ * tokens. No run is involved; the tokens are charged to the caller and count
471
+ * against its token budget. Mirrors `POST /v1/_decide`.
472
+ *
473
+ * A refusal throws with the decision's own code on `e.code` and the HTTP
474
+ * status on `e.status`: 400 `invalid_input` / `bad_question` / `bad_options`
475
+ * / `too_many_questions` / `model_not_allowed`, 403
476
+ * `operator_key_restricted`, 413 `prompt_too_large`, 429
477
+ * `token_limit_exceeded`, 502 `model_not_found` / `call_failed`, 503
478
+ * `decision_not_configured`, 504 `timeout`. Branch on the code: apart from
479
+ * `PromptTooLargeError` (413) and `TokenLimitExceededError` (429), the
480
+ * error class follows the status alone, and a status is shared with
481
+ * unrelated conditions. */
482
+ decide(req: DecideRequest, opts?: {
483
+ signal?: AbortSignal;
484
+ }): Promise<DecideResponse>;
485
+ /** The decision models a {@link decide} call may name, with each one's
486
+ * limits, and the default. Throws with `code: "decision_not_configured"`
487
+ * (503) on a deployment that declares none. Mirrors `GET /v1/_decide/models`. */
488
+ listDecisionModels(opts?: {
489
+ signal?: AbortSignal;
490
+ }): Promise<DecisionModelsResponse>;
467
491
  /** Read the full event log for a session. Each entry has seq,
468
492
  * run_id, ts_ns, type, event (the providers.Event payload). */
469
493
  getTranscript(sessionId: string, opts?: {
package/dist/client.js CHANGED
@@ -769,6 +769,36 @@ export class LoomcycleClient {
769
769
  params.set("tenant", opts.tenant);
770
770
  return deleteRequest(this.ctx, `/v1/_limits?${params.toString()}`, opts);
771
771
  }
772
+ /** Ask a decision model typed questions (`choice` / `noul` / `score`) about
773
+ * `state` and get each answer with its probabilities. A decision model does
774
+ * not write: there is no reply to parse and the call costs a few output
775
+ * tokens. No run is involved; the tokens are charged to the caller and count
776
+ * against its token budget. Mirrors `POST /v1/_decide`.
777
+ *
778
+ * A refusal throws with the decision's own code on `e.code` and the HTTP
779
+ * status on `e.status`: 400 `invalid_input` / `bad_question` / `bad_options`
780
+ * / `too_many_questions` / `model_not_allowed`, 403
781
+ * `operator_key_restricted`, 413 `prompt_too_large`, 429
782
+ * `token_limit_exceeded`, 502 `model_not_found` / `call_failed`, 503
783
+ * `decision_not_configured`, 504 `timeout`. Branch on the code: apart from
784
+ * `PromptTooLargeError` (413) and `TokenLimitExceededError` (429), the
785
+ * error class follows the status alone, and a status is shared with
786
+ * unrelated conditions. */
787
+ async decide(req, opts) {
788
+ const body = {
789
+ state: req.state,
790
+ questions: req.questions,
791
+ };
792
+ if (req.model !== undefined)
793
+ body.model = req.model;
794
+ return postJSON(this.ctx, "/v1/_decide", body, opts);
795
+ }
796
+ /** The decision models a {@link decide} call may name, with each one's
797
+ * limits, and the default. Throws with `code: "decision_not_configured"`
798
+ * (503) on a deployment that declares none. Mirrors `GET /v1/_decide/models`. */
799
+ async listDecisionModels(opts) {
800
+ return jsonFetch(this.ctx, "/v1/_decide/models", opts);
801
+ }
772
802
  /** Read the full event log for a session. Each entry has seq,
773
803
  * run_id, ts_ns, type, event (the providers.Event payload). */
774
804
  async getTranscript(sessionId, opts) {