@ultimat3/ai 24.0.0 → 25.0.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/src/embeddings.ts CHANGED
@@ -64,6 +64,11 @@ export interface HashEmbedderInput {
64
64
  readonly dimension?: number;
65
65
  }
66
66
 
67
+ /** The hashing embedder — the one way to build one; the class is a type only. */
68
+ export function hashEmbedder(input: HashEmbedderInput = {}): HashEmbedder {
69
+ return new HashEmbedder(input);
70
+ }
71
+
67
72
  /**
68
73
  * Deterministic bag-of-words hashing embedder. Not semantic — two paraphrases share no
69
74
  * vocabulary and land far apart — but it IS stable, dependency-free, and fast, which is
@@ -76,7 +81,7 @@ export class HashEmbedder implements Embedder {
76
81
 
77
82
  constructor(input: HashEmbedderInput = {}) {
78
83
  // Floored at 1: `new Float32Array(NaN)` is a vector of LENGTH ZERO, so every embedding is
79
- // empty, `cosine` answers 0 for every pair, and the ranking collapses with nothing thrown —
84
+ // empty, `cosine` answers `NaN` for every pair, and the ranking collapses with nothing thrown —
80
85
  // the silent relevance collapse this file's own header says the dimension exists to catch.
81
86
  this.dimension = finiteCount('HashEmbedder', 'dimension', input.dimension ?? 256, 1);
82
87
  }
@@ -87,19 +92,19 @@ export class HashEmbedder implements Embedder {
87
92
 
88
93
  private one(text: string): Float32Array {
89
94
  const vector = new Float32Array(this.dimension);
90
- for (const token of tokenize(text)) {
95
+ for (const token of wordTokens(text)) {
91
96
  const slot = hashOf(token) % this.dimension;
92
97
  // Signed accumulation: without it every vector is non-negative and cosine
93
98
  // similarity compresses into a narrow band where nothing ranks apart.
94
99
  const sign = hashOf(`${token}#sign`) % 2 === 0 ? 1 : -1;
95
100
  vector[slot] = (vector[slot] ?? 0) + sign;
96
101
  }
97
- return normalize(vector);
102
+ return normalizeVector(vector);
98
103
  }
99
104
  }
100
105
 
101
106
  /** Lowercased word tokens. Punctuation is dropped; digits are kept (versions, ids). */
