@ultimat3/ai 23.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.
Files changed (48) hide show
  1. package/CLAUDE.md +96 -32
  2. package/README.md +249 -75
  3. package/package.json +12 -11
  4. package/src/agent-facts.ts +9 -2
  5. package/src/agent-transcript.ts +1 -1
  6. package/src/agent.ts +43 -25
  7. package/src/budget.ts +178 -51
  8. package/src/content-blocks.ts +227 -0
  9. package/src/content-errors.ts +67 -0
  10. package/src/echo-provider.ts +102 -0
  11. package/src/embeddings.ts +40 -21
  12. package/src/error-body.ts +38 -3
  13. package/src/errors.ts +19 -11
  14. package/src/eval-baseline.ts +5 -1
  15. package/src/eval-errors.ts +3 -1
  16. package/src/evals.ts +1 -1
  17. package/src/failure-disclosure.ts +90 -0
  18. package/src/gateway.ts +73 -37
  19. package/src/hive-pool.ts +24 -16
  20. package/src/hive-result.ts +4 -1
  21. package/src/hive.ts +15 -5
  22. package/src/index.ts +66 -25
  23. package/src/llm-cache.ts +4 -3
  24. package/src/llm.ts +27 -65
  25. package/src/model-resolve.ts +79 -0
  26. package/src/models.ts +125 -111
  27. package/src/openai-body.ts +13 -2
  28. package/src/openai-messages.ts +86 -15
  29. package/src/openai-provider.ts +28 -31
  30. package/src/openai-wire.ts +11 -3
  31. package/src/pg-vector-sql.ts +11 -7
  32. package/src/pg-vector.ts +21 -10
  33. package/src/prompt-fence.ts +128 -0
  34. package/src/prompt.ts +43 -13
  35. package/src/provider.ts +82 -117
  36. package/src/rag.ts +17 -9
  37. package/src/remote-embedder.ts +15 -41
  38. package/src/respond.ts +86 -0
  39. package/src/runtime.ts +12 -14
  40. package/src/scorers.ts +14 -3
  41. package/src/tool-idempotency.ts +79 -0
  42. package/src/tools.ts +52 -39
  43. package/src/vector-scope.ts +46 -4
  44. package/src/vector.ts +117 -41
  45. package/src/wire.ts +7 -2
  46. package/src/fix-line.evals.ts +0 -35
  47. package/src/fix-line.ts +0 -27
  48. package/src/openai-models.ts +0 -84
@@ -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
@@ -7,18 +7,26 @@
7
7
  import type { Random } from '@ultimat3/core';
8
8
  import {
9
9
  backoffDelay,
10
+ fingerprint,
10
11
  finiteCount,
11
12
  isRetryableStatus,
12
13
  isUltimateError,
14
+ logger,
13
15
  renderThrowable,
14
16
  stringField,
15
17
  } from '@ultimat3/core';
16
18
  import type { Money } from '@ultimat3/money';
17
- import type { BudgetLimits, BudgetStore } from './budget';
18
- import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
19
+ import type { BudgetKeys, BudgetLimits, BudgetStore } from './budget';
20
+ import {
21
+ assertFiniteLimits,
22
+ BudgetLedger,
23
+ currentBudget,
24
+ estimateSpend,
25
+ withBudget,
26
+ } from './budget';
19
27
  import { AiProviderUnavailableError } from './errors';
28
+ import { resolveModel } from './model-resolve';
20
29
  import type { ModelId } from './models';
21
- import { DEFAULT_MODEL } from './models';
22
30
  import type { GenerateRequest, GenerateResult, Provider, StreamChunk } from './provider';
23
31
 
