@ultimat3/ai 2.0.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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The transcript one agent turn leaves behind: the assistant message replaying what the model
3
+ * emitted, and the user message answering it. One file because the Messages API's rule spans both
4
+ * halves — every `tool_use` block replayed here must be answered by a `tool_result` in the very
5
+ * next message — and a rule split across two call sites is a rule one of them will miss.
6
+ */
7
+
8
+ import { RESPOND } from './llm';
9
+ import type { AiContentBlock, AiMessage, GenerateResult } from './provider';
10
+ import type { LlmToolCall, LlmToolResult } from './tools';
11
+
12
+ /**
13
+ * The model's turn, replayed as the transcript the next request needs. `tool_use` blocks survive
14
+ * as themselves here — unlike `llm()`'s repair turn, which flattens them to text precisely
15
+ * because it has no `tool_result` to follow them with, and the API demands one.
16
+ */
17
+ export function assistantTurn(result: GenerateResult): AiMessage {
18
+ const blocks: AiContentBlock[] = [];
19
+ if (result.text !== '') blocks.push({ type: 'text', text: result.text });
20
+ for (const call of result.toolCalls) {
21
+ blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
22
+ }
23
+ // A turn with neither text nor a tool call would be an empty content array, which is a 400.
24
+ return blocks.length === 0
25
+ ? { role: 'assistant', content: '(no answer)' }
26
+ : { role: 'assistant', content: blocks };
27
+ }
28
+
29
+ /**
30
+ * What the model is told when it answered in the same turn it asked for a tool. Parallel tool use
31
+ * is normal, and the answer that comes with it was written before the tool result existed — so it
32
+ * is superseded, not wrong, and saying which is what stops the next turn repeating it verbatim.
33
+ */
34
+ const SUPERSEDED = `This answer arrived in the same turn as the tool calls above, so it was written before their results existed. Read the results in this message, then call the "${RESPOND}" tool again.`;
35
+
36
+ /**
37
+ * The tool results of one turn, plus a `tool_result` for every `respond` call the loop did not
38
+ * accept. The second half is not optional: `respond` is filtered out of the calls that get RUN,
39
+ * but it is replayed as a `tool_use` like any other, and a `tool_use` the next message does not
40
+ * answer is a 400 (`tool_use ids were found without tool_result blocks`) — the whole run lost to
41
+ * an `X_AI_PROVIDER_UNAVAILABLE` because the model did the ordinary thing.
42
+ *
43
+ * Real results stay first: they are what the model asked for, and the rejection reads as the
44
+ * instruction that follows them.
45
+ */
46
+ export function toolResultTurn(
47
+ results: readonly LlmToolResult[],
48
+ chars: number,
49
+ unaccepted: readonly LlmToolCall[],
50
+ ): AiMessage {
51
+ return {
52
+ role: 'user',
53
+ content: [
54
+ ...results.map((result) => ({
55
+ type: 'tool_result' as const,
56
+ tool_use_id: result.toolUseId,
57
+ content: truncate(result.content, chars),
58
+ // A denial or a failure is an outcome the model should read and react to, flagged so it
59
+ // does not read as data.
60
+ ...(result.isError === true ? { is_error: true } : {}),
61
+ })),
62
+ ...rejections(unaccepted, SUPERSEDED),
63
+ ],
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The correction after an answer that failed its schema, in whichever shape the turn it corrects
69
+ * demands: a `tool_result` when the answer came through `respond` (the dominant path, and a
70
+ * `tool_use` the API insists on seeing answered), a plain user message when the model answered in
71
+ * prose and there is no id to name. Framework text, so never truncated by `maxToolResultChars`.
72
+ */
73
+ export function repairTurn(unaccepted: readonly LlmToolCall[], issues: string): AiMessage {
74
+ const text = `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`;
75
+ return unaccepted.length === 0
76
+ ? { role: 'user', content: text }
77
+ : { role: 'user', content: rejections(unaccepted, text) };
78
+ }
79
+
80
+ /** A `tool_result` saying an answer was not taken, flagged so the model does not read it as data. */
81
+ function rejections(calls: readonly LlmToolCall[], text: string): readonly AiContentBlock[] {
82
+ return calls.map((call) => ({
83
+ type: 'tool_result' as const,
84
+ tool_use_id: call.id,
85
+ content: text,
86
+ is_error: true,
87
+ }));
88
+ }
89
+
90
+ /** Truncation says so. A silently shortened tool result is a model reasoning over half a table. */
91
+ function truncate(text: string, chars: number): string {
92
+ if (text.length <= chars) return text;
93
+ return `${text.slice(0, chars)}\n[truncated: ${text.length - chars} more characters]`;
94
+ }
package/src/agent.ts CHANGED
@@ -20,10 +20,14 @@
20
20
 
21
21
  import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
22
22
  import { action } from '@ultimat3/action';
23
- import type { Ctx } from '@ultimat3/core';
24
- import { withSpan } from '@ultimat3/core';
23
+ import type { Ctx, Span } from '@ultimat3/core';
24
+ import { isMcpExposed, throwIfAborted, withSpan } from '@ultimat3/core';
25
+ import type { Money } from '@ultimat3/money';
25
26
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
26
27
  import { formatIssues, validateAsync } from '@ultimat3/schema';
28
+ import type { AgentFact } from './agent-facts';
29
+ import { registerAgentFact } from './agent-facts';
30
+ import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
27
31
  import type { BudgetLimits } from './budget';
28
32
  import { BudgetLedger, currentBudget, withBudget } from './budget';
29
33
  import {
@@ -38,11 +42,17 @@ import { answerAttributes, RESPOND, respondToolFor, structuredOutputOf } from '.
38
42
  import type { ModelId } from './models';
39
43
  import { DEFAULT_MODEL, moreCapableThan } from './models';
40
44
  import type { Prompt, PromptVars } from './prompt';
41
- import type { AiContentBlock, AiMessage, GenerateRequest, GenerateResult } from './provider';
45
+ import type {
46
+ AiMessage,
47
+ GenerateRequest,
48
+ GenerateResult,
49
+ StopReason,
50
+ TokenUsage,
51
+ } from './provider';
42
52
  import { assertNoSecrets } from './redaction';
43
53
  import { aiGateway, aiRedactor } from './runtime';
44
- import type { LlmTool, LlmToolResult, ProjectableAction } from './tools';
45
- import { runLlmToolCall, toLlmTools } from './tools';
54
+ import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
55
+ import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
46
56
 
47
57
  /**
48
58
  * Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
@@ -91,7 +101,7 @@ export interface AgentDef<
91
101
  * exactly the same tools. Listing one that is not exposed is refused at declaration rather than
92
102
  * dropped, because a tool that reads as offered and silently is not is the worst of both.
93
103
  */
94
- readonly tools: readonly ProjectableAction[];
104
+ readonly tools: readonly AgentTool[];
95
105
  /** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
96
106
  readonly maxTurns?: number;
97
107
  readonly maxToolResultChars?: number;
@@ -100,6 +110,36 @@ export interface AgentDef<
100
110
  readonly mcp?: ActionMcp;
101
111
  /** Enforced completion ceiling PER TURN. The model never sees it. */
102
112
  readonly maxTokens?: number;
113
+ /**
114
+ * Called once per completed model turn, before the answer or the tool calls are acted on. The
115
+ * one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
116
+ * it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
117
+ * hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
118
+ *
119
+ * Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
120
+ * actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
121
+ * run's own path, and an observer that silently stopped working would be indistinguishable from
122
+ * one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
123
+ * declares no hook is still readable in a trace.
124
+ *
125
+ * NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
126
+ * one turn deep by construction; this is per TURN.
127
+ */
128
+ onTurn?(event: AgentTurn): void | Promise<void>;
129
+ }
130
+
131
+ /** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
132
+ export interface AgentTurn {
133
+ /** 1-based, so `turn === maxTurns` is the last one this run will take. */
134
+ readonly turn: number;
135
+ readonly maxTurns: number;
136
+ readonly model: ModelId;
137
+ /** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
138
+ readonly toolCalls: readonly string[];
139
+ readonly stopReason: StopReason;
140
+ readonly usage: TokenUsage;
141
+ /** This turn's cost alone, integer minor units — never the run's running total. */
142
+ readonly cost: Money;
103
143
  }
104
144
 
105
145
  export function agent<
@@ -109,21 +149,69 @@ export function agent<
109
149
  >(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
110
150
  const respond = respondToolFor(def.output);
111
151
  // At declaration, because the tools are values by then and a run that discovers this at the
112
- // first request has already been declared, registered and projected as if it worked.
113
- const offered = toLlmTools(def.tools);
114
- if (offered.length !== def.tools.length) {
152
+ // first request has already been declared, registered and projected as if it worked. Asked of
153
+ // the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
154
+ // `action()` beside this one in the same module has none until `registerActions` runs at boot.
155
+ const unexposed = def.tools.filter((tool) => !isMcpExposed(tool.mcp));
156
+ if (unexposed.length > 0) {
115
157
  throw new AgentToolUnexposedError({
116
158
  agent: def.prompt.ref,
117
- tools: def.tools.filter((a) => !offered.some((o) => o.name === a.name)).map((a) => a.name),
159
+ tools: unexposed.map(toolLabel),
118
160
  });
119
161
  }
120
- return action<TInput, TOutput>({
162
+ // Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
163
+ // scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
164
+ // the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
165
+ let adapted: Adapted | undefined;
166
+ const adapt = (): Adapted => {
167
+ if (adapted === undefined) adapted = project(def.tools);
168
+ return adapted;
169
+ };
170
+ const built = action<TInput, TOutput>({
121
171
  input: def.input,
122
172
  output: def.output,
123
173
  policy: def.policy,
124
174
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
125
- handle: (args) => run(def, respond, offered, args),
175
+ handle: (args) => run(def, respond, adapt(), args),
126
176
  });
177
+ // A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
178
+ // line runs at module scope.
179
+ registerAgentFact(built, () => factsOf(def));
180
+ return built;
181
+ }
182
+
183
+ function factsOf<
184
+ TInput extends StandardSchemaV1,
185
+ TOutput extends StandardSchemaV1,
186
+ V extends PromptVars,
187
+ >(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
188
+ const budget = def.budget;
189
+ return {
190
+ prompt: def.prompt.ref,
191
+ promptId: def.prompt.id,
192
+ promptHash: def.prompt.hash,
193
+ model: def.model ?? def.prompt.model ?? DEFAULT_MODEL,
194
+ maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
195
+ maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
196
+ tools: [...def.tools.map(toolLabel)].sort(),
197
+ budget: {
198
+ tokensIn: budget?.tokensIn ?? null,
199
+ tokensPerRun: budget?.tokensPerRun ?? null,
200
+ costPerCall: budget?.costPerCall ?? null,
201
+ },
202
+ mcp: isMcpExposed(def.mcp),
203
+ };
204
+ }
205
+
206
+ /** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
207
+ interface Adapted {
208
+ readonly tools: readonly ProjectableAction[];
209
+ readonly offered: readonly LlmTool[];
210
+ }
211
+
212
+ function project(tools: readonly AgentTool[]): Adapted {
213
+ const projected = tools.map(asProjectableAction);
214
+ return { tools: projected, offered: toLlmTools(projected) };
127
215
  }
128
216
 
129
217
  async function run<
@@ -133,7 +221,7 @@ async function run<
133
221
  >(
134
222
  def: AgentDef<TInput, TOutput, V>,
135
223
  respond: LlmTool,
136
- offered: readonly LlmTool[],
224
+ adapted: Adapted,
137
225
  args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
138
226
  ): Promise<InferOutput<TOutput>> {
139
227
  const { prompt } = def;
@@ -153,7 +241,7 @@ async function run<
153
241
  'agent.model': model,
154
242
  'agent.prompt': name,
155
243
  'agent.prompt.hash': prompt.hash,
156
- 'agent.tools': offered.length,
244
+ 'agent.tools': adapted.offered.length,
157
245
  'agent.max_turns': maxTurns,
158
246
  'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
159
247
  });
@@ -167,7 +255,10 @@ async function run<
167
255
  maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
168
256
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
169
257
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
170
- tools: [...offered, respond],
258
+ tools: [...adapted.offered, respond],
259
+ // The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
260
+ // not just the top of the next turn: a provider call already in flight is the expensive one.
261
+ signal: args.ctx.signal,
171
262
  };
172
263
 
173
264
  return withBudget(ledger, async () => {
@@ -180,20 +271,57 @@ async function run<
180
271
  let issues: string | undefined;
181
272
 
182
273
  for (let turn = 1; turn <= maxTurns; turn += 1) {
274
+ // The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
275
+ // transcript, so a loop that keeps going after a disconnect spends the rest of the run's
276
+ // budget, runs every remaining tool's side effects, and discards the answer.
277
+ throwIfAborted(args.ctx);
183
278
  const result = await gateway.generate({ ...base, messages });
279
+ const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
280
+ // The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
281
+ // `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
282
+ const answers = result.toolCalls.filter((call) => call.name === RESPOND);
184
283
  span.setAttributes({
185
284
  'agent.turns': turn,
186
285
  'agent.tool_calls': calls,
187
286
  ...answerAttributes(result),
188
287
  });
288
+ await observeTurn(def, span, {
289
+ turn,
290
+ maxTurns,
291
+ model: result.model,
292
+ toolCalls: requested.map((call) => call.name),
293
+ stopReason: result.stopReason,
294
+ usage: result.usage,
295
+ cost: result.cost,
296
+ });
189
297
  assertAnswerable(result, name);
190
298
 
191
- const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
192
299
  if (requested.length > 0) {
193
- const results: LlmToolResult[] = [];
194
- for (const call of requested) results.push(await runLlmToolCall(def.tools, call, actor));
300
+ // Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
301
+ // model turn asked for, each entry is an ordinary `action` carrying its own policy and
302
+ // its own `rateLimit`, and a second ceiling here would be a throttle competing with
303
+ // those. A tool that calls a model still queues on the ledger's root turnstile, so the
304
+ // budget holds. Serial cost 5x wall clock for a turn that asked for 5 tools, and nothing
305
+ // in the types or the docs ever said so.
306
+ //
307
+ // Order is by INDEX, not by completion: `Promise.all` resolves positionally and each
308
+ // result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
309
+ // first cannot be paired with a slow tool's call.
310
+ throwIfAborted(args.ctx);
311
+ const results: readonly LlmToolResult[] = await Promise.all(
312
+ requested.map((call) => runLlmToolCall(adapted.tools, call, actor)),
313
+ );
195
314
  calls += requested.length;
196
- messages = [...messages, assistantTurn(result), toolResults(results, chars)];
315
+ messages = [
316
+ ...messages,
317
+ assistantTurn(result),
318
+ // Parallel tool use — one turn asking for a tool AND answering — is normal, and the
319
+ // answer is speculative: it was written before the result it asked for existed. So
320
+ // the loop continues, and the answer is REJECTED on the wire rather than dropped,
321
+ // because a replayed `respond` block with no `tool_result` is a 400 and the whole run
322
+ // becomes an `X_AI_PROVIDER_UNAVAILABLE`.
323
+ toolResultTurn(results, chars, answers),
324
+ ];
197
325
  continue;
198
326
  }
199
327
 
@@ -205,7 +333,7 @@ async function run<
205
333
  if (result.stopReason === 'max_tokens') {
206
334
  throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
207
335
  }
208
- messages = [...messages, assistantTurn(result), { role: 'user', content: repair(issues) }];
336
+ messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
209
337
  }
210
338
 
211
339
  // Two different exhaustions, so two different causes: a loop that kept calling tools and
@@ -218,6 +346,28 @@ async function run<
218
346
  });
219
347
  }
220
348
 
349
+ /**
350
+ * One turn, reported twice: onto the span every run already opens, and to the declaration's own
351
+ * hook. The span event is free — nothing to install, and a trace is where a stalled run is
352
+ * actually diagnosed — and the hook is what a progress indicator or a per-turn spend line reads.
353
+ *
354
+ * Awaited, and not guarded: a throw from `onTurn` fails the run. It is the app's code on the run's
355
+ * path, and an observer that quietly stopped working reads exactly like one that is fine.
356
+ */
357
+ async function observeTurn<
358
+ TInput extends StandardSchemaV1,
359
+ TOutput extends StandardSchemaV1,
360
+ V extends PromptVars,
361
+ >(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
362
+ span.addEvent('agent.turn', {
363
+ 'agent.turn': event.turn,
364
+ 'agent.turn.tool_calls': event.toolCalls.length,
365
+ 'llm.stop': event.stopReason,
366
+ 'llm.cost.minor': event.cost.minor,
367
+ });
368
+ await def.onTurn?.(event);
369
+ }
370
+
221
371
  /** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
222
372
  function assertAnswerable(result: GenerateResult, name: string): void {
223
373
  if (result.stopReason !== 'refusal') return;
@@ -230,47 +380,6 @@ function assertAnswerable(result: GenerateResult, name: string): void {
230
380
  });
231
381
  }
232
382
 
233
- /**
234
- * The model's turn, replayed as the transcript the next request needs. `tool_use` blocks survive
235
- * as themselves here — unlike `llm()`'s repair turn, which flattens them to text precisely
236
- * because it has no `tool_result` to follow them with, and the API demands one.
237
- */
238
- function assistantTurn(result: GenerateResult): AiMessage {
239
- const blocks: AiContentBlock[] = [];
240
- if (result.text !== '') blocks.push({ type: 'text', text: result.text });
241
- for (const call of result.toolCalls) {
242
- blocks.push({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
243
- }
244
- // A turn with neither text nor a tool call would be an empty content array, which is a 400.
245
- return blocks.length === 0
246
- ? { role: 'assistant', content: '(no answer)' }
247
- : { role: 'assistant', content: blocks };
248
- }
249
-
250
- function toolResults(results: readonly LlmToolResult[], chars: number): AiMessage {
251
- return {
252
- role: 'user',
253
- content: results.map((result) => ({
254
- type: 'tool_result' as const,
255
- tool_use_id: result.toolUseId,
256
- content: truncate(result.content, chars),
257
- // A denial or a failure is an outcome the model should read and react to, flagged so it
258
- // does not read as data.
259
- ...(result.isError === true ? { is_error: true } : {}),
260
- })),
261
- };
262
- }
263
-
264
- /** Truncation says so. A silently shortened tool result is a model reasoning over half a table. */
265
- function truncate(text: string, chars: number): string {
266
- if (text.length <= chars) return text;
267
- return `${text.slice(0, chars)}\n[truncated: ${text.length - chars} more characters]`;
268
- }
269
-
270
- function repair(issues: string): string {
271
- return `That answer failed its schema: ${issues}. Call the "${RESPOND}" tool with a value that satisfies it. Answer only through the tool.`;
272
- }
273
-
274
383
  function limitsOf<
275
384
  TInput extends StandardSchemaV1,
276
385
  TOutput extends StandardSchemaV1,
package/src/errors.ts CHANGED
@@ -18,6 +18,7 @@ export const AI_ERROR_CODES = [
18
18
  'X_LLM_STREAM_INVALID',
19
19
  'X_AGENT_MAX_TURNS',
20
20
  'X_AGENT_TOOL_UNEXPOSED',
21
+ 'X_HIVE_EMPTY',
21
22
  'X_EVAL_THRESHOLD',
22
23
  'X_EVAL_BASELINE_MISSING',
23
24
  'X_EVAL_BASELINE_INVALID',
@@ -45,6 +46,7 @@ export const AI_ERROR_TITLES: Readonly<Record<AiErrorCode, string>> = {
45
46
  X_LLM_STREAM_INVALID: 'a streamed answer failed its output schema, and a stream cannot repair',
46
47
  X_AGENT_MAX_TURNS: 'an agent hit its turn ceiling without answering',
47
48
  X_AGENT_TOOL_UNEXPOSED: 'an agent lists a tool that is not an MCP-exposed action',
49
+ X_HIVE_EMPTY: 'a hive split produced no members, so the run did nothing',
48
50
  X_EVAL_THRESHOLD: 'an eval scored below its tolerance',
49
51
  X_EVAL_BASELINE_MISSING: 'an eval has no recorded baseline to gate against',
50
52
  X_EVAL_BASELINE_INVALID: 'a recorded baseline cannot be read',
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';
@@ -183,7 +184,11 @@ class GatewayImpl implements Gateway {
183
184
  try {
184
185
  return { ...(await call(provider)), provider: provider.name };
185
186
  } catch (error) {
186
- failures.push(`${provider.name}#${attempt}: ${messageOf(error)}`);
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)}`);
187
192
  if (!isRetryable(error) || attempt === this.retry.attempts) break;
188
193
  await this.sleep(backoffMs(this.retry, attempt));
189
194
  }
@@ -205,14 +210,17 @@ export function backoffMs(policy: RetryPolicy, attempt: number): number {
205
210
  */
206
211
  export function isRetryable(error: unknown): boolean {
207
212
  if (typeof error !== 'object' || error === null) return false;
208
- const e = error as { status?: unknown; code?: unknown };
209
- if (typeof e.status === 'number') return e.status === 429 || e.status >= 500;
210
- return e.code === 'ETIMEDOUT' || e.code === 'ECONNRESET';
211
- }
212
-
213
- function messageOf(error: unknown): string {
214
- if (error instanceof Error) return error.message;
215
- return String(error);
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
+ }
216
224
  }
217
225
 
218
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
+ }
@@ -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
+ }