@ultimat3/ai 27.8.1 → 27.10.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/src/agent.ts CHANGED
@@ -20,169 +20,73 @@
20
20
  * one run's world state to another. Version bump plus the tools' own caching is the story.
21
21
  */
22
22
 
23
- import type { Action, ActionMcp, ActionPolicy } from '@ultimat3/action';
23
+ import type { Action } from '@ultimat3/action';
24
24
  import { action } from '@ultimat3/action';
25
- import type { Ctx, Span } from '@ultimat3/core';
26
- import { finiteCount, isMcpExposed, throwIfAborted, withSpan } from '@ultimat3/core';
27
- import type { Money } from '@ultimat3/money';
25
+ import type { Ctx } from '@ultimat3/core';
26
+ import { throwIfAborted, withSpan } from '@ultimat3/core';
28
27
  import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
29
28
  import { formatIssues, validateAsync } from '@ultimat3/schema';
30
- import type { AgentFact } from './agent-facts';
29
+ import type { AgentAction, TranscriptBinding } from './agent-continue';
30
+ import {
31
+ claimAgentSession,
32
+ continued,
33
+ screenTranscript,
34
+ sealTranscript,
35
+ sessionCalls,
36
+ withTranscript,
37
+ } from './agent-continue';
38
+ import type { AgentBounds } from './agent-declared';
39
+ import { factsOf, limitsOf, SUBJECT, screenBounds } from './agent-declared';
40
+ import type { AgentDef } from './agent-def';
41
+ import { AgentTranscriptInvalidError } from './agent-errors';
31
42
  import { registerAgentFact } from './agent-facts';
43
+ import { runDeadline, toolLoopGuard } from './agent-guards';
44
+ import { sameSystem, systemOf } from './agent-system';
32
45
  import { assistantTurn, repairTurn, toolResultTurn } from './agent-transcript';
33
- import type { BudgetLimits } from './budget';
34
- import { budgetKeysFor, currentBudget, screenedMoney, withBudget } from './budget';
35
- import {
36
- AgentMaxTurnsError,
37
- AgentToolUnexposedError,
38
- LlmOutputInvalidError,
39
- LlmRefusedError,
40
- LlmTruncatedError,
41
- } from './errors';
42
- import type { LlmBudget } from './llm';
46
+ import { assertAnswerable, observeTurn } from './agent-turn';
47
+ import { runTotal } from './agent-usage';
48
+ import { LlmTruncatedError } from './answer-errors';
49
+ import { budgetKeysFor, currentBudget, withBudget } from './budget';
50
+ import { AgentMaxTurnsError, AgentToolUnexposedError, LlmOutputInvalidError } from './errors';
43
51
  import { answerAttributes } from './llm';
44
- import { describedModel, type ModelSource, modelForCall } from './model-resolve';
45
- import type { ModelId } from './models';
46
- import { moreCapableThan } from './models';
47
- import type { Prompt, PromptVars } from './prompt';
48
- import type {
49
- AiMessage,
50
- GenerateRequest,
51
- GenerateResult,
52
- StopReason,
53
- TokenUsage,
54
- } from './provider';
52
+ import { modelForCall } from './model-resolve';
53
+ import type { PromptVars } from './prompt';
54
+ import { promptCacheHint } from './prompt-cache';
55
+ import type { AiMessage, GenerateRequest } from './provider';
55
56
  import { isTruncated } from './provider';
56
57
  import { assertNoSecrets } from './redaction';
57
58
  import type { Respond } from './respond';
58
59
  import { RESPOND, respondFor } from './respond';
60
+ import { runMeter } from './run-meter';
59
61
  import { aiGateway, aiRedactor, installedGateway } from './runtime';
62
+ import type { Sampling } from './sampling';
63
+ import { ignoredSampling, screenSampling } from './sampling';
60
64
  import type { AgentTool, LlmTool, LlmToolResult, ProjectableAction } from './tools';