24
32
  /**
@@ -32,7 +40,7 @@ export interface GatewayCache {
32
40
 
33
41
  /**
34
42
  * A gateway's retry budget. NOT core's `RetryPolicy`, and deliberately still its own declaration:
35
- * 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
36
44
  * onto `base`/`max`/`jitter` would break every caller for no behaviour. What WAS a duplicate is the
37
45
  * arithmetic, and that is gone — `backoffMs` is core's `backoffDelay` with this shape mapped onto
38
46
  * it, so there is one curve in the framework and one place a jitter bug can live.
@@ -45,12 +53,16 @@ export interface RetryPolicy {
45
53
  readonly maxDelayMs: number;
46
54
  }
47
55
 
48
- 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
+ };
49
61
 
50
62
  /** The one code `attempt` may collect into. Every other coded refusal is the caller's answer. */
51
63
  const PROVIDER_UNAVAILABLE = 'X_AI_PROVIDER_UNAVAILABLE';
52
64
 
53
- export interface CreateGatewayInput {
65
+ export interface ProviderGatewayInput {
54
66
  /** Tried in order for a given model. First provider that lists the model wins. */
55
67
  readonly providers: readonly Provider[];
56
68
  readonly budget?: BudgetLimits;
@@ -70,6 +82,13 @@ export interface CreateGatewayInput {
70
82
  }
71
83
 
72
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;
73
92
  generate(request: GenerateRequest): Promise<GenerateResult>;
74
93
  stream(request: GenerateRequest): AsyncIterable<StreamChunk>;
75
94
  /** Open a budget scope. Nested gateway calls inside `fn` share one ledger. */
@@ -78,59 +97,63 @@ export interface Gateway {
78
97
  spent(): Promise<Money>;
79
98
  /**
80
99
  * A ledger over this gateway's own `budget`, for a call made with no `scope()` open — which is
81
- * every call `llm()` and `agent()` make. Optional so a hand-written gateway still satisfies the
82
- * interface; absent, such a call runs under the ceilings its own declaration sets and no others.
100
+ * every call `llm()`, `agent()` and `hive()` make, each passing `budgetKeysFor(ctx.actor)` so the
101
+ * `actor` and `org` ceilings count the caller. Required: a gateway without it ran every such call
102
+ * under no gateway ceiling at all.
83
103
  */
84
- callLedger?(): BudgetLedger;
104
+ callLedger(keys: BudgetKeys): BudgetLedger;
85
105
  }
86
106
 
87
- export function createGateway(input: CreateGatewayInput): Gateway {
107
+ export function providerGateway(input: ProviderGatewayInput): Gateway {
88
108
  return new GatewayImpl(input);
89
109
  }
90
110
 
91
111
  class GatewayImpl implements Gateway {
92
- private readonly config: CreateGatewayInput;
112
+ private readonly config: ProviderGatewayInput;
93
113
  private readonly retry: RetryPolicy;
94
114
  private readonly sleep: (ms: number) => Promise<void>;
95
115
  /** Left `undefined` rather than defaulted, so `backoffDelay` owns the one fallback to `Math.random`. */
96
116
  private readonly random: Random | undefined;
97
117
 
98
- constructor(config: CreateGatewayInput) {
118
+ get defaultModel(): ModelId | undefined {
119
+ return this.config.defaultModel;
120
+ }
121
+
122
+ constructor(config: ProviderGatewayInput) {
99
123
  this.config = config;
100
- this.retry = config.retry ?? DEFAULT_RETRY;
124
+ this.retry = config.retry ?? DEFAULT_GATEWAY_RETRY;
101
125
  // `attempts` is the retry loop's only exit condition and nothing screened it: `attempt <= NaN`
102
126
  // is false on the first comparison, so `attempt()` calls no provider at all and raises
103
127
  // `X_AI_PROVIDER_UNAVAILABLE` with an EMPTY attempt list — measured, "no provider could serve
104
- // 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
105
129
  // is documented as total attempts INCLUDING the first, so zero of them is not a policy.
106
130
  // `baseDelayMs` and `maxDelayMs` are deliberately not screened here: `backoffDelay` refuses a
107
131
  // non-finite one already, and a NEGATIVE base is clamped to a zero wait on purpose
108
132
  // (`gateway-backoff.test.ts` pins it), which a count check here would start refusing.
109
- 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.');
110
137
  this.sleep = config.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
111
138
  this.random = config.random;
112
139
  }
113
140
 
114
141
  /**
115
142
  * `budget` held with no scope open. It was enforced ONLY inside `scope()`, which nothing in the
116
- * framework calls — so `createGateway({ budget: { request: 100 } })` capped no call any app made.
117
- * No actor or org key here: those scopes need `scope()` to name whose window they are.
143
+ * framework calls — so `providerGateway({ budget: { request: 100 } })` capped no call any app made.
144
+ * A raw `generate` / `stream` outside any scope passes no keys: it has no caller to count.
118
145
  */
119
- callLedger(): BudgetLedger {
146
+ callLedger(keys: BudgetKeys): BudgetLedger {
120
147
  return new BudgetLedger({
121
148
  limits: this.config.budget ?? {},
149
+ ...(keys.actorKey !== undefined ? { actorKey: keys.actorKey } : {}),
150
+ ...(keys.orgKey !== undefined ? { orgKey: keys.orgKey } : {}),
122
151
  ...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
123
152
  });
124
153
  }
125
154
 
126
- scope<T>(input: { actorKey?: string; orgKey?: string }, fn: () => Promise<T>): Promise<T> {
127
- const ledger = new BudgetLedger({
128
- limits: this.config.budget ?? {},
129
- ...(input.actorKey !== undefined ? { actorKey: input.actorKey } : {}),
130
- ...(input.orgKey !== undefined ? { orgKey: input.orgKey } : {}),
131
- ...(this.config.budgetStore !== undefined ? { store: this.config.budgetStore } : {}),
132
- });
133
- return withBudget(ledger, fn);
155
+ scope<T>(input: BudgetKeys, fn: () => Promise<T>): Promise<T> {
156
+ return withBudget(this.callLedger(input), fn);
134
157
  }
135
158
 
136
159
  async spent(): Promise<Money> {
@@ -140,10 +163,10 @@ class GatewayImpl implements Gateway {
140
163
  }
141
164
 
142
165
  async generate(request: GenerateRequest): Promise<GenerateResult> {
143
- const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
166
+ const model = resolveModel('gateway', request.model, this.config.defaultModel);
144
167
  const resolved: GenerateRequest = { ...request, model, maxTokens: ceilingOf(request) };
145
168
 
146
- const cacheKey = cacheKeyFor(resolved);
169
+ const cacheKey = promptCacheKey(resolved);
147
170
  const cached = await this.config.cache?.get(cacheKey);
148
171
  if (cached !== undefined) {
149
172
  // A cache hit costs nothing, so it is deliberately NOT debited from the budget.
@@ -153,7 +176,7 @@ class GatewayImpl implements Gateway {
153
176
  // Reserve against the ESTIMATE before spending anything — tokens AND money, since a
154
177
  // cheap-in-tokens call on an expensive model is still a cost cap the app declared.
155
178
  // `record` below replaces the estimate with the provider's real counts.
156
- const ledger = currentBudget() ?? this.callLedger();
179
+ const ledger = currentBudget() ?? this.callLedger({});
157
180
  // The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
158
181
  // all read the same `spent()` otherwise, all pass, and all three record against a ceiling
159
182
  // only one of them fitted.
@@ -170,14 +193,24 @@ class GatewayImpl implements Gateway {
170
193
  await ledger?.record(result.usage, result.cost, reservation);
171
194
  // A refusal is not an answer, so it is not cached. Storing one would keep serving a decision
172
195
  // the classifier might not make twice, long after the prompt that provoked it was fixed.
173
- if (result.stopReason !== 'refusal') {
196
+ if (result.stopReason !== 'refusal') await this.remember(cacheKey, result);
197
+ return result;
198
+ }
199
+
200
+ /**
201
+ * The cache write, AFTER the call was paid for and recorded. A throw here must not turn that
202
+ * answer into a failure: the caller would retry and pay for it twice. Logged, and the answer kept.
203
+ */
204
+ private async remember(cacheKey: string, result: GenerateResult): Promise<void> {
205
+ try {
174
206
  await this.config.cache?.set(cacheKey, JSON.stringify(result));
207
+ } catch (error) {
208
+ logger.warn('ai.cache.write_failed', { cacheKey, error: renderThrowable(error) });
175
209
  }
176
- return result;
177
210
  }
178
211
 
179
212
  async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
180
- const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
213
+ const model = resolveModel('gateway', request.model, this.config.defaultModel);
181
214
  const resolved: GenerateRequest = { ...request, model, maxTokens: ceilingOf(request) };
182
215
 
183
216
  // Routed BEFORE the reservation, not after it. `providerFor` throws for a registered model no
@@ -196,7 +229,7 @@ class GatewayImpl implements Gateway {
196
229
  // `generate`, or reconnects itself and knows what it has already shown.
197
230
  const provider = this.providerFor(model);
198
231
 
199
- const ledger = currentBudget() ?? this.callLedger();
232
+ const ledger = currentBudget() ?? this.callLedger({});
200
233
  const reservation = await ledger?.reserve(estimateSpend(resolved));
201
234
  let settled = false;
202
235
  try {
@@ -368,17 +401,20 @@ export function isRetryable(error: unknown): boolean {
368
401
 
369
402
  /**
370
403
  * Cache key. Every field that changes the answer is in it — a key that ignores `effort` or
371
- * `system` would serve one prompt's answer for another.
404
+ * `system` would serve one prompt's answer for another — and each tool WHOLE: every `llm()` tool
405
+ * is named `respond`, so a key over names alone served an answer shaped for an old `output` schema
406
+ * after the schema changed. Core's `fingerprint`, so key order never splits one request in two and
407
+ * the width is the one every other sharing key in the framework has.
372
408
  */
373
- export function cacheKeyFor(request: GenerateRequest): string {
374
- return JSON.stringify({
409
+ export function promptCacheKey(request: GenerateRequest): string {
410
+ return fingerprint({
375
411
  model: request.model,
376
412
  system: request.system ?? '',
377
413
  messages: request.messages,
378
414
  maxTokens: request.maxTokens,
379
415
  effort: request.effort ?? 'high',
380
416
  thinking: request.thinking ?? 'adaptive',
381
- tools: request.tools?.map((t) => t.name) ?? [],
417
+ tools: request.tools ?? [],
382
418
  stop: request.stopSequences ?? [],
383
419
  });
384
420
  }
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,12 +20,13 @@ 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, 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';
27
27
  import { hiveResultSchema } from './hive-result';
28
28
  import type { LlmBudget } from './llm';
29
+ import { installedGateway } from './runtime';
29
30
 
30
31
  /**
31
32
  * Members in flight at once when the declaration omits one. Small and deliberately arbitrary — it
@@ -131,7 +132,14 @@ async function run<
131
132
  'hive.concurrency': width,
132
133
  'hive.on_member_error': def.onMemberError,
133
134
  });
134
- const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
135
+ // Rooted as `llm()` and `agent()` root: the gateway's ceilings, keyed on the caller. An empty
136
+ // root dropped every gateway ceiling for every member. No gateway installed is a hive of plain
137
+ // actions, which spends nothing — a model member is refused `X_AI_GATEWAY_MISSING` on its own.
138
+ const root =
139
+ currentBudget() ??
140
+ installedGateway()?.callLedger(budgetKeysFor(args.ctx.actor)) ??
141
+ new BudgetLedger({ limits: {} });
142
+ const ledger = root.derive(limitsOf(def));
135
143
  const result = await withBudget(ledger, () =>
136
144
  runPool<InferInput<MIn>, InferOutput<MOut>>({
137
145
  inputs,
@@ -183,7 +191,9 @@ function limitsOf<
183
191
  ...(budget?.tokensIn === undefined
184
192
  ? {}
185
193
  : { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
186
- ...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
194
+ ...(budget?.costPerCall === undefined
195
+ ? {}
196
+ : { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
187
197
  // The ledger's `request` scope accumulates across every call made under it, which for a hive
188
198
  // under `withBudget` is every member's every turn.
189
199
  ...(budget?.tokensPerRun === undefined