@ultimat3/ai 1.2.0 → 3.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 +521 -0
- package/README.md +367 -6
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +396 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +144 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +57 -19
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +55 -9
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +274 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +112 -38
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +107 -11
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
package/src/gateway.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// budgeted, and cost-accounted in integer minor units. Everything an app does with a model
|
|
5
5
|
// goes through here, so budgets and accounting cannot be bypassed by a stray fetch.
|
|
6
6
|
|
|
7
|
+
import { renderThrowable } from '@ultimat3/core';
|
|
7
8
|
import type { Money } from '@ultimat3/money';
|
|
8
9
|
import type { BudgetLimits, BudgetStore } from './budget';
|
|
9
10
|
import { BudgetLedger, currentBudget, estimateSpend, withBudget } from './budget';
|
|
@@ -98,10 +99,20 @@ class GatewayImpl implements Gateway {
|
|
|
98
99
|
// cheap-in-tokens call on an expensive model is still a cost cap the app declared.
|
|
99
100
|
// `record` below replaces the estimate with the provider's real counts.
|
|
100
101
|
const ledger = currentBudget();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
await ledger?.
|
|
102
|
+
// The estimate is DEBITED here, not merely checked: three concurrent calls under one ledger
|
|
103
|
+
// all read the same `spent()` otherwise, all pass, and all three record against a ceiling
|
|
104
|
+
// only one of them fitted.
|
|
105
|
+
const reservation = await ledger?.reserve(estimateSpend(resolved));
|
|
106
|
+
|
|
107
|
+
let result: GenerateResult;
|
|
108
|
+
try {
|
|
109
|
+
result = await this.attempt(model, (provider) => provider.generate(resolved));
|
|
110
|
+
} catch (error) {
|
|
111
|
+
// A call that never landed must not go on holding its reservation.
|
|
112
|
+
await ledger?.release(reservation);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
await ledger?.record(result.usage, result.cost, reservation);
|
|
105
116
|
// A refusal is not an answer, so it is not cached. Storing one would keep serving a decision
|
|
106
117
|
// the classifier might not make twice, long after the prompt that provoked it was fixed.
|
|
107
118
|
if (result.stopReason !== 'refusal') {
|
|
@@ -114,14 +125,26 @@ class GatewayImpl implements Gateway {
|
|
|
114
125
|
const model = request.model ?? this.config.defaultModel ?? DEFAULT_MODEL;
|
|
115
126
|
const resolved: GenerateRequest = { ...request, model };
|
|
116
127
|
const ledger = currentBudget();
|
|
117
|
-
await ledger?.reserve(estimateSpend(resolved));
|
|
128
|
+
const reservation = await ledger?.reserve(estimateSpend(resolved));
|
|
118
129
|
|
|
119
130
|
// A stream is not retried mid-flight: the consumer has already seen tokens, and
|
|
120
131
|
// replaying from the top would duplicate them. Only the handshake retries.
|
|
121
132
|
const provider = this.providerFor(model);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
133
|
+
let settled = false;
|
|
134
|
+
try {
|
|
135
|
+
for await (const chunk of provider.stream(resolved)) {
|
|
136
|
+
if (chunk.type !== 'done') {
|
|
137
|
+
yield chunk;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
settled = true;
|
|
141
|
+
await ledger?.record(chunk.result.usage, chunk.result.cost, reservation);
|
|
142
|
+
yield { type: 'done', result: { ...chunk.result, provider: provider.name } };
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
// A stream that threw, or that its consumer abandoned, never reached `done` — so nothing
|
|
146
|
+
// reconciled the reservation and it would hold the ceiling for the rest of the window.
|
|
147
|
+
if (!settled) await ledger?.release(reservation);
|
|
125
148
|
}
|
|
126
149
|
}
|
|
127
150
|
|
|
@@ -140,8 +163,16 @@ class GatewayImpl implements Gateway {
|
|
|
140
163
|
* Try every provider that serves `model`, retrying each on a retryable failure with
|
|
141
164
|
* exponential backoff plus full jitter (jitter matters: synchronised retries from N
|
|
142
165
|
* workers reproduce the rate limit they are backing off from).
|
|
166
|
+
*
|
|
167
|
+
* Fallback here is across PROVIDERS serving one model, never across models — a silent model
|
|
168
|
+
* swap changes what answered, what it cost and which eval baseline the answer belongs to. The
|
|
169
|
+
* provider that did answer is stamped onto the result, so the fallback that DOES exist reaches
|
|
170
|
+
* the span instead of being invisible.
|
|
143
171
|
*/
|
|
144
|
-
private async attempt
|
|
172
|
+
private async attempt(
|
|
173
|
+
model: ModelId,
|
|
174
|
+
call: (provider: Provider) => Promise<GenerateResult>,
|
|
175
|
+
): Promise<GenerateResult> {
|
|
145
176
|
const candidates = this.config.providers.filter((p) => p.models.includes(model));
|
|
146
177
|
const failures: string[] = [];
|
|
147
178
|
if (candidates.length === 0) {
|
|
@@ -151,9 +182,13 @@ class GatewayImpl implements Gateway {
|
|
|
151
182
|
for (const provider of candidates) {
|
|
152
183
|
for (let attempt = 1; attempt <= this.retry.attempts; attempt += 1) {
|
|
153
184
|
try {
|
|
154
|
-
return await call(provider);
|
|
185
|
+
return { ...(await call(provider)), provider: provider.name };
|
|
155
186
|
} catch (error) {
|
|
156
|
-
|
|
187
|
+
// `renderThrowable`, never `error.message` or `String(error)`: this line becomes the
|
|
188
|
+
// `cause` of `X_AI_PROVIDER_UNAVAILABLE`, and a renderer that throws replaces the coded
|
|
189
|
+
// refusal with a `TypeError` nothing downstream can catch by code. It bounds the text
|
|
190
|
+
// too — a provider's 1MB body is not a cause.
|
|
191
|
+
failures.push(`${provider.name}#${attempt}: ${renderThrowable(error)}`);
|
|
157
192
|
if (!isRetryable(error) || attempt === this.retry.attempts) break;
|
|
158
193
|
await this.sleep(backoffMs(this.retry, attempt));
|
|
159
194
|
}
|
|
@@ -175,14 +210,17 @@ export function backoffMs(policy: RetryPolicy, attempt: number): number {
|
|
|
175
210
|
*/
|
|
176
211
|
export function isRetryable(error: unknown): boolean {
|
|
177
212
|
if (typeof error !== 'object' || error === null) return false;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
213
|
+
// A `Provider` is the APP's object, so the value it rejected with is one the framework did not
|
|
214
|
+
// build: `e.status` is a getter call and, on a `Proxy`, a trap. A value that fights being read
|
|
215
|
+
// cannot be SHOWN to be retryable, and this runs inside the catch block that has nothing left
|
|
216
|
+
// to answer with — so it fails closed rather than raising.
|
|
217
|
+
try {
|
|
218
|
+
const e = error as { status?: unknown; code?: unknown };
|
|
219
|
+
if (typeof e.status === 'number') return e.status === 429 || e.status >= 500;
|
|
220
|
+
return e.code === 'ETIMEDOUT' || e.code === 'ECONNRESET';
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
186
224
|
}
|
|
187
225
|
|
|
188
226
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The X_HIVE_* codes, apart from ./errors only because one file has one job and that catalogue is
|
|
2
|
+
// already at its ceiling — the same split `eval-errors.ts` made. The codes, their titles and the
|
|
3
|
+
// single `registerErrorCodes` call stay in ./errors: one owner, one registration, one place a
|
|
4
|
+
// duplicate can surface.
|
|
5
|
+
|
|
6
|
+
import { UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { AiErrorCode } from './errors';
|
|
8
|
+
|
|
9
|
+
const docsFor = (code: AiErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `split` handed back an empty list, so the hive fanned out to nobody and would have reported a
|
|
13
|
+
* successful run of zero members.
|
|
14
|
+
*
|
|
15
|
+
* Refused rather than returned, because the two readings of "0 ok, 0 failed" are "there was
|
|
16
|
+
* genuinely nothing to do" and "the query behind `split` returned no rows and nobody noticed",
|
|
17
|
+
* and only the caller can tell them apart. A hive whose empty case is legitimate says so by not
|
|
18
|
+
* being called: guard the `split` source at the call site, where the emptiness is visible.
|
|
19
|
+
*/
|
|
20
|
+
export class HiveEmptyError extends UltimateError {
|
|
21
|
+
constructor(input: { member: string }) {
|
|
22
|
+
super({
|
|
23
|
+
code: 'X_HIVE_EMPTY',
|
|
24
|
+
cause: `the hive over "${input.member}" split into 0 members, so no member ran`,
|
|
25
|
+
fix: `return at least one member input from the hive's split() over "${input.member}", or skip the hive call when the source is empty — a hive reporting 0 ok and 0 failed cannot be told apart from one whose query returned no rows`,
|
|
26
|
+
docs: docsFor('X_HIVE_EMPTY'),
|
|
27
|
+
meta: { member: input.member },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/hive-pool.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The bounded, order-preserving, cancellation-linked worker pool a hive fans out through.
|
|
2
|
+
//
|
|
3
|
+
// Apart from `hive.ts` because it is a different job with a different failure mode: that file owns
|
|
4
|
+
// the declaration and the budget scope, this one owns "how many at once, in what order, and what
|
|
5
|
+
// happens to the siblings when one throws". Nothing here knows what a model is.
|
|
6
|
+
|
|
7
|
+
import type { Ctx } from '@ultimat3/core';
|
|
8
|
+
import { isThrownError, isUltimateError, stringField, withChildContext } from '@ultimat3/core';
|
|
9
|
+
import type { HiveMember, HiveMemberError } from './hive-result';
|
|
10
|
+
import { SKIPPED_ABORTED, SKIPPED_NO_INPUT } from './hive-result';
|
|
11
|
+
|
|
12
|
+
export interface PoolInput<I, O> {
|
|
13
|
+
readonly inputs: readonly I[];
|
|
14
|
+
readonly width: number;
|
|
15
|
+
readonly ctx: Ctx;
|
|
16
|
+
readonly onMemberError: HiveMemberError;
|
|
17
|
+
/** One member run. The caller supplies it already bound, so this file never sees an action. */
|
|
18
|
+
member(payload: I): Promise<O>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A bounded pool of `width` workers over one shared cursor. Results land BY INDEX, so the answer is
|
|
23
|
+
* in split order however the members interleave — `Promise.all` over a mapped array would give the
|
|
24
|
+
* same ordering but no ceiling, and a settle-ordered push would give neither.
|
|
25
|
+
*
|
|
26
|
+
* The controller is linked to `ctx.signal` in both directions that matter: the caller going away
|
|
27
|
+
* aborts every member, and `onMemberError: 'abort'` aborts the siblings without touching the
|
|
28
|
+
* caller's own signal. Each member runs under `withChildContext({ signal })`, which carries the
|
|
29
|
+
* actor forward untouched — the hive never names an identity.
|
|
30
|
+
*/
|
|
31
|
+
export async function runPool<I, O>(input: PoolInput<I, O>): Promise<readonly HiveMember<O>[]> {
|
|
32
|
+
const { inputs, width, ctx } = input;
|
|
33
|
+
const members = new Array<HiveMember<O>>(inputs.length);
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const relay = (): void => controller.abort();
|
|
36
|
+
if (ctx.signal.aborted) controller.abort();
|
|
37
|
+
ctx.signal.addEventListener('abort', relay, { once: true });
|
|
38
|
+
|
|
39
|
+
let cursor = 0;
|
|
40
|
+
const worker = async (): Promise<void> => {
|
|
41
|
+
for (;;) {
|
|
42
|
+
const index = cursor;
|
|
43
|
+
cursor += 1;
|
|
44
|
+
if (index >= inputs.length) return;
|
|
45
|
+
const payload = inputs[index];
|
|
46
|
+
// Every index is claimed by exactly one worker and assigned exactly once, so the array has
|
|
47
|
+
// no holes for a caller to trip over — `skipped` is a recorded outcome, never an absence.
|
|
48
|
+
// Two reasons, because they are two facts: the run stopped, or the split had nothing here.
|
|
49
|
+
if (controller.signal.aborted || payload === undefined) {
|
|
50
|
+
const reason = controller.signal.aborted ? SKIPPED_ABORTED : SKIPPED_NO_INPUT;
|
|
51
|
+
members[index] = { status: 'skipped', index, reason };
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const value = await withChildContext({ signal: controller.signal }, () =>
|
|
56
|
+
input.member(payload),
|
|
57
|
+
);
|
|
58
|
+
members[index] = { status: 'ok', index, value };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
members[index] = { status: 'failed', index, ...failureOf(error) };
|
|
61
|
+
if (input.onMemberError === 'abort') controller.abort();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await Promise.all(Array.from({ length: width }, worker));
|
|
68
|
+
} finally {
|
|
69
|
+
ctx.signal.removeEventListener('abort', relay);
|
|
70
|
+
}
|
|
71
|
+
return members;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
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.
|
|
78
|
+
*/
|
|
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' };
|
|
90
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What a hive run answers, as a TYPE and as a SCHEMA built from the member's own `output`.
|
|
2
|
+
//
|
|
3
|
+
// It is a schema and not a plain interface because `hive()` returns an `action`, and an action's
|
|
4
|
+
// `output:` is what drives `validateOutput`, the OpenAPI response body, the typed client, the MCP
|
|
5
|
+
// tool and the manifest row. A hand-written interface would give the type and none of the six
|
|
6
|
+
// projections — the whole reason a hive is a factory over `action()` rather than a helper.
|
|
7
|
+
|
|
8
|
+
import type { Money } from '@ultimat3/money';
|
|
9
|
+
import type { AnySchema, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
10
|
+
import { t } from '@ultimat3/schema';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One member's outcome. THREE arms, not two: a member that ran and threw and a member that never
|
|
14
|
+
* ran at all are different facts, and the second one is what an aborted sibling is. Collapsing
|
|
15
|
+
* them would make "the hive stopped early" indistinguishable from "every remaining item is bad
|
|
16
|
+
* data", which is the difference between retrying the tail and fixing the source.
|
|
17
|
+
*
|
|
18
|
+
* `index` is the position in `split`'s output on every arm, so a caller can join a result back to
|
|
19
|
+
* the row it came from without depending on array position surviving a filter.
|
|
20
|
+
*/
|
|
21
|
+
export type HiveMember<O> =
|
|
22
|
+
| { readonly status: 'ok'; readonly index: number; readonly value: O }
|
|
23
|
+
| {
|
|
24
|
+
readonly status: 'failed';
|
|
25
|
+
readonly index: number;
|
|
26
|
+
/** The `UltimateError` code the member threw, or `'unknown'` for a foreign throw. */
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly reason: string;
|
|
29
|
+
}
|
|
30
|
+
| { readonly status: 'skipped'; readonly index: number; readonly reason: string };
|
|
31
|
+
|
|
32
|
+
export interface HiveResult<O> {
|
|
33
|
+
/** In SPLIT order, always — never completion order, and never with the failures filtered out. */
|
|
34
|
+
readonly members: readonly HiveMember<O>[];
|
|
35
|
+
readonly ok: number;
|
|
36
|
+
readonly failed: number;
|
|
37
|
+
/**
|
|
38
|
+
* Published beside `ok` and `failed` rather than left to be derived: three arms and two counters
|
|
39
|
+
* means `members.length - ok - failed`, and a caller who writes that once writes it wrong once.
|
|
40
|
+
*/
|
|
41
|
+
readonly skipped: number;
|
|
42
|
+
/** Tokens the whole run debited, every member counted, from the hive's own derived ledger. */
|
|
43
|
+
readonly tokens: number;
|
|
44
|
+
readonly cost: Money;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The declared shape of `hive()`'s output, given the member action's own `output`. */
|
|
48
|
+
export type HiveOutput<MOut extends AnySchema> = StandardSchemaV1<
|
|
49
|
+
unknown,
|
|
50
|
+
HiveResult<InferOutput<MOut>>
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
/** What a member failure means for its siblings. No default: both answers are somebody's bug. */
|
|
54
|
+
export type HiveMemberError =
|
|
55
|
+
/** Stop. Siblings that have not started are `skipped`; ones in flight see the aborted signal. */
|
|
56
|
+
| 'abort'
|
|
57
|
+
/** Record it as `failed` and keep going — a partial harvest is the point of a hive. */
|
|
58
|
+
| 'collect';
|
|
59
|
+
|
|
60
|
+
export const SKIPPED_ABORTED = 'a sibling failed and onMemberError is abort';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The OTHER way a member never runs, and a different fact: `split` produced nothing at this
|
|
64
|
+
* index. Its own string because `SKIPPED_ABORTED` named a cause that did not happen — a caller
|
|
65
|
+
* reading it retries the tail against a hive that stopped early, when the split is what to fix.
|
|
66
|
+
*/
|
|
67
|
+
export const SKIPPED_NO_INPUT = 'split produced no input at this index';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The member's `output` embedded verbatim in the `ok` arm, so a hive over `summarisePost` publishes
|
|
71
|
+
* a summary in its OpenAPI response and its MCP `outputSchema` — not an opaque object.
|
|
72
|
+
*
|
|
73
|
+
* A DISCRIMINATED union, not a plain one: `status` routes the parse, so a malformed `ok` reports
|
|
74
|
+
* that arm's issues instead of all three arms' at once.
|
|
75
|
+
*/
|
|
76
|
+
export function hiveResultSchema(output: AnySchema): AnySchema {
|
|
77
|
+
const member = t.discriminatedUnion(
|
|
78
|
+
'status',
|
|
79
|
+
t.object({ status: t.literal('ok'), index: t.number.int(), value: output }),
|
|
80
|
+
t.object({
|
|
81
|
+
status: t.literal('failed'),
|
|
82
|
+
index: t.number.int(),
|
|
83
|
+
code: t.string,
|
|
84
|
+
reason: t.string,
|
|
85
|
+
}),
|
|
86
|
+
t.object({ status: t.literal('skipped'), index: t.number.int(), reason: t.string }),
|
|
87
|
+
);
|
|
88
|
+
return t.object({
|
|
89
|
+
members: t.array(member),
|
|
90
|
+
ok: t.number.int(),
|
|
91
|
+
failed: t.number.int(),
|
|
92
|
+
skipped: t.number.int(),
|
|
93
|
+
tokens: t.number.int(),
|
|
94
|
+
cost: t.money,
|
|
95
|
+
});
|
|
96
|
+
}
|
package/src/hive.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hive()` — one action fanned out over many inputs, declared as an `action`.
|
|
3
|
+
*
|
|
4
|
+
* The fourth instance of the framework's factory rule, after `llm()`, `backfill()` and `agent()`:
|
|
5
|
+
* a fan-out is still one server-authoritative operation with an input schema, an output schema and
|
|
6
|
+
* a policy, so this returns an `action` and inherits `.tool()`, `.openapi()`, `.client()`,
|
|
7
|
+
* `.job()`, `.contract()` and its manifest row without a line here.
|
|
8
|
+
*
|
|
9
|
+
* It exists because the alternative is a hand-rolled `Promise.all` over `agent()` calls, and that
|
|
10
|
+
* loop gets four things wrong every time: it takes the actor from somewhere other than the request,
|
|
11
|
+
* it reports results in completion order, it cannot tell "ran and failed" from "never ran", and it
|
|
12
|
+
* has no ceiling — so the first bad split spends the whole budget in parallel.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
|
|
16
|
+
import { action, actionName } from '@ultimat3/action';
|
|
17
|
+
import type { Ctx } from '@ultimat3/core';
|
|
18
|
+
import { throwIfAborted, withSpan } from '@ultimat3/core';
|
|
19
|
+
import type { AnySchema, InferInput, InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
20
|
+
import type { BudgetLimits } from './budget';
|
|
21
|
+
import { BudgetLedger, currentBudget, withBudget } from './budget';
|
|
22
|
+
import { HiveEmptyError } from './hive-errors';
|
|
23
|
+
import { runPool } from './hive-pool';
|
|
24
|
+
import type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
25
|
+
import { hiveResultSchema } from './hive-result';
|
|
26
|
+
import type { LlmBudget } from './llm';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Members in flight at once when the declaration omits one. Small and deliberately arbitrary — it
|
|
30
|
+
* is a floor to start from, not a number measured off any run: the framework cannot know a
|
|
31
|
+
* provider's concurrency allowance, and a default read off one benchmark would be wrong for
|
|
32
|
+
* everybody else's account. Raise it in the declaration once the run demonstrably fits.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Below this many members the split is not fanned out at all. A member carries a fixed cost — a
|
|
38
|
+
* child context, a derived ledger, a whole model call's handshake — and paying it in parallel for
|
|
39
|
+
* one or two items buys nothing but a second way for the run to fail.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_MIN_MEMBERS = 2;
|
|
42
|
+
|
|
43
|
+
export interface HiveSplitArgs<TInput extends StandardSchemaV1> {
|
|
44
|
+
readonly input: InferOutput<TInput>;
|
|
45
|
+
readonly ctx: Ctx;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HiveDef<
|
|
49
|
+
TInput extends StandardSchemaV1,
|
|
50
|
+
MIn extends StandardSchemaV1,
|
|
51
|
+
MOut extends AnySchema,
|
|
52
|
+
> {
|
|
53
|
+
readonly input: TInput;
|
|
54
|
+
/**
|
|
55
|
+
* The action every member runs — an `agent()`, an `llm()`, or any action at all. Its own
|
|
56
|
+
* `policy` decides every member call and its own `input:` parses every member payload, which is
|
|
57
|
+
* what keeps a hive from being a second authz system.
|
|
58
|
+
*/
|
|
59
|
+
readonly member: Action<MIn, MOut>;
|
|
60
|
+
/**
|
|
61
|
+
* The one declared place a run decides what the members are. Derived from `input` and `ctx` and
|
|
62
|
+
* from NOTHING a model emitted — that boundary is the same one `agent()` holds, and the reason
|
|
63
|
+
* both belong in the framework rather than in a loop somebody writes per feature.
|
|
64
|
+
*/
|
|
65
|
+
split(
|
|
66
|
+
args: HiveSplitArgs<TInput>,
|
|
67
|
+
): readonly InferInput<MIn>[] | Promise<readonly InferInput<MIn>[]>;
|
|
68
|
+
/** Members in flight at once. */
|
|
69
|
+
readonly concurrency?: number;
|
|
70
|
+
/** Below this many members the split runs serially instead of fanning out. */
|
|
71
|
+
readonly minMembers?: number;
|
|
72
|
+
readonly onMemberError: HiveMemberError;
|
|
73
|
+
/** Ceilings for the WHOLE fan-out. `tokensPerRun` is the run's, every member counted. */
|
|
74
|
+
readonly budget?: LlmBudget & { readonly tokensPerRun?: number };
|
|
75
|
+
readonly policy: ActionPolicy;
|
|
76
|
+
readonly mcp?: ActionMcp;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function hive<
|
|
80
|
+
TInput extends StandardSchemaV1,
|
|
81
|
+
MIn extends StandardSchemaV1,
|
|
82
|
+
MOut extends AnySchema,
|
|
83
|
+
>(def: HiveDef<TInput, MIn, MOut>): Action<TInput, HiveOutput<MOut>> {
|
|
84
|
+
// The one cast in this file, and it narrows nothing at runtime: `hiveResultSchema` builds the
|
|
85
|
+
// shape `HiveResult<InferOutput<MOut>>` describes, and this restates that in the type system
|
|
86
|
+
// rather than making the caller infer it back out of a nested `t.discriminatedUnion`.
|
|
87
|
+
const output = hiveResultSchema(def.member.output) as HiveOutput<MOut>;
|
|
88
|
+
return action<TInput, HiveOutput<MOut>>({
|
|
89
|
+
input: def.input,
|
|
90
|
+
output,
|
|
91
|
+
policy: def.policy,
|
|
92
|
+
...(def.mcp === undefined ? {} : { mcp: def.mcp }),
|
|
93
|
+
handle: (args) => run(def, args),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function run<
|
|
98
|
+
TInput extends StandardSchemaV1,
|
|
99
|
+
MIn extends StandardSchemaV1,
|
|
100
|
+
MOut extends AnySchema,
|
|
101
|
+
>(
|
|
102
|
+
def: HiveDef<TInput, MIn, MOut>,
|
|
103
|
+
args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
|
|
104
|
+
): Promise<HiveResult<InferOutput<MOut>>> {
|
|
105
|
+
const name = actionName(def.member);
|
|
106
|
+
const inputs = await def.split({ input: args.input, ctx: args.ctx });
|
|
107
|
+
if (inputs.length === 0) throw new HiveEmptyError({ member: name });
|
|
108
|
+
|
|
109
|
+
const floor = def.minMembers ?? DEFAULT_MIN_MEMBERS;
|
|
110
|
+
// A split below the floor still runs every input it produced — dropping one would be silent
|
|
111
|
+
// data loss — it just stops paying for a pool to do it.
|
|
112
|
+
const width =
|
|
113
|
+
inputs.length < floor
|
|
114
|
+
? 1
|
|
115
|
+
: Math.max(1, Math.min(def.concurrency ?? DEFAULT_CONCURRENCY, inputs.length));
|
|
116
|
+
|
|
117
|
+
return withSpan('ai.hive', async (span) => {
|
|
118
|
+
span.setAttributes({
|
|
119
|
+
'hive.member': name,
|
|
120
|
+
'hive.members': inputs.length,
|
|
121
|
+
'hive.concurrency': width,
|
|
122
|
+
'hive.on_member_error': def.onMemberError,
|
|
123
|
+
});
|
|
124
|
+
const ledger = (currentBudget() ?? new BudgetLedger({ limits: {} })).derive(limitsOf(def));
|
|
125
|
+
const result = await withBudget(ledger, () =>
|
|
126
|
+
runPool<InferInput<MIn>, InferOutput<MOut>>({
|
|
127
|
+
inputs,
|
|
128
|
+
width,
|
|
129
|
+
ctx: args.ctx,
|
|
130
|
+
onMemberError: def.onMemberError,
|
|
131
|
+
member: (payload) => def.member(payload),
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
// The caller went away DURING the fan-out. Distinct from `onMemberError: 'abort'`, which is a
|
|
135
|
+
// completed run that stopped early and has a partial harvest worth returning: here there is
|
|
136
|
+
// nobody left to hand it to, so unwind the way every other abort in this package does.
|
|
137
|
+
throwIfAborted(args.ctx);
|
|
138
|
+
const report = await ledger.report();
|
|
139
|
+
const counts = tally(result);
|
|
140
|
+
span.setAttributes({ ...counts, 'hive.tokens': report.requestTokens });
|
|
141
|
+
return {
|
|
142
|
+
members: result,
|
|
143
|
+
ok: counts['hive.ok'],
|
|
144
|
+
failed: counts['hive.failed'],
|
|
145
|
+
skipped: counts['hive.skipped'],
|
|
146
|
+
tokens: report.requestTokens,
|
|
147
|
+
cost: report.cost,
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function tally<O>(members: readonly HiveMember<O>[]): {
|
|
153
|
+
'hive.ok': number;
|
|
154
|
+
'hive.failed': number;
|
|
155
|
+
'hive.skipped': number;
|
|
156
|
+
} {
|
|
157
|
+
return {
|
|
158
|
+
'hive.ok': members.filter((one) => one.status === 'ok').length,
|
|
159
|
+
'hive.failed': members.filter((one) => one.status === 'failed').length,
|
|
160
|
+
'hive.skipped': members.filter((one) => one.status === 'skipped').length,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function limitsOf<
|
|
165
|
+
TInput extends StandardSchemaV1,
|
|
166
|
+
MIn extends StandardSchemaV1,
|
|
167
|
+
MOut extends AnySchema,
|
|
168
|
+
>(def: HiveDef<TInput, MIn, MOut>): BudgetLimits {
|
|
169
|
+
const budget = def.budget;
|
|
170
|
+
return {
|
|
171
|
+
...(budget?.tokensIn === undefined ? {} : { tokensIn: budget.tokensIn }),
|
|
172
|
+
...(budget?.costPerCall === undefined ? {} : { costPerCall: budget.costPerCall }),
|
|
173
|
+
// The ledger's `request` scope accumulates across every call made under it, which for a hive
|
|
174
|
+
// under `withBudget` is every member's every turn.
|
|
175
|
+
...(budget?.tokensPerRun === undefined ? {} : { request: budget.tokensPerRun }),
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
/** Re-exported so an `llm` file needs one import, not two. Same object as schema's. */
|
|
5
5
|
export type { Infer } from '@ultimat3/schema';
|
|
6
6
|
export { t } from '@ultimat3/schema';
|
|
7
|
+
export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent';
|
|
8
|
+
export { agent } from './agent';
|
|
9
|
+
export type { AgentBudgetFact, AgentFact } from './agent-facts';
|
|
10
|
+
export { describeAgents, resetAgents } from './agent-facts';
|
|
11
|
+
export type { AgentJobOptions } from './agent-job';
|
|
12
|
+
export { agentJob } from './agent-job';
|
|
7
13
|
export type {
|
|
8
14
|
BudgetLedgerInput,
|
|
9
15
|
BudgetLimits,
|
|
@@ -30,24 +36,24 @@ export {
|
|
|
30
36
|
} from './embeddings';
|
|
31
37
|
export type { AiErrorCode } from './errors';
|
|
32
38
|
export {
|
|
39
|
+
AgentMaxTurnsError,
|
|
40
|
+
AgentToolUnexposedError,
|
|
33
41
|
AI_ERROR_CODES,
|
|
34
42
|
AI_ERROR_TITLES,
|
|
35
43
|
AiBudgetExceededError,
|
|
36
44
|
AiGatewayMissingError,
|
|
37
45
|
AiKeyMissingError,
|
|
46
|
+
AiModelUnknownError,
|
|
38
47
|
AiPromptRenderError,
|
|
48
|
+
AiPromptSecretError,
|
|
39
49
|
AiPromptVersionError,
|
|
40
50
|
AiProviderUnavailableError,
|
|
41
51
|
AiRequestInvalidError,
|
|
42
52
|
AiTransportError,
|
|
43
53
|
EmbedderDimMismatchError,
|
|
44
|
-
EvalBaselineInvalidError,
|
|
45
|
-
EvalBaselineMissingError,
|
|
46
|
-
EvalMissingError,
|
|
47
|
-
EvalRecordingError,
|
|
48
|
-
EvalThresholdError,
|
|
49
54
|
LlmOutputInvalidError,
|
|
50
55
|
LlmRefusedError,
|
|
56
|
+
LlmStreamInvalidError,
|
|
51
57
|
LlmTruncatedError,
|
|
52
58
|
VectorDimMismatchError,
|
|
53
59
|
VectorScopeWidenedError,
|
|
@@ -63,6 +69,13 @@ export {
|
|
|
63
69
|
regressionsAgainst,
|
|
64
70
|
writeBaseline,
|
|
65
71
|
} from './eval-baseline';
|
|
72
|
+
export {
|
|
73
|
+
EvalBaselineInvalidError,
|
|
74
|
+
EvalBaselineMissingError,
|
|
75
|
+
EvalMissingError,
|
|
76
|
+
EvalRecordingError,
|
|
77
|
+
EvalThresholdError,
|
|
78
|
+
} from './eval-errors';
|
|
66
79
|
export type {
|
|
67
80
|
CaseResult,
|
|
68
81
|
DefineEvalInput,
|
|
@@ -80,7 +93,12 @@ export {
|
|
|
80
93
|
} from './evals';
|
|
81
94
|
export type { CreateGatewayInput, Gateway, GatewayCache, RetryPolicy } from './gateway';
|
|
82
95
|
export { backoffMs, cacheKeyFor, createGateway, DEFAULT_RETRY, isRetryable } from './gateway';
|
|
96
|
+
export type { HiveDef, HiveSplitArgs } from './hive';
|
|
97
|
+
export { hive } from './hive';
|
|
98
|
+
export { HiveEmptyError } from './hive-errors';
|
|
99
|
+
export type { HiveMember, HiveMemberError, HiveOutput, HiveResult } from './hive-result';
|
|
83
100
|
export type {
|
|
101
|
+
LlmAction,
|
|
84
102
|
LlmBudget,
|
|
85
103
|
LlmCache,
|
|
86
104
|
LlmDef,
|
|
@@ -88,8 +106,25 @@ export type {
|
|
|
88
106
|
LlmVarsArgs,
|
|
89
107
|
} from './llm';
|
|
90
108
|
export { llm } from './llm';
|
|
109
|
+
export type { LlmStreamChunk } from './llm-stream';
|
|
91
110
|
export type { Effort, ModelId, ModelReasoning, ModelSpec, ThinkingMode } from './models';
|
|
92
|
-
export {
|
|
111
|
+
export {
|
|
112
|
+
ANTHROPIC_MODEL_IDS,
|
|
113
|
+
assertModel,
|
|
114
|
+
DEFAULT_MODEL,
|
|
115
|
+
EFFORTS,
|
|
116
|
+
isModelRegistered,
|
|
117
|
+
modelIds,
|
|
118
|
+
modelSpec,
|
|
119
|
+
moreCapableThan,
|
|
120
|
+
reasoningBody,
|
|
121
|
+
registeredModels,
|
|
122
|
+
registerModel,
|
|
123
|
+
resetModels,
|
|
124
|
+
} from './models';
|
|
125
|
+
export { OPENAI_MODEL_IDS, registerOpenAiModels } from './openai-models';
|
|
126
|
+
export type { OpenAiProviderInput } from './openai-provider';
|
|
127
|
+
export { openAiProvider } from './openai-provider';
|
|
93
128
|
export type { PgVectorStoreInput } from './pg-vector';
|
|
94
129
|
export { PgVectorStore } from './pg-vector';
|
|
95
130
|
export type {
|
|
@@ -118,6 +153,7 @@ export {
|
|
|
118
153
|
resetPrompts,
|
|
119
154
|
} from './prompt';
|
|
120
155
|
export type {
|
|
156
|
+
AiContentBlock,
|
|
121
157
|
AiMessage,
|
|
122
158
|
AnthropicProviderInput,
|
|
123
159
|
EchoProviderInput,
|
|
@@ -137,6 +173,7 @@ export {
|
|
|
137
173
|
estimateInputTokens,
|
|
138
174
|
estimateTextTokens,
|
|
139
175
|
estimateTokens,
|
|
176
|
+
messageText,
|
|
140
177
|
parseMessage,
|
|
141
178
|
requiresStreaming,
|
|
142
179
|
STREAM_ONLY_MAX_TOKENS,
|
|
@@ -150,10 +187,18 @@ export type {
|
|
|
150
187
|
RetrieveInput,
|
|
151
188
|
} from './rag';
|
|
152
189
|
export { assembleContext, chunk, indexDocument, passthroughReranker, retrieve } from './rag';
|
|
190
|
+
export { assertNoSecrets } from './redaction';
|
|
153
191
|
export type { RemoteEmbedderInput } from './remote-embedder';
|
|
154
192
|
export { RemoteEmbedder } from './remote-embedder';
|
|
155
|
-
export type { AiRuntimeInput } from './runtime';
|
|
156
|
-
export {
|
|
193
|
+
export type { AiRuntimeInput, Redactor } from './runtime';
|
|
194
|
+
export {
|
|
195
|
+
aiEmbedder,
|
|
196
|
+
aiGateway,
|
|
197
|
+
aiRedactor,
|
|
198
|
+
configureAi,
|
|
199
|
+
resetAiRuntime,
|
|
200
|
+
semanticCacheFor,
|
|
201
|
+
} from './runtime';
|
|
157
202
|
export type { Scorer } from './scorers';
|
|
158
203
|
export {
|
|
159
204
|
contains,
|
|
@@ -164,13 +209,14 @@ export {
|
|
|
164
209
|
numericTolerance,
|
|
165
210
|
} from './scorers';
|
|
166
211
|
export type {
|
|
212
|
+
AgentTool,
|
|
167
213
|
JsonSchema,
|
|
168
214
|
LlmTool,
|
|
169
215
|
LlmToolCall,
|
|
170
216
|
LlmToolResult,
|
|
171
217
|
ProjectableAction,
|
|
172
218
|
} from './tools';
|
|
173
|
-
export { runLlmToolCall, toLlmTool, toLlmTools } from './tools';
|
|
219
|
+
export { asProjectableAction, runLlmToolCall, toLlmTool, toLlmTools } from './tools';
|
|
174
220
|
export type {
|
|
175
221
|
HybridSearchInput,
|
|
176
222
|
MemoryVectorStoreInput,
|