102
- export function tokenize(text: string): readonly string[] {
107
+ export function wordTokens(text: string): readonly string[] {
103
108
  return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
104
109
  }
105
110
 
@@ -112,8 +117,8 @@ function hashOf(text: string): number {
112
117
  return Number.parseInt(fingerprint(text).slice(0, 8), 16);
113
118
  }
114
119
 
115
- /** L2 normalise in place so cosine similarity reduces to a dot product. */
116
- export function normalize(vector: Float32Array): Float32Array {
120
+ /** L2 normalise in place, so every embedder hands a store unit vectors whatever its scale. */
121
+ export function normalizeVector(vector: Float32Array): Float32Array {
117
122
  let sum = 0;
118
123
  for (const value of vector) sum += value * value;
119
124
  if (sum === 0) return vector;
@@ -122,10 +127,25 @@ export function normalize(vector: Float32Array): Float32Array {
122
127
  return vector;
123
128
  }
124
129
 
125
- /** Dot product. Correct as cosine only for normalised vectors — which `normalize` ensures. */
130
+ /**
131
+ * Cosine similarity — pgvector's `1 - (a <=> b)`, computed the way pgvector computes it, so the dev
132
+ * store and production rank alike. A bare dot product was cosine only for unit vectors, and a
133
+ * store accepts whatever an app upserts. A zero-norm side is `0 / 0`: `NaN`, as pgvector answers,
134
+ * never a `0` that would rank a vector with no direction above every opposed one. Clamped to
135
+ * [-1, 1] as pgvector clamps, so float error never reports a similarity past identical.
136
+ */
126
137
  export function cosine(a: Float32Array, b: Float32Array): number {
127
- let sum = 0;
138
+ let dot = 0;
139
+ let normA = 0;
140
+ let normB = 0;
128
141
  const length = Math.min(a.length, b.length);
129
- for (let i = 0; i < length; i += 1) sum += (a[i] ?? 0) * (b[i] ?? 0);
130
- return sum;
142
+ for (let i = 0; i < length; i += 1) {
143
+ const x = a[i] ?? 0;
144
+ const y = b[i] ?? 0;
145
+ dot += x * y;
146
+ normA += x * x;
147
+ normB += y * y;
148
+ }
149
+ const similarity = dot / Math.sqrt(normA * normB);
150
+ return similarity > 1 ? 1 : similarity < -1 ? -1 : similarity;
131
151
  }
package/src/errors.ts CHANGED
@@ -28,6 +28,8 @@ export const AI_ERROR_CODES = [
28
28
  'X_VECTOR_SCOPE_WIDENED',
29
29
  'X_AI_EMBEDDER_INVALID',
30
30
  'X_VECTOR_UNSCOPED',
31
+ 'X_AI_CONTENT_UNSUPPORTED',
32
+ 'X_AI_MODEL_UNRESOLVED',
31
33
  ] as const;
32
34
 
33
35
  export type AiErrorCode = (typeof AI_ERROR_CODES)[number];
@@ -57,6 +59,8 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
57
59
  X_VECTOR_SCOPE_WIDENED: 'a derived vector scope tried to leave its tenant',
58
60
  X_AI_EMBEDDER_INVALID: 'an Embedder returned fewer vectors than texts it was given',
59
61
  X_VECTOR_UNSCOPED: 'a vector store was read with no tenant bound inside an org request',
62
+ X_AI_CONTENT_UNSUPPORTED: 'an image or document block the role, model or wire format cannot take',
63
+ X_AI_MODEL_UNRESOLVED: 'a model call named no model',
60
64
  };
61
65
 
62
66
  // Titles must be registered for `format()` to render the contract's first line. Unconditional and
@@ -130,25 +134,27 @@ export class AiGatewayMissingError extends UltimateError {
130
134
  constructor(input: { prompt: string }) {
131
135
  super({
132
136
  code: 'X_AI_GATEWAY_MISSING',
133
- cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured`,
134
- fix: 'configureAi({ gateway: createGateway({ providers: [new AnthropicProvider()] }) }) at boot',
137
+ // The provider is the app's choice, so no vendor is the default (M12): the fix is the shape
138
+ // and the cause names what `provider` is — one of the peers, or the app's own.
139
+ cause: `an llm action on prompt "${input.prompt}" ran before any gateway was configured (provider: anthropicProvider({ models }) for the Anthropic Messages format, openAiProvider({ baseUrl, models }) for the OpenAI chat-completions format on any compatible server, or your own Provider)`,
140
+ fix: 'configureAi({ gateway: providerGateway({ providers: [provider] }) }) at boot',
135
141
  });
136
142
  }
137
143
  }
138
144
 
139
145
  /**
140
- * A model id nothing put in the catalogue. This is what replaced the closed `ModelId` union: the
141
- * union made a company's own model id inexpressible, so the only way past `tsc` was to claim a
142
- * Claude id — and then `costOf` priced an internal model at Anthropic list rates and the budget
143
- * ledger reserved against a number belonging to a model nobody ran. A wrong id is still refused;
144
- * it is refused HERE, at the first read of the spec, instead of by making a right one impossible.
146
+ * A model id the app never registered. The framework registers none, so every id a call names —
147
+ * any vendor's — is one the app's own `registerModel` wrote: priced, reserved and recorded by the
148
+ * app's numbers, never by a list this package shipped. Refused HERE, at the first read of the spec
149
+ * (`costOf`, the budget's estimate, the request builders), before anything is sent or charged.
145
150
  */
146
151
  export class AiModelUnknownError extends UltimateError {
147
152
  constructor(input: { model: string; registered: readonly string[] }) {
148
153
  super({
149
154
  code: 'X_AI_MODEL_UNKNOWN',
150
155
  cause:
151
- `model "${input.model}" has no registered spec, so nothing can price it ` +
156
+ `model "${input.model}" has no registered spec, so nothing can price or budget it — ` +
157
+ `the framework registers no model of its own ` +
152
158
  `(registered: ${input.registered.length > 0 ? input.registered.join(', ') : 'none'})`,
153
159
  // The `errors` gate blanks every interpolation, so the literal half alone has to name the
154
160
  // call. Which ids ARE registered is a fact of the failure, and cause is where facts live.
@@ -261,8 +267,8 @@ export class LlmRefusedError extends UltimateError {
261
267
  prompt: string;
262
268
  model: string;
263
269
  /**
264
- * A blessed model MORE capable than the one that refused, or `undefined` when the refusal
265
- * came from the most capable one this build knows. Retrying a refusal on a weaker model is
270
+ * A model the APP registered, in the same family, MORE capable than the one that refused —
271
+ * or `undefined` when the app registered none above it. Retrying a refusal on a weaker model is
266
272
  * the one retry that cannot help, so the fix line drops the suggestion rather than inventing
267
273
  * a downgrade.
268
274
  */
@@ -278,7 +284,7 @@ export class LlmRefusedError extends UltimateError {
278
284
  `${input.explanation === undefined ? '' : `: ${input.explanation}`}`,
279
285
  fix:
280
286
  input.alternative === undefined
281
- ? `edit the template in definePrompt('${input.prompt}') and bump its version — no blessed model is more capable than '${input.model}'`
287
+ ? `edit the template in definePrompt('${input.prompt}') and bump its version — no model your app registered in its family is more capable than '${input.model}'`
282
288
  : `set model: '${input.alternative}' on the llm() declaration, or edit the template in definePrompt('${input.prompt}') and bump its version`,
283
289
  meta: { model: input.model, category: input.category },
284
290
  });
@@ -61,7 +61,9 @@ export class EvalBaselineInvalidError extends UltimateError {
61
61
  super({
62
62
  code: 'X_EVAL_BASELINE_INVALID',
63
63
  cause: `the recorded baseline ${input.path} ${input.problem}`,
64
- fix: `ULTIMATE_EVAL_RECORD=1 x test eval to re-record ${input.path}`,
64
+ // The command is the record run; the path is named behind the `#`, where it is read and
65
+ // never run — `x test eval` takes no path, and a file name may carry `$(…)` (sweep 1c).
66
+ fix: `ULTIMATE_EVAL_RECORD=1 x test eval # re-records ${input.path}`,
65
67
  });
66
68
  }
67
69
  }
package/src/evals.ts CHANGED
@@ -187,7 +187,7 @@ async function runEval<V extends PromptVars>(
187
187
  ...(input.prompt.system !== undefined ? { system: input.prompt.system } : {}),
188
188
  ...(input.prompt.model !== undefined ? { model: input.prompt.model } : {}),
189
189
  ...(input.prompt.effort !== undefined ? { effort: input.prompt.effort } : {}),
190
- // `thinking` too, and not because it is tidy: `contentHash` covers it, so the baseline is
190
+ // `thinking` too, and not because it is tidy: `promptHash` covers it, so the baseline is
191
191
  // filed under a hash describing a configuration the measurement did not use. `llm()` and
192
192
  // `agent()` have always sent both.
193
193
  ...(input.prompt.thinking !== undefined ? { thinking: input.prompt.thinking } : {}),
@@ -0,0 +1,90 @@
1
+ // The one rule for what of a thrown value a remote reader may see: an LLM tool result and a hive
2
+ // member's reason both reach someone outside this process, so both ask this file — and the half
3
+ // it withholds goes to the log as content-free facts (code, name, frames) — never its text.
4
+
5
+ import type { Logger } from '@ultimat3/core';
6
+ import { hasPublicCause, isUltimateError, stringField } from '@ultimat3/core';
7
+ import { statusFor } from '@ultimat3/http';
8
+
9
+ /**
10
+ * What may be shown. `code` is absent for an uncoded throw — a foreign `.message` is the server's
11
+ * (a pg `Key (email)=(…)`), so nothing of it is carried. `cause` is absent when it is withheld:
12
+ * the caller substitutes its own fixed sentence, because who is reading decides the wording.
13
+ */
14
+ export interface DisclosedFailure {
15
+ readonly code: string | undefined;
16
+ readonly cause: string | undefined;
17
+ readonly fix: string | undefined;
18
+ }
19
+
20
+ /**
21
+ * Read STRUCTURALLY and totally — `stringField`, never `typeof e.code` or `.message`: the value is
22
+ * whatever an app's handler, its driver or its SDK threw, so each read is a getter call or a
23
+ * `Proxy` trap, and this runs inside a catch block with nothing left to answer if a probe raises.
24
+ *
25
+ * A 5xx code's cause is shown only when core's `hasPublicCause` allows it — the rule a production
26
+ * problem document follows (`@ultimat3/http`'s `toProblem`). Hidden, the developer's `fix` goes
27
+ * with it (a driver writes it from the statement it failed on); a `callerFix` an `UltimateError`
28
+ * built in this process is authored for a remote reader and stays.
29
+ */
30
+ export function discloseFailure(
31
+ error: unknown,
32
+ logger: Logger | undefined,
33
+ where: string,
34
+ ): DisclosedFailure {
35
+ const code = stringField(error, 'code');
36
+ if (code === undefined) return withheld(error, logger, where, undefined, undefined);
37
+ if (statusFor(code) >= 500 && !hasPublicCause(code)) {
38
+ const callerFix = isUltimateError(error) ? stringField(error, 'callerFix') : undefined;
39
+ return withheld(error, logger, where, code, callerFix);
40
+ }
41
+ const fix = stringField(error, 'fix');
42
+ return {
43
+ code,
44
+ cause: stringField(error, 'cause') ?? 'unknown',
45
+ fix: fix === undefined || fix === '' ? undefined : fix,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * A stack's FRAMES and nothing else: every line that is a frame (` at …`), none that is not.
51
+ * The message is the first line — and, when it spans several, the lines after it until the first
52
+ * frame — so keeping only frame-shaped lines drops all of it, whatever it held. A frame is code
53
+ * position, which carries no caller data.
54
+ */
55
+ const FRAME = /^\s+at\s/;
56
+
57
+ function framesOf(error: unknown): string | null {
58
+ const stack = stringField(error, 'stack');
59
+ if (stack === undefined) return null;
60
+ const lines = stack.split('\n');
61
+ // Skip to the first frame line that FOLLOWS the message: a message may itself contain text
62
+ // shaped like a frame, and only lines after the message block are the stack.
63
+ const message = stringField(error, 'message') ?? '';
64
+ const skip = message === '' ? 1 : message.split('\n').length;
65
+ const frames = lines.slice(skip).filter((line) => FRAME.test(line));
66
+ return frames.length === 0 ? null : frames.join('\n');
67
+ }
68
+
69
+ /**
70
+ * The withheld half, logged as FACTS ONLY — the code, the error's name, and its frames. Never the
71
+ * message and never the cause: the logger redacts by field NAME, not by content, so a pg
72
+ * `Key (email)=(ceo@corp.com)` logged as text reached the logs whole. What identifies the failure
73
+ * is the code and where it was thrown, both of which survive this.
74
+ */
75
+ function withheld(
76
+ error: unknown,
77
+ logger: Logger | undefined,
78
+ where: string,
79
+ code: string | undefined,
80
+ fix: string | undefined,
81
+ ): DisclosedFailure {
82
+ logger?.error('ai.failure.withheld', {
83
+ where,
84
+ code: code ?? 'uncoded',
85
+ // `name`, read structurally: what kind of throw it was (`Error`, `TypeError`, a driver's own).
86
+ name: stringField(error, 'name') ?? typeof error,
87
+ frames: framesOf(error),
88
+ });
89
+ return { code, cause: undefined, fix };
90
+ }
package/src/gateway.ts CHANGED
@@ -17,10 +17,16 @@ import {
17
17
  } from '@ultimat3/core';
18
18
  import type { Money } from '@ultimat3/money';
19
19
  import type { BudgetKeys, BudgetLimits, BudgetStore } from './budget';
20
- import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
20
+ import {
21
+ assertFiniteLimits,
22
+ BudgetLedger,
23
+ currentBudget,
24
+ estimateSpend,
25
+ withBudget,
26
+ } from './budget';
21
27
  import { AiProviderUnavailableError } from './errors';
28
+ import { resolveModel } from './model-resolve';
22
29
  import type { ModelId } from './models';
23
- import { DEFAULT_MODEL } from './models';
24
30
  import type { GenerateRequest, GenerateResult, Provider, StreamChunk } from './provider';
25
31
 
26
32
  /**
@@ -34,7 +40,7 @@ export interface GatewayCache {
34
40
 
35
41
  /**
36
42
  * A gateway's retry budget. NOT core's `RetryPolicy`, and deliberately still its own declaration:
37
- * these three field names are what an app writes in `createGateway({ retry })`, so renaming them
43
+ * these three field names are what an app writes in `providerGateway({ retry })`, so renaming them
38
44
  * onto `base`/`max`/`jitter` would break every caller for no behaviour. What WAS a duplicate is the
39
45
  * arithmetic, and that is gone — `backoffMs` is core's `backoffDelay` with this shape mapped onto
40
46
  * it, so there is one curve in the framework and one place a jitter bug can live.
@@ -47,12 +53,16 @@ export interface RetryPolicy {
47
53
  readonly maxDelayMs: number;
48
54
  }
49
55
 
50
- export const DEFAULT_RETRY: RetryPolicy = { attempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 };
56
+ export const DEFAULT_GATEWAY_RETRY: RetryPolicy = {
57
+ attempts: 3,
58
+ baseDelayMs: 500,
59
+ maxDelayMs: 8_000,
60
+ };
51
61
 
52
62
  /** The one code `attempt` may collect into. Every other coded refusal is the caller's answer. */
53
63
  const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
54
64
 
55
- export interface CreateGatewayInput {
65
+ export interface ProviderGatewayInput {
56
66
  /** Tried in order for a given model. First provider that lists the model wins. */
57
67
  readonly providers: readonly Provider[];
58
68
  readonly budget?: BudgetLimits;
@@ -72,6 +82,13 @@ export interface CreateGatewayInput {
72
82
  }
73
83
 
74
84
  export interface Gateway {
85
+ /**
86
+ * The model a call with none declared runs on — `providerGateway({ defaultModel })`. Read by
87
+ * `llm()` and `agent()` after the declaration and its prompt, so the app's choice is the one
88
+ * fallback; absent, a call naming no model is refused (`X_AI_MODEL_UNRESOLVED`) — never routed to
89
+ * a vendor the framework picked.
90
+ */
91
+ readonly defaultModel?: ModelId | undefined;
75
92
  generate(request: GenerateRequest): Promise<GenerateResult>;
76
93
  stream(request: GenerateRequest): AsyncIterable<StreamChunk>;
77
94
  /** Open a budget scope. Nested gateway calls inside `fn` share one ledger. */
@@ -87,36 +104,43 @@ export interface Gateway {
87
104
  callLedger(keys: BudgetKeys): BudgetLedger;
88
105
  }
89
106
 
90
- export function createGateway(input: CreateGatewayInput): Gateway {
107
+ export function providerGateway(input: ProviderGatewayInput): Gateway {
91
108
  return new GatewayImpl(input);
92
109
  }
93
110
 
94
111
  class GatewayImpl implements Gateway {
95
- private readonly config: CreateGatewayInput;
112
+ private readonly config: ProviderGatewayInput;
96
113
  private readonly retry: RetryPolicy;
97
114
  private readonly sleep: (ms: number) => Promise<void>;
98
115
  /** Left `undefined` rather than defaulted, so `backoffDelay` owns the one fallback to `Math.random`. */
99
116
  private readonly random: Random | undefined;
100
117
 
101
- constructor(config: CreateGatewayInput) {
118
+ get defaultModel(): ModelId | undefined {
119
+ return this.config.defaultModel;
120
+ }
121
+
122
+ constructor(config: ProviderGatewayInput) {
102
123
  this.config = config;
103
- this.retry = config.retry ?? DEFAULT_RETRY;
124
+ this.retry = config.retry ?? DEFAULT_GATEWAY_RETRY;
104
125
  // `attempts` is the retry loop's only exit condition and nothing screened it: `attempt <= NaN`
105
126
  // is false on the first comparison, so `attempt()` calls no provider at all and raises
106
127
  // `X_AI_PROVIDER_UNAVAILABLE` with an EMPTY attempt list — measured, "no provider could serve
107
- // model claude-opus-5 ()" for a provider that was never asked. A floor of 1 because the field
128
+ // model <id> ()" for a provider that was never asked. A floor of 1 because the field
108
129
  // is documented as total attempts INCLUDING the first, so zero of them is not a policy.
109
130
  // `baseDelayMs` and `maxDelayMs` are deliberately not screened here: `backoffDelay` refuses a
110
131
  // non-finite one already, and a NEGATIVE base is clamped to a zero wait on purpose
111
132
  // (`gateway-backoff.test.ts` pins it), which a count check here would start refusing.
112
- finiteCount('createGateway', 'retry.attempts', this.retry.attempts, 1);
133
+ finiteCount('gateway', 'retry.attempts', this.retry.attempts, 1);
134
+ // Screened here, under the config's own key, rather than by the ledger `callLedger` builds:
135
+ // that refused on the first CALL and named `the AI budget`, not the `gateway` to edit.
136
+ if (config.budget !== undefined) assertFiniteLimits(config.budget, 'gateway', 'budget.');
113
137
  this.sleep = config.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
114
138
  this.random = config.random;
115
139
  }
116
140
 
117
141
  /**
118
142
  * `budget` held with no scope open. It was enforced ONLY inside `scope()`, which nothing in the
119
- * framework calls — so `createGateway({ budget: { request: 100 } })` capped no call any app made.
143
+ * framework calls — so `providerGateway({ budget: { request: 100 } })` capped no call any app made.
120
144
  * A raw `generate` / `stream` outside any scope passes no keys: it has no caller to count.
121
145
  */
122
146
  callLedger(keys: BudgetKeys): BudgetLedger {
@@ -139,10 +163,10 @@ class GatewayImpl implements Gateway {
139
163
  }
140
164
 
141
165
  async generate(request: GenerateRequest): Promise<GenerateResult> {
142
- const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
166
+ const model = resolveModel('gateway', request.model, this.config.defaultModel);
143
167
  const resolved: GenerateRequest = { ...request, model, maxTokens: ceilingOf(request) };
144
168
 
145
- const cacheKey = cacheKeyFor(resolved);
169
+ const cacheKey = promptCacheKey(resolved);
146
170
  const cached = await this.config.cache?.get(cacheKey);
147
171
  if (cached !== undefined) {
148
172
  // A cache hit costs nothing, so it is deliberately NOT debited from the budget.
@@ -186,7 +210,7 @@ class GatewayImpl implements Gateway {
186
210
  }
187
211
 
188
212
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
189
- const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
213
+ const model = resolveModel('gateway', request.model, this.config.defaultModel);
190
214
  const resolved: GenerateRequest = { ...request, model, maxTokens: ceilingOf(request) };
191
215
 
192
216
  // Routed BEFORE the reservation, not after it. `providerFor` throws for a registered model no
@@ -382,7 +406,7 @@ export function isRetryable(error: unknown): boolean {
382
406
  * after the schema changed. Core's `fingerprint`, so key order never splits one request in two and
383
407
  * the width is the one every other sharing key in the framework has.
384
408
  */
385
- export function cacheKeyFor(request: GenerateRequest): string {
409
+ export function promptCacheKey(request: GenerateRequest): string {
386
410
  return fingerprint({
387
411
  model: request.model,
388
412
  system: request.system ?? '',
package/src/hive-pool.ts CHANGED
@@ -5,7 +5,8 @@
5
5
  // happens to the siblings when one throws". Nothing here knows what a model is.
6
6
 
7
7
  import type { Ctx } from '@ultimat3/core';
8
- import { isThrownError, isUltimateError, stringField, withChildContext } from '@ultimat3/core';
8
+ import { withChildContext } from '@ultimat3/core';
9
+ import { discloseFailure } from './failure-disclosure';
9
10
  import type { HiveMember, HiveMemberError } from './hive-result';
10
11
  import { SKIPPED_ABORTED, SKIPPED_NO_INPUT } from './hive-result';
11
12
 
@@ -57,7 +58,7 @@ export async function runPool<I, O>(input: PoolInput<I, O>): Promise<readonly Hi
57
58
  );
58
59
  members[index] = { status: 'ok', index, value };
59
60
  } catch (error) {
60
- members[index] = { status: 'failed', index, ...failureOf(error) };
61
+ members[index] = { status: 'failed', index, ...failureOf(error, ctx, index) };
61
62
  if (input.onMemberError === 'abort') controller.abort();
62
63
  }
63
64
  }
@@ -72,19 +73,26 @@ export async function runPool<I, O>(input: PoolInput<I, O>): Promise<readonly Hi
72
73
  }
73
74
 
74
75
  /**
75
- * What a member threw, as two data fields — never as an error's `cause:`, which is why the thrown
76
- * value is read structurally and never interpolated. A foreign throw gets `'unknown'` rather than
77
- * an invented `X_` code: a code nothing declares is a code no `x errors explain` can answer.
76
+ * What the caller may read of a member's throw, as two data fields — never as an error's `cause:`.
77
+ * The reason ships in the hive action's 200 answer, so it is `discloseFailure`'s verdict, the one
78
+ * the tool result gets: a 5xx cause or a foreign `.message` (a pg `Key (email)=(…)`) is withheld
79
+ * and logged whole. A throw with no code gets `'unknown'` rather than an invented `X_` code: a code
80
+ * nothing declares is a code no `x errors explain` can answer.
78
81
  */
79
- function failureOf(error: unknown): { readonly code: string; readonly reason: string } {
80
- if (isUltimateError(error)) return { code: error.code, reason: error.cause };
81
- // `isThrownError` and `stringField`, never `error instanceof Error` and `.message`: a member
82
- // is an app's action, so the value is one the framework did not build — `instanceof` runs a
83
- // `Proxy`'s `getPrototypeOf` trap and `.message` is a getter call. A throw HERE would take the
84
- // whole hive down with it, which is the one outcome the three arms exist to prevent.
85
- const message = stringField(error, 'message');
86
- if (isThrownError(error) && message !== undefined && message !== '') {
87
- return { code: 'unknown', reason: message };
88
- }
89
- return { code: 'unknown', reason: 'the member threw a value that is not an Error' };
82
+ function failureOf(
83
+ error: unknown,
84
+ ctx: Ctx,
85
+ index: number,
86
+ ): { readonly code: string; readonly reason: string } {
87
+ // `fix` is not carried: `reason` has always been the cause alone, and a member's caller acts on
88
+ // the code. What `discloseFailure` withholds stays withheld either way.
89
+ const shown = discloseFailure(error, ctx.logger, `hive member ${index}`);
90
+ return { code: shown.code ?? 'unknown', reason: shown.cause ?? HIDDEN_MEMBER_CAUSE };
90
91
  }
92
+
93
+ /**
94
+ * What a caller reads in place of a reason it may not see. Fixed, so nothing of the throw rides on
95
+ * it — the hive's answer goes wherever its caller sends it, a model's context included.
96
+ */
97
+ export const HIDDEN_MEMBER_CAUSE =
98
+ 'the member failed inside the server; the details are withheld from this answer; the server log records its code and where it failed';
@@ -23,7 +23,10 @@ export type HiveMember<O> =
23
23
  | {
24
24
  readonly status: 'failed';
25
25
  readonly index: number;
26
- /** The `UltimateError` code the member threw, or `'unknown'` for a foreign throw. */
26
+ /**
27
+ * The code the member threw — read structurally, so a foreign throw carrying a string `code`
28
+ * (a driver's SQLSTATE) reports it — or `'unknown'` for a throw with none.
29
+ */
27
30
  readonly code: string;
28
31
  readonly reason: string;
29
32
  }
package/src/hive.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  * `@ultimat3/core`, and it is a list rather than a sentence because the ordinal a file writes for
6
6
  * itself is wrong the day the next one lands and no file can see the others. A fan-out is still
7
7
  * one server-authoritative operation with an input schema, an output schema and a policy, so this
8
- * returns an `action` and inherits `.tool()`, `.openapi()`, `.client()`, `.job()`, `.contract()`
9
- * and its manifest row without a line here.
8
+ * returns an `action` and inherits `.openapi()`, `.client()`, `.job()`, `.contract()`, its MCP
9
+ * tool and its manifest row without a line here.
10
10
  *
11
11
  * It exists because the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that
12
12
  * loop gets four things wrong every time: it takes the actor from somewhere other than the request,
@@ -20,7 +20,7 @@ import type { Ctx } from '@ultimat3/core';
20
20
  import { finiteCount, throwIfAborted, withSpan } from '@ultimat3/core';
21
21
  import type { AnySchema, InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
22
22
  import type { BudgetLimits } from './budget';
23
- import { BudgetLedger, budgetKeysFor, currentBudget, withBudget } from './budget';
23
+ import { BudgetLedger, budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
24
24
  import { HiveEmptyError } from './hive-errors';
25
25
  import { runPool } from './hive-pool';
26
26
  import type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
@@ -191,7 +191,9 @@ function limitsOf<
191
191
  ...(budget?.tokensIn === undefined
192
192
  ? {}
193
193
  : { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
194
- ...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
194
+ ...(budget?.costPerCall === undefined
195
+ ? {}
196
+ : { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
195
197
  // The ledger's `request` scope accumulates across every call made under it, which for a hive
196
198
  // under `withBudget` is every member's every turn.
197
199
  ...(budget?.tokensPerRun === undefined