61
- import { asProjectableAction, runLlmToolCall, toLlmTools, toolLabel } from './tools';
62
-
63
- /**
64
- * Turn ceiling when the declaration omits one. Low on purpose: a loop that needs more than this
65
- * is usually a loop with no exit condition, and every turn re-sends the whole transcript, so cost
66
- * grows quadratically in turns rather than linearly.
67
- */
68
- const DEFAULT_MAX_TURNS = 8;
69
-
70
- /** Output ceiling per turn when the declaration omits one. Same reasoning as `llm()`'s. */
71
- const DEFAULT_MAX_TOKENS = 4_096;
72
-
73
- /**
74
- * Characters of ONE tool result the model may read. A tool that returns a 2MB row set otherwise
75
- * spends the whole context window on turn two and every later turn re-sends it — the transcript
76
- * is the request, so an untruncated result is billed once per remaining turn.
77
- */
78
- const DEFAULT_TOOL_RESULT_CHARS = 4_000;
79
-
80
- /** Named in every bound refusal, so the fix names the key the declaration carries. */
81
- const SUBJECT = 'agent';
82
-
83
- export interface AgentBudget extends LlmBudget {
84
- /**
85
- * Token ceiling for the WHOLE run, every turn counted. The one ceiling `llm()` does not need:
86
- * a single call is bounded by `maxTokens`, a loop is bounded by nothing until this is set.
87
- */
88
- readonly tokensPerRun?: number;
89
- }
90
-
91
- export interface AgentVarsArgs<TInput extends StandardSchemaV1> {
92
- readonly input: InferOutput<TInput>;
93
- readonly ctx: Ctx;
94
- }
95
-
96
- export interface AgentDef<
97
- TInput extends StandardSchemaV1,
98
- TOutput extends StandardSchemaV1,
99
- V extends PromptVars,
100
- > {
101
- readonly model?: ModelId;
102
- readonly input: TInput;
103
- readonly output: TOutput;
104
- readonly prompt: Prompt<V>;
105
- /** Same contract as `llm()`: the one declared place a run loads data. */
106
- vars(args: AgentVarsArgs<TInput>): V | Promise<V>;
107
- /**
108
- * The actions the model may call. Each must be `mcp: { expose: true }` — the same predicate an
109
- * external MCP client is filtered by, so an in-app agent and an external one are offered
110
- * exactly the same tools. Listing one that is not exposed is refused at declaration rather than
111
- * dropped, because a tool that reads as offered and silently is not is the worst of both.
112
- */
113
- readonly tools: readonly AgentTool[];
114
- /** Hard ceiling on model turns. Reaching it is `X_AGENT_MAX_TURNS`, never a partial answer. */
115
- readonly maxTurns?: number;
116
- readonly maxToolResultChars?: number;
117
- readonly budget?: AgentBudget;
118
- readonly policy: ActionPolicy;
119
- readonly mcp?: ActionMcp;
120
- /** Enforced completion ceiling PER TURN. The model never sees it. */
121
- readonly maxTokens?: number;
122
- /**
123
- * Called once per completed model turn, before the answer or the tool calls are acted on. The
124
- * one thing a multi-turn run could not do until 2026-08: a 90-second loop emitted nothing until
125
- * it returned, so a progress indicator, a per-turn spend line and a transcript log all had to be
126
- * hand-rolled outside the framework — which is exactly the loop `agent()` exists to replace.
127
- *
128
- * Observation only: it cannot steer the loop, cannot see the transcript and cannot reach the
129
- * actor. A throw from it FAILS the run rather than being swallowed — it is the app's code on the
130
- * run's own path, and an observer that silently stopped working would be indistinguishable from
131
- * one that is fine. The same facts land on the span as an `agent.turn` event, so a run that
132
- * declares no hook is still readable in a trace.
133
- *
134
- * NOT `.stream()`. Tokens on a screen is a different contract, and `llm()`'s streamed half is
135
- * one turn deep by construction; this is per TURN.
136
- */
137
- onTurn?(event: AgentTurn): void | Promise<void>;
138
- }
65
+ import { asProjectableAction, isOfferedTool, runLlmToolCall, toLlmTools, toolLabel } from './tools';
139
66
 
140
- /** One completed model turn, as an observer sees it. Facts only — no transcript, no actor. */
141
- export interface AgentTurn {
142
- /** 1-based, so `turn === maxTurns` is the last one this run will take. */
143
- readonly turn: number;
144
- readonly maxTurns: number;
145
- readonly model: ModelId;
146
- /** Tools this turn asked for, in the order the model emitted them. `respond` is not one. */
147
- readonly toolCalls: readonly string[];
148
- readonly stopReason: StopReason;
149
- readonly usage: TokenUsage;
150
- /** This turn's cost alone, integer minor units — never the run's running total. */
151
- readonly cost: Money;
152
- }
67
+ export type { AgentBudget, AgentDef, AgentTurn, AgentVarsArgs } from './agent-def';
153
68
 
154
69
  export function agent<
155
70
  TInput extends StandardSchemaV1,
156
71
  TOutput extends StandardSchemaV1,
