@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.
- package/CLAUDE.md +161 -3
- package/README.md +139 -1
- package/package.json +10 -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 +170 -61
- package/src/errors.ts +2 -0
- package/src/gateway.ts +17 -9
- 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 +11 -2
- package/src/openai-provider.ts +17 -3
- package/src/provider.ts +18 -3
- package/src/tools.ts +95 -8
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
114
|
-
|
|
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:
|
|
159
|
+
tools: unexposed.map(toolLabel),
|
|
118
160
|
});
|
|
119
161
|
}
|
|
120
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
for
|
|
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 = [
|
|
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),
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
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
|
+
}
|