@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.
- package/CLAUDE.md +96 -32
- package/README.md +249 -75
- package/package.json +12 -11
- package/src/agent-facts.ts +9 -2
- package/src/agent-transcript.ts +1 -1
- package/src/agent.ts +43 -25
- package/src/budget.ts +178 -51
- package/src/content-blocks.ts +227 -0
- package/src/content-errors.ts +67 -0
- package/src/echo-provider.ts +102 -0
- package/src/embeddings.ts +40 -21
- package/src/error-body.ts +38 -3
- package/src/errors.ts +19 -11
- package/src/eval-baseline.ts +5 -1
- package/src/eval-errors.ts +3 -1
- package/src/evals.ts +1 -1
- package/src/failure-disclosure.ts +90 -0
- package/src/gateway.ts +73 -37
- package/src/hive-pool.ts +24 -16
- package/src/hive-result.ts +4 -1
- package/src/hive.ts +15 -5
- package/src/index.ts +66 -25
- package/src/llm-cache.ts +4 -3
- package/src/llm.ts +27 -65
- package/src/model-resolve.ts +79 -0
- package/src/models.ts +125 -111
- package/src/openai-body.ts +13 -2
- package/src/openai-messages.ts +86 -15
- package/src/openai-provider.ts +28 -31
- package/src/openai-wire.ts +11 -3
- package/src/pg-vector-sql.ts +11 -7
- package/src/pg-vector.ts +21 -10
- package/src/prompt-fence.ts +128 -0
- package/src/prompt.ts +43 -13
- package/src/provider.ts +82 -117
- package/src/rag.ts +17 -9
- package/src/remote-embedder.ts +15 -41
- package/src/respond.ts +86 -0
- package/src/runtime.ts +12 -14
- package/src/scorers.ts +14 -3
- package/src/tool-idempotency.ts +79 -0
- package/src/tools.ts +52 -39
- package/src/vector-scope.ts +46 -4
- package/src/vector.ts +117 -41
- package/src/wire.ts +7 -2
- package/src/fix-line.evals.ts +0 -35
- package/src/fix-line.ts +0 -27
- package/src/openai-models.ts +0 -84
package/src/eval-errors.ts
CHANGED
|
@@ -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
|
-
|
|
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: `
|
|
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 {
|
|
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 `
|
|
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
|
|
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
|
|
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 `
|
|
82
|
-
*
|
|
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
|
|
104
|
+
callLedger(keys: BudgetKeys): BudgetLedger;
|
|
85
105
|
}
|
|
86
106
|
|
|
87
|
-
export function
|
|
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:
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
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('
|
|
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 `
|
|
117
|
-
*
|
|
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:
|
|
127
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
374
|
-
return
|
|
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
|
|
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 {
|
|
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
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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';
|
package/src/hive-result.ts
CHANGED
|
@@ -23,7 +23,10 @@ export type HiveMember<O> =
|
|
|
23
23
|
| {
|
|
24
24
|
readonly status: 'failed';
|
|
25
25
|
readonly index: number;
|
|
26
|
-
/**
|
|
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 `.
|
|
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
|
-
|
|
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
|
|
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
|