157
72
  V extends PromptVars,
158
- >(def: AgentDef<TInput, TOutput, V>): Action<TInput, TOutput> {
73
+ >(def: AgentDef<TInput, TOutput, V>): AgentAction<TInput, TOutput> {
159
74
  const respond = respondFor(def.output);
160
75
  // At declaration, because the tools are values by then and a run that discovers this at the
161
76
  // first request has already been declared, registered and projected as if it worked. Asked of
162
77
  // the DECLARATION rather than of the projection: `isMcpExposed` needs no name, and a real
163
78
  // `action()` beside this one in the same module has none until `registerActions` runs at boot.
164
- const unexposed = def.tools.filter((tool) => !isMcpExposed(tool.mcp));
79
+ const unexposed = def.tools.filter((tool) => !isOfferedTool(tool));
165
80
  if (unexposed.length > 0) {
166
81
  throw new AgentToolUnexposedError({
167
82
  agent: def.prompt.ref,
168
83
  tools: unexposed.map(toolLabel),
169
84
  });
170
85
  }
171
- // The three loop bounds, screened at DECLARATION for the same reason the tool check above is:
172
- // the values are here, and an `agent()` runs at module scope, so a bound that is not one fails
173
- // the boot instead of the ninetieth second of a run. None of them is checked by what it lands
174
- // on — `turn <= NaN` is false on the FIRST comparison, so the loop takes zero turns and reports
175
- // `X_AGENT_MAX_TURNS` about a model that was never called; `slice(0, NaN)` is `''`, so every
176
- // tool result reaches the model empty while the run keeps paying for turns; and `maxTokens`
177
- // becomes the pre-flight estimate, where a `NaN` disables the ledger for the whole process.
178
- finiteCount(SUBJECT, 'maxTurns', def.maxTurns ?? DEFAULT_MAX_TURNS, 1);
179
- finiteCount(
180
- SUBJECT,
181
- 'maxToolResultChars',
182
- def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
183
- 1,
184
- );
185
- finiteCount(SUBJECT, 'maxTokens', def.maxTokens ?? DEFAULT_MAX_TOKENS, 1);
86
+ const bounds = screenBounds(def);
87
+ // This declaration's identity for `.run()` / `.continue()` sessions (`claimAgentSession`).
88
+ const owner = {};
89
+ const sampling = screenSampling(SUBJECT, def);
186
90
  // Projected on FIRST RUN, memoised, for the reason above: `agent()` is evaluated at module
187
91
  // scope and a tool's name is stamped by `registerAction` at boot, so naming it here would make
188
92
  // the ordinary `export const publishPost = action(...)` beside it `X_ACTION_UNREGISTERED`.
@@ -196,48 +100,27 @@ export function agent<
196
100
  output: def.output,
197
101
  policy: def.policy,
198
102
  ...(def.mcp === undefined ? {} : { mcp: def.mcp }),
199
- handle: (args) => run(def, respond, adapt(), args),
103
+ handle: (args) => run(def, respond, adapt(), { ...bounds, sampling, owner }, args),
200
104
  });
201
105
  // A thunk, resolved when the manifest asks: every name in the row is stamped at boot, and this
202
106
  // line runs at module scope.
203
107
  registerAgentFact(built, () => factsOf(def));
204
- return built;
108
+ return continuable(built, owner);
205
109
  }
206
110
 
207
- // `null`, not `undefined`, for a row naming no model yet: the row is a published fact, and an
208
- // absent key reads as a reader that forgot to ask.
209
- const modelFact = (described: {
210
- readonly model: string | undefined;
211
- readonly from: ModelSource | undefined;
212
- }): Pick<AgentFact, 'model' | 'modelFrom'> => ({
213
- model: described.model ?? null,
214
- modelFrom: described.from ?? null,
215
- });
216
-
217
- function factsOf<
218
- TInput extends StandardSchemaV1,
219
- TOutput extends StandardSchemaV1,
220
- V extends PromptVars,
221
- >(def: AgentDef<TInput, TOutput, V>): Omit<AgentFact, 'name'> {
222
- const budget = def.budget;
223
- return {
224
- prompt: def.prompt.ref,
225
- promptId: def.prompt.id,
226
- promptHash: def.prompt.hash,
227
- // The run's own precedence, so the row names the model a call would use NOW: read when the
228
- // facts are, at describe time, like every name in the row. `modelFrom` says which place
229
- // answered — `gateway` (or `null`, none yet) depends on the gateway installed at that moment.
230
- ...modelFact(describedModel(def.model, def.prompt.model, installedGateway()?.defaultModel)),
231
- maxTurns: def.maxTurns ?? DEFAULT_MAX_TURNS,
232
- maxToolResultChars: def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS,
233
- tools: [...def.tools.map(toolLabel)].sort(),
234
- budget: {
235
- tokensIn: budget?.tokensIn ?? null,
236
- tokensPerRun: budget?.tokensPerRun ?? null,
237
- costPerCall: budget?.costPerCall ?? null,
238
- },
239
- mcp: isMcpExposed(def.mcp),
240
- };
111
+ /**
112
+ * Attach `.run()` and `.continue()` IN PLACE, for the reason `llm()`'s `streamable` gives: the
113
+ * object an app exported is the one the registry names, and `named()`'s twin gets them too.
114
+ */
115
+ function continuable<TInput extends StandardSchemaV1, TOutput extends StandardSchemaV1>(
116
+ target: Action<TInput, TOutput>,
117
+ owner: object,
118
+ ): AgentAction<TInput, TOutput> {
119
+ const rename = target.named.bind(target);
120
+ return Object.assign(target, {
121
+ ...sessionCalls(target, owner),
122
+ named: (next: string): AgentAction<TInput, TOutput> => continuable(rename(next), owner),
123
+ });
241
124
  }
242
125
 
243
126
  /** The tools as `runLlmToolCall` consumes them, and as the request offers them. One projection. */
@@ -259,22 +142,50 @@ async function run<
259
142
  def: AgentDef<TInput, TOutput, V>,
260
143
  respond: Respond,
261
144
  adapted: Adapted,
145
+ bounds: AgentBounds & { readonly sampling: Sampling; readonly owner: object },
262
146
  args: { readonly input: InferOutput<TInput>; readonly ctx: Ctx },
263
147
  ): Promise<InferOutput<TOutput>> {
264
148
  const { prompt } = def;
265
149
  const name = prompt.ref;
150
+ // A `.continue()` in flight: its handle is the app's stored data, so it is screened before
151
+ // anything is loaded, reserved or sent.
152
+ const session = claimAgentSession(bounds.owner);
153
+ const resume = session?.resume;
154
+ if (resume !== undefined && resume.user.trim() === '') {
155
+ throw new AgentTranscriptInvalidError({ agent: name, reason: 'the user turn is empty' });
156
+ }
266
157
  // Screened first: a bad declaration is refused whether or not a gateway exists.
267
- const limits = limitsOf(def);
158
+ limitsOf(def);
268
159
  // The declaration, its prompt, then the gateway's `defaultModel` — the app's three places.
269
160
  const model = modelForCall('agent', name, def.model, prompt.model);
270
- const vars = await def.vars({ input: args.input, ctx: args.ctx });
271
- assertNoSecrets(name, vars);
161
+ // What a handle is bound to, read off the declaration and the request — never off the handle.
162
+ const binding: TranscriptBinding = {
163
+ agent: name,
164
+ model,
165
+ tools: [...adapted.tools.map((tool) => tool.name), RESPOND],
166
+ org: args.ctx.actor.orgId ?? null,
167
+ };
168
+ const prior = resume === undefined ? undefined : screenTranscript(binding, resume.transcript);
169
+ const limits = limitsOf(def, prior?.usage);
170
+ // Armed before anything is loaded: `vars()` is part of the run a deadline bounds.
171
+ const deadline = runDeadline(name, def.deadlineMs, args.ctx.signal);
272
172
  const redact = aiRedactor();
273
- const rawPrompt = prompt.render(vars);
173
+ // A continuation does not render the prompt again: the transcript holds what was sent, and
174
+ // `vars()` — the load — is not run a second time. Its own turn is the caller's text.
175
+ let rawPrompt = resume?.user;
176
+ if (rawPrompt === undefined) {
177
+ const vars = await deadline.guard(Promise.resolve(def.vars(args)));
178
+ assertNoSecrets(name, vars);
179
+ rawPrompt = prompt.render(vars);
180
+ }
274
181
  const rendered = redact(rawPrompt);
275
- const system = prompt.system === undefined ? undefined : redact(prompt.system);
276
- const maxTurns = def.maxTurns ?? DEFAULT_MAX_TURNS;
277
- const chars = def.maxToolResultChars ?? DEFAULT_TOOL_RESULT_CHARS;
182
+ const extra =
183
+ def.system === undefined ? {} : await deadline.guard(Promise.resolve(def.system(args)));
184
+ const rawSystem = systemOf(prompt.system, extra, (text) => text);
185
+ const system = systemOf(prompt.system, extra, redact);
186
+ const promptCache = promptCacheHint(def.promptCache, 'auto', args);
187
+ const repeated = toolLoopGuard(name, bounds.maxRepeatedToolCalls);
188
+ const { maxTurns, maxToolResultChars: chars } = bounds;
278
189
 
279
190
  return withSpan('ai.agent', async (span) => {
280
191
  span.setAttributes({
@@ -283,7 +194,13 @@ async function run<
283
194
  'agent.prompt.hash': prompt.hash,
284
195
  'agent.tools': adapted.offered.length,
285
196
  'agent.max_turns': maxTurns,
286
- 'llm.redacted': rendered !== rawPrompt || system !== prompt.system,
197
+ 'llm.redacted': rendered !== rawPrompt || !sameSystem(system, rawSystem),
198
+ 'llm.prompt_cache': promptCache !== undefined,
199
+ 'llm.sampling.ignored': ignoredSampling(
200
+ model,
201
+ bounds.sampling,
202
+ installedGateway()?.samplingWires?.(model),
203
+ ).join(','),
287
204
  });
288
205
 
289
206
  // Rooted in the GATEWAY's own ceilings when no scope is open — an empty root ignored them.
@@ -295,31 +212,54 @@ async function run<
295
212
  const base: GenerateRequest = {
296
213
  model,
297
214
  ...(system === undefined ? {} : { system }),
215
+ ...(promptCache === undefined ? {} : { promptCache }),
298
216
  messages: [],
299
- maxTokens: def.maxTokens ?? DEFAULT_MAX_TOKENS,
217
+ maxTokens: bounds.maxTokens,
300
218
  ...(prompt.effort === undefined ? {} : { effort: prompt.effort }),
301
219
  ...(prompt.thinking === undefined ? {} : { thinking: prompt.thinking }),
220
+ ...bounds.sampling,
302
221
  tools: [...adapted.offered, respond.tool],
303
222
  // The caller's own signal, forwarded to the transport. A disconnect has to reach the socket,
304
223
  // not just the top of the next turn: a provider call already in flight is the expensive one.
305
- signal: args.ctx.signal,
224
+ // The run's deadline is folded into the same signal, so it reaches the socket too.
225
+ signal: deadline.signal,
306
226
  };
307
227
 
308
- return withBudget(ledger, async () => {
228
+ // Every provider call below goes through the meter, so whatever ends the run — a ceiling, a
229
+ // deadline, a truncated turn, the caller leaving — is thrown carrying what the run was billed.
230
+ const meter = runMeter();
231
+ const disclosure = { discloseErrors: def.discloseToolErrors !== false };
232
+ // Where a failure would resume from: the request in flight, and the turns answered so far.
233
+ const at = { messages: [] as readonly AiMessage[], turns: prior?.turns ?? 0 };
234
+ const loop = async (): Promise<InferOutput<TOutput>> => {
309
235
  // The actor is read ONCE, from the context the request established, and is the only
310
236
  // identity any tool runs as. Nothing below reads an actor out of `result` — a model cannot
311
237
  // name the identity it acts as, and a loop that let it would be an escalation primitive.
312
238
  const { actor } = args.ctx;
313
- let messages: readonly AiMessage[] = [{ role: 'user', content: rendered }];
239
+ let messages: readonly AiMessage[] =
240
+ prior === undefined ? [{ role: 'user', content: rendered }] : continued(prior, rendered);
314
241
  let calls = 0;
315
242
  let issues: string | undefined;
243
+ // The issues of the answer the turn in flight is repairing — `onTurn`'s `repairOf`.
244
+ let rejected: readonly string[] | undefined;
316
245
 
317
- for (let turn = 1; turn <= maxTurns; turn += 1) {
246
+ // `maxTurns` counts the run AND its continuations: a handle that used them all takes none.
247
+ for (let turn = at.turns + 1; turn <= maxTurns; turn += 1) {
248
+ at.messages = messages;
318
249
  // The caller is gone, so unwind instead of finishing. Every turn re-sends the WHOLE
319
250
  // transcript, so a loop that keeps going after a disconnect spends the rest of the run's
320
251
  // budget, runs every remaining tool's side effects, and discards the answer.
321
252
  throwIfAborted(args.ctx);
322
- const result = await gateway.generate({ ...base, messages });
253
+ deadline.check();
254
+ const result = await deadline
255
+ .guard(meter.call(gateway.generate({ ...base, messages })))
256
+ .catch((error: unknown) => {
257
+ // The caller left mid-call: the aborted socket reads as a provider failure, which is
258
+ // RETRYABLE. Say what happened instead.
259
+ throwIfAborted(args.ctx);
260
+ throw error;
261
+ });
262
+ at.turns = turn;
323
263
  const requested = result.toolCalls.filter((call) => call.name !== RESPOND);
324
264
  // The same turn's `respond` calls, kept rather than forgotten: they are never RUN, but
325
265
  // `assistantTurn` replays them, and every replayed `tool_use` has to be answered.
@@ -337,8 +277,22 @@ async function run<
337
277
  stopReason: result.stopReason,
338
278
  usage: result.usage,
339
279
  cost: result.cost,
280
+ ...(result.costExact === undefined ? {} : { costExact: result.costExact }),
281
+ ...(rejected === undefined ? {} : { repairOf: rejected }),
282
+ run: runTotal(result.model, prior?.usage, meter.total(), meter.below()),
340
283
  });
341
284
  assertAnswerable(result, name, base);
285
+ // Before a tool runs and before the answer is read: a turn cut off at the ceiling carries
286
+ // arguments that stop wherever the ceiling fell — `{}` on both wires for a call that never
287
+ // reached its first byte — so running a tool on them is a side effect the model did not
288
+ // ask for, and validating them accepts an empty answer for any `output` of optional keys.
289
+ if (isTruncated(result.stopReason)) {
290
+ throw new LlmTruncatedError({
291
+ prompt: name,
292
+ maxTokens: base.maxTokens,
293
+ subject: SUBJECT,
294
+ });
295
+ }
342
296
 
343
297
  if (requested.length > 0) {
344
298
  // Concurrent, and deliberately unbounded WITHIN one turn: the batch is what a single
@@ -352,10 +306,20 @@ async function run<
352
306
  // result carries the `tool_use` id `runLlmToolCall` was handed, so a fast tool answering
353
307
  // first cannot be paired with a slow tool's call.
354
308
  throwIfAborted(args.ctx);
355
- const results: readonly LlmToolResult[] = await Promise.all(
356
- requested.map((call) => runLlmToolCall(adapted.tools, call, actor)),
309
+ deadline.check();
310
+ const results: readonly LlmToolResult[] = await deadline.guard(
311
+ // Inside the deadline's scope, so each tool's own `ctx.signal` carries it.
312
+ deadline.scope(() =>
313
+ Promise.all(
314
+ requested.map((call) => runLlmToolCall(adapted.tools, call, actor, disclosure)),
315
+ ),
316
+ ),
357
317
  );
358
318
  calls += requested.length;
319
+ rejected = undefined;
320
+ // Before the next turn is bought: the same calls with the same results cannot lead
321
+ // anywhere a further turn would not also lead.
322
+ repeated(requested, results);
359
323
  messages = [
360
324
  ...messages,
361
325
  assistantTurn(result),
@@ -370,79 +334,55 @@ async function run<
370
334
  }
371
335
 
372
336
  const parsed = await validateAsync(def.output, respond.read(result));
373
- if (parsed.issues === undefined) return parsed.value;
337
+ if (parsed.issues === undefined) {
338
+ // Kept only for `.run()` / `.continue()`: a plain call leaves nothing behind.
339
+ if (session !== undefined) {
340
+ const usage = runTotal(result.model, prior?.usage, meter.total(), meter.below());
341
+ session.finished = {
342
+ transcript: sealTranscript(binding, {
343
+ messages: [...messages, assistantTurn(result)],
344
+ turns: turn,
345
+ usage: usage.usage,
346
+ }),
347
+ turns: turn,
348
+ usage,
349
+ };
350
+ }
351
+ return parsed.value;
352
+ }
374
353
  // A wrong shape gets another turn like any other, because unlike `llm()` this loop has
375
354
  // turns left by construction — and unlike a tool result, the correction is the message.
376
- issues = formatIssues(parsed.issues).join('; ');
377
- if (isTruncated(result.stopReason)) {
378
- throw new LlmTruncatedError({ prompt: name, maxTokens: base.maxTokens });
379
- }
355
+ rejected = formatIssues(parsed.issues);
356
+ issues = rejected.join('; ');
380
357
  messages = [...messages, assistantTurn(result), repairTurn(answers, issues)];
381
358
  }
382
359
 
360
+ at.messages = messages;
383
361
  // Two different exhaustions, so two different causes: a loop that kept calling tools and
384
362
  // never answered is not the same event as one that answered the wrong shape every time.
385
363
  if (issues !== undefined) {
386
- throw new LlmOutputInvalidError({ prompt: name, attempts: maxTurns, issues });
364
+ throw new LlmOutputInvalidError({
365
+ prompt: name,
366
+ attempts: maxTurns,
367
+ issues,
368
+ ...(rejected === undefined ? {} : { list: rejected }),
369
+ });
387
370
  }
388
371
  throw new AgentMaxTurnsError({ agent: name, turns: maxTurns, calls });
389
- });
390
- });
391
- }
392
-
393
- /**
394
- * One turn, reported twice: onto the span every run already opens, and to the declaration's own
395
- * hook. The span event is free — nothing to install, and a trace is where a stalled run is
396
- * actually diagnosed — and the hook is what a progress indicator or a per-turn spend line reads.
397
- *
398
- * Awaited, and not guarded: a throw from `onTurn` fails the run. It is the app's code on the run's
399
- * path, and an observer that quietly stopped working reads exactly like one that is fine.
400
- */
401
- async function observeTurn<
402
- TInput extends StandardSchemaV1,
403
- TOutput extends StandardSchemaV1,
404
- V extends PromptVars,
405
- >(def: AgentDef<TInput, TOutput, V>, span: Span, event: AgentTurn): Promise<void> {
406
- span.addEvent('agent.turn', {
407
- 'agent.turn': event.turn,
408
- 'agent.turn.tool_calls': event.toolCalls.length,
409
- 'llm.stop': event.stopReason,
410
- 'llm.cost.minor': event.cost.minor,
411
- });
412
- await def.onTurn?.(event);
413
- }
414
-
415
- /** Refuse before the answer is read, for the reason `llm()` does: a refusal is a 200 with no answer. */
416
- function assertAnswerable(result: GenerateResult, name: string, request: GenerateRequest): void {
417
- if (result.stopReason !== 'refusal') return;
418
- throw new LlmRefusedError({
419
- prompt: name,
420
- model: result.model,
421
- alternative: moreCapableThan(result.model, request),
422
- category: result.stopDetails?.category,
423
- explanation: result.stopDetails?.explanation,
372
+ };
373
+ return withBudget(ledger, () =>
374
+ meter.within(loop).catch((error: unknown) => {
375
+ const failed = meter.fail(error);
376
+ // A failed `.run()` / `.continue()` leaves a handle too: the transcript up to the request
377
+ // the failed turn was answering, with `turns` and `usage` counting what this call was
378
+ // billed. Without it a retry resumes from the OLD handle, which never heard of that spend
379
+ // — six truncated continuations billed six times against a ceiling that saw none of them.
380
+ if (session !== undefined && at.messages.length > 0) {
381
+ const { usage } = runTotal(model, prior?.usage, meter.billed());
382
+ withTranscript(failed, sealTranscript(binding, { ...at, usage }));
383
+ }
384
+ throw failed;
385
+ }),
386
+ );
424
387
  });
425
388
  }
426
-
427
- function limitsOf<
428
- TInput extends StandardSchemaV1,
429
- TOutput extends StandardSchemaV1,
430
- V extends PromptVars,
431
- >(def: AgentDef<TInput, TOutput, V>): BudgetLimits {
432
- const budget = def.budget;
433
- return {
434
- // Screened under the names the DECLARATION uses, not the ledger's: `tokensPerRun` arrives at
435
- // `BudgetLimits` as `request`, and a fix line naming a key the app never wrote is not a fix.
436
- ...(budget?.tokensIn === undefined
437
- ? {}
438
- : { tokensIn: finiteCount(SUBJECT, 'budget.tokensIn', budget.tokensIn) }),
439
- ...(budget?.costPerCall === undefined
440
- ? {}
441
- : { costPerCall: screenedMoney(SUBJECT, 'budget.costPerCall', budget.costPerCall) }),
442
- // The ledger's `request` scope accumulates across every call made under one ledger, which for
443
- // a run under `withBudget` is exactly "the whole run".
444
- ...(budget?.tokensPerRun === undefined
445
- ? {}
446
- : { request: finiteCount(SUBJECT, 'budget.tokensPerRun', budget.tokensPerRun) }),
447
- };
448
- }
@@ -0,0 +1,88 @@
1
+ // The two failures of an answer that ARRIVED and cannot be used: cut off at the ceiling, or a tool
2
+ // call whose arguments do not parse. Apart from ./errors only because one file has one job and
3
+ // that catalogue is at its line ceiling — the split `hive-errors.ts` and `agent-errors.ts` made.
4
+ // The codes, their titles and the single `registerErrorCodes` call stay in ./errors.
5
+
6
+ import { UltimateError } from '@ultimat3/core';
7
+ import type { Billed } from './billed';
8
+ import { BILLED_META } from './billed';
9
+
10
+ /**
11
+ * The answer was cut off at the enforced ceiling. Thrown BEFORE the answer is read, and instead of
12
+ * a repair turn: a cut-off `respond` call arrives with no arguments, which both wires read as `{}`
13
+ * and an `output` of optional keys accepts — and the ceiling does not move between attempts, so a
14
+ * second answer truncates at exactly the same place and only spends money.
15
+ */
16
+ export class LlmTruncatedError extends UltimateError {
17
+ constructor(input: {
18
+ prompt: string;
19
+ maxTokens: number;
20
+ /** The factory whose declaration carries the ceiling. */
21
+ subject?: 'llm' | 'agent';
22
+ }) {
23
+ super({
24
+ code: 'X_LLM_TRUNCATED',
25
+ cause: `prompt "${input.prompt}" was cut off at its ${input.maxTokens}-token ceiling`,
26
+ fix: `maxTokens: ${input.maxTokens * 2} # on the ${input.subject ?? 'llm'}() declaration — or drop fields from its output schema`,
27
+ meta: { prompt: input.prompt, maxTokens: input.maxTokens },
28
+ });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The model finished its turn and the arguments of a tool call it made are not JSON. Not a
34
+ * transport failure: the provider answered, the answer was billed, and asking the next provider
35
+ * (or the same one three more times) buys the same request again. So it is its own code, the
36
+ * gateway does not retry it, and it carries the bill.
37
+ *
38
+ * The cause never quotes the arguments. They are the model's text, and for `respond` they are the
39
+ * answer — which is where whatever the prompt loaded ends up.
40
+ */
41
+ export class LlmToolArgumentsError extends UltimateError {
42
+ constructor(input: {
43
+ provider: string;
44
+ model: string;
45
+ tool: string;
46
+ chars: number;
47
+ billed: Billed;
48
+ /** Fragments with no index and no id: two calls merged, not one call broken. */
49
+ unkeyed?: boolean;
50
+ }) {
51
+ super({
52
+ code: 'X_LLM_TOOL_ARGUMENTS_INVALID',
53
+ cause:
54
+ input.unkeyed === true
55
+ ? `provider "${input.provider}" streamed tool-call fragments for model "${input.model}" with no index and no id, and more than one of them named a tool — the calls cannot be told apart, so none was run`
56
+ : `model "${input.model}" on provider "${input.provider}" finished its turn calling tool "${input.tool}" with ${input.chars} characters of arguments that are not JSON`,
57
+ fix: `maxTokens: 8192 # on the declaration, if the arguments were long — and shorten or split the string fields in the input schema of "${input.tool}", the place a model breaks JSON. Terminal for a job: to run the same request once more, catch this code and re-enqueue it deliberately`,
58
+ meta: {
59
+ provider: input.provider,
60
+ model: input.model,
61
+ tool: input.tool,
62
+ [BILLED_META]: input.billed,
63
+ },
64
+ });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * A chat-completions endpoint finished a turn with a reason this build does not know, and the turn
70
+ * is not whole — a tool call whose arguments do not parse — or the reason was `error`. Not
71
+ * truncation (nothing says the ceiling was reached) and not a transport failure (the endpoint
72
+ * answered, and billed): its own code, carrying the reason and the bill.
73
+ */
74
+ export class AiProviderUnexpectedError extends UltimateError {
75
+ constructor(input: { provider: string; model: string; reason: string; billed: Billed }) {
76
+ super({
77
+ code: 'X_AI_PROVIDER_UNEXPECTED',
78
+ cause: `provider "${input.provider}" finished a turn of model "${input.model}" with the finish reason ${JSON.stringify(input.reason.slice(0, 64))}, which this build does not know, and the answer is not complete`,
79
+ fix: "x doctor --json # then check what the endpoint means by the reason in cause: a truncation needs a higher maxTokens on the declaration, anything else is the endpoint's to explain",
80
+ meta: {
81
+ provider: input.provider,
82
+ model: input.model,
83
+ reason: input.reason.slice(0, 64),
84
+ [BILLED_META]: input.billed,
85
+ },
86
+ });
87
+ }
88
+ }